Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05af05e449 | |||
| 2c4dc44661 | |||
| 7d08e13393 | |||
| 9d6c154609 | |||
| 807ec2003c | |||
| 91895f4d4c | |||
| 8d7ef18fc1 | |||
| 81a24e9a87 | |||
| 13da9eb20a | |||
| 53ac48aeca | |||
| e7d91d018d | |||
| b3a1948a7b | |||
| 9682c46cc4 | |||
| b49274ea62 | |||
| 5b5b97eb30 | |||
| 9f452e4781 | |||
| b3b9fb9f93 | |||
| c144bc7f91 | |||
| bd421f471b | |||
| 55469a90dc | |||
| f69a4f7153 | |||
| e81113e488 | |||
| b555ac092d | |||
| c833e07ee3 | |||
| a5c2539c38 | |||
| 047d447163 | |||
| 660b129b75 | |||
| e434c0c9e3 | |||
| 85032b3eb8 | |||
| f080bef7b8 | |||
| dfcee356d7 | |||
| 70719d4a43 | |||
| 2da0e1a542 | |||
| 3fc7f7e8f8 | |||
| ec402abba3 | |||
| 41c93f250e | |||
| f46acac691 | |||
| 908714b73c | |||
| 68f1a2c329 | |||
| 5b268d855e | |||
| 037cc47354 | |||
| 87cfcf1190 | |||
| 2ef6e340a8 | |||
| e69b5c45df | |||
| cdc6ab61df | |||
| ef2bed2fe7 | |||
| deb6fbbe21 | |||
| 05cf55ac15 | |||
| 1d28e4ddcb | |||
| 342bf59c91 | |||
| b2597206f3 | |||
| d6dea7d082 | |||
| 14ee6651b4 | |||
| 029768e868 | |||
| ac0544614b | |||
| ef8675c070 | |||
| cfb5b6024c | |||
| d7428227f6 | |||
| 4a5bd6938b | |||
| 607b6d4c1d | |||
| a436f81e1c | |||
| 5b55a53b07 | |||
| e64a7520cb | |||
| f6b49dce15 | |||
| ded1cde2ff | |||
| 12b1893287 | |||
| c504c9af5d | |||
| 8816896166 | |||
| 663e25b311 | |||
| d70e9a48f1 | |||
| 2e201a4355 | |||
| 1bd8e2a157 | |||
| 4a9610e200 | |||
| 652506f1b3 | |||
| 8ffa48ab34 | |||
| e3d98a3f5b | |||
| 1f35f66820 | |||
| b0fe72910f | |||
| ad4de7aaef | |||
| 8ec0f0a2bc | |||
| 1baad070d7 | |||
| b243fce7ec | |||
| 894ddf3fa1 | |||
| 8f484651e3 | |||
| 8d38cb8663 | |||
| 1fb829ddaa | |||
| 017f32b978 | |||
| 2dd99898ca | |||
| 1327404e35 | |||
| b04fd36c53 | |||
| d351c61606 | |||
| f11236a30e | |||
| 27fffe0c5e | |||
| b935068b1f | |||
| 4116111b57 | |||
| 16b2d0f1bf | |||
| 0ff0027aa6 | |||
| b3a05d2bbb | |||
| 8462931253 | |||
| f19f1aecc0 | |||
| 7b04596b7f | |||
| 68e61b407d | |||
| 024dcda92a | |||
| 3ae8c74160 | |||
| 6eaaee2052 | |||
| e007b26b8a | |||
| e22ef769cb | |||
| 9bab77ad05 | |||
| cff8c3ac55 | |||
| f845a96f97 | |||
| 1c22e03a40 |
@@ -65,6 +65,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
|
||||
apiType = constant.APITypeCoze
|
||||
case constant.ChannelTypeJimeng:
|
||||
apiType = constant.APITypeJimeng
|
||||
case constant.ChannelTypeMoonshot:
|
||||
apiType = constant.APITypeMoonshot
|
||||
}
|
||||
if apiType == -1 {
|
||||
return constant.APITypeOpenAI, false
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package common
|
||||
|
||||
import "one-api/constant"
|
||||
|
||||
// EndpointInfo 描述单个端点的默认请求信息
|
||||
// path: 上游路径
|
||||
// method: HTTP 请求方式,例如 POST/GET
|
||||
// 目前均为 POST,后续可扩展
|
||||
//
|
||||
// json 标签用于直接序列化到 API 输出
|
||||
// 例如:{"path":"/v1/chat/completions","method":"POST"}
|
||||
|
||||
type EndpointInfo struct {
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
|
||||
var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
|
||||
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
|
||||
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
|
||||
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
|
||||
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
|
||||
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
|
||||
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
|
||||
}
|
||||
|
||||
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
|
||||
func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
|
||||
info, ok := defaultEndpointInfoMap[et]
|
||||
return info, ok
|
||||
}
|
||||
@@ -31,6 +31,9 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//if DebugEnabled {
|
||||
// println("UnmarshalBodyReusable request body:", string(requestBody))
|
||||
//}
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "application/json") {
|
||||
err = Unmarshal(requestBody, &v)
|
||||
|
||||
@@ -31,5 +31,6 @@ const (
|
||||
APITypeXai
|
||||
APITypeCoze
|
||||
APITypeJimeng
|
||||
APITypeDummy // this one is only for count, do not add any channel after this
|
||||
APITypeMoonshot // this one is only for count, do not add any channel after this
|
||||
APITypeDummy // this one is only for count, do not add any channel after this
|
||||
)
|
||||
|
||||
@@ -40,4 +40,6 @@ const (
|
||||
ContextKeyUserGroup ContextKey = "user_group"
|
||||
ContextKeyUsingGroup ContextKey = "group"
|
||||
ContextKeyUserName ContextKey = "username"
|
||||
|
||||
ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
|
||||
)
|
||||
|
||||
+10
-50
@@ -36,30 +36,11 @@ type OpenAIModel struct {
|
||||
Parent string `json:"parent"`
|
||||
}
|
||||
|
||||
type GoogleOpenAICompatibleModels []struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputTokenLimit int `json:"inputTokenLimit"`
|
||||
OutputTokenLimit int `json:"outputTokenLimit"`
|
||||
SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"topP,omitempty"`
|
||||
TopK int `json:"topK,omitempty"`
|
||||
MaxTemperature int `json:"maxTemperature,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIModelsResponse struct {
|
||||
Data []OpenAIModel `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
type GoogleOpenAICompatibleResponse struct {
|
||||
Models []GoogleOpenAICompatibleModels `json:"models"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
|
||||
func parseStatusFilter(statusParam string) int {
|
||||
switch strings.ToLower(statusParam) {
|
||||
case "enabled", "1":
|
||||
@@ -203,7 +184,7 @@ func FetchUpstreamModels(c *gin.Context) {
|
||||
switch channel.Type {
|
||||
case constant.ChannelTypeGemini:
|
||||
// curl https://example.com/v1beta/models?key=$GEMINI_API_KEY
|
||||
url = fmt.Sprintf("%s/v1beta/openai/models?key=%s", baseURL, channel.Key)
|
||||
url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader
|
||||
case constant.ChannelTypeAli:
|
||||
url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
|
||||
default:
|
||||
@@ -212,10 +193,11 @@ func FetchUpstreamModels(c *gin.Context) {
|
||||
|
||||
// 获取响应体 - 根据渠道类型决定是否添加 AuthHeader
|
||||
var body []byte
|
||||
key := strings.Split(channel.Key, "\n")[0]
|
||||
if channel.Type == constant.ChannelTypeGemini {
|
||||
body, err = GetResponseBody("GET", url, channel, nil) // I don't know why, but Gemini requires no AuthHeader
|
||||
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key)) // Use AuthHeader since Gemini now forces it
|
||||
} else {
|
||||
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
|
||||
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key))
|
||||
}
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -223,34 +205,12 @@ func FetchUpstreamModels(c *gin.Context) {
|
||||
}
|
||||
|
||||
var result OpenAIModelsResponse
|
||||
var parseSuccess bool
|
||||
|
||||
// 适配特殊格式
|
||||
switch channel.Type {
|
||||
case constant.ChannelTypeGemini:
|
||||
var googleResult GoogleOpenAICompatibleResponse
|
||||
if err = json.Unmarshal(body, &googleResult); err == nil {
|
||||
// 转换Google格式到OpenAI格式
|
||||
for _, model := range googleResult.Models {
|
||||
for _, gModel := range model {
|
||||
result.Data = append(result.Data, OpenAIModel{
|
||||
ID: gModel.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
parseSuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
// 如果解析失败,尝试OpenAI格式
|
||||
if !parseSuccess {
|
||||
if err = json.Unmarshal(body, &result); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("解析响应失败: %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err = json.Unmarshal(body, &result); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("解析响应失败: %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var ids []string
|
||||
|
||||
@@ -145,6 +145,22 @@ func UpdateMidjourneyTaskBulk() {
|
||||
buttonStr, _ := json.Marshal(responseItem.Buttons)
|
||||
task.Buttons = string(buttonStr)
|
||||
}
|
||||
// 映射 VideoUrl
|
||||
task.VideoUrl = responseItem.VideoUrl
|
||||
|
||||
// 映射 VideoUrls - 将数组序列化为 JSON 字符串
|
||||
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
|
||||
videoUrlsStr, err := json.Marshal(responseItem.VideoUrls)
|
||||
if err != nil {
|
||||
common.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
|
||||
task.VideoUrls = "[]" // 失败时设置为空数组
|
||||
} else {
|
||||
task.VideoUrls = string(videoUrlsStr)
|
||||
}
|
||||
} else {
|
||||
task.VideoUrls = "" // 空值时清空字段
|
||||
}
|
||||
|
||||
shouldReturnQuota := false
|
||||
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
|
||||
common.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
|
||||
@@ -208,6 +224,20 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
|
||||
if oldTask.Progress != "100%" && newTask.FailReason != "" {
|
||||
return true
|
||||
}
|
||||
// 检查 VideoUrl 是否需要更新
|
||||
if oldTask.VideoUrl != newTask.VideoUrl {
|
||||
return true
|
||||
}
|
||||
// 检查 VideoUrls 是否需要更新
|
||||
if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 {
|
||||
newVideoUrlsStr, _ := json.Marshal(newTask.VideoUrls)
|
||||
if oldTask.VideoUrls != string(newVideoUrlsStr) {
|
||||
return true
|
||||
}
|
||||
} else if oldTask.VideoUrls != "" {
|
||||
// 如果新数据没有 VideoUrls 但旧数据有,需要更新(清空)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"one-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMissingModels returns the list of model names that are referenced by channels
|
||||
// but do not have corresponding records in the models meta table.
|
||||
// This helps administrators quickly discover models that need configuration.
|
||||
func GetMissingModels(c *gin.Context) {
|
||||
missing, err := model.GetMissingModels()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": missing,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetAllModelsMeta 获取模型列表(分页)
|
||||
func GetAllModelsMeta(c *gin.Context) {
|
||||
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
// 填充附加字段
|
||||
for _, m := range modelsMeta {
|
||||
fillModelExtra(m)
|
||||
}
|
||||
var total int64
|
||||
model.DB.Model(&model.Model{}).Count(&total)
|
||||
|
||||
// 统计供应商计数(全部数据,不受分页影响)
|
||||
vendorCounts, _ := model.GetVendorModelCounts()
|
||||
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(modelsMeta)
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"items": modelsMeta,
|
||||
"total": total,
|
||||
"page": pageInfo.GetPage(),
|
||||
"page_size": pageInfo.GetPageSize(),
|
||||
"vendor_counts": vendorCounts,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchModelsMeta 搜索模型列表
|
||||
func SearchModelsMeta(c *gin.Context) {
|
||||
|
||||
keyword := c.Query("keyword")
|
||||
vendor := c.Query("vendor")
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
|
||||
modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
for _, m := range modelsMeta {
|
||||
fillModelExtra(m)
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(modelsMeta)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
|
||||
// GetModelMeta 根据 ID 获取单条模型信息
|
||||
func GetModelMeta(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var m model.Model
|
||||
if err := model.DB.First(&m, id).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
fillModelExtra(&m)
|
||||
common.ApiSuccess(c, &m)
|
||||
}
|
||||
|
||||
// CreateModelMeta 新建模型
|
||||
func CreateModelMeta(c *gin.Context) {
|
||||
var m model.Model
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if m.ModelName == "" {
|
||||
common.ApiErrorMsg(c, "模型名称不能为空")
|
||||
return
|
||||
}
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "模型名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.Insert(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RefreshPricing()
|
||||
common.ApiSuccess(c, &m)
|
||||
}
|
||||
|
||||
// UpdateModelMeta 更新模型
|
||||
func UpdateModelMeta(c *gin.Context) {
|
||||
statusOnly := c.Query("status_only") == "true"
|
||||
|
||||
var m model.Model
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if m.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少模型 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if statusOnly {
|
||||
// 只更新状态,防止误清空其他字段
|
||||
if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "模型名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.Update(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
model.RefreshPricing()
|
||||
common.ApiSuccess(c, &m)
|
||||
}
|
||||
|
||||
// DeleteModelMeta 删除模型
|
||||
func DeleteModelMeta(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DB.Delete(&model.Model{}, id).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RefreshPricing()
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// 辅助函数:填充 Endpoints 和 BoundChannels 和 EnableGroups
|
||||
func fillModelExtra(m *model.Model) {
|
||||
if m.Endpoints == "" {
|
||||
eps := model.GetModelSupportEndpointTypes(m.ModelName)
|
||||
if b, err := json.Marshal(eps); err == nil {
|
||||
m.Endpoints = string(b)
|
||||
}
|
||||
}
|
||||
if channels, err := model.GetBoundChannels(m.ModelName); err == nil {
|
||||
m.BoundChannels = channels
|
||||
}
|
||||
// 填充启用分组
|
||||
m.EnableGroups = model.GetModelEnableGroups(m.ModelName)
|
||||
// 填充计费类型
|
||||
m.QuotaType = model.GetModelQuotaType(m.ModelName)
|
||||
}
|
||||
@@ -5,10 +5,8 @@ import (
|
||||
"fmt"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/dto"
|
||||
"one-api/middleware"
|
||||
"one-api/model"
|
||||
"one-api/setting"
|
||||
"one-api/types"
|
||||
"time"
|
||||
|
||||
@@ -32,30 +30,8 @@ func Playground(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
playgroundRequest := &dto.PlayGroundRequest{}
|
||||
err := common.UnmarshalBodyReusable(c, playgroundRequest)
|
||||
if err != nil {
|
||||
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||
return
|
||||
}
|
||||
|
||||
if playgroundRequest.Model == "" {
|
||||
newAPIError = types.NewError(errors.New("请选择模型"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||
return
|
||||
}
|
||||
c.Set("original_model", playgroundRequest.Model)
|
||||
group := playgroundRequest.Group
|
||||
userGroup := c.GetString("group")
|
||||
|
||||
if group == "" {
|
||||
group = userGroup
|
||||
} else {
|
||||
if !setting.GroupInUserUsableGroups(group) && group != userGroup {
|
||||
newAPIError = types.NewError(errors.New("无权访问该分组"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
|
||||
return
|
||||
}
|
||||
c.Set("group", group)
|
||||
}
|
||||
group := c.GetString("group")
|
||||
modelName := c.GetString("original_model")
|
||||
|
||||
userId := c.GetInt("id")
|
||||
|
||||
@@ -73,7 +49,7 @@ func Playground(c *gin.Context) {
|
||||
Group: group,
|
||||
}
|
||||
_ = middleware.SetupContextForToken(c, tempToken)
|
||||
_, newAPIError = getChannel(c, group, playgroundRequest.Model, 0)
|
||||
_, newAPIError = getChannel(c, group, modelName, 0)
|
||||
if newAPIError != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤
|
||||
func GetPrefillGroups(c *gin.Context) {
|
||||
groupType := c.Query("type")
|
||||
groups, err := model.GetAllPrefillGroups(groupType)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, groups)
|
||||
}
|
||||
|
||||
// CreatePrefillGroup 创建新的预填组
|
||||
func CreatePrefillGroup(c *gin.Context) {
|
||||
var g model.PrefillGroup
|
||||
if err := c.ShouldBindJSON(&g); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if g.Name == "" || g.Type == "" {
|
||||
common.ApiErrorMsg(c, "组名称和类型不能为空")
|
||||
return
|
||||
}
|
||||
// 创建前检查名称
|
||||
if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "组名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := g.Insert(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &g)
|
||||
}
|
||||
|
||||
// UpdatePrefillGroup 更新预填组
|
||||
func UpdatePrefillGroup(c *gin.Context) {
|
||||
var g model.PrefillGroup
|
||||
if err := c.ShouldBindJSON(&g); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if g.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少组 ID")
|
||||
return
|
||||
}
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "组名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := g.Update(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &g)
|
||||
}
|
||||
|
||||
// DeletePrefillGroup 删除预填组
|
||||
func DeletePrefillGroup(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DeletePrefillGroupByID(id); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
@@ -41,9 +41,12 @@ func GetPricing(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"data": pricing,
|
||||
"vendors": model.GetVendors(),
|
||||
"group_ratio": groupRatio,
|
||||
"usable_group": usableGroup,
|
||||
})
|
||||
"usable_group": usableGroup,
|
||||
"supported_endpoint": model.GetSupportedEndpointMap(),
|
||||
"auto_groups": setting.AutoGroups,
|
||||
})
|
||||
}
|
||||
|
||||
func ResetModelRatio(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetAllVendors 获取供应商列表(分页)
|
||||
func GetAllVendors(c *gin.Context) {
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var total int64
|
||||
model.DB.Model(&model.Vendor{}).Count(&total)
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(vendors)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
|
||||
// SearchVendors 搜索供应商
|
||||
func SearchVendors(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(vendors)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
|
||||
// GetVendorMeta 根据 ID 获取供应商
|
||||
func GetVendorMeta(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
v, err := model.GetVendorByID(id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, v)
|
||||
}
|
||||
|
||||
// CreateVendorMeta 新建供应商
|
||||
func CreateVendorMeta(c *gin.Context) {
|
||||
var v model.Vendor
|
||||
if err := c.ShouldBindJSON(&v); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if v.Name == "" {
|
||||
common.ApiErrorMsg(c, "供应商名称不能为空")
|
||||
return
|
||||
}
|
||||
// 创建前先检查名称
|
||||
if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "供应商名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := v.Insert(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &v)
|
||||
}
|
||||
|
||||
// UpdateVendorMeta 更新供应商
|
||||
func UpdateVendorMeta(c *gin.Context) {
|
||||
var v model.Vendor
|
||||
if err := c.ShouldBindJSON(&v); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if v.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少供应商 ID")
|
||||
return
|
||||
}
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "供应商名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := v.Update(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &v)
|
||||
}
|
||||
|
||||
// DeleteVendorMeta 删除供应商
|
||||
func DeleteVendorMeta(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
@@ -6,4 +6,5 @@ type ChannelSettings struct {
|
||||
Proxy string `json:"proxy"`
|
||||
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
|
||||
}
|
||||
|
||||
@@ -199,6 +199,18 @@ type ClaudeRequest struct {
|
||||
Thinking *Thinking `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ClaudeRequest) SearchToolNameByToolCallId(toolCallId string) string {
|
||||
for _, message := range c.Messages {
|
||||
content, _ := message.ParseContent()
|
||||
for _, mediaMessage := range content {
|
||||
if mediaMessage.Id == toolCallId {
|
||||
return mediaMessage.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AddTool 添加工具到请求中
|
||||
func (c *ClaudeRequest) AddTool(tool any) {
|
||||
if c.Tools == nil {
|
||||
|
||||
+7
-3
@@ -216,10 +216,14 @@ type GeminiEmbeddingRequest struct {
|
||||
OutputDimensionality int `json:"outputDimensionality,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiEmbeddingResponse struct {
|
||||
Embedding ContentEmbedding `json:"embedding"`
|
||||
type GeminiBatchEmbeddingRequest struct {
|
||||
Requests []*GeminiEmbeddingRequest `json:"requests"`
|
||||
}
|
||||
|
||||
type ContentEmbedding struct {
|
||||
type GeminiEmbedding struct {
|
||||
Values []float64 `json:"values"`
|
||||
}
|
||||
|
||||
type GeminiBatchEmbeddingResponse struct {
|
||||
Embeddings []*GeminiEmbedding `json:"embeddings"`
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
|
||||
if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
|
||||
return "developer"
|
||||
}
|
||||
} else if strings.HasPrefix(r.Model, "gpt-5") {
|
||||
return "developer"
|
||||
}
|
||||
return "system"
|
||||
}
|
||||
|
||||
@@ -143,6 +143,13 @@ type ChatCompletionsStreamResponse struct {
|
||||
Usage *Usage `json:"usage"`
|
||||
}
|
||||
|
||||
func (c *ChatCompletionsStreamResponse) IsFinished() bool {
|
||||
if len(c.Choices) == 0 {
|
||||
return false
|
||||
}
|
||||
return c.Choices[0].FinishReason != nil && *c.Choices[0].FinishReason != ""
|
||||
}
|
||||
|
||||
func (c *ChatCompletionsStreamResponse) IsToolCall() bool {
|
||||
if len(c.Choices) == 0 {
|
||||
return false
|
||||
@@ -157,6 +164,19 @@ func (c *ChatCompletionsStreamResponse) GetFirstToolCall() *ToolCallResponse {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ChatCompletionsStreamResponse) ClearToolCalls() {
|
||||
if !c.IsToolCall() {
|
||||
return
|
||||
}
|
||||
for choiceIdx := range c.Choices {
|
||||
for callIdx := range c.Choices[choiceIdx].Delta.ToolCalls {
|
||||
c.Choices[choiceIdx].Delta.ToolCalls[callIdx].ID = ""
|
||||
c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Type = nil
|
||||
c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Function.Name = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ChatCompletionsStreamResponse) Copy() *ChatCompletionsStreamResponse {
|
||||
choices := make([]ChatCompletionsStreamResponseChoice, len(c.Choices))
|
||||
copy(choices, c.Choices)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"one-api/model"
|
||||
relayconstant "one-api/relay/constant"
|
||||
"one-api/service"
|
||||
"one-api/setting"
|
||||
"one-api/setting/ratio_setting"
|
||||
"one-api/types"
|
||||
"strconv"
|
||||
@@ -78,6 +79,22 @@ func Distribute() func(c *gin.Context) {
|
||||
}
|
||||
var selectGroup string
|
||||
userGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||
// check path is /pg/chat/completions
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") {
|
||||
playgroundRequest := &dto.PlayGroundRequest{}
|
||||
err = common.UnmarshalBodyReusable(c, playgroundRequest)
|
||||
if err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的请求, "+err.Error())
|
||||
return
|
||||
}
|
||||
if playgroundRequest.Group != "" {
|
||||
if !setting.GroupInUserUsableGroups(playgroundRequest.Group) && playgroundRequest.Group != userGroup {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, "无权访问该分组")
|
||||
return
|
||||
}
|
||||
userGroup = playgroundRequest.Group
|
||||
}
|
||||
}
|
||||
channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0)
|
||||
if err != nil {
|
||||
showGroup := userGroup
|
||||
@@ -250,6 +267,8 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
|
||||
common.SetContextKey(c, constant.ContextKeyChannelKey, key)
|
||||
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL())
|
||||
|
||||
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, false)
|
||||
|
||||
// TODO: api_version统一
|
||||
switch channel.Type {
|
||||
case constant.ChannelTypeAzure:
|
||||
|
||||
+8
-3
@@ -142,7 +142,7 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
|
||||
return &channel, err
|
||||
}
|
||||
|
||||
func (channel *Channel) AddAbilities() error {
|
||||
func (channel *Channel) AddAbilities(tx *gorm.DB) error {
|
||||
models_ := strings.Split(channel.Models, ",")
|
||||
groups_ := strings.Split(channel.Group, ",")
|
||||
abilitySet := make(map[string]struct{})
|
||||
@@ -169,8 +169,13 @@ func (channel *Channel) AddAbilities() error {
|
||||
if len(abilities) == 0 {
|
||||
return nil
|
||||
}
|
||||
// choose DB or provided tx
|
||||
useDB := DB
|
||||
if tx != nil {
|
||||
useDB = tx
|
||||
}
|
||||
for _, chunk := range lo.Chunk(abilities, 50) {
|
||||
err := DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
|
||||
err := useDB.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -321,7 +326,7 @@ func FixAbility() (int, int, error) {
|
||||
}
|
||||
// Then add new abilities
|
||||
for _, channel := range chunk {
|
||||
err = channel.AddAbilities()
|
||||
err = channel.AddAbilities(nil)
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("Add abilities for channel %d failed: %s", channel.Id, err.Error()))
|
||||
failCount++
|
||||
|
||||
+40
-23
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -337,38 +338,54 @@ func GetChannelById(id int, selectAll bool) (*Channel, error) {
|
||||
}
|
||||
|
||||
func BatchInsertChannels(channels []Channel) error {
|
||||
var err error
|
||||
err = DB.Create(&channels).Error
|
||||
if err != nil {
|
||||
return err
|
||||
if len(channels) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, channel_ := range channels {
|
||||
err = channel_.AddAbilities()
|
||||
if err != nil {
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
for _, chunk := range lo.Chunk(channels, 50) {
|
||||
if err := tx.Create(&chunk).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
for _, channel_ := range chunk {
|
||||
if err := channel_.AddAbilities(tx); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func BatchDeleteChannels(ids []int) error {
|
||||
//使用事务 删除channel表和channel_ability表
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 使用事务 分批删除channel表和abilities表
|
||||
tx := DB.Begin()
|
||||
err := tx.Where("id in (?)", ids).Delete(&Channel{}).Error
|
||||
if err != nil {
|
||||
// 回滚事务
|
||||
tx.Rollback()
|
||||
return err
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
err = tx.Where("channel_id in (?)", ids).Delete(&Ability{}).Error
|
||||
if err != nil {
|
||||
// 回滚事务
|
||||
tx.Rollback()
|
||||
return err
|
||||
for _, chunk := range lo.Chunk(ids, 200) {
|
||||
if err := tx.Where("id in (?)", chunk).Delete(&Channel{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("channel_id in (?)", chunk).Delete(&Ability{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 提交事务
|
||||
tx.Commit()
|
||||
return err
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func (channel *Channel) GetPriority() int64 {
|
||||
@@ -412,7 +429,7 @@ func (channel *Channel) Insert() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = channel.AddAbilities()
|
||||
err = channel.AddAbilities(nil)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -129,8 +129,6 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string,
|
||||
}
|
||||
|
||||
func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
|
||||
model = ratio_setting.FormatMatchingModelName(model)
|
||||
|
||||
// if memory cache is disabled, get channel directly from database
|
||||
if !common.MemoryCacheEnabled {
|
||||
return GetRandomSatisfiedChannel(group, model, retry)
|
||||
@@ -138,8 +136,16 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
|
||||
|
||||
channelSyncLock.RLock()
|
||||
defer channelSyncLock.RUnlock()
|
||||
|
||||
// First, try to find channels with the exact model name.
|
||||
channels := group2model2channels[group][model]
|
||||
|
||||
// If no channels found, try to find channels with the normalized model name.
|
||||
if len(channels) == 0 {
|
||||
normalizedModel := ratio_setting.FormatMatchingModelName(model)
|
||||
channels = group2model2channels[group][normalizedModel]
|
||||
}
|
||||
|
||||
if len(channels) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -250,6 +250,9 @@ func migrateDB() error {
|
||||
&TopUp{},
|
||||
&QuotaData{},
|
||||
&Task{},
|
||||
&Model{},
|
||||
&Vendor{},
|
||||
&PrefillGroup{},
|
||||
&Setup{},
|
||||
&TwoFA{},
|
||||
&TwoFABackupCode{},
|
||||
@@ -278,6 +281,9 @@ func migrateDBFast() error {
|
||||
{&TopUp{}, "TopUp"},
|
||||
{&QuotaData{}, "QuotaData"},
|
||||
{&Task{}, "Task"},
|
||||
{&Model{}, "Model"},
|
||||
{&Vendor{}, "Vendor"},
|
||||
{&PrefillGroup{}, "PrefillGroup"},
|
||||
{&Setup{}, "Setup"},
|
||||
{&TwoFA{}, "TwoFA"},
|
||||
{&TwoFABackupCode{}, "TwoFABackupCode"},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
// GetMissingModels returns model names that are referenced in the system
|
||||
func GetMissingModels() ([]string, error) {
|
||||
// 1. 获取所有已启用模型(去重)
|
||||
models := GetEnabledModels()
|
||||
if len(models) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// 2. 查询已有的元数据模型名
|
||||
var existing []string
|
||||
if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingSet := make(map[string]struct{}, len(existing))
|
||||
for _, e := range existing {
|
||||
existingSet[e] = struct{}{}
|
||||
}
|
||||
|
||||
// 3. 收集缺失模型
|
||||
var missing []string
|
||||
for _, name := range models {
|
||||
if _, ok := existingSet[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package model
|
||||
|
||||
// GetModelEnableGroups 返回指定模型名称可用的用户分组列表。
|
||||
// 使用在 updatePricing() 中维护的缓存映射,O(1) 读取,适合高并发场景。
|
||||
func GetModelEnableGroups(modelName string) []string {
|
||||
// 确保缓存最新
|
||||
GetPricing()
|
||||
|
||||
if modelName == "" {
|
||||
return make([]string, 0)
|
||||
}
|
||||
|
||||
modelEnableGroupsLock.RLock()
|
||||
groups, ok := modelEnableGroups[modelName]
|
||||
modelEnableGroupsLock.RUnlock()
|
||||
if !ok {
|
||||
return make([]string, 0)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// GetModelQuotaType 返回指定模型的计费类型(quota_type)。
|
||||
// 同样使用缓存映射,避免每次遍历定价切片。
|
||||
func GetModelQuotaType(modelName string) int {
|
||||
GetPricing()
|
||||
|
||||
modelEnableGroupsLock.RLock()
|
||||
quota, ok := modelQuotaTypeMap[modelName]
|
||||
modelEnableGroupsLock.RUnlock()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return quota
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"one-api/common"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Model 用于存储模型的元数据,例如描述、标签等
|
||||
// ModelName 字段具有唯一性约束,确保每个模型只会出现一次
|
||||
// Tags 字段使用逗号分隔的字符串保存标签集合,后期可根据需要扩展为 JSON 类型
|
||||
// Status: 1 表示启用,0 表示禁用,保留以便后续功能扩展
|
||||
// CreatedTime 和 UpdatedTime 使用 Unix 时间戳(秒)保存方便跨数据库移植
|
||||
// DeletedAt 采用 GORM 的软删除特性,便于后续数据恢复
|
||||
//
|
||||
// 该表设计遵循第三范式(3NF):
|
||||
// 1. 每一列都与主键(Id 或 ModelName)直接相关
|
||||
// 2. 不存在部分依赖(ModelName 是唯一键)
|
||||
// 3. 不存在传递依赖(描述、标签等都依赖于 ModelName,而非依赖于其他非主键列)
|
||||
// 这样既保证了数据一致性,也方便后期扩展
|
||||
|
||||
// 模型名称匹配规则
|
||||
const (
|
||||
NameRuleExact = iota // 0 精确匹配
|
||||
NameRulePrefix // 1 前缀匹配
|
||||
NameRuleContains // 2 包含匹配
|
||||
NameRuleSuffix // 3 后缀匹配
|
||||
)
|
||||
|
||||
type BoundChannel struct {
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Id int `json:"id"`
|
||||
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,where:deleted_at IS NULL"`
|
||||
Description string `json:"description,omitempty" gorm:"type:text"`
|
||||
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
|
||||
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
|
||||
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
|
||||
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
|
||||
QuotaType int `json:"quota_type" gorm:"-"`
|
||||
NameRule int `json:"name_rule" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// Insert 创建新的模型元数据记录
|
||||
func (mi *Model) Insert() error {
|
||||
now := common.GetTimestamp()
|
||||
mi.CreatedTime = now
|
||||
mi.UpdatedTime = now
|
||||
return DB.Create(mi).Error
|
||||
}
|
||||
|
||||
// IsModelNameDuplicated 检查模型名称是否重复(排除自身 ID)
|
||||
func IsModelNameDuplicated(id int, name string) (bool, error) {
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
var cnt int64
|
||||
err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error
|
||||
return cnt > 0, err
|
||||
}
|
||||
|
||||
// Update 更新现有模型记录
|
||||
func (mi *Model) Update() error {
|
||||
// 仅更新需要变更的字段,避免覆盖 CreatedTime
|
||||
mi.UpdatedTime = common.GetTimestamp()
|
||||
|
||||
// 排除 created_time,其余字段自动更新,避免新增字段时需要维护列表
|
||||
return DB.Model(&Model{}).Where("id = ?", mi.Id).Omit("created_time").Updates(mi).Error
|
||||
}
|
||||
|
||||
// Delete 软删除模型记录
|
||||
func (mi *Model) Delete() error {
|
||||
return DB.Delete(mi).Error
|
||||
}
|
||||
|
||||
// GetModelByName 根据模型名称查询元数据
|
||||
func GetModelByName(name string) (*Model, error) {
|
||||
var mi Model
|
||||
err := DB.Where("model_name = ?", name).First(&mi).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mi, nil
|
||||
}
|
||||
|
||||
// GetVendorModelCounts 统计每个供应商下模型数量(不受分页影响)
|
||||
func GetVendorModelCounts() (map[int64]int64, error) {
|
||||
var stats []struct {
|
||||
VendorID int64
|
||||
Count int64
|
||||
}
|
||||
if err := DB.Model(&Model{}).
|
||||
Select("vendor_id as vendor_id, count(*) as count").
|
||||
Group("vendor_id").
|
||||
Scan(&stats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[int64]int64, len(stats))
|
||||
for _, s := range stats {
|
||||
m[s.VendorID] = s.Count
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// GetAllModels 分页获取所有模型元数据
|
||||
func GetAllModels(offset int, limit int) ([]*Model, error) {
|
||||
var models []*Model
|
||||
err := DB.Offset(offset).Limit(limit).Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// GetBoundChannels 查询支持该模型的渠道(名称+类型)
|
||||
func GetBoundChannels(modelName string) ([]BoundChannel, error) {
|
||||
var channels []BoundChannel
|
||||
err := DB.Table("channels").
|
||||
Select("channels.name, channels.type").
|
||||
Joins("join abilities on abilities.channel_id = channels.id").
|
||||
Where("abilities.model = ? AND abilities.enabled = ?", modelName, true).
|
||||
Group("channels.id").
|
||||
Scan(&channels).Error
|
||||
return channels, err
|
||||
}
|
||||
|
||||
// FindModelByNameWithRule 根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含
|
||||
func FindModelByNameWithRule(name string) (*Model, error) {
|
||||
// 1. 精确匹配
|
||||
if m, err := GetModelByName(name); err == nil {
|
||||
return m, nil
|
||||
}
|
||||
// 2. 规则匹配
|
||||
var models []*Model
|
||||
if err := DB.Where("name_rule <> ?", NameRuleExact).Find(&models).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var prefixMatch, suffixMatch, containsMatch *Model
|
||||
for _, m := range models {
|
||||
switch m.NameRule {
|
||||
case NameRulePrefix:
|
||||
if strings.HasPrefix(name, m.ModelName) {
|
||||
if prefixMatch == nil || len(m.ModelName) > len(prefixMatch.ModelName) {
|
||||
prefixMatch = m
|
||||
}
|
||||
}
|
||||
case NameRuleSuffix:
|
||||
if strings.HasSuffix(name, m.ModelName) {
|
||||
if suffixMatch == nil || len(m.ModelName) > len(suffixMatch.ModelName) {
|
||||
suffixMatch = m
|
||||
}
|
||||
}
|
||||
case NameRuleContains:
|
||||
if strings.Contains(name, m.ModelName) {
|
||||
if containsMatch == nil || len(m.ModelName) > len(containsMatch.ModelName) {
|
||||
containsMatch = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if prefixMatch != nil {
|
||||
return prefixMatch, nil
|
||||
}
|
||||
if suffixMatch != nil {
|
||||
return suffixMatch, nil
|
||||
}
|
||||
if containsMatch != nil {
|
||||
return containsMatch, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// SearchModels 根据关键词和供应商搜索模型,支持分页
|
||||
func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) {
|
||||
var models []*Model
|
||||
db := DB.Model(&Model{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like)
|
||||
}
|
||||
if vendor != "" {
|
||||
// 如果是数字,按供应商 ID 精确匹配;否则按名称模糊匹配
|
||||
if vid, err := strconv.Atoi(vendor); err == nil {
|
||||
db = db.Where("models.vendor_id = ?", vid)
|
||||
} else {
|
||||
db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%")
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
err := db.Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err = db.Offset(offset).Limit(limit).Order("models.id DESC").Find(&models).Error
|
||||
return models, total, err
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"database/sql/driver"
|
||||
"one-api/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PrefillGroup 用于存储可复用的“组”信息,例如模型组、标签组、端点组等。
|
||||
// Name 字段保持唯一,用于在前端下拉框中展示。
|
||||
// Type 字段用于区分组的类别,可选值如:model、tag、endpoint。
|
||||
// Items 字段使用 JSON 数组保存对应类型的字符串集合,示例:
|
||||
// ["gpt-4o", "gpt-3.5-turbo"]
|
||||
// 设计遵循 3NF,避免冗余,提供灵活扩展能力。
|
||||
|
||||
// JSONValue 基于 json.RawMessage 实现,支持从数据库的 []byte 和 string 两种类型读取
|
||||
type JSONValue json.RawMessage
|
||||
|
||||
// Value 实现 driver.Valuer 接口,用于数据库写入
|
||||
func (j JSONValue) Value() (driver.Value, error) {
|
||||
if j == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(j), nil
|
||||
}
|
||||
|
||||
// Scan 实现 sql.Scanner 接口,兼容不同驱动返回的类型
|
||||
func (j *JSONValue) Scan(value interface{}) error {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
*j = nil
|
||||
return nil
|
||||
case []byte:
|
||||
// 拷贝底层字节,避免保留底层缓冲区
|
||||
b := make([]byte, len(v))
|
||||
copy(b, v)
|
||||
*j = JSONValue(b)
|
||||
return nil
|
||||
case string:
|
||||
*j = JSONValue([]byte(v))
|
||||
return nil
|
||||
default:
|
||||
// 其他类型尝试序列化为 JSON
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*j = JSONValue(b)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON 确保在对外编码时与 json.RawMessage 行为一致
|
||||
func (j JSONValue) MarshalJSON() ([]byte, error) {
|
||||
if j == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON 确保在对外解码时与 json.RawMessage 行为一致
|
||||
func (j *JSONValue) UnmarshalJSON(data []byte) error {
|
||||
if data == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
b := make([]byte, len(data))
|
||||
copy(b, data)
|
||||
*j = JSONValue(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
type PrefillGroup struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_prefill_name,where:deleted_at IS NULL"`
|
||||
Type string `json:"type" gorm:"size:32;index;not null"`
|
||||
Items JSONValue `json:"items" gorm:"type:json"`
|
||||
Description string `json:"description,omitempty" gorm:"type:varchar(255)"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
// Insert 新建组
|
||||
func (g *PrefillGroup) Insert() error {
|
||||
now := common.GetTimestamp()
|
||||
g.CreatedTime = now
|
||||
g.UpdatedTime = now
|
||||
return DB.Create(g).Error
|
||||
}
|
||||
|
||||
// IsPrefillGroupNameDuplicated 检查组名称是否重复(排除自身 ID)
|
||||
func IsPrefillGroupNameDuplicated(id int, name string) (bool, error) {
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
var cnt int64
|
||||
err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
|
||||
return cnt > 0, err
|
||||
}
|
||||
|
||||
// Update 更新组
|
||||
func (g *PrefillGroup) Update() error {
|
||||
g.UpdatedTime = common.GetTimestamp()
|
||||
return DB.Save(g).Error
|
||||
}
|
||||
|
||||
// DeleteByID 根据 ID 删除组
|
||||
func DeletePrefillGroupByID(id int) error {
|
||||
return DB.Delete(&PrefillGroup{}, id).Error
|
||||
}
|
||||
|
||||
// GetAllPrefillGroups 获取全部组,可按类型过滤(为空则返回全部)
|
||||
func GetAllPrefillGroups(groupType string) ([]*PrefillGroup, error) {
|
||||
var groups []*PrefillGroup
|
||||
query := DB.Model(&PrefillGroup{})
|
||||
if groupType != "" {
|
||||
query = query.Where("type = ?", groupType)
|
||||
}
|
||||
if err := query.Order("updated_time DESC").Find(&groups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
+233
-53
@@ -1,30 +1,50 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/setting/ratio_setting"
|
||||
"one-api/types"
|
||||
"sync"
|
||||
"time"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/setting/ratio_setting"
|
||||
"one-api/types"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Pricing struct {
|
||||
ModelName string `json:"model_name"`
|
||||
QuotaType int `json:"quota_type"`
|
||||
ModelRatio float64 `json:"model_ratio"`
|
||||
ModelPrice float64 `json:"model_price"`
|
||||
OwnerBy string `json:"owner_by"`
|
||||
CompletionRatio float64 `json:"completion_ratio"`
|
||||
EnableGroup []string `json:"enable_groups"`
|
||||
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
|
||||
ModelName string `json:"model_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
VendorID int `json:"vendor_id,omitempty"`
|
||||
QuotaType int `json:"quota_type"`
|
||||
ModelRatio float64 `json:"model_ratio"`
|
||||
ModelPrice float64 `json:"model_price"`
|
||||
OwnerBy string `json:"owner_by"`
|
||||
CompletionRatio float64 `json:"completion_ratio"`
|
||||
EnableGroup []string `json:"enable_groups"`
|
||||
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
|
||||
}
|
||||
|
||||
type PricingVendor struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
pricingMap []Pricing
|
||||
lastGetPricingTime time.Time
|
||||
updatePricingLock sync.Mutex
|
||||
pricingMap []Pricing
|
||||
vendorsList []PricingVendor
|
||||
supportedEndpointMap map[string]common.EndpointInfo
|
||||
lastGetPricingTime time.Time
|
||||
updatePricingLock sync.Mutex
|
||||
|
||||
// 缓存映射:模型名 -> 启用分组 / 计费类型
|
||||
modelEnableGroups = make(map[string][]string)
|
||||
modelQuotaTypeMap = make(map[string]int)
|
||||
modelEnableGroupsLock = sync.RWMutex{}
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -46,6 +66,15 @@ func GetPricing() []Pricing {
|
||||
return pricingMap
|
||||
}
|
||||
|
||||
// GetVendors 返回当前定价接口使用到的供应商信息
|
||||
func GetVendors() []PricingVendor {
|
||||
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
|
||||
// 保证先刷新一次
|
||||
GetPricing()
|
||||
}
|
||||
return vendorsList
|
||||
}
|
||||
|
||||
func GetModelSupportEndpointTypes(model string) []constant.EndpointType {
|
||||
if model == "" {
|
||||
return make([]constant.EndpointType, 0)
|
||||
@@ -65,6 +94,77 @@ func updatePricing() {
|
||||
common.SysError(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err))
|
||||
return
|
||||
}
|
||||
// 预加载模型元数据与供应商一次,避免循环查询
|
||||
var allMeta []Model
|
||||
_ = DB.Find(&allMeta).Error
|
||||
metaMap := make(map[string]*Model)
|
||||
prefixList := make([]*Model, 0)
|
||||
suffixList := make([]*Model, 0)
|
||||
containsList := make([]*Model, 0)
|
||||
for i := range allMeta {
|
||||
m := &allMeta[i]
|
||||
if m.NameRule == NameRuleExact {
|
||||
metaMap[m.ModelName] = m
|
||||
} else {
|
||||
switch m.NameRule {
|
||||
case NameRulePrefix:
|
||||
prefixList = append(prefixList, m)
|
||||
case NameRuleSuffix:
|
||||
suffixList = append(suffixList, m)
|
||||
case NameRuleContains:
|
||||
containsList = append(containsList, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将非精确规则模型匹配到 metaMap
|
||||
for _, m := range prefixList {
|
||||
for _, pricingModel := range enableAbilities {
|
||||
if strings.HasPrefix(pricingModel.Model, m.ModelName) {
|
||||
if _, exists := metaMap[pricingModel.Model]; !exists {
|
||||
metaMap[pricingModel.Model] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range suffixList {
|
||||
for _, pricingModel := range enableAbilities {
|
||||
if strings.HasSuffix(pricingModel.Model, m.ModelName) {
|
||||
if _, exists := metaMap[pricingModel.Model]; !exists {
|
||||
metaMap[pricingModel.Model] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range containsList {
|
||||
for _, pricingModel := range enableAbilities {
|
||||
if strings.Contains(pricingModel.Model, m.ModelName) {
|
||||
if _, exists := metaMap[pricingModel.Model]; !exists {
|
||||
metaMap[pricingModel.Model] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载供应商
|
||||
var vendors []Vendor
|
||||
_ = DB.Find(&vendors).Error
|
||||
vendorMap := make(map[int]*Vendor)
|
||||
for i := range vendors {
|
||||
vendorMap[vendors[i].Id] = &vendors[i]
|
||||
}
|
||||
|
||||
// 构建对前端友好的供应商列表
|
||||
vendorsList = make([]PricingVendor, 0, len(vendors))
|
||||
for _, v := range vendors {
|
||||
vendorsList = append(vendorsList, PricingVendor{
|
||||
ID: v.Id,
|
||||
Name: v.Name,
|
||||
Description: v.Description,
|
||||
Icon: v.Icon,
|
||||
})
|
||||
}
|
||||
|
||||
modelGroupsMap := make(map[string]*types.Set[string])
|
||||
|
||||
for _, ability := range enableAbilities {
|
||||
@@ -79,20 +179,34 @@ func updatePricing() {
|
||||
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
|
||||
modelSupportEndpointsStr := make(map[string][]string)
|
||||
|
||||
for _, ability := range enableAbilities {
|
||||
endpoints, ok := modelSupportEndpointsStr[ability.Model]
|
||||
if !ok {
|
||||
endpoints = make([]string, 0)
|
||||
modelSupportEndpointsStr[ability.Model] = endpoints
|
||||
}
|
||||
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
|
||||
for _, channelType := range channelTypes {
|
||||
if !common.StringsContains(endpoints, string(channelType)) {
|
||||
endpoints = append(endpoints, string(channelType))
|
||||
}
|
||||
}
|
||||
modelSupportEndpointsStr[ability.Model] = endpoints
|
||||
}
|
||||
// 先根据已有能力填充原生端点
|
||||
for _, ability := range enableAbilities {
|
||||
endpoints := modelSupportEndpointsStr[ability.Model]
|
||||
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
|
||||
for _, channelType := range channelTypes {
|
||||
if !common.StringsContains(endpoints, string(channelType)) {
|
||||
endpoints = append(endpoints, string(channelType))
|
||||
}
|
||||
}
|
||||
modelSupportEndpointsStr[ability.Model] = endpoints
|
||||
}
|
||||
|
||||
// 再补充模型自定义端点
|
||||
for modelName, meta := range metaMap {
|
||||
if strings.TrimSpace(meta.Endpoints) == "" {
|
||||
continue
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
||||
endpoints := modelSupportEndpointsStr[modelName]
|
||||
for k := range raw {
|
||||
if !common.StringsContains(endpoints, k) {
|
||||
endpoints = append(endpoints, k)
|
||||
}
|
||||
}
|
||||
modelSupportEndpointsStr[modelName] = endpoints
|
||||
}
|
||||
}
|
||||
|
||||
modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
|
||||
for model, endpoints := range modelSupportEndpointsStr {
|
||||
@@ -102,26 +216,92 @@ func updatePricing() {
|
||||
supportedEndpoints = append(supportedEndpoints, endpointType)
|
||||
}
|
||||
modelSupportEndpointTypes[model] = supportedEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
pricingMap = make([]Pricing, 0)
|
||||
for model, groups := range modelGroupsMap {
|
||||
pricing := Pricing{
|
||||
ModelName: model,
|
||||
EnableGroup: groups.Items(),
|
||||
SupportedEndpointTypes: modelSupportEndpointTypes[model],
|
||||
}
|
||||
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
|
||||
if findPrice {
|
||||
pricing.ModelPrice = modelPrice
|
||||
pricing.QuotaType = 1
|
||||
} else {
|
||||
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
|
||||
pricing.ModelRatio = modelRatio
|
||||
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
|
||||
pricing.QuotaType = 0
|
||||
}
|
||||
pricingMap = append(pricingMap, pricing)
|
||||
}
|
||||
lastGetPricingTime = time.Now()
|
||||
// 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
|
||||
supportedEndpointMap = make(map[string]common.EndpointInfo)
|
||||
// 1. 默认端点
|
||||
for _, endpoints := range modelSupportEndpointTypes {
|
||||
for _, et := range endpoints {
|
||||
if info, ok := common.GetDefaultEndpointInfo(et); ok {
|
||||
if _, exists := supportedEndpointMap[string(et)]; !exists {
|
||||
supportedEndpointMap[string(et)] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. 自定义端点(models 表)覆盖默认
|
||||
for _, meta := range metaMap {
|
||||
if strings.TrimSpace(meta.Endpoints) == "" {
|
||||
continue
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
||||
for k, v := range raw {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"}
|
||||
case map[string]interface{}:
|
||||
ep := common.EndpointInfo{Method: "POST"}
|
||||
if p, ok := val["path"].(string); ok {
|
||||
ep.Path = p
|
||||
}
|
||||
if m, ok := val["method"].(string); ok {
|
||||
ep.Method = strings.ToUpper(m)
|
||||
}
|
||||
supportedEndpointMap[k] = ep
|
||||
default:
|
||||
// ignore unsupported types
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pricingMap = make([]Pricing, 0)
|
||||
for model, groups := range modelGroupsMap {
|
||||
pricing := Pricing{
|
||||
ModelName: model,
|
||||
EnableGroup: groups.Items(),
|
||||
SupportedEndpointTypes: modelSupportEndpointTypes[model],
|
||||
}
|
||||
|
||||
// 补充模型元数据(描述、标签、供应商、状态)
|
||||
if meta, ok := metaMap[model]; ok {
|
||||
// 若模型被禁用(status!=1),则直接跳过,不返回给前端
|
||||
if meta.Status != 1 {
|
||||
continue
|
||||
}
|
||||
pricing.Description = meta.Description
|
||||
pricing.Tags = meta.Tags
|
||||
pricing.VendorID = meta.VendorID
|
||||
}
|
||||
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
|
||||
if findPrice {
|
||||
pricing.ModelPrice = modelPrice
|
||||
pricing.QuotaType = 1
|
||||
} else {
|
||||
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
|
||||
pricing.ModelRatio = modelRatio
|
||||
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
|
||||
pricing.QuotaType = 0
|
||||
}
|
||||
pricingMap = append(pricingMap, pricing)
|
||||
}
|
||||
|
||||
// 刷新缓存映射,供高并发快速查询
|
||||
modelEnableGroupsLock.Lock()
|
||||
modelEnableGroups = make(map[string][]string)
|
||||
modelQuotaTypeMap = make(map[string]int)
|
||||
for _, p := range pricingMap {
|
||||
modelEnableGroups[p.ModelName] = p.EnableGroup
|
||||
modelQuotaTypeMap[p.ModelName] = p.QuotaType
|
||||
}
|
||||
modelEnableGroupsLock.Unlock()
|
||||
|
||||
lastGetPricingTime = time.Now()
|
||||
}
|
||||
|
||||
// GetSupportedEndpointMap 返回全局端点到路径的映射
|
||||
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
|
||||
return supportedEndpointMap
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
// RefreshPricing 强制立即重新计算与定价相关的缓存。
|
||||
// 该方法用于需要最新数据的内部管理 API,
|
||||
// 因此会绕过默认的 1 分钟延迟刷新。
|
||||
func RefreshPricing() {
|
||||
updatePricingLock.Lock()
|
||||
defer updatePricingLock.Unlock()
|
||||
|
||||
modelSupportEndpointsLock.Lock()
|
||||
defer modelSupportEndpointsLock.Unlock()
|
||||
|
||||
updatePricing()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"one-api/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Vendor 用于存储供应商信息,供模型引用
|
||||
// Name 唯一,用于在模型中关联
|
||||
// Icon 采用 @lobehub/icons 的图标名,前端可直接渲染
|
||||
// Status 预留字段,1 表示启用
|
||||
// 本表同样遵循 3NF 设计范式
|
||||
|
||||
type Vendor struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,where:deleted_at IS NULL"`
|
||||
Description string `json:"description,omitempty" gorm:"type:text"`
|
||||
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
// Insert 创建新的供应商记录
|
||||
func (v *Vendor) Insert() error {
|
||||
now := common.GetTimestamp()
|
||||
v.CreatedTime = now
|
||||
v.UpdatedTime = now
|
||||
return DB.Create(v).Error
|
||||
}
|
||||
|
||||
// IsVendorNameDuplicated 检查供应商名称是否重复(排除自身 ID)
|
||||
func IsVendorNameDuplicated(id int, name string) (bool, error) {
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
var cnt int64
|
||||
err := DB.Model(&Vendor{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
|
||||
return cnt > 0, err
|
||||
}
|
||||
|
||||
// Update 更新供应商记录
|
||||
func (v *Vendor) Update() error {
|
||||
v.UpdatedTime = common.GetTimestamp()
|
||||
return DB.Save(v).Error
|
||||
}
|
||||
|
||||
// Delete 软删除供应商
|
||||
func (v *Vendor) Delete() error {
|
||||
return DB.Delete(v).Error
|
||||
}
|
||||
|
||||
// GetVendorByID 根据 ID 获取供应商
|
||||
func GetVendorByID(id int) (*Vendor, error) {
|
||||
var v Vendor
|
||||
err := DB.First(&v, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// GetAllVendors 获取全部供应商(分页)
|
||||
func GetAllVendors(offset int, limit int) ([]*Vendor, error) {
|
||||
var vendors []*Vendor
|
||||
err := DB.Offset(offset).Limit(limit).Find(&vendors).Error
|
||||
return vendors, err
|
||||
}
|
||||
|
||||
// SearchVendors 按关键字搜索供应商
|
||||
func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, error) {
|
||||
db := DB.Model(&Vendor{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
db = db.Where("name LIKE ? OR description LIKE ?", like, like)
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var vendors []*Vendor
|
||||
if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return vendors, total, nil
|
||||
}
|
||||
@@ -125,20 +125,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
err, usage = claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
|
||||
}
|
||||
default:
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeImagesGenerations:
|
||||
err, usage = aliImageHandler(c, resp, info)
|
||||
case constant.RelayModeEmbeddings:
|
||||
err, usage = aliEmbeddingHandler(c, resp)
|
||||
case constant.RelayModeRerank:
|
||||
err, usage = RerankHandler(c, resp, info)
|
||||
default:
|
||||
if info.IsStream {
|
||||
usage, err = openai.OaiStreamHandler(c, info, resp)
|
||||
} else {
|
||||
usage, err = openai.OpenaiHandler(c, info, resp)
|
||||
}
|
||||
}
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.DoResponse(c, resp, info)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"one-api/relay/channel"
|
||||
"one-api/relay/channel/openai"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/constant"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
|
||||
@@ -23,10 +24,9 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
return nil, nil
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.ConvertClaudeRequest(c, info, req)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
@@ -43,7 +43,20 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
return fmt.Sprintf("%s/v2/chat/completions", info.BaseUrl), nil
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeChatCompletions:
|
||||
return fmt.Sprintf("%s/v2/chat/completions", info.BaseUrl), nil
|
||||
case constant.RelayModeEmbeddings:
|
||||
return fmt.Sprintf("%s/v2/embeddings", info.BaseUrl), nil
|
||||
case constant.RelayModeImagesGenerations:
|
||||
return fmt.Sprintf("%s/v2/images/generations", info.BaseUrl), nil
|
||||
case constant.RelayModeImagesEdits:
|
||||
return fmt.Sprintf("%s/v2/images/edits", info.BaseUrl), nil
|
||||
case constant.RelayModeRerank:
|
||||
return fmt.Sprintf("%s/v2/rerank", info.BaseUrl), nil
|
||||
default:
|
||||
}
|
||||
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
|
||||
}
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
@@ -99,11 +112,8 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
if info.IsStream {
|
||||
usage, err = openai.OaiStreamHandler(c, info, resp)
|
||||
} else {
|
||||
usage, err = openai.OpenaiHandler(c, info, resp)
|
||||
}
|
||||
adaptor := openai.Adaptor{}
|
||||
usage, err = adaptor.DoResponse(c, resp, info)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -114,12 +114,15 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
if strings.HasPrefix(info.UpstreamModelName, "text-embedding") ||
|
||||
strings.HasPrefix(info.UpstreamModelName, "embedding") ||
|
||||
strings.HasPrefix(info.UpstreamModelName, "gemini-embedding") {
|
||||
return fmt.Sprintf("%s/%s/models/%s:embedContent", info.BaseUrl, version, info.UpstreamModelName), nil
|
||||
return fmt.Sprintf("%s/%s/models/%s:batchEmbedContents", info.BaseUrl, version, info.UpstreamModelName), nil
|
||||
}
|
||||
|
||||
action := "generateContent"
|
||||
if info.IsStream {
|
||||
action = "streamGenerateContent?alt=sse"
|
||||
if info.RelayMode == constant.RelayModeGemini {
|
||||
info.DisablePing = true
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/models/%s:%s", info.BaseUrl, version, info.UpstreamModelName, action), nil
|
||||
}
|
||||
@@ -156,29 +159,35 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
if len(inputs) == 0 {
|
||||
return nil, errors.New("input is empty")
|
||||
}
|
||||
|
||||
// only process the first input
|
||||
geminiRequest := dto.GeminiEmbeddingRequest{
|
||||
Content: dto.GeminiChatContent{
|
||||
Parts: []dto.GeminiPart{
|
||||
{
|
||||
Text: inputs[0],
|
||||
// process all inputs
|
||||
geminiRequests := make([]map[string]interface{}, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
geminiRequest := map[string]interface{}{
|
||||
"model": fmt.Sprintf("models/%s", info.UpstreamModelName),
|
||||
"content": dto.GeminiChatContent{
|
||||
Parts: []dto.GeminiPart{
|
||||
{
|
||||
Text: input,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// set specific parameters for different models
|
||||
// https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent
|
||||
switch info.UpstreamModelName {
|
||||
case "text-embedding-004":
|
||||
// except embedding-001 supports setting `OutputDimensionality`
|
||||
if request.Dimensions > 0 {
|
||||
geminiRequest.OutputDimensionality = request.Dimensions
|
||||
}
|
||||
|
||||
// set specific parameters for different models
|
||||
// https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent
|
||||
switch info.UpstreamModelName {
|
||||
case "text-embedding-004","gemini-embedding-exp-03-07","gemini-embedding-001":
|
||||
// Only newer models introduced after 2024 support OutputDimensionality
|
||||
if request.Dimensions > 0 {
|
||||
geminiRequest["outputDimensionality"] = request.Dimensions
|
||||
}
|
||||
}
|
||||
geminiRequests = append(geminiRequests, geminiRequest)
|
||||
}
|
||||
|
||||
return geminiRequest, nil
|
||||
return map[string]interface{}{
|
||||
"requests": geminiRequests,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
@@ -193,7 +202,6 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
if info.RelayMode == constant.RelayModeGemini {
|
||||
if info.IsStream {
|
||||
info.DisablePing = true
|
||||
return GeminiTextGenerationStreamHandler(c, info, resp)
|
||||
} else {
|
||||
return GeminiTextGenerationHandler(c, info, resp)
|
||||
|
||||
@@ -835,6 +835,7 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
|
||||
call.SetIndex(len(choice.Delta.ToolCalls))
|
||||
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call)
|
||||
}
|
||||
|
||||
} else if part.Thought {
|
||||
isThought = true
|
||||
texts = append(texts, part.Text)
|
||||
@@ -895,6 +896,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
responseText := strings.Builder{}
|
||||
var usage = &dto.Usage{}
|
||||
var imageCount int
|
||||
finishReason := constant.FinishReasonStop
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
var geminiResponse dto.GeminiChatResponse
|
||||
@@ -936,9 +938,21 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
|
||||
if info.SendResponseCount == 0 {
|
||||
// send first response
|
||||
err = handleStream(c, info, helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil))
|
||||
if err != nil {
|
||||
common.LogError(c, err.Error())
|
||||
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
|
||||
if response.IsToolCall() {
|
||||
emptyResponse.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 1)
|
||||
emptyResponse.Choices[0].Delta.ToolCalls[0] = *response.GetFirstToolCall()
|
||||
emptyResponse.Choices[0].Delta.ToolCalls[0].Function.Arguments = ""
|
||||
finishReason = constant.FinishReasonToolCalls
|
||||
err = handleStream(c, info, emptyResponse)
|
||||
if err != nil {
|
||||
common.LogError(c, err.Error())
|
||||
}
|
||||
|
||||
response.ClearToolCalls()
|
||||
if response.IsFinished() {
|
||||
response.Choices[0].FinishReason = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,7 +961,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
common.LogError(c, err.Error())
|
||||
}
|
||||
if isStop {
|
||||
_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, constant.FinishReasonStop))
|
||||
_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason))
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -1026,13 +1040,26 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
}
|
||||
|
||||
fullTextResponse.Usage = usage
|
||||
jsonResponse, err := json.Marshal(fullTextResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
|
||||
switch info.RelayFormat {
|
||||
case relaycommon.RelayFormatOpenAI:
|
||||
responseBody, err = common.Marshal(fullTextResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
case relaycommon.RelayFormatClaude:
|
||||
claudeResp := service.ResponseOpenAI2Claude(fullTextResponse, info)
|
||||
claudeRespStr, err := common.Marshal(claudeResp)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
responseBody = claudeRespStr
|
||||
case relaycommon.RelayFormatGemini:
|
||||
break
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
c.Writer.Write(jsonResponse)
|
||||
|
||||
common.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
return &usage, nil
|
||||
}
|
||||
|
||||
@@ -1044,7 +1071,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
|
||||
return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var geminiResponse dto.GeminiEmbeddingResponse
|
||||
var geminiResponse dto.GeminiBatchEmbeddingResponse
|
||||
if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
|
||||
return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
@@ -1052,14 +1079,16 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
|
||||
// convert to openai format response
|
||||
openAIResponse := dto.OpenAIEmbeddingResponse{
|
||||
Object: "list",
|
||||
Data: []dto.OpenAIEmbeddingResponseItem{
|
||||
{
|
||||
Object: "embedding",
|
||||
Embedding: geminiResponse.Embedding.Values,
|
||||
Index: 0,
|
||||
},
|
||||
},
|
||||
Model: info.UpstreamModelName,
|
||||
Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(geminiResponse.Embeddings)),
|
||||
Model: info.UpstreamModelName,
|
||||
}
|
||||
|
||||
for i, embedding := range geminiResponse.Embeddings {
|
||||
openAIResponse.Data = append(openAIResponse.Data, dto.OpenAIEmbeddingResponseItem{
|
||||
Object: "embedding",
|
||||
Embedding: embedding.Values,
|
||||
Index: i,
|
||||
})
|
||||
}
|
||||
|
||||
// calculate usage
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package moonshot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/dto"
|
||||
"one-api/relay/channel"
|
||||
"one-api/relay/channel/claude"
|
||||
"one-api/relay/channel/openai"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/constant"
|
||||
"one-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Adaptor struct {
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
|
||||
//TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.ConvertClaudeRequest(c, info, req)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
//TODO implement me
|
||||
return nil, errors.New("not supported")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.ConvertImageRequest(c, info, request)
|
||||
}
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
switch info.RelayFormat {
|
||||
case relaycommon.RelayFormatClaude:
|
||||
return fmt.Sprintf("%s/anthropic/v1/messages", info.BaseUrl), nil
|
||||
default:
|
||||
if info.RelayMode == constant.RelayModeRerank {
|
||||
return fmt.Sprintf("%s/v1/rerank", info.BaseUrl), nil
|
||||
} else if info.RelayMode == constant.RelayModeEmbeddings {
|
||||
return fmt.Sprintf("%s/v1/embeddings", info.BaseUrl), nil
|
||||
} else if info.RelayMode == constant.RelayModeChatCompletions {
|
||||
return fmt.Sprintf("%s/v1/chat/completions", info.BaseUrl), nil
|
||||
} else if info.RelayMode == constant.RelayModeCompletions {
|
||||
return fmt.Sprintf("%s/v1/completions", info.BaseUrl), nil
|
||||
}
|
||||
return fmt.Sprintf("%s/v1/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", fmt.Sprintf("Bearer %s", info.ApiKey))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
// TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
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) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
switch info.RelayFormat {
|
||||
case relaycommon.RelayFormatOpenAI:
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.DoResponse(c, resp, info)
|
||||
case relaycommon.RelayFormatClaude:
|
||||
if info.IsStream {
|
||||
err, usage = claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
|
||||
} else {
|
||||
err, usage = claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetModelList() []string {
|
||||
return ModelList
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetChannelName() string {
|
||||
return ChannelName
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"one-api/relay/channel/ai360"
|
||||
"one-api/relay/channel/lingyiwanwu"
|
||||
"one-api/relay/channel/minimax"
|
||||
"one-api/relay/channel/moonshot"
|
||||
"one-api/relay/channel/openrouter"
|
||||
"one-api/relay/channel/xinference"
|
||||
relaycommon "one-api/relay/common"
|
||||
@@ -35,6 +34,21 @@ type Adaptor struct {
|
||||
ResponseFormat string
|
||||
}
|
||||
|
||||
// parseReasoningEffortFromModelSuffix 从模型名称中解析推理级别
|
||||
// support OAI models: o1-mini/o3-mini/o4-mini/o1/o3 etc...
|
||||
// minimal effort only available in gpt-5
|
||||
func parseReasoningEffortFromModelSuffix(model string) (string, string) {
|
||||
effortSuffixes := []string{"-high", "-minimal", "-low", "-medium"}
|
||||
for _, suffix := range effortSuffixes {
|
||||
if strings.HasSuffix(model, suffix) {
|
||||
effort := strings.TrimPrefix(suffix, "-")
|
||||
originModel := strings.TrimSuffix(model, suffix)
|
||||
return effort, originModel
|
||||
}
|
||||
}
|
||||
return "", model
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
|
||||
// 使用 service.GeminiToOpenAIRequest 转换请求格式
|
||||
openaiRequest, err := service.GeminiToOpenAIRequest(request, info)
|
||||
@@ -48,11 +62,27 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
//if !strings.Contains(request.Model, "claude") {
|
||||
// return nil, fmt.Errorf("you are using openai channel type with path /v1/messages, only claude model supported convert, but got %s", request.Model)
|
||||
//}
|
||||
//if common.DebugEnabled {
|
||||
// bodyBytes := []byte(common.GetJsonString(request))
|
||||
// err := os.WriteFile(fmt.Sprintf("claude_request_%s.txt", c.GetString(common.RequestIdKey)), bodyBytes, 0644)
|
||||
// if err != nil {
|
||||
// println(fmt.Sprintf("failed to save request body to file: %v", err))
|
||||
// }
|
||||
//}
|
||||
aiRequest, err := service.ClaudeToOpenAIRequest(*request, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.SupportStreamOptions {
|
||||
//if common.DebugEnabled {
|
||||
// println(fmt.Sprintf("convert claude to openai request result: %s", common.GetJsonString(aiRequest)))
|
||||
// // Save request body to file for debugging
|
||||
// bodyBytes := []byte(common.GetJsonString(aiRequest))
|
||||
// err = os.WriteFile(fmt.Sprintf("claude_to_openai_request_%s.txt", c.GetString(common.RequestIdKey)), bodyBytes, 0644)
|
||||
// if err != nil {
|
||||
// println(fmt.Sprintf("failed to save request body to file: %v", err))
|
||||
// }
|
||||
//}
|
||||
if info.SupportStreamOptions && info.IsStream {
|
||||
aiRequest.StreamOptions = &dto.StreamOptions{
|
||||
IncludeUsage: true,
|
||||
}
|
||||
@@ -173,6 +203,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if len(request.Usage) == 0 {
|
||||
request.Usage = json.RawMessage(`{"include":true}`)
|
||||
}
|
||||
// 适配 OpenRouter 的 thinking 后缀
|
||||
if strings.HasSuffix(info.UpstreamModelName, "-thinking") {
|
||||
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
|
||||
request.Model = info.UpstreamModelName
|
||||
@@ -180,7 +211,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
reasoning := map[string]any{
|
||||
"enabled": true,
|
||||
}
|
||||
if request.ReasoningEffort != "" {
|
||||
if request.ReasoningEffort != "" && request.ReasoningEffort != "none" {
|
||||
reasoning["effort"] = request.ReasoningEffort
|
||||
}
|
||||
marshal, err := common.Marshal(reasoning)
|
||||
@@ -189,24 +220,48 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
}
|
||||
request.Reasoning = marshal
|
||||
}
|
||||
} else {
|
||||
if len(request.Reasoning) == 0 {
|
||||
// 适配 OpenAI 的 ReasoningEffort 格式
|
||||
if request.ReasoningEffort != "" {
|
||||
reasoning := map[string]any{
|
||||
"enabled": true,
|
||||
}
|
||||
if request.ReasoningEffort != "none" {
|
||||
reasoning["effort"] = request.ReasoningEffort
|
||||
marshal, err := common.Marshal(reasoning)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
|
||||
}
|
||||
request.Reasoning = marshal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(request.Model, "o") {
|
||||
if strings.HasPrefix(request.Model, "o") || strings.HasPrefix(request.Model, "gpt-5") {
|
||||
if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 {
|
||||
request.MaxCompletionTokens = request.MaxTokens
|
||||
request.MaxTokens = 0
|
||||
}
|
||||
request.Temperature = nil
|
||||
if strings.HasSuffix(request.Model, "-high") {
|
||||
request.ReasoningEffort = "high"
|
||||
request.Model = strings.TrimSuffix(request.Model, "-high")
|
||||
} else if strings.HasSuffix(request.Model, "-low") {
|
||||
request.ReasoningEffort = "low"
|
||||
request.Model = strings.TrimSuffix(request.Model, "-low")
|
||||
} else if strings.HasSuffix(request.Model, "-medium") {
|
||||
request.ReasoningEffort = "medium"
|
||||
request.Model = strings.TrimSuffix(request.Model, "-medium")
|
||||
|
||||
if strings.HasPrefix(request.Model, "o") {
|
||||
request.Temperature = nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(request.Model, "gpt-5") {
|
||||
if request.Model != "gpt-5-chat-latest" {
|
||||
request.Temperature = nil
|
||||
}
|
||||
}
|
||||
|
||||
// 转换模型推理力度后缀
|
||||
effort, originModel := parseReasoningEffortFromModelSuffix(request.Model)
|
||||
if effort != "" {
|
||||
request.ReasoningEffort = effort
|
||||
request.Model = originModel
|
||||
}
|
||||
|
||||
info.ReasoningEffort = request.ReasoningEffort
|
||||
info.UpstreamModelName = request.Model
|
||||
|
||||
@@ -423,16 +478,11 @@ func detectImageMimeType(filename string) string {
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
// 模型后缀转换 reasoning effort
|
||||
if strings.HasSuffix(request.Model, "-high") {
|
||||
request.Reasoning.Effort = "high"
|
||||
request.Model = strings.TrimSuffix(request.Model, "-high")
|
||||
} else if strings.HasSuffix(request.Model, "-low") {
|
||||
request.Reasoning.Effort = "low"
|
||||
request.Model = strings.TrimSuffix(request.Model, "-low")
|
||||
} else if strings.HasSuffix(request.Model, "-medium") {
|
||||
request.Reasoning.Effort = "medium"
|
||||
request.Model = strings.TrimSuffix(request.Model, "-medium")
|
||||
// 转换模型推理力度后缀
|
||||
effort, originModel := parseReasoningEffortFromModelSuffix(request.Model)
|
||||
if effort != "" {
|
||||
request.Reasoning.Effort = effort
|
||||
request.Model = originModel
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
@@ -483,8 +533,6 @@ func (a *Adaptor) GetModelList() []string {
|
||||
switch a.ChannelType {
|
||||
case constant.ChannelType360:
|
||||
return ai360.ModelList
|
||||
case constant.ChannelTypeMoonshot:
|
||||
return moonshot.ModelList
|
||||
case constant.ChannelTypeLingYiWanWu:
|
||||
return lingyiwanwu.ModelList
|
||||
case constant.ChannelTypeMiniMax:
|
||||
@@ -502,8 +550,6 @@ func (a *Adaptor) GetChannelName() string {
|
||||
switch a.ChannelType {
|
||||
case constant.ChannelType360:
|
||||
return ai360.ChannelName
|
||||
case constant.ChannelTypeMoonshot:
|
||||
return moonshot.ChannelName
|
||||
case constant.ChannelTypeLingYiWanWu:
|
||||
return lingyiwanwu.ChannelName
|
||||
case constant.ChannelTypeMiniMax:
|
||||
|
||||
@@ -18,6 +18,9 @@ var ModelList = []string{
|
||||
"o3-mini-high", "o3-mini-2025-01-31-high",
|
||||
"o3-mini-low", "o3-mini-2025-01-31-low",
|
||||
"o3-mini-medium", "o3-mini-2025-01-31-medium",
|
||||
"gpt-5", "gpt-5-2025-08-07", "gpt-5-chat-latest",
|
||||
"gpt-5-mini", "gpt-5-mini-2025-08-07",
|
||||
"gpt-5-nano", "gpt-5-nano-2025-08-07",
|
||||
"o1", "o1-2024-12-17",
|
||||
"gpt-4o-audio-preview", "gpt-4o-audio-preview-2024-10-01",
|
||||
"gpt-4o-realtime-preview", "gpt-4o-realtime-preview-2024-10-01", "gpt-4o-realtime-preview-2024-12-17",
|
||||
|
||||
@@ -3,6 +3,7 @@ package openai
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/samber/lo"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
@@ -186,7 +187,9 @@ func handleLastResponse(lastStreamData string, responseId *string, createAt *int
|
||||
*containStreamUsage = true
|
||||
*usage = lastStreamResponse.Usage
|
||||
if !info.ShouldIncludeUsage {
|
||||
*shouldSendLastResp = false
|
||||
*shouldSendLastResp = lo.SomeBy(lastStreamResponse.Choices, func(choice dto.ChatCompletionsStreamResponseChoice) bool {
|
||||
return choice.Delta.GetContentString() != "" || choice.Delta.GetReasoningContent() != ""
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,9 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("upstream response body:", string(responseBody))
|
||||
}
|
||||
err = common.Unmarshal(responseBody, &simpleResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
|
||||
@@ -28,10 +28,9 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
return nil, nil
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.ConvertClaudeRequest(c, info, req)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
@@ -196,6 +195,10 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
return fmt.Sprintf("%s/api/v3/embeddings", info.BaseUrl), nil
|
||||
case constant.RelayModeImagesGenerations:
|
||||
return fmt.Sprintf("%s/api/v3/images/generations", info.BaseUrl), nil
|
||||
case constant.RelayModeImagesEdits:
|
||||
return fmt.Sprintf("%s/api/v3/images/edits", info.BaseUrl), nil
|
||||
case constant.RelayModeRerank:
|
||||
return fmt.Sprintf("%s/api/v3/rerank", info.BaseUrl), nil
|
||||
default:
|
||||
}
|
||||
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
|
||||
@@ -232,18 +235,8 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeChatCompletions:
|
||||
if info.IsStream {
|
||||
usage, err = openai.OaiStreamHandler(c, info, resp)
|
||||
} else {
|
||||
usage, err = openai.OpenaiHandler(c, info, resp)
|
||||
}
|
||||
case constant.RelayModeEmbeddings:
|
||||
usage, err = openai.OpenaiHandler(c, info, resp)
|
||||
case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits:
|
||||
usage, err = openai.OpenaiHandlerWithUsage(c, info, resp)
|
||||
}
|
||||
adaptor := openai.Adaptor{}
|
||||
usage, err = adaptor.DoResponse(c, resp, info)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
channel.SetupApiRequestHeader(info, c, req)
|
||||
token := getZhipuToken(info.ApiKey)
|
||||
req.Set("Authorization", token)
|
||||
req.Set("Authorization", "Bearer "+info.ApiKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,69 +1,10 @@
|
||||
package zhipu_4v
|
||||
|
||||
import (
|
||||
"github.com/golang-jwt/jwt"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://open.bigmodel.cn/doc/api#chatglm_std
|
||||
// chatglm_std, chatglm_lite
|
||||
// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/invoke
|
||||
// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/sse-invoke
|
||||
|
||||
var zhipuTokens sync.Map
|
||||
var expSeconds int64 = 24 * 3600
|
||||
|
||||
func getZhipuToken(apikey string) string {
|
||||
data, ok := zhipuTokens.Load(apikey)
|
||||
if ok {
|
||||
tokenData := data.(tokenData)
|
||||
if time.Now().Before(tokenData.ExpiryTime) {
|
||||
return tokenData.Token
|
||||
}
|
||||
}
|
||||
|
||||
split := strings.Split(apikey, ".")
|
||||
if len(split) != 2 {
|
||||
common.SysError("invalid zhipu key: " + apikey)
|
||||
return ""
|
||||
}
|
||||
|
||||
id := split[0]
|
||||
secret := split[1]
|
||||
|
||||
expMillis := time.Now().Add(time.Duration(expSeconds)*time.Second).UnixNano() / 1e6
|
||||
expiryTime := time.Now().Add(time.Duration(expSeconds) * time.Second)
|
||||
|
||||
timestamp := time.Now().UnixNano() / 1e6
|
||||
|
||||
payload := jwt.MapClaims{
|
||||
"api_key": id,
|
||||
"exp": expMillis,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload)
|
||||
|
||||
token.Header["alg"] = "HS256"
|
||||
token.Header["sign_type"] = "SIGN"
|
||||
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
zhipuTokens.Store(apikey, tokenData{
|
||||
Token: tokenString,
|
||||
ExpiryTime: expiryTime,
|
||||
})
|
||||
|
||||
return tokenString
|
||||
}
|
||||
|
||||
func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest {
|
||||
messages := make([]dto.Message, 0, len(request.Messages))
|
||||
for _, message := range request.Messages {
|
||||
|
||||
@@ -201,6 +201,26 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
|
||||
Content: relayInfo.ChannelSetting.SystemPrompt,
|
||||
}
|
||||
request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
|
||||
} else if relayInfo.ChannelSetting.SystemPromptOverride {
|
||||
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
|
||||
// 如果有系统提示,且允许覆盖,则拼接到前面
|
||||
for i, message := range request.Messages {
|
||||
if message.Role == request.GetSystemRoleName() {
|
||||
if message.IsStringContent() {
|
||||
request.Messages[i].SetStringContent(relayInfo.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
|
||||
} else {
|
||||
contents := message.ParseContent()
|
||||
contents = append([]dto.MediaContent{
|
||||
{
|
||||
Type: dto.ContentTypeText,
|
||||
Text: relayInfo.ChannelSetting.SystemPrompt,
|
||||
},
|
||||
}, contents...)
|
||||
request.Messages[i].Content = contents
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"one-api/relay/channel/jina"
|
||||
"one-api/relay/channel/mistral"
|
||||
"one-api/relay/channel/mokaai"
|
||||
"one-api/relay/channel/moonshot"
|
||||
"one-api/relay/channel/ollama"
|
||||
"one-api/relay/channel/openai"
|
||||
"one-api/relay/channel/palm"
|
||||
@@ -98,6 +99,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
|
||||
return &coze.Adaptor{}
|
||||
case constant.APITypeJimeng:
|
||||
return &jimeng.Adaptor{}
|
||||
case constant.APITypeMoonshot:
|
||||
return &moonshot.Adaptor{} // Moonshot uses Claude API
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -179,6 +179,16 @@ func SetApiRouter(router *gin.Engine) {
|
||||
{
|
||||
groupRoute.GET("/", controller.GetGroups)
|
||||
}
|
||||
|
||||
prefillGroupRoute := apiRouter.Group("/prefill_group")
|
||||
prefillGroupRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
prefillGroupRoute.GET("/", controller.GetPrefillGroups)
|
||||
prefillGroupRoute.POST("/", controller.CreatePrefillGroup)
|
||||
prefillGroupRoute.PUT("/", controller.UpdatePrefillGroup)
|
||||
prefillGroupRoute.DELETE("/:id", controller.DeletePrefillGroup)
|
||||
}
|
||||
|
||||
mjRoute := apiRouter.Group("/mj")
|
||||
mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney)
|
||||
mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney)
|
||||
@@ -188,5 +198,28 @@ func SetApiRouter(router *gin.Engine) {
|
||||
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
|
||||
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
|
||||
}
|
||||
|
||||
vendorRoute := apiRouter.Group("/vendors")
|
||||
vendorRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
vendorRoute.GET("/", controller.GetAllVendors)
|
||||
vendorRoute.GET("/search", controller.SearchVendors)
|
||||
vendorRoute.GET("/:id", controller.GetVendorMeta)
|
||||
vendorRoute.POST("/", controller.CreateVendorMeta)
|
||||
vendorRoute.PUT("/", controller.UpdateVendorMeta)
|
||||
vendorRoute.DELETE("/:id", controller.DeleteVendorMeta)
|
||||
}
|
||||
|
||||
modelsRoute := apiRouter.Group("/models")
|
||||
modelsRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
modelsRoute.GET("/missing", controller.GetMissingModels)
|
||||
modelsRoute.GET("/", controller.GetAllModelsMeta)
|
||||
modelsRoute.GET("/search", controller.SearchModelsMeta)
|
||||
modelsRoute.GET("/:id", controller.GetModelMeta)
|
||||
modelsRoute.POST("/", controller.CreateModelMeta)
|
||||
modelsRoute.PUT("/", controller.UpdateModelMeta)
|
||||
modelsRoute.DELETE("/:id", controller.DeleteModelMeta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-14
@@ -153,9 +153,13 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
case "tool_result":
|
||||
// Add tool result as a separate message
|
||||
toolName := mediaMsg.Name
|
||||
if toolName == "" {
|
||||
toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId)
|
||||
}
|
||||
oaiToolMessage := dto.Message{
|
||||
Role: "tool",
|
||||
Name: &mediaMsg.Name,
|
||||
Name: &toolName,
|
||||
ToolCallId: mediaMsg.ToolUseId,
|
||||
}
|
||||
//oaiToolMessage.SetStringContent(*mediaMsg.GetMediaContent().Text)
|
||||
@@ -218,12 +222,14 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
// Type: "ping",
|
||||
//})
|
||||
if openAIResponse.IsToolCall() {
|
||||
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools
|
||||
resp := &dto.ClaudeResponse{
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Id: openAIResponse.GetFirstToolCall().ID,
|
||||
Type: "tool_use",
|
||||
Name: openAIResponse.GetFirstToolCall().Function.Name,
|
||||
Id: openAIResponse.GetFirstToolCall().ID,
|
||||
Type: "tool_use",
|
||||
Name: openAIResponse.GetFirstToolCall().Function.Name,
|
||||
Input: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
resp.SetIndex(0)
|
||||
@@ -401,22 +407,26 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco
|
||||
}
|
||||
for _, choice := range openAIResponse.Choices {
|
||||
stopReason = stopReasonOpenAI2Claude(choice.FinishReason)
|
||||
claudeContent := dto.ClaudeMediaMessage{}
|
||||
if choice.FinishReason == "tool_calls" {
|
||||
claudeContent.Type = "tool_use"
|
||||
claudeContent.Id = choice.Message.ToolCallId
|
||||
claudeContent.Name = choice.Message.ParseToolCalls()[0].Function.Name
|
||||
var mapParams map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(choice.Message.ParseToolCalls()[0].Function.Arguments), &mapParams); err == nil {
|
||||
claudeContent.Input = mapParams
|
||||
} else {
|
||||
claudeContent.Input = choice.Message.ParseToolCalls()[0].Function.Arguments
|
||||
for _, toolUse := range choice.Message.ParseToolCalls() {
|
||||
claudeContent := dto.ClaudeMediaMessage{}
|
||||
claudeContent.Type = "tool_use"
|
||||
claudeContent.Id = toolUse.ID
|
||||
claudeContent.Name = toolUse.Function.Name
|
||||
var mapParams map[string]interface{}
|
||||
if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &mapParams); err == nil {
|
||||
claudeContent.Input = mapParams
|
||||
} else {
|
||||
claudeContent.Input = toolUse.Function.Arguments
|
||||
}
|
||||
contents = append(contents, claudeContent)
|
||||
}
|
||||
} else {
|
||||
claudeContent := dto.ClaudeMediaMessage{}
|
||||
claudeContent.Type = "text"
|
||||
claudeContent.SetText(choice.Message.StringContent())
|
||||
contents = append(contents, claudeContent)
|
||||
}
|
||||
contents = append(contents, claudeContent)
|
||||
}
|
||||
claudeResponse.Content = contents
|
||||
claudeResponse.StopReason = stopReason
|
||||
|
||||
@@ -28,6 +28,12 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
|
||||
other["is_model_mapped"] = true
|
||||
other["upstream_model_name"] = relayInfo.UpstreamModelName
|
||||
}
|
||||
|
||||
isSystemPromptOverwritten := common.GetContextKeyBool(ctx, constant.ContextKeySystemPromptOverride)
|
||||
if isSystemPromptOverwritten {
|
||||
other["is_system_prompt_overwritten"] = true
|
||||
}
|
||||
|
||||
adminInfo := make(map[string]interface{})
|
||||
adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
|
||||
isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey)
|
||||
|
||||
@@ -25,6 +25,16 @@ var defaultCacheRatio = map[string]float64{
|
||||
"gpt-4o-mini-realtime-preview": 0.5,
|
||||
"gpt-4.5-preview": 0.5,
|
||||
"gpt-4.5-preview-2025-02-27": 0.5,
|
||||
"gpt-4.1": 0.25,
|
||||
"gpt-4.1-mini": 0.25,
|
||||
"gpt-4.1-nano": 0.25,
|
||||
"gpt-5": 0.1,
|
||||
"gpt-5-2025-08-07": 0.1,
|
||||
"gpt-5-chat-latest": 0.1,
|
||||
"gpt-5-mini": 0.1,
|
||||
"gpt-5-mini-2025-08-07": 0.1,
|
||||
"gpt-5-nano": 0.1,
|
||||
"gpt-5-nano-2025-08-07": 0.1,
|
||||
"deepseek-chat": 0.25,
|
||||
"deepseek-reasoner": 0.25,
|
||||
"deepseek-coder": 0.25,
|
||||
|
||||
@@ -73,6 +73,13 @@ var defaultModelRatio = map[string]float64{
|
||||
"gpt-4-turbo-2024-04-09": 5, // $0.01 / 1K tokens
|
||||
"gpt-4.5-preview": 37.5,
|
||||
"gpt-4.5-preview-2025-02-27": 37.5,
|
||||
"gpt-5": 0.625,
|
||||
"gpt-5-2025-08-07": 0.625,
|
||||
"gpt-5-chat-latest": 0.625,
|
||||
"gpt-5-mini": 0.125,
|
||||
"gpt-5-mini-2025-08-07": 0.125,
|
||||
"gpt-5-nano": 0.025,
|
||||
"gpt-5-nano-2025-08-07": 0.025,
|
||||
//"gpt-3.5-turbo-0301": 0.75, //deprecated
|
||||
"gpt-3.5-turbo": 0.25,
|
||||
"gpt-3.5-turbo-0613": 0.75,
|
||||
@@ -150,6 +157,7 @@ var defaultModelRatio = map[string]float64{
|
||||
"gemini-2.5-flash-preview-05-20-nothinking": 0.075,
|
||||
"gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率
|
||||
"gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率
|
||||
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
|
||||
"gemini-2.5-flash-lite-preview-06-17": 0.05,
|
||||
"gemini-2.5-flash": 0.15,
|
||||
"text-embedding-004": 0.001,
|
||||
@@ -449,6 +457,10 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
}
|
||||
return 4, true
|
||||
}
|
||||
// gpt-5 匹配
|
||||
if strings.HasPrefix(name, "gpt-5") {
|
||||
return 8, true
|
||||
}
|
||||
// gpt-4.5-preview匹配
|
||||
if strings.HasPrefix(name, "gpt-4.5-preview") {
|
||||
return 2, true
|
||||
@@ -503,9 +515,6 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
return 3.5 / 0.15, false
|
||||
}
|
||||
if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
|
||||
if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") {
|
||||
return 4, false
|
||||
}
|
||||
return 4, false
|
||||
}
|
||||
return 2.5 / 0.3, true
|
||||
@@ -657,8 +666,15 @@ func GetCompletionRatioCopy() map[string]float64 {
|
||||
|
||||
// 转换模型名,减少渠道必须配置各种带参数模型
|
||||
func FormatMatchingModelName(name string) string {
|
||||
name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*")
|
||||
name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*")
|
||||
|
||||
if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
|
||||
name = handleThinkingBudgetModel(name, "gemini-2.5-flash-lite", "gemini-2.5-flash-lite-thinking-*")
|
||||
} else if strings.HasPrefix(name, "gemini-2.5-flash") {
|
||||
name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*")
|
||||
} else if strings.HasPrefix(name, "gemini-2.5-pro") {
|
||||
name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "gpt-4-gizmo") {
|
||||
name = "gpt-4-gizmo-*"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import Chat2Link from './pages/Chat2Link';
|
||||
import Midjourney from './pages/Midjourney';
|
||||
import Pricing from './pages/Pricing/index.js';
|
||||
import Task from './pages/Task/index.js';
|
||||
import ModelPage from './pages/Model/index.js';
|
||||
import Playground from './pages/Playground/index.js';
|
||||
import OAuth2Callback from './components/auth/OAuth2Callback.js';
|
||||
import PersonalSetting from './components/settings/PersonalSetting.js';
|
||||
@@ -71,6 +72,14 @@ function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/models'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ModelPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/channel'
|
||||
element={
|
||||
|
||||
@@ -1,622 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Space,
|
||||
Button,
|
||||
Form,
|
||||
Card,
|
||||
Typography,
|
||||
Banner,
|
||||
Row,
|
||||
Col,
|
||||
InputNumber,
|
||||
Switch,
|
||||
Select,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCode,
|
||||
IconEdit,
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconSetting,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const JSONEditor = ({
|
||||
value = '',
|
||||
onChange,
|
||||
field,
|
||||
label,
|
||||
placeholder,
|
||||
extraText,
|
||||
showClear = true,
|
||||
template,
|
||||
templateLabel,
|
||||
editorType = 'keyValue', // keyValue, object, region
|
||||
autosize = true,
|
||||
rules = [],
|
||||
formApi = null,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 初始化JSON数据
|
||||
const [jsonData, setJsonData] = useState(() => {
|
||||
// 初始化时解析JSON数据
|
||||
if (value && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// 根据键数量决定默认编辑模式
|
||||
const [editMode, setEditMode] = useState(() => {
|
||||
// 如果初始JSON数据的键数量大于10个,则默认使用手动模式
|
||||
if (value && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const keyCount = Object.keys(parsed).length;
|
||||
return keyCount > 10 ? 'manual' : 'visual';
|
||||
} catch (error) {
|
||||
// JSON无效时默认显示手动编辑模式
|
||||
return 'manual';
|
||||
}
|
||||
}
|
||||
return 'visual';
|
||||
});
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
|
||||
// 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const parsed = value && value.trim() ? JSON.parse(value) : {};
|
||||
setJsonData(parsed);
|
||||
setJsonError('');
|
||||
} catch (error) {
|
||||
console.log('JSON解析失败:', error.message);
|
||||
setJsonError(error.message);
|
||||
// JSON格式错误时不更新jsonData
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
|
||||
// 处理可视化编辑的数据变化
|
||||
const handleVisualChange = useCallback((newData) => {
|
||||
setJsonData(newData);
|
||||
setJsonError('');
|
||||
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
|
||||
|
||||
// 通过formApi设置值(如果提供的话)
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, jsonString);
|
||||
}
|
||||
|
||||
onChange?.(jsonString);
|
||||
}, [onChange, formApi, field]);
|
||||
|
||||
// 处理手动编辑的数据变化
|
||||
const handleManualChange = useCallback((newValue) => {
|
||||
onChange?.(newValue);
|
||||
// 验证JSON格式
|
||||
if (newValue && newValue.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(newValue);
|
||||
setJsonError('');
|
||||
// 预先准备可视化数据,但不立即应用
|
||||
// 这样切换到可视化模式时数据已经准备好了
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
}
|
||||
} else {
|
||||
setJsonError('');
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
// 切换编辑模式
|
||||
const toggleEditMode = useCallback(() => {
|
||||
if (editMode === 'visual') {
|
||||
// 从可视化模式切换到手动模式
|
||||
setEditMode('manual');
|
||||
} else {
|
||||
// 从手动模式切换到可视化模式,需要验证JSON
|
||||
try {
|
||||
const parsed = value && value.trim() ? JSON.parse(value) : {};
|
||||
setJsonData(parsed);
|
||||
setJsonError('');
|
||||
setEditMode('visual');
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
// JSON格式错误时不切换模式
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [editMode, value]);
|
||||
|
||||
// 添加键值对
|
||||
const addKeyValue = useCallback(() => {
|
||||
const newData = { ...jsonData };
|
||||
const keys = Object.keys(newData);
|
||||
let newKey = 'key';
|
||||
let counter = 1;
|
||||
while (newData.hasOwnProperty(newKey)) {
|
||||
newKey = `key${counter}`;
|
||||
counter++;
|
||||
}
|
||||
newData[newKey] = '';
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 删除键值对
|
||||
const removeKeyValue = useCallback((keyToRemove) => {
|
||||
const newData = { ...jsonData };
|
||||
delete newData[keyToRemove];
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 更新键名
|
||||
const updateKey = useCallback((oldKey, newKey) => {
|
||||
if (oldKey === newKey) return;
|
||||
const newData = { ...jsonData };
|
||||
const value = newData[oldKey];
|
||||
delete newData[oldKey];
|
||||
newData[newKey] = value;
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 更新值
|
||||
const updateValue = useCallback((key, newValue) => {
|
||||
const newData = { ...jsonData };
|
||||
newData[key] = newValue;
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 填入模板
|
||||
const fillTemplate = useCallback(() => {
|
||||
if (template) {
|
||||
const templateString = JSON.stringify(template, null, 2);
|
||||
|
||||
// 通过formApi设置值(如果提供的话)
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, templateString);
|
||||
}
|
||||
|
||||
// 无论哪种模式都要更新值
|
||||
onChange?.(templateString);
|
||||
|
||||
// 如果是可视化模式,同时更新jsonData
|
||||
if (editMode === 'visual') {
|
||||
setJsonData(template);
|
||||
}
|
||||
|
||||
// 清除错误状态
|
||||
setJsonError('');
|
||||
}
|
||||
}, [template, onChange, editMode, formApi, field]);
|
||||
|
||||
// 渲染键值对编辑器
|
||||
const renderKeyValueEditor = () => {
|
||||
if (typeof jsonData !== 'object' || jsonData === null) {
|
||||
return (
|
||||
<div className="text-center py-6 px-4">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<IconCode size={32} />
|
||||
</div>
|
||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||
{t('无效的JSON数据,请检查格式')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const entries = Object.entries(jsonData);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{entries.length === 0 && (
|
||||
<div className="text-center py-6 px-4">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<IconCode size={32} />
|
||||
</div>
|
||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||
{t('暂无数据,点击下方按钮添加键值对')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.map(([key, value], index) => (
|
||||
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
|
||||
<Row gutter={12} align="middle">
|
||||
<Col span={10}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('键名')}</Text>
|
||||
<Input
|
||||
placeholder={t('键名')}
|
||||
value={key}
|
||||
onChange={(newKey) => updateKey(key, newKey)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('值')}</Text>
|
||||
<Input
|
||||
placeholder={t('值')}
|
||||
value={value}
|
||||
onChange={(newValue) => updateValue(key, newValue)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={() => removeKeyValue(key)}
|
||||
className="hover:bg-red-50"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={addKeyValue}
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="shadow-sm hover:shadow-md transition-shadow px-4"
|
||||
>
|
||||
{t('添加键值对')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染对象编辑器(用于复杂JSON)
|
||||
const renderObjectEditor = () => {
|
||||
const entries = Object.entries(jsonData);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{entries.length === 0 && (
|
||||
<div className="text-center py-6 px-4">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<IconSetting size={32} />
|
||||
</div>
|
||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||
{t('暂无参数,点击下方按钮添加请求参数')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.map(([key, value], index) => (
|
||||
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
|
||||
<Row gutter={12} align="middle">
|
||||
<Col span={8}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('参数名')}</Text>
|
||||
<Input
|
||||
placeholder={t('参数名')}
|
||||
value={key}
|
||||
onChange={(newKey) => updateKey(key, newKey)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={13}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('参数值')} ({typeof value})</Text>
|
||||
{renderValueInput(key, value)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={() => removeKeyValue(key)}
|
||||
className="hover:bg-red-50"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={addKeyValue}
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="shadow-sm hover:shadow-md transition-shadow px-4"
|
||||
>
|
||||
{t('添加参数')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染参数值输入控件
|
||||
const renderValueInput = (key, value) => {
|
||||
const valueType = typeof value;
|
||||
|
||||
if (valueType === 'boolean') {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={(newValue) => updateValue(key, newValue)}
|
||||
size="small"
|
||||
/>
|
||||
<Text type="tertiary" size="small" className="ml-2">
|
||||
{value ? t('true') : t('false')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'number') {
|
||||
return (
|
||||
<InputNumber
|
||||
value={value}
|
||||
onChange={(newValue) => updateValue(key, newValue)}
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
step={key === 'temperature' ? 0.1 : 1}
|
||||
precision={key === 'temperature' ? 2 : 0}
|
||||
placeholder={t('输入数字')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 字符串类型或其他类型
|
||||
return (
|
||||
<Input
|
||||
placeholder={t('参数值')}
|
||||
value={String(value)}
|
||||
onChange={(newValue) => {
|
||||
// 尝试转换为适当的类型
|
||||
let convertedValue = newValue;
|
||||
if (newValue === 'true') convertedValue = true;
|
||||
else if (newValue === 'false') convertedValue = false;
|
||||
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
|
||||
convertedValue = Number(newValue);
|
||||
}
|
||||
|
||||
updateValue(key, convertedValue);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染区域编辑器(特殊格式)
|
||||
const renderRegionEditor = () => {
|
||||
const entries = Object.entries(jsonData);
|
||||
const defaultEntry = entries.find(([key]) => key === 'default');
|
||||
const modelEntries = entries.filter(([key]) => key !== 'default');
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* 默认区域 */}
|
||||
<Card className="!p-2 !border-blue-200 !bg-blue-50">
|
||||
<div className="flex items-center mb-1">
|
||||
<Text strong size="small" className="text-blue-700">{t('默认区域')}</Text>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('默认区域,如: us-central1')}
|
||||
value={defaultEntry ? defaultEntry[1] : ''}
|
||||
onChange={(value) => updateValue('default', value)}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 模型专用区域 */}
|
||||
<div className="space-y-1">
|
||||
<Text strong size="small">{t('模型专用区域')}</Text>
|
||||
{modelEntries.map(([modelName, region], index) => (
|
||||
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
|
||||
<Row gutter={12} align="middle">
|
||||
<Col span={10}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('模型名称')}</Text>
|
||||
<Input
|
||||
placeholder={t('模型名称')}
|
||||
value={modelName}
|
||||
onChange={(newKey) => updateKey(modelName, newKey)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('区域')}</Text>
|
||||
<Input
|
||||
placeholder={t('区域')}
|
||||
value={region}
|
||||
onChange={(newValue) => updateValue(modelName, newValue)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={() => removeKeyValue(modelName)}
|
||||
className="hover:bg-red-50"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={addKeyValue}
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="shadow-sm hover:shadow-md transition-shadow px-4"
|
||||
>
|
||||
{t('添加模型区域')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染可视化编辑器
|
||||
const renderVisualEditor = () => {
|
||||
switch (editorType) {
|
||||
case 'region':
|
||||
return renderRegionEditor();
|
||||
case 'object':
|
||||
return renderObjectEditor();
|
||||
case 'keyValue':
|
||||
default:
|
||||
return renderKeyValueEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const hasJsonError = jsonError && jsonError.trim() !== '';
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* Label统一显示在上方 */}
|
||||
{label && (
|
||||
<div className="flex items-center">
|
||||
<Text className="text-sm font-medium text-gray-900">{label}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑模式切换 */}
|
||||
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
{editMode === 'visual' && (
|
||||
<Text type="tertiary" size="small" className="bg-blue-100 text-blue-700 px-2 py-0.5 rounded text-xs">
|
||||
{t('可视化模式')}
|
||||
</Text>
|
||||
)}
|
||||
{editMode === 'manual' && (
|
||||
<Text type="tertiary" size="small" className="bg-green-100 text-green-700 px-2 py-0.5 rounded text-xs">
|
||||
{t('手动编辑模式')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{template && templateLabel && (
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
onClick={fillTemplate}
|
||||
className="!text-semi-color-primary hover:bg-blue-50 text-xs"
|
||||
>
|
||||
{templateLabel}
|
||||
</Button>
|
||||
)}
|
||||
<Space size="tight">
|
||||
<Button
|
||||
size="small"
|
||||
type={editMode === 'visual' ? 'primary' : 'tertiary'}
|
||||
icon={<IconEdit />}
|
||||
onClick={toggleEditMode}
|
||||
disabled={editMode === 'manual' && hasJsonError}
|
||||
className={editMode === 'visual' ? 'shadow-sm' : ''}
|
||||
>
|
||||
{t('可视化')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type={editMode === 'manual' ? 'primary' : 'tertiary'}
|
||||
icon={<IconCode />}
|
||||
onClick={toggleEditMode}
|
||||
className={editMode === 'manual' ? 'shadow-sm' : ''}
|
||||
>
|
||||
{t('手动编辑')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON错误提示 */}
|
||||
{hasJsonError && (
|
||||
<Banner
|
||||
type="danger"
|
||||
description={`JSON 格式错误: ${jsonError}`}
|
||||
className="!rounded-md text-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑器内容 */}
|
||||
{editMode === 'visual' ? (
|
||||
<div>
|
||||
<Card className="!p-3 !border-gray-200 !shadow-sm !rounded-md bg-white">
|
||||
{renderVisualEditor()}
|
||||
</Card>
|
||||
{/* 可视化模式下的额外文本显示在下方 */}
|
||||
{extraText && (
|
||||
<div className="text-xs text-gray-600 mt-0.5">
|
||||
{extraText}
|
||||
</div>
|
||||
)}
|
||||
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
||||
<Form.Input
|
||||
field={field}
|
||||
value={value}
|
||||
rules={rules}
|
||||
style={{ display: 'none' }}
|
||||
noLabel={true}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Form.TextArea
|
||||
field={field}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleManualChange}
|
||||
showClear={showClear}
|
||||
rows={Math.max(8, value ? value.split('\n').length : 8)}
|
||||
rules={rules}
|
||||
noLabel={true}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 额外文本在手动编辑模式下显示 */}
|
||||
{extraText && editMode === 'manual' && (
|
||||
<div className="text-xs text-gray-600">
|
||||
{extraText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONEditor;
|
||||
@@ -112,6 +112,7 @@ const CardPro = ({
|
||||
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='outline'
|
||||
block
|
||||
>
|
||||
{showMobileActions ? t('隐藏操作项') : t('显示操作项')}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
||||
|
||||
/**
|
||||
* CardTable 响应式表格组件
|
||||
@@ -40,25 +41,8 @@ const CardTable = ({
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [showSkeleton, setShowSkeleton] = useState(loading);
|
||||
const loadingStartRef = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
loadingStartRef.current = Date.now();
|
||||
setShowSkeleton(true);
|
||||
} else {
|
||||
const elapsed = Date.now() - loadingStartRef.current;
|
||||
const remaining = Math.max(0, 500 - elapsed);
|
||||
if (remaining === 0) {
|
||||
setShowSkeleton(false);
|
||||
} else {
|
||||
const timer = setTimeout(() => setShowSkeleton(false), remaining);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
|
||||
const getRowKey = (record, index) => {
|
||||
if (typeof rowKey === 'function') return rowKey(record);
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Typography,
|
||||
Banner,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Card,
|
||||
Input,
|
||||
InputNumber,
|
||||
Switch,
|
||||
TextArea,
|
||||
Row,
|
||||
Col,
|
||||
Divider,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCode,
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconRefresh,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const JSONEditor = ({
|
||||
value = '',
|
||||
onChange,
|
||||
field,
|
||||
label,
|
||||
placeholder,
|
||||
extraText,
|
||||
extraFooter,
|
||||
showClear = true,
|
||||
template,
|
||||
templateLabel,
|
||||
editorType = 'keyValue',
|
||||
rules = [],
|
||||
formApi = null,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 初始化JSON数据
|
||||
const [jsonData, setJsonData] = useState(() => {
|
||||
// 初始化时解析JSON数据
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// 手动模式下的本地文本缓冲,避免无效 JSON 时被外部值重置
|
||||
const [manualText, setManualText] = useState(() => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return '';
|
||||
});
|
||||
|
||||
// 根据键数量决定默认编辑模式
|
||||
const [editMode, setEditMode] = useState(() => {
|
||||
// 如果初始JSON数据的键数量大于10个,则默认使用手动模式
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const keyCount = Object.keys(parsed).length;
|
||||
return keyCount > 10 ? 'manual' : 'visual';
|
||||
} catch (error) {
|
||||
// JSON无效时默认显示手动编辑模式
|
||||
return 'manual';
|
||||
}
|
||||
}
|
||||
return 'visual';
|
||||
});
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
|
||||
// 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
|
||||
useEffect(() => {
|
||||
try {
|
||||
let parsed = {};
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
parsed = JSON.parse(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
parsed = value;
|
||||
}
|
||||
setJsonData(parsed);
|
||||
setJsonError('');
|
||||
} catch (error) {
|
||||
console.log('JSON解析失败:', error.message);
|
||||
setJsonError(error.message);
|
||||
// JSON格式错误时不更新jsonData
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 外部 value 变化时,若不在手动模式,则同步手动文本;在手动模式下不打断用户输入
|
||||
useEffect(() => {
|
||||
if (editMode !== 'manual') {
|
||||
if (typeof value === 'string') setManualText(value);
|
||||
else if (value && typeof value === 'object') setManualText(JSON.stringify(value, null, 2));
|
||||
else setManualText('');
|
||||
}
|
||||
}, [value, editMode]);
|
||||
|
||||
// 处理可视化编辑的数据变化
|
||||
const handleVisualChange = useCallback((newData) => {
|
||||
setJsonData(newData);
|
||||
setJsonError('');
|
||||
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
|
||||
|
||||
// 通过formApi设置值(如果提供的话)
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, jsonString);
|
||||
}
|
||||
|
||||
onChange?.(jsonString);
|
||||
}, [onChange, formApi, field]);
|
||||
|
||||
// 处理手动编辑的数据变化(无效 JSON 不阻断输入,也不立刻回传上游)
|
||||
const handleManualChange = useCallback((newValue) => {
|
||||
setManualText(newValue);
|
||||
if (newValue && newValue.trim()) {
|
||||
try {
|
||||
JSON.parse(newValue);
|
||||
setJsonError('');
|
||||
onChange?.(newValue);
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
// 无效 JSON 时不回传,避免外部值把输入重置
|
||||
}
|
||||
} else {
|
||||
setJsonError('');
|
||||
onChange?.('');
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
// 切换编辑模式
|
||||
const toggleEditMode = useCallback(() => {
|
||||
if (editMode === 'visual') {
|
||||
// 从可视化模式切换到手动模式
|
||||
setManualText(Object.keys(jsonData).length === 0 ? '' : JSON.stringify(jsonData, null, 2));
|
||||
setEditMode('manual');
|
||||
} else {
|
||||
// 从手动模式切换到可视化模式,需要验证JSON
|
||||
try {
|
||||
let parsed = {};
|
||||
if (manualText && manualText.trim()) {
|
||||
parsed = JSON.parse(manualText);
|
||||
} else if (typeof value === 'string' && value.trim()) {
|
||||
parsed = JSON.parse(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
parsed = value;
|
||||
}
|
||||
setJsonData(parsed);
|
||||
setJsonError('');
|
||||
setEditMode('visual');
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
// JSON格式错误时不切换模式
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [editMode, value, manualText, jsonData]);
|
||||
|
||||
// 添加键值对
|
||||
const addKeyValue = useCallback(() => {
|
||||
const newData = { ...jsonData };
|
||||
const keys = Object.keys(newData);
|
||||
let counter = 1;
|
||||
let newKey = `field_${counter}`;
|
||||
while (newData.hasOwnProperty(newKey)) {
|
||||
counter += 1;
|
||||
newKey = `field_${counter}`;
|
||||
}
|
||||
newData[newKey] = '';
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 删除键值对
|
||||
const removeKeyValue = useCallback((keyToRemove) => {
|
||||
const newData = { ...jsonData };
|
||||
delete newData[keyToRemove];
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 更新键名
|
||||
const updateKey = useCallback((oldKey, newKey) => {
|
||||
if (oldKey === newKey || !newKey) return;
|
||||
const newData = {};
|
||||
Object.entries(jsonData).forEach(([k, v]) => {
|
||||
if (k === oldKey) {
|
||||
newData[newKey] = v;
|
||||
} else {
|
||||
newData[k] = v;
|
||||
}
|
||||
});
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 更新值
|
||||
const updateValue = useCallback((key, newValue) => {
|
||||
const newData = { ...jsonData };
|
||||
newData[key] = newValue;
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 填入模板
|
||||
const fillTemplate = useCallback(() => {
|
||||
if (template) {
|
||||
const templateString = JSON.stringify(template, null, 2);
|
||||
|
||||
// 通过formApi设置值(如果提供的话)
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, templateString);
|
||||
}
|
||||
|
||||
// 同步内部与外部值,避免出现杂字符
|
||||
setManualText(templateString);
|
||||
setJsonData(template);
|
||||
onChange?.(templateString);
|
||||
|
||||
// 清除错误状态
|
||||
setJsonError('');
|
||||
}
|
||||
}, [template, onChange, editMode, formApi, field]);
|
||||
|
||||
// 渲染键值对编辑器
|
||||
const renderKeyValueEditor = () => {
|
||||
if (typeof jsonData !== 'object' || jsonData === null) {
|
||||
return (
|
||||
<div className="text-center py-6 px-4">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<IconCode size={32} />
|
||||
</div>
|
||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||
{t('无效的JSON数据,请检查格式')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const entries = Object.entries(jsonData);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{entries.length === 0 && (
|
||||
<div className="text-center py-6 px-4">
|
||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||
{t('暂无数据,点击下方按钮添加键值对')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.map(([key, value], index) => (
|
||||
<Row key={index} gutter={8} align="middle">
|
||||
<Col span={6}>
|
||||
<Input
|
||||
placeholder={t('键名')}
|
||||
value={key}
|
||||
onChange={(newKey) => updateKey(key, newKey)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
{renderValueInput(key, value)}
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
onClick={() => removeKeyValue(key)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<div className="mt-2 flex justify-center">
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
type="primary"
|
||||
theme="outline"
|
||||
onClick={addKeyValue}
|
||||
>
|
||||
{t('添加键值对')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 添加嵌套对象
|
||||
const flattenObject = useCallback((parentKey) => {
|
||||
const newData = { ...jsonData };
|
||||
let primitive = '';
|
||||
const obj = newData[parentKey];
|
||||
if (obj && typeof obj === 'object') {
|
||||
const firstKey = Object.keys(obj)[0];
|
||||
if (firstKey !== undefined) {
|
||||
const firstVal = obj[firstKey];
|
||||
if (typeof firstVal !== 'object') primitive = firstVal;
|
||||
}
|
||||
}
|
||||
newData[parentKey] = primitive;
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
const addNestedObject = useCallback((parentKey) => {
|
||||
const newData = { ...jsonData };
|
||||
if (typeof newData[parentKey] !== 'object' || newData[parentKey] === null) {
|
||||
newData[parentKey] = {};
|
||||
}
|
||||
const existingKeys = Object.keys(newData[parentKey]);
|
||||
let counter = 1;
|
||||
let newKey = `field_${counter}`;
|
||||
while (newData[parentKey].hasOwnProperty(newKey)) {
|
||||
counter += 1;
|
||||
newKey = `field_${counter}`;
|
||||
}
|
||||
newData[parentKey][newKey] = '';
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 渲染参数值输入控件(支持嵌套)
|
||||
const renderValueInput = (key, value) => {
|
||||
const valueType = typeof value;
|
||||
|
||||
if (valueType === 'boolean') {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={(newValue) => updateValue(key, newValue)}
|
||||
/>
|
||||
<Text type="tertiary" className="ml-2">
|
||||
{value ? t('true') : t('false')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'number') {
|
||||
return (
|
||||
<InputNumber
|
||||
value={value}
|
||||
onChange={(newValue) => updateValue(key, newValue)}
|
||||
style={{ width: '100%' }}
|
||||
step={key === 'temperature' ? 0.1 : 1}
|
||||
precision={key === 'temperature' ? 2 : 0}
|
||||
placeholder={t('输入数字')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'object' && value !== null) {
|
||||
// 渲染嵌套对象
|
||||
const entries = Object.entries(value);
|
||||
return (
|
||||
<Card className="!rounded-2xl">
|
||||
{entries.length === 0 && (
|
||||
<Text type="tertiary" className="text-gray-500 text-xs">
|
||||
{t('空对象,点击下方加号添加字段')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{entries.map(([nestedKey, nestedValue], index) => (
|
||||
<Row key={index} gutter={4} align="middle" className="mb-1">
|
||||
<Col span={8}>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder={t('键名')}
|
||||
value={nestedKey}
|
||||
onChange={(newKey) => {
|
||||
const newData = { ...jsonData };
|
||||
const oldValue = newData[key][nestedKey];
|
||||
delete newData[key][nestedKey];
|
||||
newData[key][newKey] = oldValue;
|
||||
handleVisualChange(newData);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={14}>
|
||||
{typeof nestedValue === 'object' && nestedValue !== null ? (
|
||||
<TextArea
|
||||
size="small"
|
||||
rows={2}
|
||||
value={JSON.stringify(nestedValue, null, 2)}
|
||||
onChange={(txt) => {
|
||||
try {
|
||||
const obj = txt.trim() ? JSON.parse(txt) : {};
|
||||
const newData = { ...jsonData };
|
||||
newData[key][nestedKey] = obj;
|
||||
handleVisualChange(newData);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder={t('值')}
|
||||
value={String(nestedValue)}
|
||||
onChange={(newValue) => {
|
||||
const newData = { ...jsonData };
|
||||
let convertedValue = newValue;
|
||||
if (newValue === 'true') convertedValue = true;
|
||||
else if (newValue === 'false') convertedValue = false;
|
||||
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
|
||||
convertedValue = Number(newValue);
|
||||
}
|
||||
newData[key][nestedKey] = convertedValue;
|
||||
handleVisualChange(newData);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
onClick={() => {
|
||||
const newData = { ...jsonData };
|
||||
delete newData[key][nestedKey];
|
||||
handleVisualChange(newData);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<div className="flex justify-center mt-1 gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconPlus />}
|
||||
type="tertiary"
|
||||
onClick={() => addNestedObject(key)}
|
||||
>
|
||||
{t('添加字段')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconRefresh />}
|
||||
type="tertiary"
|
||||
onClick={() => flattenObject(key)}
|
||||
>
|
||||
{t('转换为值')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 字符串或其他原始类型
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
placeholder={t('参数值')}
|
||||
value={String(value)}
|
||||
onChange={(newValue) => {
|
||||
let convertedValue = newValue;
|
||||
if (newValue === 'true') convertedValue = true;
|
||||
else if (newValue === 'false') convertedValue = false;
|
||||
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
|
||||
convertedValue = Number(newValue);
|
||||
}
|
||||
updateValue(key, convertedValue);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
type="tertiary"
|
||||
onClick={() => {
|
||||
// 将当前值转换为对象
|
||||
const newData = { ...jsonData };
|
||||
newData[key] = { '1': value };
|
||||
handleVisualChange(newData);
|
||||
}}
|
||||
title={t('转换为对象')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染区域编辑器(特殊格式)
|
||||
const renderRegionEditor = () => {
|
||||
const entries = Object.entries(jsonData);
|
||||
const defaultEntry = entries.find(([key]) => key === 'default');
|
||||
const modelEntries = entries.filter(([key]) => key !== 'default');
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 默认区域 */}
|
||||
<Form.Slot label={t('默认区域')}>
|
||||
<Input
|
||||
placeholder={t('默认区域,如: us-central1')}
|
||||
value={defaultEntry ? defaultEntry[1] : ''}
|
||||
onChange={(value) => updateValue('default', value)}
|
||||
/>
|
||||
</Form.Slot>
|
||||
|
||||
{/* 模型专用区域 */}
|
||||
<Form.Slot label={t('模型专用区域')}>
|
||||
<div>
|
||||
{modelEntries.map(([modelName, region], index) => (
|
||||
<Row key={index} gutter={8} align="middle" className="mb-2">
|
||||
<Col span={10}>
|
||||
<Input
|
||||
placeholder={t('模型名称')}
|
||||
value={modelName}
|
||||
onChange={(newKey) => updateKey(modelName, newKey)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Input
|
||||
placeholder={t('区域')}
|
||||
value={region}
|
||||
onChange={(newValue) => updateValue(modelName, newValue)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
onClick={() => removeKeyValue(modelName)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<div className="mt-2 flex justify-center">
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={addKeyValue}
|
||||
type="primary"
|
||||
theme="outline"
|
||||
>
|
||||
{t('添加模型区域')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Slot>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染可视化编辑器
|
||||
const renderVisualEditor = () => {
|
||||
switch (editorType) {
|
||||
case 'region':
|
||||
return renderRegionEditor();
|
||||
case 'object':
|
||||
case 'keyValue':
|
||||
default:
|
||||
return renderKeyValueEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const hasJsonError = jsonError && jsonError.trim() !== '';
|
||||
|
||||
return (
|
||||
<Form.Slot label={label}>
|
||||
<Card
|
||||
header={
|
||||
<div className="flex justify-between items-center">
|
||||
<Tabs
|
||||
type="slash"
|
||||
activeKey={editMode}
|
||||
onChange={(key) => {
|
||||
if (key === 'manual' && editMode === 'visual') {
|
||||
setEditMode('manual');
|
||||
} else if (key === 'visual' && editMode === 'manual') {
|
||||
toggleEditMode();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabPane tab={t('可视化')} itemKey="visual" />
|
||||
<TabPane tab={t('手动编辑')} itemKey="manual" />
|
||||
</Tabs>
|
||||
|
||||
{template && templateLabel && (
|
||||
<Button
|
||||
type="tertiary"
|
||||
onClick={fillTemplate}
|
||||
size="small"
|
||||
>
|
||||
{templateLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
headerStyle={{ padding: '12px 16px' }}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
className="!rounded-2xl"
|
||||
>
|
||||
{/* JSON错误提示 */}
|
||||
{hasJsonError && (
|
||||
<Banner
|
||||
type="danger"
|
||||
description={`JSON 格式错误: ${jsonError}`}
|
||||
className="mb-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑器内容 */}
|
||||
{editMode === 'visual' ? (
|
||||
<div>
|
||||
{renderVisualEditor()}
|
||||
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
||||
<Form.Input
|
||||
field={field}
|
||||
value={value}
|
||||
rules={rules}
|
||||
style={{ display: 'none' }}
|
||||
noLabel={true}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<TextArea
|
||||
placeholder={placeholder}
|
||||
value={manualText}
|
||||
onChange={handleManualChange}
|
||||
showClear={showClear}
|
||||
rows={Math.max(8, manualText ? manualText.split('\n').length : 8)}
|
||||
/>
|
||||
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
||||
<Form.Input
|
||||
field={field}
|
||||
value={value}
|
||||
rules={rules}
|
||||
style={{ display: 'none' }}
|
||||
noLabel={true}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 额外文本显示在卡片底部 */}
|
||||
{extraText && (
|
||||
<Divider margin='12px' align='center'>
|
||||
<Text type="tertiary" size="small">{extraText}</Text>
|
||||
</Divider>
|
||||
)}
|
||||
{extraFooter && (
|
||||
<div className="mt-1">
|
||||
{extraFooter}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Form.Slot>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONEditor;
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Space, Tag, Typography, Popover } from '@douyinfe/semi-ui';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 通用渲染函数:限制项目数量显示,支持popover展开
|
||||
export function renderLimitedItems({ items, renderItem, maxDisplay = 3 }) {
|
||||
if (!items || items.length === 0) return '-';
|
||||
const displayItems = items.slice(0, maxDisplay);
|
||||
const remainingItems = items.slice(maxDisplay);
|
||||
return (
|
||||
<Space spacing={1} wrap>
|
||||
{displayItems.map((item, idx) => renderItem(item, idx))}
|
||||
{remainingItems.length > 0 && (
|
||||
<Popover
|
||||
content={
|
||||
<div className='p-2'>
|
||||
<Space spacing={1} wrap>
|
||||
{remainingItems.map((item, idx) => renderItem(item, idx))}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
position='top'
|
||||
>
|
||||
<Tag size='small' shape='circle' color='grey'>
|
||||
+{remainingItems.length}
|
||||
</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染描述字段,长文本支持tooltip
|
||||
export const renderDescription = (text, maxWidth = 200) => {
|
||||
return (
|
||||
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth }}>
|
||||
{text || '-'}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
||||
import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton } from '@douyinfe/semi-ui';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
|
||||
/**
|
||||
* 通用可选择按钮组组件
|
||||
*
|
||||
* @param {string} title 标题
|
||||
* @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 按钮项
|
||||
* @param {*|Array} activeValue 当前激活的值,可以是单个值或数组(多选)
|
||||
* @param {(value:any)=>void} onChange 选择改变回调
|
||||
* @param {function} t i18n
|
||||
* @param {object} style 额外样式
|
||||
* @param {boolean} collapsible 是否支持折叠,默认true
|
||||
* @param {number} collapseHeight 折叠时的高度,默认200
|
||||
* @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态
|
||||
* @param {boolean} loading 是否处于加载状态
|
||||
*/
|
||||
const SelectableButtonGroup = ({
|
||||
title,
|
||||
items = [],
|
||||
activeValue,
|
||||
onChange,
|
||||
t = (v) => v,
|
||||
style = {},
|
||||
collapsible = true,
|
||||
collapseHeight = 200,
|
||||
withCheckbox = false,
|
||||
loading = false
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [skeletonCount] = useState(6);
|
||||
const isMobile = useIsMobile();
|
||||
const perRow = 3;
|
||||
const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32
|
||||
const needCollapse = collapsible && items.length > perRow * maxVisibleRows;
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const maskStyle = isOpen
|
||||
? {}
|
||||
: {
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const linkStyle = {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
bottom: -10,
|
||||
fontWeight: 400,
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
const renderSkeletonButtons = () => {
|
||||
|
||||
const placeholder = (
|
||||
<Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }}>
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<Col
|
||||
{...(isMobile
|
||||
? { span: 12 }
|
||||
: { span: 8 }
|
||||
)}
|
||||
key={index}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 'var(--semi-border-radius-medium)',
|
||||
padding: '0 12px',
|
||||
gap: '8px'
|
||||
}}>
|
||||
{withCheckbox && (
|
||||
<Skeleton.Title active style={{ width: 14, height: 14 }} />
|
||||
)}
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{
|
||||
width: `${60 + (index % 3) * 20}px`,
|
||||
height: 14
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
|
||||
return (
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
const contentElement = showSkeleton ? renderSkeletonButtons() : (
|
||||
<Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }} ref={contentRef}>
|
||||
{items.map((item) => {
|
||||
const isDisabled = item.disabled || (typeof item.tagCount === 'number' && item.tagCount === 0);
|
||||
const isActive = Array.isArray(activeValue)
|
||||
? activeValue.includes(item.value)
|
||||
: activeValue === item.value;
|
||||
|
||||
if (withCheckbox) {
|
||||
return (
|
||||
<Col
|
||||
{...(isMobile
|
||||
? { span: 12 }
|
||||
: { span: 8 }
|
||||
)}
|
||||
key={item.value}
|
||||
>
|
||||
<Button
|
||||
onClick={() => { /* disabled */ }}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
disabled={isDisabled}
|
||||
icon={
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
onChange={() => onChange(item.value)}
|
||||
disabled={isDisabled}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
}
|
||||
style={{ width: '100%', cursor: 'default' }}
|
||||
>
|
||||
{item.icon && (
|
||||
<span style={{ marginRight: 4 }}>{item.icon}</span>
|
||||
)}
|
||||
<span style={{ marginRight: item.tagCount !== undefined ? 4 : 0 }}>{item.label}</span>
|
||||
{item.tagCount !== undefined && (
|
||||
<Tag
|
||||
color='white'
|
||||
shape="circle"
|
||||
size="small"
|
||||
>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
)}
|
||||
</Button>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Col
|
||||
{...(isMobile
|
||||
? { span: 12 }
|
||||
: { span: 8 }
|
||||
)}
|
||||
key={item.value}
|
||||
>
|
||||
<Button
|
||||
onClick={() => onChange(item.value)}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
icon={item.icon}
|
||||
disabled={isDisabled}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<span style={{ marginRight: item.tagCount !== undefined ? 4 : 0 }}>{item.label}</span>
|
||||
{item.tagCount !== undefined && (
|
||||
<Tag
|
||||
color='white'
|
||||
shape="circle"
|
||||
size="small"
|
||||
>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
)}
|
||||
</Button>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{title && (
|
||||
<Divider margin="12px" align="left">
|
||||
{showSkeleton ? (
|
||||
<Skeleton.Title active style={{ width: 80, height: 14 }} />
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Divider>
|
||||
)}
|
||||
{needCollapse && !showSkeleton ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Collapsible isOpen={isOpen} collapseHeight={collapseHeight} style={{ ...maskStyle }}>
|
||||
{contentElement}
|
||||
</Collapsible>
|
||||
{isOpen ? null : (
|
||||
<div onClick={toggle} style={{ ...linkStyle }}>
|
||||
<IconChevronDown size="small" />
|
||||
<span>{t('展开更多')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && (
|
||||
<div onClick={toggle} style={{
|
||||
...linkStyle,
|
||||
position: 'static',
|
||||
marginTop: 8,
|
||||
bottom: 'auto'
|
||||
}}>
|
||||
<IconChevronUp size="small" />
|
||||
<span>{t('收起')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
contentElement
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectableButtonGroup;
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile.js';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
|
||||
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime.js';
|
||||
|
||||
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -59,7 +60,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const isMobile = useIsMobile();
|
||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
let navigate = useNavigate();
|
||||
const [currentLang, setCurrentLang] = useState(i18n.language);
|
||||
@@ -67,7 +67,9 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const location = useLocation();
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const loadingStartRef = useRef(Date.now());
|
||||
|
||||
const loading = statusState?.status === undefined;
|
||||
const isLoading = useMinimumLoadingTime(loading);
|
||||
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
@@ -128,7 +130,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
to: '/console',
|
||||
},
|
||||
{
|
||||
text: t('定价'),
|
||||
text: t('模型广场'),
|
||||
itemKey: 'pricing',
|
||||
to: '/pricing',
|
||||
},
|
||||
@@ -216,17 +218,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
};
|
||||
}, [i18n]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusState?.status !== undefined) {
|
||||
const elapsed = Date.now() - loadingStartRef.current;
|
||||
const remaining = Math.max(0, 500 - elapsed);
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, remaining);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [statusState?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
setLogoLoaded(false);
|
||||
if (!logo) return;
|
||||
|
||||
@@ -42,7 +42,7 @@ const PageLayout = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const shouldHideFooter = location.pathname.startsWith('/console');
|
||||
const shouldHideFooter = location.pathname.startsWith('/console') || location.pathname === '/pricing';
|
||||
|
||||
const shouldInnerPadding = location.pathname.includes('/console') &&
|
||||
!location.pathname.startsWith('/console/chat') &&
|
||||
|
||||
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
|
||||
import { getLucideIcon } from '../../helpers/render.js';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
|
||||
import {
|
||||
@@ -49,6 +49,7 @@ const routerMap = {
|
||||
detail: '/console',
|
||||
pricing: '/pricing',
|
||||
task: '/console/task',
|
||||
models: '/console/models',
|
||||
playground: '/console/playground',
|
||||
personal: '/console/personal',
|
||||
};
|
||||
@@ -133,6 +134,12 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
to: '/channel',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('模型管理'),
|
||||
itemKey: 'models',
|
||||
to: '/console/models',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('兑换码管理'),
|
||||
itemKey: 'redemption',
|
||||
@@ -244,28 +251,8 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
}
|
||||
}, [collapsed]);
|
||||
|
||||
// 获取菜单项对应的颜色
|
||||
const getItemColor = (itemKey) => {
|
||||
switch (itemKey) {
|
||||
case 'detail': return sidebarIconColors.dashboard;
|
||||
case 'playground': return sidebarIconColors.terminal;
|
||||
case 'chat': return sidebarIconColors.message;
|
||||
case 'token': return sidebarIconColors.key;
|
||||
case 'log': return sidebarIconColors.chart;
|
||||
case 'midjourney': return sidebarIconColors.image;
|
||||
case 'task': return sidebarIconColors.check;
|
||||
case 'topup': return sidebarIconColors.credit;
|
||||
case 'channel': return sidebarIconColors.layers;
|
||||
case 'redemption': return sidebarIconColors.gift;
|
||||
case 'user':
|
||||
case 'personal': return sidebarIconColors.user;
|
||||
case 'setting': return sidebarIconColors.settings;
|
||||
default:
|
||||
// 处理聊天项
|
||||
if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message;
|
||||
return 'currentColor';
|
||||
}
|
||||
};
|
||||
// 选中高亮颜色(统一)
|
||||
const SELECTED_COLOR = 'var(--semi-color-primary)';
|
||||
|
||||
// 渲染自定义菜单项
|
||||
const renderNavItem = (item) => {
|
||||
@@ -273,7 +260,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
if (item.className === 'tableHiddle') return null;
|
||||
|
||||
const isSelected = selectedKeys.includes(item.itemKey);
|
||||
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
|
||||
const textColor = isSelected ? SELECTED_COLOR : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Item
|
||||
@@ -300,7 +287,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
const renderSubItem = (item) => {
|
||||
if (item.items && item.items.length > 0) {
|
||||
const isSelected = selectedKeys.includes(item.itemKey);
|
||||
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
|
||||
const textColor = isSelected ? SELECTED_COLOR : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Sub
|
||||
@@ -321,7 +308,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
>
|
||||
{item.items.map((subItem) => {
|
||||
const isSubSelected = selectedKeys.includes(subItem.itemKey);
|
||||
const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit';
|
||||
const subTextColor = isSubSelected ? SELECTED_COLOR : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Item
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
|
||||
import ModelSelectModal from './ModelSelectModal';
|
||||
import JSONEditor from '../../../common/JSONEditor';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
@@ -131,6 +131,7 @@ const EditChannelModal = (props) => {
|
||||
proxy: '',
|
||||
pass_through_body_enabled: false,
|
||||
system_prompt: '',
|
||||
system_prompt_override: false,
|
||||
};
|
||||
const [batch, setBatch] = useState(false);
|
||||
const [multiToSingle, setMultiToSingle] = useState(false);
|
||||
@@ -142,6 +143,7 @@ const EditChannelModal = (props) => {
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [basicModels, setBasicModels] = useState([]);
|
||||
const [fullModels, setFullModels] = useState([]);
|
||||
const [modelGroups, setModelGroups] = useState([]);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
@@ -339,12 +341,15 @@ const EditChannelModal = (props) => {
|
||||
data.proxy = parsedSettings.proxy || '';
|
||||
data.pass_through_body_enabled = parsedSettings.pass_through_body_enabled || false;
|
||||
data.system_prompt = parsedSettings.system_prompt || '';
|
||||
data.system_prompt_override = parsedSettings.system_prompt_override || false;
|
||||
} catch (error) {
|
||||
console.error('解析渠道设置失败:', error);
|
||||
data.force_format = false;
|
||||
data.thinking_to_content = false;
|
||||
data.proxy = '';
|
||||
data.pass_through_body_enabled = false;
|
||||
data.system_prompt = '';
|
||||
data.system_prompt_override = false;
|
||||
}
|
||||
} else {
|
||||
data.force_format = false;
|
||||
@@ -352,6 +357,7 @@ const EditChannelModal = (props) => {
|
||||
data.proxy = '';
|
||||
data.pass_through_body_enabled = false;
|
||||
data.system_prompt = '';
|
||||
data.system_prompt_override = false;
|
||||
}
|
||||
|
||||
setInputs(data);
|
||||
@@ -371,6 +377,7 @@ const EditChannelModal = (props) => {
|
||||
proxy: data.proxy,
|
||||
pass_through_body_enabled: data.pass_through_body_enabled,
|
||||
system_prompt: data.system_prompt,
|
||||
system_prompt_override: data.system_prompt_override || false,
|
||||
});
|
||||
// console.log(data);
|
||||
} else {
|
||||
@@ -477,6 +484,17 @@ const EditChannelModal = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchModelGroups = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/prefill_group?type=model');
|
||||
if (res?.data?.success) {
|
||||
setModelGroups(res.data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const modelMap = new Map();
|
||||
|
||||
@@ -549,6 +567,7 @@ const EditChannelModal = (props) => {
|
||||
} else {
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
}
|
||||
fetchModelGroups();
|
||||
// 重置手动输入模式状态
|
||||
setUseManualInput(false);
|
||||
} else {
|
||||
@@ -560,6 +579,7 @@ const EditChannelModal = (props) => {
|
||||
proxy: '',
|
||||
pass_through_body_enabled: false,
|
||||
system_prompt: '',
|
||||
system_prompt_override: false,
|
||||
});
|
||||
// 重置密钥模式状态
|
||||
setKeyMode('append');
|
||||
@@ -708,6 +728,7 @@ const EditChannelModal = (props) => {
|
||||
proxy: localInputs.proxy || '',
|
||||
pass_through_body_enabled: localInputs.pass_through_body_enabled || false,
|
||||
system_prompt: localInputs.system_prompt || '',
|
||||
system_prompt_override: localInputs.system_prompt_override || false,
|
||||
};
|
||||
localInputs.setting = JSON.stringify(channelExtraSettings);
|
||||
|
||||
@@ -717,6 +738,7 @@ const EditChannelModal = (props) => {
|
||||
delete localInputs.proxy;
|
||||
delete localInputs.pass_through_body_enabled;
|
||||
delete localInputs.system_prompt;
|
||||
delete localInputs.system_prompt_override;
|
||||
|
||||
let res;
|
||||
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
|
||||
@@ -1174,27 +1196,27 @@ const EditChannelModal = (props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEdit && isMultiKeyChannel && (
|
||||
<Form.Select
|
||||
field='key_mode'
|
||||
label={t('密钥更新模式')}
|
||||
placeholder={t('请选择密钥更新模式')}
|
||||
optionList={[
|
||||
{ label: t('追加到现有密钥'), value: 'append' },
|
||||
{ label: t('覆盖现有密钥'), value: 'replace' },
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
value={keyMode}
|
||||
onChange={(value) => setKeyMode(value)}
|
||||
extraText={
|
||||
<Text type="tertiary" size="small">
|
||||
{keyMode === 'replace'
|
||||
? t('覆盖模式:将完全替换现有的所有密钥')
|
||||
: t('追加模式:将新密钥添加到现有密钥列表末尾')
|
||||
}
|
||||
</Text>
|
||||
{isEdit && isMultiKeyChannel && (
|
||||
<Form.Select
|
||||
field='key_mode'
|
||||
label={t('密钥更新模式')}
|
||||
placeholder={t('请选择密钥更新模式')}
|
||||
optionList={[
|
||||
{ label: t('追加到现有密钥'), value: 'append' },
|
||||
{ label: t('覆盖现有密钥'), value: 'replace' },
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
value={keyMode}
|
||||
onChange={(value) => setKeyMode(value)}
|
||||
extraText={
|
||||
<Text type="tertiary" size="small">
|
||||
{keyMode === 'replace'
|
||||
? t('覆盖模式:将完全替换现有的所有密钥')
|
||||
: t('追加模式:将新密钥添加到现有密钥列表末尾')
|
||||
}
|
||||
/>
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{batch && multiToSingle && (
|
||||
<>
|
||||
@@ -1247,11 +1269,7 @@ const EditChannelModal = (props) => {
|
||||
templateLabel={t('填入模板')}
|
||||
editorType="region"
|
||||
formApi={formApiRef.current}
|
||||
extraText={
|
||||
<Text type="tertiary" size="small">
|
||||
{t('设置默认地区和特定模型的专用地区')}
|
||||
</Text>
|
||||
}
|
||||
extraText={t('设置默认地区和特定模型的专用地区')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1482,6 +1500,32 @@ const EditChannelModal = (props) => {
|
||||
>
|
||||
{t('复制所有模型')}
|
||||
</Button>
|
||||
{modelGroups && modelGroups.length > 0 && modelGroups.map(group => (
|
||||
<Button
|
||||
key={group.id}
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
let items = [];
|
||||
try {
|
||||
if (Array.isArray(group.items)) {
|
||||
items = group.items;
|
||||
} else if (typeof group.items === 'string') {
|
||||
const parsed = JSON.parse(group.items || '[]');
|
||||
if (Array.isArray(parsed)) items = parsed;
|
||||
}
|
||||
} catch { }
|
||||
const current = formApiRef.current?.getValue('models') || inputs.models || [];
|
||||
const merged = Array.from(new Set([...
|
||||
current,
|
||||
...items
|
||||
].map(m => (m || '').trim()).filter(Boolean)));
|
||||
handleInputChange('models', merged);
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
@@ -1520,11 +1564,7 @@ const EditChannelModal = (props) => {
|
||||
templateLabel={t('填入模板')}
|
||||
editorType="keyValue"
|
||||
formApi={formApiRef.current}
|
||||
extraText={
|
||||
<Text type="tertiary" size="small">
|
||||
{t('键为请求中的模型名称,值为要替换的模型名称')}
|
||||
</Text>
|
||||
}
|
||||
extraText={t('键为请求中的模型名称,值为要替换的模型名称')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1628,11 +1668,7 @@ const EditChannelModal = (props) => {
|
||||
templateLabel={t('填入模板')}
|
||||
editorType="keyValue"
|
||||
formApi={formApiRef.current}
|
||||
extraText={
|
||||
<Text type="tertiary" size="small">
|
||||
{t('键为原状态码,值为要复写的状态码,仅影响本地判断')}
|
||||
</Text>
|
||||
}
|
||||
extraText={t('键为原状态码,值为要复写的状态码,仅影响本地判断')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1695,6 +1731,14 @@ const EditChannelModal = (props) => {
|
||||
showClear
|
||||
extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')}
|
||||
/>
|
||||
<Form.Switch
|
||||
field='system_prompt_override'
|
||||
label={t('系统提示词拼接')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) => handleChannelSettingsChange('system_prompt_override', value)}
|
||||
extraText={t('如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面')}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
@@ -175,7 +175,7 @@ const ModelTestModal = ({
|
||||
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
|
||||
{currentTestChannel.name} {t('渠道的模型测试')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="tertiary" className="!text-xs flex items-center">
|
||||
<Typography.Text type="tertiary" size="small">
|
||||
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tag, Space, Tooltip, Switch } from '@douyinfe/semi-ui';
|
||||
import { IconVerify, IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { Popover } from '@douyinfe/semi-ui';
|
||||
import { renderModelTag, stringToColor } from '../../../helpers';
|
||||
|
||||
function renderQuotaType(type, t) {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='teal' shape='circle'>
|
||||
{t('按次计费')}
|
||||
</Tag>
|
||||
);
|
||||
case 0:
|
||||
return (
|
||||
<Tag color='violet' shape='circle'>
|
||||
{t('按量计费')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return t('未知');
|
||||
}
|
||||
}
|
||||
|
||||
function renderAvailable(available, t) {
|
||||
return available ? (
|
||||
<Popover
|
||||
content={
|
||||
<div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
|
||||
}
|
||||
position='top'
|
||||
key={available}
|
||||
className="bg-green-50"
|
||||
>
|
||||
<IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
|
||||
</Popover>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function renderSupportedEndpoints(endpoints) {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Space wrap>
|
||||
{endpoints.map((endpoint, idx) => (
|
||||
<Tag
|
||||
key={endpoint}
|
||||
color={stringToColor(endpoint)}
|
||||
shape='circle'
|
||||
>
|
||||
{endpoint}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export const getModelPricingColumns = ({
|
||||
t,
|
||||
selectedGroup,
|
||||
usableGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
showWithRecharge,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
displayPrice,
|
||||
handleGroupClick,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: t('可用性'),
|
||||
dataIndex: 'available',
|
||||
render: (text, record, index) => {
|
||||
return renderAvailable(record.enable_groups.includes(selectedGroup), t);
|
||||
},
|
||||
sorter: (a, b) => {
|
||||
const aAvailable = a.enable_groups.includes(selectedGroup);
|
||||
const bAvailable = b.enable_groups.includes(selectedGroup);
|
||||
return Number(aAvailable) - Number(bAvailable);
|
||||
},
|
||||
defaultSortOrder: 'descend',
|
||||
},
|
||||
{
|
||||
title: t('可用端点类型'),
|
||||
dataIndex: 'supported_endpoint_types',
|
||||
render: (text, record, index) => {
|
||||
return renderSupportedEndpoints(text);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
render: (text, record, index) => {
|
||||
return renderModelTag(text, {
|
||||
onClick: () => {
|
||||
copyText(text);
|
||||
}
|
||||
});
|
||||
},
|
||||
onFilter: (value, record) =>
|
||||
record.model_name.toLowerCase().includes(value.toLowerCase()),
|
||||
},
|
||||
{
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'quota_type',
|
||||
render: (text, record, index) => {
|
||||
return renderQuotaType(parseInt(text), t);
|
||||
},
|
||||
sorter: (a, b) => a.quota_type - b.quota_type,
|
||||
},
|
||||
{
|
||||
title: t('可用分组'),
|
||||
dataIndex: 'enable_groups',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<Space wrap>
|
||||
{text.map((group) => {
|
||||
if (usableGroup[group]) {
|
||||
if (group === selectedGroup) {
|
||||
return (
|
||||
<Tag key={group} color='blue' shape='circle' prefixIcon={<IconVerify />}>
|
||||
{group}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag
|
||||
key={group}
|
||||
color='blue'
|
||||
shape='circle'
|
||||
onClick={() => handleGroupClick(group)}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{group}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>{t('倍率')}</span>
|
||||
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
|
||||
<IconHelpCircle
|
||||
className="text-blue-500 cursor-pointer"
|
||||
onClick={() => {
|
||||
setModalImageUrl('/ratio.png');
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'model_ratio',
|
||||
render: (text, record, index) => {
|
||||
let content = text;
|
||||
let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
|
||||
content = (
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-700">
|
||||
{t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('补全倍率')}:
|
||||
{record.quota_type === 0 ? completionRatio : t('无')}
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('分组倍率')}:{groupRatio[selectedGroup]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return content;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{t('模型价格')}</span>
|
||||
{/* 计费单位切换 */}
|
||||
<Switch
|
||||
checked={tokenUnit === 'K'}
|
||||
onChange={(checked) => setTokenUnit(checked ? 'K' : 'M')}
|
||||
checkedText="K"
|
||||
uncheckedText="M"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'model_price',
|
||||
render: (text, record, index) => {
|
||||
let content = text;
|
||||
if (record.quota_type === 0) {
|
||||
let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
|
||||
let completionRatioPriceUSD =
|
||||
record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
|
||||
|
||||
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
|
||||
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
|
||||
|
||||
let displayInput = displayPrice(inputRatioPriceUSD);
|
||||
let displayCompletion = displayPrice(completionRatioPriceUSD);
|
||||
|
||||
const divisor = unitDivisor;
|
||||
const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor;
|
||||
const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor;
|
||||
|
||||
displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`;
|
||||
displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`;
|
||||
content = (
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-700">
|
||||
{t('提示')} {displayInput} / 1{unitLabel} tokens
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('补全')} {displayCompletion} / 1{unitLabel} tokens
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
let priceUSD = parseFloat(text) * groupRatio[selectedGroup];
|
||||
let displayVal = displayPrice(priceUSD);
|
||||
content = (
|
||||
<div className="text-gray-700">
|
||||
{t('模型价格')}:{displayVal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return content;
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, Input, Button, Space, Switch, Select } from '@douyinfe/semi-ui';
|
||||
import { IconSearch, IconCopy } from '@douyinfe/semi-icons';
|
||||
|
||||
const ModelPricingFilters = ({
|
||||
selectedRowKeys,
|
||||
copyText,
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
handleChange,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
t
|
||||
}) => {
|
||||
const SearchAndActions = useMemo(() => (
|
||||
<Card className="!rounded-xl mb-6" bordered={false}>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模糊搜索模型名称')}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onChange={handleChange}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconCopy />}
|
||||
onClick={() => copyText(selectedRowKeys)}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!bg-blue-500 hover:!bg-blue-600 text-white"
|
||||
>
|
||||
{t('复制选中模型')}
|
||||
</Button>
|
||||
|
||||
{/* 充值价格显示开关 */}
|
||||
<Space align="center">
|
||||
<span>{t('以充值价格显示')}</span>
|
||||
<Switch
|
||||
checked={showWithRecharge}
|
||||
onChange={setShowWithRecharge}
|
||||
size="small"
|
||||
/>
|
||||
{showWithRecharge && (
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
size="small"
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<Select.Option value="USD">USD ($)</Select.Option>
|
||||
<Select.Option value="CNY">CNY (¥)</Select.Option>
|
||||
</Select>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
), [selectedRowKeys, t, showWithRecharge, currency, handleCompositionStart, handleCompositionEnd, handleChange, copyText, setShowWithRecharge, setCurrency]);
|
||||
|
||||
return SearchAndActions;
|
||||
};
|
||||
|
||||
export default ModelPricingFilters;
|
||||
@@ -1,123 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@douyinfe/semi-ui';
|
||||
import { IconVerify, IconLayers, IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
const ModelPricingHeader = ({
|
||||
userState,
|
||||
groupRatio,
|
||||
selectedGroup,
|
||||
models,
|
||||
t
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
|
||||
position: 'relative'
|
||||
}}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
|
||||
<IconLayers size="extra-large" className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
|
||||
{t('模型定价')}
|
||||
</div>
|
||||
<div className="text-sm text-white/80">
|
||||
{userState.user ? (
|
||||
<div className="flex items-center">
|
||||
<IconVerify className="mr-1.5 flex-shrink-0" size="small" />
|
||||
<span className="truncate">
|
||||
{t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{t('未登录,使用默认分组倍率:')}{groupRatio['default']}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
|
||||
<div
|
||||
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
|
||||
style={{ backdropFilter: 'blur(10px)' }}
|
||||
>
|
||||
<div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
|
||||
<div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
|
||||
style={{ backdropFilter: 'blur(10px)' }}
|
||||
>
|
||||
<div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
|
||||
<div className="text-sm sm:text-base font-semibold">
|
||||
{models.filter(m => m.enable_groups.includes(selectedGroup)).length}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
|
||||
style={{ backdropFilter: 'blur(10px)' }}
|
||||
>
|
||||
<div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
|
||||
<div className="text-sm sm:text-base font-semibold">2</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 计费说明 */}
|
||||
<div className="mt-4 sm:mt-5">
|
||||
<div className="flex items-start">
|
||||
<div
|
||||
className="w-full flex items-start space-x-2 px-3 py-2 sm:px-4 sm:py-2.5 rounded-lg text-xs sm:text-sm"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
<IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
|
||||
<span>
|
||||
{t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPricingHeader;
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tabs, TabPane, Tag } from '@douyinfe/semi-ui';
|
||||
|
||||
const ModelPricingTabs = ({
|
||||
activeKey,
|
||||
setActiveKey,
|
||||
modelCategories,
|
||||
categoryCounts,
|
||||
availableCategories,
|
||||
t
|
||||
}) => {
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
type="card"
|
||||
collapsible
|
||||
onChange={key => setActiveKey(key)}
|
||||
className="mt-2"
|
||||
>
|
||||
{Object.entries(modelCategories)
|
||||
.filter(([key]) => availableCategories.includes(key))
|
||||
.map(([key, category]) => {
|
||||
const modelCount = categoryCounts[key] || 0;
|
||||
|
||||
return (
|
||||
<TabPane
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
|
||||
{category.label}
|
||||
<Tag
|
||||
color={activeKey === key ? 'red' : 'grey'}
|
||||
shape='circle'
|
||||
>
|
||||
{modelCount}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
itemKey={key}
|
||||
key={key}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPricingTabs;
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tooltip } from '@douyinfe/semi-ui';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
|
||||
const PricingDisplaySettings = ({
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
loading = false,
|
||||
t
|
||||
}) => {
|
||||
const items = [
|
||||
{
|
||||
value: 'recharge',
|
||||
label: t('以充值价格显示')
|
||||
},
|
||||
{
|
||||
value: 'ratio',
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
{t('显示倍率')}
|
||||
<Tooltip content={t('倍率是用于系统计算不同模型的最终价格用的,如果您不理解倍率,请忽略')}>
|
||||
<IconHelpCircle
|
||||
size="small"
|
||||
style={{ color: 'var(--semi-color-text-2)', cursor: 'help' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'tableView',
|
||||
label: t('表格视图')
|
||||
},
|
||||
{
|
||||
value: 'tokenUnit',
|
||||
label: t('按K显示单位')
|
||||
}
|
||||
];
|
||||
|
||||
const currencyItems = [
|
||||
{ value: 'USD', label: 'USD ($)' },
|
||||
{ value: 'CNY', label: 'CNY (¥)' }
|
||||
];
|
||||
|
||||
const handleChange = (value) => {
|
||||
switch (value) {
|
||||
case 'recharge':
|
||||
setShowWithRecharge(!showWithRecharge);
|
||||
break;
|
||||
case 'ratio':
|
||||
setShowRatio(!showRatio);
|
||||
break;
|
||||
case 'tableView':
|
||||
setViewMode(viewMode === 'table' ? 'card' : 'table');
|
||||
break;
|
||||
case 'tokenUnit':
|
||||
setTokenUnit(tokenUnit === 'K' ? 'M' : 'K');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveValues = () => {
|
||||
const activeValues = [];
|
||||
if (showWithRecharge) activeValues.push('recharge');
|
||||
if (showRatio) activeValues.push('ratio');
|
||||
if (viewMode === 'table') activeValues.push('tableView');
|
||||
if (tokenUnit === 'K') activeValues.push('tokenUnit');
|
||||
return activeValues;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SelectableButtonGroup
|
||||
title={t('显示设置')}
|
||||
items={items}
|
||||
activeValue={getActiveValues()}
|
||||
onChange={handleChange}
|
||||
withCheckbox
|
||||
collapsible={false}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{showWithRecharge && (
|
||||
<SelectableButtonGroup
|
||||
title={t('货币单位')}
|
||||
items={currencyItems}
|
||||
activeValue={currency}
|
||||
onChange={setCurrency}
|
||||
collapsible={false}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingDisplaySettings;
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
|
||||
/**
|
||||
* 端点类型筛选组件
|
||||
* @param {string|'all'} filterEndpointType 当前值
|
||||
* @param {Function} setFilterEndpointType setter
|
||||
* @param {Array} models 模型列表
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingEndpointTypes = ({ filterEndpointType, setFilterEndpointType, models = [], allModels = [], loading = false, t }) => {
|
||||
// 获取系统中所有端点类型(基于 allModels,如果未提供则退化为 models)
|
||||
const getAllEndpointTypes = () => {
|
||||
const endpointTypes = new Set();
|
||||
(allModels.length > 0 ? allModels : models).forEach(model => {
|
||||
if (model.supported_endpoint_types && Array.isArray(model.supported_endpoint_types)) {
|
||||
model.supported_endpoint_types.forEach(endpoint => {
|
||||
endpointTypes.add(endpoint);
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(endpointTypes).sort();
|
||||
};
|
||||
|
||||
// 计算每个端点类型的模型数量
|
||||
const getEndpointTypeCount = (endpointType) => {
|
||||
if (endpointType === 'all') {
|
||||
return models.length;
|
||||
}
|
||||
return models.filter(model =>
|
||||
model.supported_endpoint_types &&
|
||||
model.supported_endpoint_types.includes(endpointType)
|
||||
).length;
|
||||
};
|
||||
|
||||
// 端点类型显示名称映射
|
||||
const getEndpointTypeLabel = (endpointType) => {
|
||||
return endpointType;
|
||||
};
|
||||
|
||||
const availableEndpointTypes = getAllEndpointTypes();
|
||||
|
||||
const items = [
|
||||
{ value: 'all', label: t('全部端点'), tagCount: getEndpointTypeCount('all'), disabled: models.length === 0 },
|
||||
...availableEndpointTypes.map(endpointType => {
|
||||
const count = getEndpointTypeCount(endpointType);
|
||||
return ({
|
||||
value: endpointType,
|
||||
label: getEndpointTypeLabel(endpointType),
|
||||
tagCount: count,
|
||||
disabled: count === 0
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('端点类型')}
|
||||
items={items}
|
||||
activeValue={filterEndpointType}
|
||||
onChange={setFilterEndpointType}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingEndpointTypes;
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
|
||||
/**
|
||||
* 分组筛选组件
|
||||
* @param {string} filterGroup 当前选中的分组,'all' 表示不过滤
|
||||
* @param {Function} setFilterGroup 设置选中分组
|
||||
* @param {Record<string, any>} usableGroup 后端返回的可用分组对象
|
||||
* @param {Record<string, number>} groupRatio 分组倍率对象
|
||||
* @param {Array} models 模型列表
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, groupRatio = {}, models = [], loading = false, t }) => {
|
||||
const groups = ['all', ...Object.keys(usableGroup).filter(key => key !== '')];
|
||||
|
||||
const items = groups.map((g) => {
|
||||
const modelCount = g === 'all'
|
||||
? models.length
|
||||
: models.filter(m => m.enable_groups && m.enable_groups.includes(g)).length;
|
||||
let ratioDisplay = '';
|
||||
if (g === 'all') {
|
||||
ratioDisplay = t('全部');
|
||||
} else {
|
||||
const ratio = groupRatio[g];
|
||||
if (ratio !== undefined && ratio !== null) {
|
||||
ratioDisplay = `x${ratio}`;
|
||||
} else {
|
||||
ratioDisplay = 'x1';
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: g,
|
||||
label: g === 'all' ? t('全部分组') : g,
|
||||
tagCount: ratioDisplay,
|
||||
disabled: modelCount === 0
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('可用令牌分组')}
|
||||
items={items}
|
||||
activeValue={filterGroup}
|
||||
onChange={setFilterGroup}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingGroups;
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
|
||||
/**
|
||||
* 计费类型筛选组件
|
||||
* @param {string|'all'|0|1} filterQuotaType 当前值
|
||||
* @param {Function} setFilterQuotaType setter
|
||||
* @param {Array} models 模型列表
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], loading = false, t }) => {
|
||||
const qtyCount = (type) => models.filter(m => type === 'all' ? true : m.quota_type === type).length;
|
||||
|
||||
const items = [
|
||||
{ value: 'all', label: t('全部类型'), tagCount: qtyCount('all') },
|
||||
{ value: 0, label: t('按量计费'), tagCount: qtyCount(0) },
|
||||
{ value: 1, label: t('按次计费'), tagCount: qtyCount(1) },
|
||||
];
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('计费类型')}
|
||||
items={items}
|
||||
activeValue={filterQuotaType}
|
||||
onChange={setFilterQuotaType}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingQuotaTypes;
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
import { getLobeHubIcon } from '../../../../helpers';
|
||||
|
||||
/**
|
||||
* 供应商筛选组件
|
||||
* @param {string|'all'} filterVendor 当前值
|
||||
* @param {Function} setFilterVendor setter
|
||||
* @param {Array} models 模型列表
|
||||
* @param {Array} allModels 所有模型列表(用于获取全部供应商)
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingVendors = ({ filterVendor, setFilterVendor, models = [], allModels = [], loading = false, t }) => {
|
||||
// 获取系统中所有供应商(基于 allModels,如果未提供则退化为 models)
|
||||
const getAllVendors = React.useMemo(() => {
|
||||
const vendors = new Set();
|
||||
const vendorIcons = new Map();
|
||||
let hasUnknownVendor = false;
|
||||
|
||||
(allModels.length > 0 ? allModels : models).forEach(model => {
|
||||
if (model.vendor_name) {
|
||||
vendors.add(model.vendor_name);
|
||||
if (model.vendor_icon && !vendorIcons.has(model.vendor_name)) {
|
||||
vendorIcons.set(model.vendor_name, model.vendor_icon);
|
||||
}
|
||||
} else {
|
||||
hasUnknownVendor = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
vendors: Array.from(vendors).sort(),
|
||||
vendorIcons,
|
||||
hasUnknownVendor
|
||||
};
|
||||
}, [allModels, models]);
|
||||
|
||||
// 计算每个供应商的模型数量(基于当前过滤后的 models)
|
||||
const getVendorCount = React.useCallback((vendor) => {
|
||||
if (vendor === 'all') {
|
||||
return models.length;
|
||||
}
|
||||
if (vendor === 'unknown') {
|
||||
return models.filter(model => !model.vendor_name).length;
|
||||
}
|
||||
return models.filter(model => model.vendor_name === vendor).length;
|
||||
}, [models]);
|
||||
|
||||
// 生成供应商选项
|
||||
const items = React.useMemo(() => {
|
||||
const result = [
|
||||
{
|
||||
value: 'all',
|
||||
label: t('全部供应商'),
|
||||
tagCount: getVendorCount('all'),
|
||||
disabled: models.length === 0
|
||||
}
|
||||
];
|
||||
|
||||
// 添加所有已知供应商
|
||||
getAllVendors.vendors.forEach(vendor => {
|
||||
const count = getVendorCount(vendor);
|
||||
const icon = getAllVendors.vendorIcons.get(vendor);
|
||||
result.push({
|
||||
value: vendor,
|
||||
label: vendor,
|
||||
icon: icon ? getLobeHubIcon(icon, 16) : null,
|
||||
tagCount: count,
|
||||
disabled: count === 0
|
||||
});
|
||||
});
|
||||
|
||||
// 如果系统中存在未知供应商,添加"未知供应商"选项
|
||||
if (getAllVendors.hasUnknownVendor) {
|
||||
const count = getVendorCount('unknown');
|
||||
result.push({
|
||||
value: 'unknown',
|
||||
label: t('未知供应商'),
|
||||
tagCount: count,
|
||||
disabled: count === 0
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [getAllVendors, getVendorCount, t]);
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('供应商')}
|
||||
items={items}
|
||||
activeValue={filterVendor}
|
||||
onChange={setFilterVendor}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingVendors;
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Layout, Card, ImagePreview } from '@douyinfe/semi-ui';
|
||||
import ModelPricingTabs from './ModelPricingTabs.jsx';
|
||||
import ModelPricingFilters from './ModelPricingFilters.jsx';
|
||||
import ModelPricingTable from './ModelPricingTable.jsx';
|
||||
import ModelPricingHeader from './ModelPricingHeader.jsx';
|
||||
import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData.js';
|
||||
|
||||
const ModelPricingPage = () => {
|
||||
const modelPricingData = useModelPricingData();
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50">
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full">
|
||||
{/* 主卡片容器 */}
|
||||
<Card bordered={false} className="!rounded-2xl shadow-lg border-0">
|
||||
{/* 顶部状态卡片 */}
|
||||
<ModelPricingHeader {...modelPricingData} />
|
||||
|
||||
{/* 模型分类 Tabs */}
|
||||
<div className="mb-6">
|
||||
<ModelPricingTabs {...modelPricingData} />
|
||||
|
||||
{/* 搜索和表格区域 */}
|
||||
<ModelPricingFilters {...modelPricingData} />
|
||||
<ModelPricingTable {...modelPricingData} />
|
||||
</div>
|
||||
|
||||
{/* 倍率说明图预览 */}
|
||||
<ImagePreview
|
||||
src={modelPricingData.modalImageUrl}
|
||||
visible={modelPricingData.isModalOpenurl}
|
||||
onVisibleChange={(visible) => modelPricingData.setIsModalOpenurl(visible)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPricingPage;
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Layout, ImagePreview } from '@douyinfe/semi-ui';
|
||||
import PricingSidebar from './PricingSidebar';
|
||||
import PricingContent from './content/PricingContent';
|
||||
import ModelDetailSideSheet from '../modal/ModelDetailSideSheet';
|
||||
import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const PricingPage = () => {
|
||||
const pricingData = useModelPricingData();
|
||||
const { Sider, Content } = Layout;
|
||||
const isMobile = useIsMobile();
|
||||
const [showRatio, setShowRatio] = React.useState(false);
|
||||
const [viewMode, setViewMode] = React.useState('card');
|
||||
const allProps = {
|
||||
...pricingData,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<Layout className="pricing-layout">
|
||||
{!isMobile && (
|
||||
<Sider
|
||||
className="pricing-scroll-hide pricing-sidebar"
|
||||
width={460}
|
||||
>
|
||||
<PricingSidebar {...allProps} />
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
<Content
|
||||
className="pricing-scroll-hide pricing-content"
|
||||
>
|
||||
<PricingContent
|
||||
{...allProps}
|
||||
isMobile={isMobile}
|
||||
sidebarProps={allProps}
|
||||
/>
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
<ImagePreview
|
||||
src={pricingData.modalImageUrl}
|
||||
visible={pricingData.isModalOpenurl}
|
||||
onVisibleChange={(visible) => pricingData.setIsModalOpenurl(visible)}
|
||||
/>
|
||||
|
||||
<ModelDetailSideSheet
|
||||
visible={pricingData.showModelDetail}
|
||||
onClose={pricingData.closeModelDetail}
|
||||
modelData={pricingData.selectedModel}
|
||||
groupRatio={pricingData.groupRatio}
|
||||
usableGroup={pricingData.usableGroup}
|
||||
currency={pricingData.currency}
|
||||
tokenUnit={pricingData.tokenUnit}
|
||||
displayPrice={pricingData.displayPrice}
|
||||
showRatio={allProps.showRatio}
|
||||
vendorsMap={pricingData.vendorsMap}
|
||||
endpointMap={pricingData.endpointMap}
|
||||
autoGroups={pricingData.autoGroups}
|
||||
t={pricingData.t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPage;
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import PricingGroups from '../filter/PricingGroups';
|
||||
import PricingQuotaTypes from '../filter/PricingQuotaTypes';
|
||||
import PricingEndpointTypes from '../filter/PricingEndpointTypes';
|
||||
import PricingVendors from '../filter/PricingVendors';
|
||||
import PricingDisplaySettings from '../filter/PricingDisplaySettings';
|
||||
import { resetPricingFilters } from '../../../../helpers/utils';
|
||||
import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
|
||||
|
||||
const PricingSidebar = ({
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
handleChange,
|
||||
setActiveKey,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
filterGroup,
|
||||
setFilterGroup,
|
||||
handleGroupClick,
|
||||
filterQuotaType,
|
||||
setFilterQuotaType,
|
||||
filterEndpointType,
|
||||
setFilterEndpointType,
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
loading,
|
||||
t,
|
||||
...categoryProps
|
||||
}) => {
|
||||
|
||||
const {
|
||||
quotaTypeModels,
|
||||
endpointTypeModels,
|
||||
vendorModels,
|
||||
groupCountModels,
|
||||
} = usePricingFilterCounts({
|
||||
models: categoryProps.models,
|
||||
filterGroup,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
searchValue: categoryProps.searchValue,
|
||||
});
|
||||
|
||||
const handleResetFilters = () =>
|
||||
resetPricingFilters({
|
||||
handleChange,
|
||||
setShowWithRecharge,
|
||||
setCurrency,
|
||||
setShowRatio,
|
||||
setViewMode,
|
||||
setFilterGroup,
|
||||
setFilterQuotaType,
|
||||
setFilterEndpointType,
|
||||
setFilterVendor,
|
||||
setCurrentPage,
|
||||
setTokenUnit,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="text-lg font-semibold text-gray-800">
|
||||
{t('筛选')}
|
||||
</div>
|
||||
<Button
|
||||
theme="outline"
|
||||
type='tertiary'
|
||||
onClick={handleResetFilters}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PricingDisplaySettings
|
||||
showWithRecharge={showWithRecharge}
|
||||
setShowWithRecharge={setShowWithRecharge}
|
||||
currency={currency}
|
||||
setCurrency={setCurrency}
|
||||
showRatio={showRatio}
|
||||
setShowRatio={setShowRatio}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
tokenUnit={tokenUnit}
|
||||
setTokenUnit={setTokenUnit}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingVendors
|
||||
filterVendor={filterVendor}
|
||||
setFilterVendor={setFilterVendor}
|
||||
models={vendorModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingGroups
|
||||
filterGroup={filterGroup}
|
||||
setFilterGroup={handleGroupClick}
|
||||
usableGroup={categoryProps.usableGroup}
|
||||
groupRatio={categoryProps.groupRatio}
|
||||
models={groupCountModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingQuotaTypes
|
||||
filterQuotaType={filterQuotaType}
|
||||
setFilterQuotaType={setFilterQuotaType}
|
||||
models={quotaTypeModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingEndpointTypes
|
||||
filterEndpointType={filterEndpointType}
|
||||
setFilterEndpointType={setFilterEndpointType}
|
||||
models={endpointTypeModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingSidebar;
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PricingTopSection from '../header/PricingTopSection';
|
||||
import PricingView from './PricingView';
|
||||
|
||||
const PricingContent = ({ isMobile, sidebarProps, ...props }) => {
|
||||
return (
|
||||
<div className={isMobile ? "pricing-content-mobile" : "pricing-scroll-hide"}>
|
||||
{/* 固定的顶部区域(分类介绍 + 搜索和操作) */}
|
||||
<div className="pricing-search-header">
|
||||
<PricingTopSection {...props} isMobile={isMobile} sidebarProps={sidebarProps} />
|
||||
</div>
|
||||
|
||||
{/* 可滚动的内容区域 */}
|
||||
<div className={isMobile ? "pricing-view-container-mobile" : "pricing-view-container"}>
|
||||
<PricingView {...props} viewMode={sidebarProps.viewMode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingContent;
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PricingTable from '../../view/table/PricingTable';
|
||||
import PricingCardView from '../../view/card/PricingCardView';
|
||||
|
||||
const PricingView = ({
|
||||
viewMode = 'table',
|
||||
...props
|
||||
}) => {
|
||||
return viewMode === 'card' ?
|
||||
<PricingCardView {...props} /> :
|
||||
<PricingTable {...props} />;
|
||||
};
|
||||
|
||||
export default PricingView;
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Input, Button } from '@douyinfe/semi-ui';
|
||||
import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons';
|
||||
import PricingFilterModal from '../../modal/PricingFilterModal';
|
||||
import PricingVendorIntroWithSkeleton from './PricingVendorIntroWithSkeleton';
|
||||
|
||||
const PricingTopSection = ({
|
||||
selectedRowKeys,
|
||||
copyText,
|
||||
handleChange,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
isMobile,
|
||||
sidebarProps,
|
||||
filterVendor,
|
||||
models,
|
||||
filteredModels,
|
||||
loading,
|
||||
t
|
||||
}) => {
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
|
||||
const SearchAndActions = useMemo(() => (
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模糊搜索模型名称')}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onChange={handleChange}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Button
|
||||
theme='outline'
|
||||
type='primary'
|
||||
icon={<IconCopy />}
|
||||
onClick={() => copyText(selectedRowKeys)}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!bg-blue-500 hover:!bg-blue-600 text-white"
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
|
||||
{/* 移动端筛选按钮 */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
theme="outline"
|
||||
type='tertiary'
|
||||
icon={<IconFilter />}
|
||||
onClick={() => setShowFilterModal(true)}
|
||||
>
|
||||
{t('筛选')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
), [selectedRowKeys, t, handleCompositionStart, handleCompositionEnd, handleChange, copyText, isMobile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 供应商介绍区域(含骨架屏) */}
|
||||
<PricingVendorIntroWithSkeleton
|
||||
loading={loading}
|
||||
filterVendor={filterVendor}
|
||||
models={filteredModels}
|
||||
allModels={models}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* 搜索和操作区域 */}
|
||||
{SearchAndActions}
|
||||
|
||||
{/* 移动端筛选Modal */}
|
||||
{isMobile && (
|
||||
<PricingFilterModal
|
||||
visible={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
sidebarProps={sidebarProps}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingTopSection;
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Card, Tag, Avatar, AvatarGroup, Typography } from '@douyinfe/semi-ui';
|
||||
import { getLobeHubIcon } from '../../../../../helpers';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
const PricingVendorIntro = ({
|
||||
filterVendor,
|
||||
models = [],
|
||||
allModels = [],
|
||||
t
|
||||
}) => {
|
||||
// 轮播动效状态(只对全部供应商生效)
|
||||
const [currentOffset, setCurrentOffset] = useState(0);
|
||||
|
||||
// 获取所有供应商信息
|
||||
const vendorInfo = useMemo(() => {
|
||||
const vendors = new Map();
|
||||
let unknownCount = 0;
|
||||
|
||||
(allModels.length > 0 ? allModels : models).forEach(model => {
|
||||
if (model.vendor_name) {
|
||||
if (!vendors.has(model.vendor_name)) {
|
||||
vendors.set(model.vendor_name, {
|
||||
name: model.vendor_name,
|
||||
icon: model.vendor_icon,
|
||||
description: model.vendor_description,
|
||||
count: 0
|
||||
});
|
||||
}
|
||||
vendors.get(model.vendor_name).count++;
|
||||
} else {
|
||||
unknownCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const vendorList = Array.from(vendors.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (unknownCount > 0) {
|
||||
vendorList.push({
|
||||
name: 'unknown',
|
||||
icon: null,
|
||||
description: t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。'),
|
||||
count: unknownCount
|
||||
});
|
||||
}
|
||||
|
||||
return vendorList;
|
||||
}, [allModels, models]);
|
||||
|
||||
// 计算当前过滤器的模型数量
|
||||
const currentModelCount = models.length;
|
||||
|
||||
// 设置轮播定时器(只对全部供应商且有足够头像时生效)
|
||||
useEffect(() => {
|
||||
if (filterVendor !== 'all' || vendorInfo.length <= 3) {
|
||||
setCurrentOffset(0); // 重置偏移
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentOffset(prev => (prev + 1) % vendorInfo.length);
|
||||
}, 2000); // 每2秒切换一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [filterVendor, vendorInfo.length]);
|
||||
|
||||
// 获取供应商描述信息(从后端数据中)
|
||||
const getVendorDescription = (vendorKey) => {
|
||||
if (vendorKey === 'all') {
|
||||
return t('查看所有可用的AI模型供应商,包括众多知名供应商的模型。');
|
||||
}
|
||||
if (vendorKey === 'unknown') {
|
||||
return t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。');
|
||||
}
|
||||
const vendor = vendorInfo.find(v => v.name === vendorKey);
|
||||
return vendor?.description || t('该供应商提供多种AI模型,适用于不同的应用场景。');
|
||||
};
|
||||
|
||||
// 为全部供应商创建特殊的头像组合
|
||||
const renderAllVendorsAvatar = () => {
|
||||
// 重新排列数组,让当前偏移量的头像在第一位
|
||||
const rotatedVendors = vendorInfo.length > 3 ? [
|
||||
...vendorInfo.slice(currentOffset),
|
||||
...vendorInfo.slice(0, currentOffset)
|
||||
] : vendorInfo;
|
||||
|
||||
// 如果没有供应商,显示占位符
|
||||
if (vendorInfo.length === 0) {
|
||||
return (
|
||||
<div className="min-w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center px-2">
|
||||
<Avatar size="default" color="transparent">
|
||||
AI
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center px-2">
|
||||
<AvatarGroup
|
||||
maxCount={4}
|
||||
size="default"
|
||||
overlapFrom='end'
|
||||
key={currentOffset}
|
||||
renderMore={(restNumber) => (
|
||||
<Avatar
|
||||
size="default"
|
||||
style={{ backgroundColor: 'transparent', color: 'var(--semi-color-text-0)' }}
|
||||
alt={`${restNumber} more vendors`}
|
||||
>
|
||||
{`+${restNumber}`}
|
||||
</Avatar>
|
||||
)}
|
||||
>
|
||||
{rotatedVendors.map((vendor) => (
|
||||
<Avatar
|
||||
key={vendor.name}
|
||||
size="default"
|
||||
color="transparent"
|
||||
alt={vendor.name === 'unknown' ? t('未知供应商') : vendor.name}
|
||||
>
|
||||
{vendor.icon ?
|
||||
getLobeHubIcon(vendor.icon, 20) :
|
||||
(vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase())
|
||||
}
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 为具体供应商渲染单个图标
|
||||
const renderVendorAvatar = (vendor) => (
|
||||
<div className="w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center">
|
||||
{vendor.icon ?
|
||||
getLobeHubIcon(vendor.icon, 40) :
|
||||
<Avatar size="large" color="transparent">
|
||||
{vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 如果是全部供应商
|
||||
if (filterVendor === 'all') {
|
||||
return (
|
||||
<div className='mb-4'>
|
||||
<Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
|
||||
<div className="flex items-start space-x-3 md:space-x-4">
|
||||
{/* 全部供应商的头像组合 */}
|
||||
<div className="flex-shrink-0">
|
||||
{renderAllVendorsAvatar()}
|
||||
</div>
|
||||
|
||||
{/* 供应商信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 truncate">{t('全部供应商')}</h2>
|
||||
<Tag color="white" shape="circle" size="small" className="self-start sm:self-center">
|
||||
{t('共 {{count}} 个模型', { count: currentModelCount })}
|
||||
</Tag>
|
||||
</div>
|
||||
<Paragraph
|
||||
className="text-xs sm:text-sm text-gray-600 leading-relaxed !mb-0"
|
||||
ellipsis={{
|
||||
rows: 2,
|
||||
expandable: true,
|
||||
collapsible: true,
|
||||
collapseText: t('收起'),
|
||||
expandText: t('展开')
|
||||
}}
|
||||
>
|
||||
{getVendorDescription('all')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 具体供应商
|
||||
const currentVendor = vendorInfo.find(v => v.name === filterVendor);
|
||||
if (!currentVendor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const vendorDisplayName = currentVendor.name === 'unknown' ? t('未知供应商') : currentVendor.name;
|
||||
|
||||
return (
|
||||
<div className='mb-4'>
|
||||
<Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
|
||||
<div className="flex items-start space-x-3 md:space-x-4">
|
||||
{/* 供应商图标 */}
|
||||
<div className="flex-shrink-0">
|
||||
{renderVendorAvatar(currentVendor)}
|
||||
</div>
|
||||
|
||||
{/* 供应商信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 truncate">{vendorDisplayName}</h2>
|
||||
<Tag color="white" shape="circle" size="small" className="self-start sm:self-center">
|
||||
{t('共 {{count}} 个模型', { count: currentModelCount })}
|
||||
</Tag>
|
||||
</div>
|
||||
<Paragraph
|
||||
className="text-xs sm:text-sm text-gray-600 leading-relaxed !mb-0"
|
||||
ellipsis={{
|
||||
rows: 2,
|
||||
expandable: true,
|
||||
collapsible: true,
|
||||
collapseText: t('收起'),
|
||||
expandText: t('展开')
|
||||
}}
|
||||
>
|
||||
{currentVendor.description || getVendorDescription(currentVendor.name)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingVendorIntro;
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Skeleton } from '@douyinfe/semi-ui';
|
||||
|
||||
const PricingVendorIntroSkeleton = ({
|
||||
isAllVendors = false
|
||||
}) => {
|
||||
const placeholder = (
|
||||
<div className='mb-4'>
|
||||
<Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
|
||||
<div className="flex items-start space-x-3 md:space-x-4">
|
||||
{/* 供应商图标骨架 */}
|
||||
<div className="flex-shrink-0 min-w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center px-2">
|
||||
{isAllVendors ? (
|
||||
<div className="flex items-center">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton.Avatar
|
||||
key={index}
|
||||
active
|
||||
size="default"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
marginRight: index < 3 ? -8 : 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton.Avatar active size="large" style={{ width: 40, height: 40, borderRadius: 8 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 供应商信息骨架 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
|
||||
<Skeleton.Title active style={{ width: 120, height: 24, marginBottom: 0 }} />
|
||||
<Skeleton.Button active size="small" style={{ width: 80, height: 20, borderRadius: 12 }} />
|
||||
</div>
|
||||
<Skeleton.Paragraph
|
||||
active
|
||||
rows={2}
|
||||
style={{ marginBottom: 0 }}
|
||||
title={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingVendorIntroSkeleton;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PricingVendorIntro from './PricingVendorIntro';
|
||||
import PricingVendorIntroSkeleton from './PricingVendorIntroSkeleton';
|
||||
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
|
||||
|
||||
const PricingVendorIntroWithSkeleton = ({
|
||||
loading = false,
|
||||
filterVendor,
|
||||
models,
|
||||
allModels,
|
||||
t
|
||||
}) => {
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<PricingVendorIntroSkeleton
|
||||
isAllVendors={filterVendor === 'all'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingVendorIntro
|
||||
filterVendor={filterVendor}
|
||||
models={models}
|
||||
allModels={allModels}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingVendorIntroWithSkeleton;
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
SideSheet,
|
||||
Typography,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconClose,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import ModelHeader from './components/ModelHeader';
|
||||
import ModelBasicInfo from './components/ModelBasicInfo';
|
||||
import ModelEndpoints from './components/ModelEndpoints';
|
||||
import ModelPricingTable from './components/ModelPricingTable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelDetailSideSheet = ({
|
||||
visible,
|
||||
onClose,
|
||||
modelData,
|
||||
groupRatio,
|
||||
currency,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
usableGroup,
|
||||
vendorsMap,
|
||||
endpointMap,
|
||||
autoGroups,
|
||||
t,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement="right"
|
||||
title={<ModelHeader modelData={modelData} vendorsMap={vendorsMap} t={t} />}
|
||||
bodyStyle={{
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderBottom: '1px solid var(--semi-color-border)'
|
||||
}}
|
||||
visible={visible}
|
||||
width={isMobile ? '100%' : 600}
|
||||
closeIcon={
|
||||
<Button
|
||||
className="semi-button-tertiary semi-button-size-small semi-button-borderless"
|
||||
type="button"
|
||||
icon={<IconClose />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="p-2">
|
||||
{!modelData && (
|
||||
<div className="flex justify-center items-center py-10">
|
||||
<Text type="secondary">{t('加载中...')}</Text>
|
||||
</div>
|
||||
)}
|
||||
{modelData && (
|
||||
<>
|
||||
<ModelBasicInfo modelData={modelData} vendorsMap={vendorsMap} t={t} />
|
||||
<ModelEndpoints modelData={modelData} endpointMap={endpointMap} t={t} />
|
||||
<ModelPricingTable
|
||||
modelData={modelData}
|
||||
groupRatio={groupRatio}
|
||||
currency={currency}
|
||||
tokenUnit={tokenUnit}
|
||||
displayPrice={displayPrice}
|
||||
showRatio={showRatio}
|
||||
usableGroup={usableGroup}
|
||||
autoGroups={autoGroups}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDetailSideSheet;
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { resetPricingFilters } from '../../../../helpers/utils';
|
||||
import FilterModalContent from './components/FilterModalContent';
|
||||
import FilterModalFooter from './components/FilterModalFooter';
|
||||
|
||||
const PricingFilterModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
sidebarProps,
|
||||
t
|
||||
}) => {
|
||||
const handleResetFilters = () =>
|
||||
resetPricingFilters({
|
||||
handleChange: sidebarProps.handleChange,
|
||||
setShowWithRecharge: sidebarProps.setShowWithRecharge,
|
||||
setCurrency: sidebarProps.setCurrency,
|
||||
setShowRatio: sidebarProps.setShowRatio,
|
||||
setViewMode: sidebarProps.setViewMode,
|
||||
setFilterGroup: sidebarProps.setFilterGroup,
|
||||
setFilterQuotaType: sidebarProps.setFilterQuotaType,
|
||||
setFilterEndpointType: sidebarProps.setFilterEndpointType,
|
||||
setFilterVendor: sidebarProps.setFilterVendor,
|
||||
setCurrentPage: sidebarProps.setCurrentPage,
|
||||
setTokenUnit: sidebarProps.setTokenUnit,
|
||||
});
|
||||
|
||||
const footer = (
|
||||
<FilterModalFooter
|
||||
onReset={handleResetFilters}
|
||||
onConfirm={onClose}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('筛选')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={footer}
|
||||
style={{ width: '100%', height: '100%', margin: 0 }}
|
||||
bodyStyle={{
|
||||
padding: 0,
|
||||
height: 'calc(100vh - 160px)',
|
||||
overflowY: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}}
|
||||
>
|
||||
<FilterModalContent sidebarProps={sidebarProps} t={t} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingFilterModal;
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PricingDisplaySettings from '../../filter/PricingDisplaySettings';
|
||||
import PricingGroups from '../../filter/PricingGroups';
|
||||
import PricingQuotaTypes from '../../filter/PricingQuotaTypes';
|
||||
import PricingEndpointTypes from '../../filter/PricingEndpointTypes';
|
||||
import PricingVendors from '../../filter/PricingVendors';
|
||||
import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts';
|
||||
|
||||
const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
const {
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
handleChange,
|
||||
setActiveKey,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
filterGroup,
|
||||
setFilterGroup,
|
||||
filterQuotaType,
|
||||
setFilterQuotaType,
|
||||
filterEndpointType,
|
||||
setFilterEndpointType,
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
loading,
|
||||
...categoryProps
|
||||
} = sidebarProps;
|
||||
|
||||
const {
|
||||
quotaTypeModels,
|
||||
endpointTypeModels,
|
||||
vendorModels,
|
||||
groupCountModels,
|
||||
} = usePricingFilterCounts({
|
||||
models: categoryProps.models,
|
||||
filterGroup,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
searchValue: sidebarProps.searchValue,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<PricingDisplaySettings
|
||||
showWithRecharge={showWithRecharge}
|
||||
setShowWithRecharge={setShowWithRecharge}
|
||||
currency={currency}
|
||||
setCurrency={setCurrency}
|
||||
showRatio={showRatio}
|
||||
setShowRatio={setShowRatio}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
tokenUnit={tokenUnit}
|
||||
setTokenUnit={setTokenUnit}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingVendors
|
||||
filterVendor={filterVendor}
|
||||
setFilterVendor={setFilterVendor}
|
||||
models={vendorModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingGroups
|
||||
filterGroup={filterGroup}
|
||||
setFilterGroup={setFilterGroup}
|
||||
usableGroup={categoryProps.usableGroup}
|
||||
groupRatio={categoryProps.groupRatio}
|
||||
models={groupCountModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingQuotaTypes
|
||||
filterQuotaType={filterQuotaType}
|
||||
setFilterQuotaType={setFilterQuotaType}
|
||||
models={quotaTypeModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingEndpointTypes
|
||||
filterEndpointType={filterEndpointType}
|
||||
setFilterEndpointType={setFilterEndpointType}
|
||||
models={endpointTypeModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterModalContent;
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
const FilterModalFooter = ({ onReset, onConfirm, t }) => {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
theme="outline"
|
||||
type='tertiary'
|
||||
onClick={onReset}
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterModalFooter;
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Typography, Tag, Space } from '@douyinfe/semi-ui';
|
||||
import { IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
import { stringToColor } from '../../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelBasicInfo = ({ modelData, vendorsMap = {}, t }) => {
|
||||
// 获取模型描述(使用后端真实数据)
|
||||
const getModelDescription = () => {
|
||||
if (!modelData) return t('暂无模型描述');
|
||||
|
||||
// 优先使用后端提供的描述
|
||||
if (modelData.description) {
|
||||
return modelData.description;
|
||||
}
|
||||
|
||||
// 如果没有描述但有供应商描述,显示供应商信息
|
||||
if (modelData.vendor_description) {
|
||||
return t('供应商信息:') + modelData.vendor_description;
|
||||
}
|
||||
|
||||
return t('暂无模型描述');
|
||||
};
|
||||
|
||||
// 获取模型标签
|
||||
const getModelTags = () => {
|
||||
const tags = [];
|
||||
|
||||
if (modelData?.tags) {
|
||||
const customTags = modelData.tags.split(',').filter(tag => tag.trim());
|
||||
customTags.forEach(tag => {
|
||||
const tagText = tag.trim();
|
||||
tags.push({ text: tagText, color: stringToColor(tagText) });
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<IconInfoCircle size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('基本信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('模型的详细描述和基本特性')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-600">
|
||||
<p className="mb-4">{getModelDescription()}</p>
|
||||
{getModelTags().length > 0 && (
|
||||
<Space wrap>
|
||||
{getModelTags().map((tag, index) => (
|
||||
<Tag
|
||||
key={index}
|
||||
color={tag.color}
|
||||
shape="circle"
|
||||
size="small"
|
||||
>
|
||||
{tag.text}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelBasicInfo;
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Typography, Badge } from '@douyinfe/semi-ui';
|
||||
import { IconLink } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelEndpoints = ({ modelData, endpointMap = {}, t }) => {
|
||||
const renderAPIEndpoints = () => {
|
||||
if (!modelData) return null;
|
||||
|
||||
const mapping = endpointMap;
|
||||
const types = modelData.supported_endpoint_types || [];
|
||||
|
||||
return types.map(type => {
|
||||
const info = mapping[type] || {};
|
||||
let path = info.path || '';
|
||||
// 如果路径中包含 {model} 占位符,替换为真实模型名称
|
||||
if (path.includes('{model}')) {
|
||||
const modelName = modelData.model_name || modelData.modelName || '';
|
||||
path = path.replaceAll('{model}', modelName);
|
||||
}
|
||||
const method = info.method || 'POST';
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className="flex justify-between border-b border-dashed last:border-0 py-2 last:pb-0"
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
<span className="flex items-center pr-5">
|
||||
<Badge dot type="success" className="mr-2" />
|
||||
{type}{path && ':'}
|
||||
{path && (
|
||||
<span className="text-gray-500 md:ml-1 break-all">
|
||||
{path}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{path && (
|
||||
<span className="text-gray-500 text-xs md:ml-1">
|
||||
{method}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Avatar size="small" color="purple" className="mr-2 shadow-md">
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('API端点')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('模型支持的接口端点信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderAPIEndpoints()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelEndpoints;
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography, Toast, Avatar } from '@douyinfe/semi-ui';
|
||||
import { getLobeHubIcon } from '../../../../../helpers';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
const CARD_STYLES = {
|
||||
container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md",
|
||||
icon: "w-8 h-8 flex items-center justify-center",
|
||||
};
|
||||
|
||||
const ModelHeader = ({ modelData, vendorsMap = {}, t }) => {
|
||||
// 获取模型图标(使用供应商图标)
|
||||
const getModelIcon = () => {
|
||||
// 优先使用供应商图标
|
||||
if (modelData?.vendor_icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<div className={CARD_STYLES.icon}>
|
||||
{getLobeHubIcon(modelData.vendor_icon, 32)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有供应商图标,使用模型名称的前两个字符
|
||||
const avatarText = modelData?.model_name?.slice(0, 2).toUpperCase() || 'AI';
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<Avatar
|
||||
size="large"
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{avatarText}
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{getModelIcon()}
|
||||
<div className="ml-3 font-normal">
|
||||
<Paragraph
|
||||
className="!mb-0 !text-lg !font-medium"
|
||||
copyable={{
|
||||
content: modelData?.model_name || '',
|
||||
onCopy: () => Toast.success({ content: t('已复制模型名称') })
|
||||
}}
|
||||
>
|
||||
<span className="truncate max-w-60 font-bold">{modelData?.model_name || t('未知模型')}</span>
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelHeader;
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Typography, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { IconCoinMoneyStroked } from '@douyinfe/semi-icons';
|
||||
import { calculateModelPrice } from '../../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelPricingTable = ({
|
||||
modelData,
|
||||
groupRatio,
|
||||
currency,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
usableGroup,
|
||||
autoGroups = [],
|
||||
t,
|
||||
}) => {
|
||||
const modelEnableGroups = Array.isArray(modelData?.enable_groups) ? modelData.enable_groups : [];
|
||||
const autoChain = autoGroups.filter(g => modelEnableGroups.includes(g));
|
||||
const renderGroupPriceTable = () => {
|
||||
// 仅展示模型可用的分组:模型 enable_groups 与用户可用分组的交集
|
||||
|
||||
const availableGroups = Object.keys(usableGroup || {})
|
||||
.filter(g => g !== '')
|
||||
.filter(g => g !== 'auto')
|
||||
.filter(g => modelEnableGroups.includes(g));
|
||||
|
||||
// 准备表格数据
|
||||
const tableData = availableGroups.map(group => {
|
||||
const priceData = modelData ? calculateModelPrice({
|
||||
record: modelData,
|
||||
selectedGroup: group,
|
||||
groupRatio,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency
|
||||
}) : { inputPrice: '-', outputPrice: '-', price: '-' };
|
||||
|
||||
// 获取分组倍率
|
||||
const groupRatioValue = groupRatio && groupRatio[group] ? groupRatio[group] : 1;
|
||||
|
||||
return {
|
||||
key: group,
|
||||
group: group,
|
||||
ratio: groupRatioValue,
|
||||
billingType: modelData?.quota_type === 0 ? t('按量计费') : t('按次计费'),
|
||||
inputPrice: modelData?.quota_type === 0 ? priceData.inputPrice : '-',
|
||||
outputPrice: modelData?.quota_type === 0 ? (priceData.completionPrice || priceData.outputPrice) : '-',
|
||||
fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-',
|
||||
};
|
||||
});
|
||||
|
||||
// 定义表格列
|
||||
const columns = [
|
||||
{
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
render: (text) => (
|
||||
<Tag color="white" size="small" shape="circle">
|
||||
{text}{t('分组')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 如果显示倍率,添加倍率列
|
||||
if (showRatio) {
|
||||
columns.push({
|
||||
title: t('倍率'),
|
||||
dataIndex: 'ratio',
|
||||
render: (text) => (
|
||||
<Tag color="white" size="small" shape="circle">
|
||||
{text}x
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// 添加计费类型列
|
||||
columns.push({
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'billingType',
|
||||
render: (text) => (
|
||||
<Tag color={text === t('按量计费') ? 'violet' : 'teal'} size="small" shape="circle">
|
||||
{text}
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
|
||||
// 根据计费类型添加价格列
|
||||
if (modelData?.quota_type === 0) {
|
||||
// 按量计费
|
||||
columns.push(
|
||||
{
|
||||
title: t('提示'),
|
||||
dataIndex: 'inputPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className="font-semibold text-orange-600">{text}</div>
|
||||
<div className="text-xs text-gray-500">/ {tokenUnit === 'K' ? '1K' : '1M'} tokens</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('补全'),
|
||||
dataIndex: 'outputPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className="font-semibold text-orange-600">{text}</div>
|
||||
<div className="text-xs text-gray-500">/ {tokenUnit === 'K' ? '1K' : '1M'} tokens</div>
|
||||
</>
|
||||
),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// 按次计费
|
||||
columns.push({
|
||||
title: t('价格'),
|
||||
dataIndex: 'fixedPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className="font-semibold text-orange-600">{text}</div>
|
||||
<div className="text-xs text-gray-500">/ 次</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered={false}
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-4">
|
||||
<Avatar size="small" color="orange" className="mr-2 shadow-md">
|
||||
<IconCoinMoneyStroked size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('分组价格')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('不同用户分组的价格信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{autoChain.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1 mb-4">
|
||||
<span className="text-sm text-gray-600">{t('auto分组调用链路')}</span>
|
||||
<span className="text-sm">→</span>
|
||||
{autoChain.map((g, idx) => (
|
||||
<React.Fragment key={g}>
|
||||
<Tag color="white" size="small" shape="circle">{g}{t('分组')}</Tag>
|
||||
{idx < autoChain.length - 1 && <span className="text-sm">→</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderGroupPriceTable()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPricingTable;
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Skeleton } from '@douyinfe/semi-ui';
|
||||
|
||||
const PricingCardSkeleton = ({
|
||||
skeletonCount = 10,
|
||||
rowSelection = false,
|
||||
showRatio = false
|
||||
}) => {
|
||||
const placeholder = (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="!rounded-2xl border border-gray-200"
|
||||
bodyStyle={{ padding: '24px' }}
|
||||
>
|
||||
{/* 头部:图标 + 模型名称 + 操作按钮 */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3 flex-1 min-w-0">
|
||||
{/* 模型图标骨架 */}
|
||||
<div className="w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm">
|
||||
<Skeleton.Avatar
|
||||
size="large"
|
||||
style={{ width: 48, height: 48, borderRadius: 16 }}
|
||||
/>
|
||||
</div>
|
||||
{/* 模型名称和价格区域 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 模型名称骨架 */}
|
||||
<Skeleton.Title
|
||||
style={{
|
||||
width: `${120 + (index % 3) * 30}px`,
|
||||
height: 20,
|
||||
marginBottom: 8
|
||||
}}
|
||||
/>
|
||||
{/* 价格信息骨架 */}
|
||||
<Skeleton.Title
|
||||
style={{
|
||||
width: `${160 + (index % 4) * 20}px`,
|
||||
height: 20,
|
||||
marginBottom: 0
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-3">
|
||||
{/* 复制按钮骨架 */}
|
||||
<Skeleton.Button size="small" style={{ width: 16, height: 16, borderRadius: 4 }} />
|
||||
{/* 勾选框骨架 */}
|
||||
{rowSelection && (
|
||||
<Skeleton.Button size="small" style={{ width: 16, height: 16, borderRadius: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型描述骨架 */}
|
||||
<div className="mb-4">
|
||||
<Skeleton.Paragraph
|
||||
rows={2}
|
||||
style={{ marginBottom: 0 }}
|
||||
title={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标签区域骨架 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: 2 + (index % 3) }).map((_, tagIndex) => (
|
||||
<Skeleton.Button
|
||||
key={tagIndex}
|
||||
size="small"
|
||||
style={{
|
||||
width: 64,
|
||||
height: 20,
|
||||
borderRadius: 10
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 倍率信息骨架(可选) */}
|
||||
{showRatio && (
|
||||
<div className="mt-4 pt-3 border-t border-gray-100">
|
||||
<div className="flex items-center space-x-1 mb-2">
|
||||
<Skeleton.Title
|
||||
style={{ width: 60, height: 12, marginBottom: 0 }}
|
||||
/>
|
||||
<Skeleton.Button size="small" style={{ width: 14, height: 14, borderRadius: 7 }} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Array.from({ length: 3 }).map((_, ratioIndex) => (
|
||||
<Skeleton.Title
|
||||
key={ratioIndex}
|
||||
style={{ width: '100%', height: 12, marginBottom: 0 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页骨架 */}
|
||||
<div className="flex justify-center mt-6 pt-4 border-t pricing-pagination-divider">
|
||||
<Skeleton.Button style={{ width: 300, height: 32 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingCardSkeleton;
|
||||
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Tag, Tooltip, Checkbox, Empty, Pagination, Button, Avatar } from '@douyinfe/semi-ui';
|
||||
import { IconHelpCircle, IconCopy } from '@douyinfe/semi-icons';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { stringToColor, calculateModelPrice, formatPriceInfo, getLobeHubIcon } from '../../../../../helpers';
|
||||
import PricingCardSkeleton from './PricingCardSkeleton';
|
||||
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
|
||||
import { renderLimitedItems } from '../../../../common/ui/RenderUtils';
|
||||
import { useIsMobile } from '../../../../../hooks/common/useIsMobile';
|
||||
|
||||
const CARD_STYLES = {
|
||||
container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md",
|
||||
icon: "w-8 h-8 flex items-center justify-center",
|
||||
selected: "border-blue-500 bg-blue-50",
|
||||
default: "border-gray-200 hover:border-gray-300"
|
||||
};
|
||||
|
||||
const PricingCardView = ({
|
||||
filteredModels,
|
||||
loading,
|
||||
rowSelection,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
t,
|
||||
selectedRowKeys = [],
|
||||
setSelectedRowKeys,
|
||||
openModelDetail,
|
||||
}) => {
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize);
|
||||
const getModelKey = (model) => model.key ?? model.model_name ?? model.id;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleCheckboxChange = (model, checked) => {
|
||||
if (!setSelectedRowKeys) return;
|
||||
const modelKey = getModelKey(model);
|
||||
const newKeys = checked
|
||||
? Array.from(new Set([...selectedRowKeys, modelKey]))
|
||||
: selectedRowKeys.filter((key) => key !== modelKey);
|
||||
setSelectedRowKeys(newKeys);
|
||||
rowSelection?.onChange?.(newKeys, null);
|
||||
};
|
||||
|
||||
// 获取模型图标
|
||||
const getModelIcon = (model) => {
|
||||
if (!model || !model.model_name) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<Avatar size='large'>?</Avatar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 优先使用供应商图标
|
||||
if (model.vendor_icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<div className={CARD_STYLES.icon}>
|
||||
{getLobeHubIcon(model.vendor_icon, 32)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有供应商图标,使用模型名称生成头像
|
||||
|
||||
const avatarText = model.model_name.slice(0, 2).toUpperCase();
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<Avatar
|
||||
size="large"
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{avatarText}
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 获取模型描述
|
||||
const getModelDescription = (record) => {
|
||||
return record.description || '';
|
||||
};
|
||||
|
||||
// 渲染价格信息
|
||||
const renderPriceInfo = (record) => {
|
||||
const priceData = calculateModelPrice({
|
||||
record,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency
|
||||
});
|
||||
return formatPriceInfo(priceData, t);
|
||||
};
|
||||
|
||||
// 渲染标签
|
||||
const renderTags = (record) => {
|
||||
// 计费类型标签(左边)
|
||||
const billingType = record.quota_type === 1 ? 'teal' : 'violet';
|
||||
const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费');
|
||||
const billingTag = (
|
||||
<Tag key="billing" shape='circle' color={billingType} size='small'>
|
||||
{billingText}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
// 自定义标签(右边)
|
||||
const customTags = [];
|
||||
if (record.tags) {
|
||||
const tagArr = record.tags.split(',').filter(Boolean);
|
||||
tagArr.forEach((tg, idx) => {
|
||||
customTags.push(
|
||||
<Tag key={`custom-${idx}`} shape='circle' color={stringToColor(tg)} size='small'>
|
||||
{tg}
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{billingTag}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{renderLimitedItems({
|
||||
items: customTags.map((tag, idx) => ({ key: `custom-${idx}`, element: tag })),
|
||||
renderItem: (item, idx) => item.element,
|
||||
maxDisplay: 3
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 显示骨架屏
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<PricingCardSkeleton
|
||||
rowSelection={!!rowSelection}
|
||||
showRatio={showRatio}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!filteredModels || filteredModels.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
|
||||
{paginatedModels.map((model, index) => {
|
||||
const modelKey = getModelKey(model);
|
||||
const isSelected = selectedRowKeys.includes(modelKey);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={modelKey || index}
|
||||
className={`!rounded-2xl transition-all duration-200 hover:shadow-lg border cursor-pointer ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`}
|
||||
bodyStyle={{ height: '100%' }}
|
||||
onClick={() => openModelDetail && openModelDetail(model)}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 头部:图标 + 模型名称 + 操作按钮 */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3 flex-1 min-w-0">
|
||||
{getModelIcon(model)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 truncate">
|
||||
{model.model_name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-xs mt-1">
|
||||
{renderPriceInfo(model)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-3">
|
||||
{/* 复制按钮 */}
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
icon={<IconCopy />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyText(model.model_name);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 选择框 */}
|
||||
{rowSelection && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCheckboxChange(model, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型描述 - 占据剩余空间 */}
|
||||
<div className="flex-1 mb-4">
|
||||
<p
|
||||
className="text-xs line-clamp-2 leading-relaxed"
|
||||
style={{ color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
{getModelDescription(model)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 底部区域 */}
|
||||
<div className="mt-auto">
|
||||
{/* 标签区域 */}
|
||||
<div className="mb-3">
|
||||
{renderTags(model)}
|
||||
</div>
|
||||
|
||||
{/* 倍率信息(可选) */}
|
||||
{showRatio && (
|
||||
<div
|
||||
className="pt-3 border-t border-dashed"
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
<div className="flex items-center space-x-1 mb-2">
|
||||
<span className="text-xs font-medium text-gray-700">{t('倍率信息')}</span>
|
||||
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
|
||||
<IconHelpCircle
|
||||
className="text-blue-500 cursor-pointer"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModalImageUrl('/ratio.png');
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs text-gray-600">
|
||||
<div>
|
||||
{t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')}
|
||||
</div>
|
||||
<div>
|
||||
{t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')}
|
||||
</div>
|
||||
<div>
|
||||
{t('分组')}: {groupRatio[selectedGroup]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{filteredModels.length > 0 && (
|
||||
<div className="flex justify-center mt-6 pt-4 border-t pricing-pagination-divider">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredModels.length}
|
||||
showSizeChanger={true}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
size={isMobile ? 'small' : 'default'}
|
||||
showQuickJumper={isMobile}
|
||||
onPageChange={(page) => setCurrentPage(page)}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingCardView;
|
||||
+29
-23
@@ -23,82 +23,88 @@ import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getModelPricingColumns } from './ModelPricingColumnDefs.js';
|
||||
import { getPricingTableColumns } from './PricingTableColumns';
|
||||
|
||||
const ModelPricingTable = ({
|
||||
const PricingTable = ({
|
||||
filteredModels,
|
||||
loading,
|
||||
rowSelection,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
selectedGroup,
|
||||
usableGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
showWithRecharge,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
displayPrice,
|
||||
filteredValue,
|
||||
handleGroupClick,
|
||||
searchValue,
|
||||
showRatio,
|
||||
compactMode = false,
|
||||
openModelDetail,
|
||||
t
|
||||
}) => {
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return getModelPricingColumns({
|
||||
return getPricingTableColumns({
|
||||
t,
|
||||
selectedGroup,
|
||||
usableGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
showWithRecharge,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
displayPrice,
|
||||
handleGroupClick,
|
||||
showRatio,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
selectedGroup,
|
||||
usableGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
showWithRecharge,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
displayPrice,
|
||||
handleGroupClick,
|
||||
showRatio,
|
||||
]);
|
||||
|
||||
// 更新列定义中的 filteredValue
|
||||
const tableColumns = useMemo(() => {
|
||||
return columns.map(column => {
|
||||
// 更新列定义中的 searchValue
|
||||
const processedColumns = useMemo(() => {
|
||||
const cols = columns.map(column => {
|
||||
if (column.dataIndex === 'model_name') {
|
||||
return {
|
||||
...column,
|
||||
filteredValue
|
||||
filteredValue: searchValue ? [searchValue] : []
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
}, [columns, filteredValue]);
|
||||
|
||||
// Remove fixed property when in compact mode (mobile view)
|
||||
if (compactMode) {
|
||||
return cols.map(({ fixed, ...rest }) => rest);
|
||||
}
|
||||
return cols;
|
||||
}, [columns, searchValue, compactMode]);
|
||||
|
||||
const ModelTable = useMemo(() => (
|
||||
<Card className="!rounded-xl overflow-hidden" bordered={false}>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
columns={processedColumns}
|
||||
dataSource={filteredModels}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
className="custom-table"
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => openModelDetail && openModelDetail(record),
|
||||
style: { cursor: 'pointer' }
|
||||
})}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
@@ -116,9 +122,9 @@ const ModelPricingTable = ({
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
), [filteredModels, loading, tableColumns, rowSelection, pageSize, setPageSize, t]);
|
||||
), [filteredModels, loading, processedColumns, rowSelection, pageSize, setPageSize, openModelDetail, t, compactMode]);
|
||||
|
||||
return ModelTable;
|
||||
};
|
||||
|
||||
export default ModelPricingTable;
|
||||
export default PricingTable;
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tag, Space, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { renderModelTag, stringToColor, calculateModelPrice, getLobeHubIcon } from '../../../../../helpers';
|
||||
import { renderLimitedItems, renderDescription } from '../../../../common/ui/RenderUtils';
|
||||
|
||||
function renderQuotaType(type, t) {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='teal' shape='circle'>
|
||||
{t('按次计费')}
|
||||
</Tag>
|
||||
);
|
||||
case 0:
|
||||
return (
|
||||
<Tag color='violet' shape='circle'>
|
||||
{t('按量计费')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return t('未知');
|
||||
}
|
||||
}
|
||||
|
||||
// Render vendor name
|
||||
const renderVendor = (vendorName, vendorIcon, t) => {
|
||||
if (!vendorName) return '-';
|
||||
return (
|
||||
<Tag color='white' shape='circle' prefixIcon={getLobeHubIcon(vendorIcon || 'Layers', 14)}>
|
||||
{vendorName}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// Render tags list using RenderUtils
|
||||
const renderTags = (text) => {
|
||||
if (!text) return '-';
|
||||
const tagsArr = text.split(',').filter(tag => tag.trim());
|
||||
return renderLimitedItems({
|
||||
items: tagsArr,
|
||||
renderItem: (tag, idx) => (
|
||||
<Tag key={idx} color={stringToColor(tag.trim())} shape='circle' size='small'>
|
||||
{tag.trim()}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3
|
||||
});
|
||||
};
|
||||
|
||||
function renderSupportedEndpoints(endpoints) {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Space wrap>
|
||||
{endpoints.map((endpoint, idx) => (
|
||||
<Tag
|
||||
key={endpoint}
|
||||
color={stringToColor(endpoint)}
|
||||
shape='circle'
|
||||
>
|
||||
{endpoint}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export const getPricingTableColumns = ({
|
||||
t,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
}) => {
|
||||
const endpointColumn = {
|
||||
title: t('可用端点类型'),
|
||||
dataIndex: 'supported_endpoint_types',
|
||||
render: (text, record, index) => {
|
||||
return renderSupportedEndpoints(text);
|
||||
},
|
||||
};
|
||||
|
||||
const modelNameColumn = {
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
render: (text, record, index) => {
|
||||
return renderModelTag(text, {
|
||||
onClick: () => {
|
||||
copyText(text);
|
||||
}
|
||||
});
|
||||
},
|
||||
onFilter: (value, record) =>
|
||||
record.model_name.toLowerCase().includes(value.toLowerCase()),
|
||||
};
|
||||
|
||||
const quotaColumn = {
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'quota_type',
|
||||
render: (text, record, index) => {
|
||||
return renderQuotaType(parseInt(text), t);
|
||||
},
|
||||
sorter: (a, b) => a.quota_type - b.quota_type,
|
||||
};
|
||||
|
||||
const descriptionColumn = {
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
render: (text) => renderDescription(text, 200),
|
||||
};
|
||||
|
||||
const tagsColumn = {
|
||||
title: t('标签'),
|
||||
dataIndex: 'tags',
|
||||
render: renderTags,
|
||||
};
|
||||
|
||||
const vendorColumn = {
|
||||
title: t('供应商'),
|
||||
dataIndex: 'vendor_name',
|
||||
render: (text, record) => renderVendor(text, record.vendor_icon, t),
|
||||
};
|
||||
|
||||
const baseColumns = [modelNameColumn, vendorColumn, descriptionColumn, tagsColumn, quotaColumn];
|
||||
|
||||
const ratioColumn = {
|
||||
title: () => (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>{t('倍率')}</span>
|
||||
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
|
||||
<IconHelpCircle
|
||||
className="text-blue-500 cursor-pointer"
|
||||
onClick={() => {
|
||||
setModalImageUrl('/ratio.png');
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'model_ratio',
|
||||
render: (text, record, index) => {
|
||||
const completionRatio = parseFloat(record.completion_ratio.toFixed(3));
|
||||
const content = (
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-700">
|
||||
{t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('补全倍率')}:
|
||||
{record.quota_type === 0 ? completionRatio : t('无')}
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('分组倍率')}:{groupRatio[selectedGroup]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return content;
|
||||
},
|
||||
};
|
||||
|
||||
const priceColumn = {
|
||||
title: t('模型价格'),
|
||||
dataIndex: 'model_price',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
const priceData = calculateModelPrice({
|
||||
record,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency
|
||||
});
|
||||
|
||||
if (priceData.isPerToken) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-700">
|
||||
{t('提示')} {priceData.inputPrice} / 1{priceData.unitLabel} tokens
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('补全')} {priceData.completionPrice} / 1{priceData.unitLabel} tokens
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="text-gray-700">
|
||||
{t('模型价格')}:{priceData.price}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const columns = [...baseColumns];
|
||||
columns.push(endpointColumn);
|
||||
if (showRatio) {
|
||||
columns.push(ratioColumn);
|
||||
}
|
||||
columns.push(priceColumn);
|
||||
return columns;
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import MissingModelsModal from './modals/MissingModelsModal.jsx';
|
||||
import PrefillGroupManagement from './modals/PrefillGroupManagement.jsx';
|
||||
import EditPrefillGroupModal from './modals/EditPrefillGroupModal.jsx';
|
||||
import { Button, Modal } from '@douyinfe/semi-ui';
|
||||
import { showSuccess, showError, copy } from '../../../helpers';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
import SelectionNotification from './components/SelectionNotification.jsx';
|
||||
|
||||
const ModelsActions = ({
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
batchDeleteModels,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
t,
|
||||
}) => {
|
||||
// Modal states
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showMissingModal, setShowMissingModal] = useState(false);
|
||||
const [showGroupManagement, setShowGroupManagement] = useState(false);
|
||||
const [showAddPrefill, setShowAddPrefill] = useState(false);
|
||||
const [prefillInit, setPrefillInit] = useState({ id: undefined });
|
||||
|
||||
// Handle delete selected models with confirmation
|
||||
const handleDeleteSelectedModels = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleConfirmDelete = () => {
|
||||
batchDeleteModels();
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
// Handle clear selection
|
||||
const handleClearSelected = () => {
|
||||
setSelectedKeys([]);
|
||||
};
|
||||
|
||||
// Handle add selected models to prefill group
|
||||
const handleCopyNames = async () => {
|
||||
const text = selectedKeys.map(m => m.model_name).join(',');
|
||||
if (!text) return;
|
||||
const ok = await copy(text);
|
||||
if (ok) {
|
||||
showSuccess(t('已复制模型名称'));
|
||||
} else {
|
||||
showError(t('复制失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToPrefill = () => {
|
||||
// Prepare initial data
|
||||
const items = selectedKeys.map((m) => m.model_name);
|
||||
setPrefillInit({ id: undefined, type: 'model', items });
|
||||
setShowAddPrefill(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
type="primary"
|
||||
className="flex-1 md:flex-initial"
|
||||
onClick={() => {
|
||||
setEditingModel({
|
||||
id: undefined,
|
||||
});
|
||||
setShowEdit(true);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="secondary"
|
||||
className="flex-1 md:flex-initial"
|
||||
size="small"
|
||||
onClick={() => setShowMissingModal(true)}
|
||||
>
|
||||
{t('未配置模型')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="secondary"
|
||||
className="flex-1 md:flex-initial"
|
||||
size="small"
|
||||
onClick={() => setShowGroupManagement(true)}
|
||||
>
|
||||
{t('预填组管理')}
|
||||
</Button>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectionNotification
|
||||
selectedKeys={selectedKeys}
|
||||
t={t}
|
||||
onDelete={handleDeleteSelectedModels}
|
||||
onAddPrefill={handleAddToPrefill}
|
||||
onClear={handleClearSelected}
|
||||
onCopy={handleCopyNames}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('批量删除模型')}
|
||||
visible={showDeleteModal}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
onOk={handleConfirmDelete}
|
||||
type="warning"
|
||||
>
|
||||
<div>
|
||||
{t('确定要删除所选的 {{count}} 个模型吗?', { count: selectedKeys.length })}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<MissingModelsModal
|
||||
visible={showMissingModal}
|
||||
onClose={() => setShowMissingModal(false)}
|
||||
onConfigureModel={(name) => {
|
||||
setEditingModel({ id: undefined, model_name: name });
|
||||
setShowEdit(true);
|
||||
setShowMissingModal(false);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PrefillGroupManagement
|
||||
visible={showGroupManagement}
|
||||
onClose={() => setShowGroupManagement(false)}
|
||||
/>
|
||||
|
||||
<EditPrefillGroupModal
|
||||
visible={showAddPrefill}
|
||||
onClose={() => setShowAddPrefill(false)}
|
||||
editingGroup={prefillInit}
|
||||
onSuccess={() => setShowAddPrefill(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsActions;
|
||||
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Space, Tag, Typography, Modal } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
timestamp2string,
|
||||
getLobeHubIcon,
|
||||
stringToColor
|
||||
} from '../../../helpers';
|
||||
import { renderLimitedItems, renderDescription } from '../../common/ui/RenderUtils';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// Render timestamp
|
||||
function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
}
|
||||
|
||||
// Render vendor column with icon
|
||||
const renderVendorTag = (vendorId, vendorMap, t) => {
|
||||
if (!vendorId || !vendorMap[vendorId]) return '-';
|
||||
const v = vendorMap[vendorId];
|
||||
return (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
prefixIcon={getLobeHubIcon(v.icon || 'Layers', 14)}
|
||||
>
|
||||
{v.name}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// Render groups (enable_groups)
|
||||
const renderGroups = (groups) => {
|
||||
if (!groups || groups.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: groups,
|
||||
renderItem: (g, idx) => (
|
||||
<Tag key={idx} size="small" shape='circle' color={stringToColor(g)}>
|
||||
{g}
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Render tags
|
||||
const renderTags = (text) => {
|
||||
if (!text) return '-';
|
||||
const tagsArr = text.split(',').filter(Boolean);
|
||||
return renderLimitedItems({
|
||||
items: tagsArr,
|
||||
renderItem: (tag, idx) => (
|
||||
<Tag key={idx} size="small" shape='circle' color={stringToColor(tag)}>
|
||||
{tag}
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Render endpoints (supports object map or legacy array)
|
||||
const renderEndpoints = (value) => {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const keys = Object.keys(parsed || {});
|
||||
if (keys.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: keys,
|
||||
renderItem: (key, idx) => (
|
||||
<Tag key={idx} size="small" shape='circle' color={stringToColor(key)}>
|
||||
{key}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: parsed,
|
||||
renderItem: (ep, idx) => (
|
||||
<Tag key={idx} color="white" size="small" shape='circle'>
|
||||
{ep}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
}
|
||||
return value || '-';
|
||||
} catch (_) {
|
||||
return value || '-';
|
||||
}
|
||||
};
|
||||
|
||||
// Render quota type
|
||||
const renderQuotaType = (qt, t) => {
|
||||
if (qt === 1) {
|
||||
return (
|
||||
<Tag color='teal' size='small' shape='circle'>
|
||||
{t('按次计费')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (qt === 0) {
|
||||
return (
|
||||
<Tag color='violet' size='small' shape='circle'>
|
||||
{t('按量计费')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return qt ?? '-';
|
||||
};
|
||||
|
||||
// Render bound channels
|
||||
const renderBoundChannels = (channels) => {
|
||||
if (!channels || channels.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: channels,
|
||||
renderItem: (c, idx) => (
|
||||
<Tag key={idx} color="white" size="small" shape='circle'>
|
||||
{c.name}({c.type})
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Render operations column
|
||||
const renderOperations = (text, record, setEditingModel, setShowEdit, manageModel, refresh, t) => {
|
||||
return (
|
||||
<Space wrap>
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size="small"
|
||||
onClick={() => manageModel(record.id, 'disable', record)}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => manageModel(record.id, 'enable', record)}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditingModel(record);
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='danger'
|
||||
size="small"
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除此模型?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => {
|
||||
(async () => {
|
||||
await manageModel(record.id, 'delete', record);
|
||||
await refresh();
|
||||
})();
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
// 名称匹配类型渲染
|
||||
const renderNameRule = (rule, t) => {
|
||||
const map = {
|
||||
0: { color: 'green', label: t('精确') },
|
||||
1: { color: 'blue', label: t('前缀') },
|
||||
2: { color: 'orange', label: t('包含') },
|
||||
3: { color: 'purple', label: t('后缀') },
|
||||
};
|
||||
const cfg = map[rule];
|
||||
if (!cfg) return '-';
|
||||
return (
|
||||
<Tag color={cfg.color} size="small" shape='circle'>
|
||||
{cfg.label}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export const getModelsColumns = ({
|
||||
t,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
render: (text) => (
|
||||
<Text copyable onClick={(e) => e.stopPropagation()}>
|
||||
{text}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('匹配类型'),
|
||||
dataIndex: 'name_rule',
|
||||
render: (val) => renderNameRule(val, t),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
render: (text) => renderDescription(text, 200),
|
||||
},
|
||||
{
|
||||
title: t('供应商'),
|
||||
dataIndex: 'vendor_id',
|
||||
render: (vendorId, record) => renderVendorTag(vendorId, vendorMap, t),
|
||||
},
|
||||
{
|
||||
title: t('标签'),
|
||||
dataIndex: 'tags',
|
||||
render: renderTags,
|
||||
},
|
||||
{
|
||||
title: t('端点'),
|
||||
dataIndex: 'endpoints',
|
||||
render: renderEndpoints,
|
||||
},
|
||||
{
|
||||
title: t('已绑定渠道'),
|
||||
dataIndex: 'bound_channels',
|
||||
render: renderBoundChannels,
|
||||
},
|
||||
{
|
||||
title: t('可用分组'),
|
||||
dataIndex: 'enable_groups',
|
||||
render: renderGroups,
|
||||
},
|
||||
{
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'quota_type',
|
||||
render: (qt) => renderQuotaType(qt, t),
|
||||
},
|
||||
{
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'created_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('更新时间'),
|
||||
dataIndex: 'updated_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => renderOperations(
|
||||
text,
|
||||
record,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
manageModel,
|
||||
refresh,
|
||||
t
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { Layers } from 'lucide-react';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelsDescription = ({ compactMode, setCompactMode, t }) => {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||
<div className="flex items-center text-green-500">
|
||||
<Layers size={16} className="mr-2" />
|
||||
<Text>{t('模型管理')}</Text>
|
||||
</div>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsDescription;
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { Form, Button } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const ModelsFilters = ({
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchModels,
|
||||
loading,
|
||||
searching,
|
||||
t,
|
||||
}) => {
|
||||
// Handle form reset and immediate search
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const handleReset = () => {
|
||||
if (!formApiRef.current) return;
|
||||
formApiRef.current.reset();
|
||||
setTimeout(() => {
|
||||
searchModels();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => {
|
||||
setFormApi(api);
|
||||
formApiRef.current = api;
|
||||
}}
|
||||
onSubmit={searchModels}
|
||||
allowEmpty={true}
|
||||
autoComplete="off"
|
||||
layout="horizontal"
|
||||
trigger="change"
|
||||
stopValidateWithError={false}
|
||||
className="w-full md:w-auto order-1 md:order-2"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row items-center gap-2 w-full md:w-auto">
|
||||
<div className="relative w-full md:w-56">
|
||||
<Form.Input
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
showClear
|
||||
pure
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full md:w-56">
|
||||
<Form.Input
|
||||
field="searchVendor"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索供应商')}
|
||||
showClear
|
||||
pure
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
type="tertiary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
size="small"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={handleReset}
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
size="small"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsFilters;
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable.js';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getModelsColumns } from './ModelsColumnDefs.js';
|
||||
|
||||
const ModelsTable = (modelsData) => {
|
||||
const {
|
||||
models,
|
||||
loading,
|
||||
activePage,
|
||||
pageSize,
|
||||
modelCount,
|
||||
compactMode,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
rowSelection,
|
||||
handleRow,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
t,
|
||||
} = modelsData;
|
||||
|
||||
// Get all columns
|
||||
const columns = useMemo(() => {
|
||||
return getModelsColumns({
|
||||
t,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
});
|
||||
}, [t, manageModel, setEditingModel, setShowEdit, refresh, vendorMap]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode ? columns.map(col => {
|
||||
if (col.dataIndex === 'operate') {
|
||||
const { fixed, ...rest } = col;
|
||||
return rest;
|
||||
}
|
||||
return col;
|
||||
}) : columns;
|
||||
}, [compactMode, columns]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={models}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: modelCount,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
hidePagination={true}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
onRow={handleRow}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsTable;
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tabs, TabPane, Tag, Button, Dropdown, Modal } from '@douyinfe/semi-ui';
|
||||
import { IconEdit, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { getLobeHubIcon, showError, showSuccess } from '../../../helpers';
|
||||
import { API } from '../../../helpers';
|
||||
|
||||
const ModelsTabs = ({
|
||||
activeVendorKey,
|
||||
setActiveVendorKey,
|
||||
vendorCounts,
|
||||
vendors,
|
||||
loadModels,
|
||||
activePage,
|
||||
pageSize,
|
||||
setActivePage,
|
||||
setShowAddVendor,
|
||||
setShowEditVendor,
|
||||
setEditingVendor,
|
||||
loadVendors,
|
||||
t
|
||||
}) => {
|
||||
const handleTabChange = (key) => {
|
||||
setActiveVendorKey(key);
|
||||
setActivePage(1);
|
||||
loadModels(1, pageSize, key);
|
||||
};
|
||||
|
||||
const handleEditVendor = (vendor, e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
|
||||
setEditingVendor(vendor);
|
||||
setShowEditVendor(true);
|
||||
};
|
||||
|
||||
const handleDeleteVendor = async (vendor, e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
|
||||
try {
|
||||
const res = await API.delete(`/api/vendors/${vendor.id}`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('供应商删除成功'));
|
||||
// 如果删除的是当前选中的供应商,切换到"全部"
|
||||
if (activeVendorKey === String(vendor.id)) {
|
||||
setActiveVendorKey('all');
|
||||
loadModels(1, pageSize, 'all');
|
||||
} else {
|
||||
loadModels(activePage, pageSize, activeVendorKey);
|
||||
}
|
||||
loadVendors(); // 重新加载供应商列表
|
||||
} else {
|
||||
showError(res.data.message || t('删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeVendorKey}
|
||||
type="card"
|
||||
collapsible
|
||||
onChange={handleTabChange}
|
||||
className="mb-2"
|
||||
tabBarExtraContent={
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => setShowAddVendor(true)}
|
||||
>
|
||||
{t('新增供应商')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<TabPane
|
||||
itemKey="all"
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
{t('全部')}
|
||||
<Tag color={activeVendorKey === 'all' ? 'red' : 'grey'} shape='circle'>
|
||||
{vendorCounts['all'] || 0}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{vendors.map((vendor) => {
|
||||
const key = String(vendor.id);
|
||||
const count = vendorCounts[vendor.id] || 0;
|
||||
return (
|
||||
<TabPane
|
||||
key={key}
|
||||
itemKey={key}
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
{getLobeHubIcon(vendor.icon || 'Layers', 14)}
|
||||
{vendor.name}
|
||||
<Tag color={activeVendorKey === key ? 'red' : 'grey'} shape='circle'>
|
||||
{count}
|
||||
</Tag>
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
icon={<IconEdit />}
|
||||
onClick={(e) => handleEditVendor(vendor, e)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
type="danger"
|
||||
icon={<IconDelete />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Modal.confirm({
|
||||
title: t('确认删除'),
|
||||
content: t('确定要删除供应商 "{{name}}" 吗?此操作不可撤销。', { name: vendor.name }),
|
||||
onOk: () => handleDeleteVendor(vendor, e),
|
||||
okText: t('删除'),
|
||||
cancelText: t('取消'),
|
||||
type: 'warning',
|
||||
okType: 'danger',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除')}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
onClickOutSide={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
theme="outline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t('操作')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsTabs;
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { Notification, Button, Space, Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
// 固定通知 ID,保持同一个实例即可避免闪烁
|
||||
const NOTICE_ID = 'models-batch-actions';
|
||||
|
||||
/**
|
||||
* SelectionNotification 选择通知组件
|
||||
* 1. 当 selectedKeys.length > 0 时,使用固定 id 创建/更新通知
|
||||
* 2. 当 selectedKeys 清空时关闭通知
|
||||
*/
|
||||
const SelectionNotification = ({ selectedKeys = [], t, onDelete, onAddPrefill, onClear, onCopy }) => {
|
||||
// 根据选中数量决定显示/隐藏或更新通知
|
||||
useEffect(() => {
|
||||
const selectedCount = selectedKeys.length;
|
||||
|
||||
if (selectedCount > 0) {
|
||||
const titleNode = (
|
||||
<Space wrap>
|
||||
<span>{t('批量操作')}</span>
|
||||
<Typography.Text type="tertiary" size="small">{t('已选择 {{count}} 个模型', { count: selectedCount })}</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Space wrap>
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
theme="solid"
|
||||
onClick={onClear}
|
||||
>
|
||||
{t('取消全选')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
theme="solid"
|
||||
onClick={onAddPrefill}
|
||||
>
|
||||
{t('加入预填组')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="secondary"
|
||||
theme="solid"
|
||||
onClick={onCopy}
|
||||
>
|
||||
{t('复制名称')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="danger"
|
||||
theme="solid"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t('删除所选')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
|
||||
// 使用相同 id 更新通知(若已存在则就地更新,不存在则创建)
|
||||
Notification.info({
|
||||
id: NOTICE_ID,
|
||||
title: titleNode,
|
||||
content,
|
||||
duration: 0, // 不自动关闭
|
||||
position: 'bottom',
|
||||
showClose: false,
|
||||
});
|
||||
} else {
|
||||
// 取消全部勾选时关闭通知
|
||||
Notification.close(NOTICE_ID);
|
||||
}
|
||||
}, [selectedKeys, t, onDelete, onAddPrefill, onClear, onCopy]);
|
||||
|
||||
// 卸载时确保关闭通知
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Notification.close(NOTICE_ID);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null; // 该组件不渲染可见内容
|
||||
};
|
||||
|
||||
export default SelectionNotification;
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import ModelsTable from './ModelsTable.jsx';
|
||||
import ModelsActions from './ModelsActions.jsx';
|
||||
import ModelsFilters from './ModelsFilters.jsx';
|
||||
import ModelsTabs from './ModelsTabs.jsx';
|
||||
import EditModelModal from './modals/EditModelModal.jsx';
|
||||
import EditVendorModal from './modals/EditVendorModal.jsx';
|
||||
import { useModelsData } from '../../../hooks/models/useModelsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
|
||||
const ModelsPage = () => {
|
||||
const modelsData = useModelsData();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
// Edit state
|
||||
showEdit,
|
||||
editingModel,
|
||||
closeEdit,
|
||||
refresh,
|
||||
|
||||
// Actions state
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
batchDeleteModels,
|
||||
|
||||
// Filters state
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchModels,
|
||||
loading,
|
||||
searching,
|
||||
|
||||
// Description state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Vendor state
|
||||
showAddVendor,
|
||||
setShowAddVendor,
|
||||
showEditVendor,
|
||||
setShowEditVendor,
|
||||
editingVendor,
|
||||
setEditingVendor,
|
||||
loadVendors,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
} = modelsData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditModelModal
|
||||
refresh={refresh}
|
||||
editingModel={editingModel}
|
||||
visiable={showEdit}
|
||||
handleClose={closeEdit}
|
||||
/>
|
||||
|
||||
<EditVendorModal
|
||||
visible={showAddVendor || showEditVendor}
|
||||
handleClose={() => {
|
||||
setShowAddVendor(false);
|
||||
setShowEditVendor(false);
|
||||
setEditingVendor({ id: undefined });
|
||||
}}
|
||||
editingVendor={showEditVendor ? editingVendor : { id: undefined }}
|
||||
refresh={() => {
|
||||
loadVendors();
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardPro
|
||||
type="type3"
|
||||
tabsArea={<ModelsTabs {...modelsData} />}
|
||||
actionsArea={
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
|
||||
<ModelsActions
|
||||
selectedKeys={selectedKeys}
|
||||
setSelectedKeys={setSelectedKeys}
|
||||
setEditingModel={setEditingModel}
|
||||
setShowEdit={setShowEdit}
|
||||
batchDeleteModels={batchDeleteModels}
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<div className="w-full md:w-full lg:w-auto order-1 md:order-2">
|
||||
<ModelsFilters
|
||||
formInitValues={formInitValues}
|
||||
setFormApi={setFormApi}
|
||||
searchModels={searchModels}
|
||||
loading={loading}
|
||||
searching={searching}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: modelsData.activePage,
|
||||
pageSize: modelsData.pageSize,
|
||||
total: modelsData.modelCount,
|
||||
onPageChange: modelsData.handlePageChange,
|
||||
onPageSizeChange: modelsData.handlePageSizeChange,
|
||||
isMobile: isMobile,
|
||||
t: modelsData.t,
|
||||
})}
|
||||
t={modelsData.t}
|
||||
>
|
||||
<ModelsTable {...modelsData} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsPage;
|
||||
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import {
|
||||
SideSheet,
|
||||
Form,
|
||||
Button,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Col,
|
||||
Row,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Save, X, FileText } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
// Example endpoint template for quick fill
|
||||
const ENDPOINT_TEMPLATE = {
|
||||
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||
'openai-response': { path: '/v1/responses', method: 'POST' },
|
||||
anthropic: { path: '/v1/messages', method: 'POST' },
|
||||
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
|
||||
'jina-rerank': { path: '/rerank', method: 'POST' },
|
||||
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||
};
|
||||
|
||||
const nameRuleOptions = [
|
||||
{ label: '精确名称匹配', value: 0 },
|
||||
{ label: '前缀名称匹配', value: 1 },
|
||||
{ label: '包含名称匹配', value: 2 },
|
||||
{ label: '后缀名称匹配', value: 3 },
|
||||
];
|
||||
|
||||
const EditModelModal = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const isEdit = props.editingModel && props.editingModel.id !== undefined;
|
||||
const placement = useMemo(() => (isEdit ? 'right' : 'left'), [isEdit]);
|
||||
|
||||
// 供应商列表
|
||||
const [vendors, setVendors] = useState([]);
|
||||
|
||||
// 预填组(标签、端点)
|
||||
const [tagGroups, setTagGroups] = useState([]);
|
||||
const [endpointGroups, setEndpointGroups] = useState([]);
|
||||
|
||||
// 获取供应商列表
|
||||
const fetchVendors = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/vendors/?page_size=1000'); // 获取全部供应商
|
||||
if (res.data.success) {
|
||||
const items = res.data.data.items || res.data.data || [];
|
||||
setVendors(Array.isArray(items) ? items : []);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// 获取预填组(标签、端点)
|
||||
const fetchPrefillGroups = async () => {
|
||||
try {
|
||||
const [tagRes, endpointRes] = await Promise.all([
|
||||
API.get('/api/prefill_group?type=tag'),
|
||||
API.get('/api/prefill_group?type=endpoint'),
|
||||
]);
|
||||
if (tagRes?.data?.success) {
|
||||
setTagGroups(tagRes.data.data || []);
|
||||
}
|
||||
if (endpointRes?.data?.success) {
|
||||
setEndpointGroups(endpointRes.data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visiable) {
|
||||
fetchVendors();
|
||||
fetchPrefillGroups();
|
||||
}
|
||||
}, [props.visiable]);
|
||||
|
||||
const getInitValues = () => ({
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
description: '',
|
||||
tags: [],
|
||||
vendor_id: undefined,
|
||||
vendor: '',
|
||||
vendor_icon: '',
|
||||
endpoints: '',
|
||||
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
|
||||
status: true,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const loadModel = async () => {
|
||||
if (!isEdit || !props.editingModel.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/models/${props.editingModel.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
// 处理tags
|
||||
if (data.tags) {
|
||||
data.tags = data.tags.split(',').filter(Boolean);
|
||||
} else {
|
||||
data.tags = [];
|
||||
}
|
||||
// endpoints 保持原始 JSON 字符串,若为空设为空串
|
||||
if (!data.endpoints) {
|
||||
data.endpoints = '';
|
||||
}
|
||||
// 处理status,将数字转为布尔值
|
||||
data.status = data.status === 1;
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载模型信息失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
if (!isEdit) {
|
||||
formApiRef.current.setValues({
|
||||
...getInitValues(),
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [props.editingModel?.id, props.editingModel?.model_name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visiable) {
|
||||
if (isEdit) {
|
||||
loadModel();
|
||||
} else {
|
||||
formApiRef.current?.setValues({
|
||||
...getInitValues(),
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
formApiRef.current?.reset();
|
||||
}
|
||||
}, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]);
|
||||
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const submitData = {
|
||||
...values,
|
||||
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
||||
endpoints: values.endpoints || '',
|
||||
status: values.status ? 1 : 0,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
submitData.id = props.editingModel.id;
|
||||
const res = await API.put('/api/models/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('模型更新成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/models/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('模型创建成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement={placement}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新模型信息') : t('创建新的模型')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='!rounded-lg'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<Save size={16} />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
key={isEdit ? 'edit' : 'new'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<FileText size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置模型的基本信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='model_name'
|
||||
label={t('模型名称')}
|
||||
placeholder={t('请输入模型名称,如:gpt-4')}
|
||||
rules={[{ required: true, message: t('请输入模型名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='name_rule'
|
||||
label={t('名称匹配类型')}
|
||||
placeholder={t('请选择名称匹配类型')}
|
||||
optionList={nameRuleOptions.map(o => ({ label: t(o.label), value: o.value }))}
|
||||
rules={[{ required: true, message: t('请选择名称匹配类型') }]}
|
||||
extraText={t('根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入模型描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TagInput
|
||||
field='tags'
|
||||
label={t('标签')}
|
||||
placeholder={t('输入标签或使用","分隔多个标签')}
|
||||
addOnBlur
|
||||
showClear
|
||||
onChange={(newTags) => {
|
||||
if (!formApiRef.current) return;
|
||||
const normalize = (tags) => {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return [...new Set(tags.flatMap(tag => tag.split(',').map(t => t.trim()).filter(Boolean)))];
|
||||
};
|
||||
const normalized = normalize(newTags);
|
||||
formApiRef.current.setValue('tags', normalized);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
{...(tagGroups.length > 0 && {
|
||||
extraText: (
|
||||
<Space wrap>
|
||||
{tagGroups.map(group => (
|
||||
<Button
|
||||
key={group.id}
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
if (formApiRef.current) {
|
||||
const currentTags = formApiRef.current.getValue('tags') || [];
|
||||
const newTags = [...currentTags, ...(group.items || [])];
|
||||
const uniqueTags = [...new Set(newTags)];
|
||||
formApiRef.current.setValue('tags', uniqueTags);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='vendor_id'
|
||||
label={t('供应商')}
|
||||
placeholder={t('选择模型供应商')}
|
||||
optionList={vendors.map(v => ({ label: v.name, value: v.id }))}
|
||||
filter
|
||||
showClear
|
||||
onChange={(value) => {
|
||||
const vendorInfo = vendors.find(v => v.id === value);
|
||||
if (vendorInfo && formApiRef.current) {
|
||||
formApiRef.current.setValue('vendor', vendorInfo.name);
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<JSONEditor
|
||||
field='endpoints'
|
||||
label={t('端点映射')}
|
||||
placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
|
||||
value={values.endpoints}
|
||||
onChange={(val) => formApiRef.current?.setValue('endpoints', val)}
|
||||
formApi={formApiRef.current}
|
||||
editorType='object'
|
||||
template={ENDPOINT_TEMPLATE}
|
||||
templateLabel={t('填入模板')}
|
||||
extraText={t('留空则使用默认端点;支持 {path, method}')}
|
||||
extraFooter={endpointGroups.length > 0 && (
|
||||
<Space wrap>
|
||||
{endpointGroups.map(group => (
|
||||
<Button
|
||||
key={group.id}
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
try {
|
||||
const current = formApiRef.current?.getValue('endpoints') || '';
|
||||
let base = {};
|
||||
if (current && current.trim()) base = JSON.parse(current);
|
||||
const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
|
||||
const merged = { ...base, ...groupObj };
|
||||
formApiRef.current?.setValue('endpoints', JSON.stringify(merged, null, 2));
|
||||
} catch (e) {
|
||||
try {
|
||||
const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
|
||||
formApiRef.current?.setValue('endpoints', JSON.stringify(groupObj, null, 2));
|
||||
} catch { }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='status'
|
||||
label={t('状态')}
|
||||
size="large"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModelModal;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user