Merge branch 'alpha' into 'feat/support-native-gemini-embedding'
This commit is contained in:
@@ -40,4 +40,6 @@ const (
|
|||||||
ContextKeyUserGroup ContextKey = "user_group"
|
ContextKeyUserGroup ContextKey = "user_group"
|
||||||
ContextKeyUsingGroup ContextKey = "group"
|
ContextKeyUsingGroup ContextKey = "group"
|
||||||
ContextKeyUserName ContextKey = "username"
|
ContextKeyUserName ContextKey = "username"
|
||||||
|
|
||||||
|
ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -145,6 +145,22 @@ func UpdateMidjourneyTaskBulk() {
|
|||||||
buttonStr, _ := json.Marshal(responseItem.Buttons)
|
buttonStr, _ := json.Marshal(responseItem.Buttons)
|
||||||
task.Buttons = string(buttonStr)
|
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
|
shouldReturnQuota := false
|
||||||
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
|
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
|
||||||
common.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
|
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 != "" {
|
if oldTask.Progress != "100%" && newTask.FailReason != "" {
|
||||||
return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ type ChannelSettings struct {
|
|||||||
Proxy string `json:"proxy"`
|
Proxy string `json:"proxy"`
|
||||||
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
|
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
|
||||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||||
|
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
|
|||||||
if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
|
if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
|
||||||
return "developer"
|
return "developer"
|
||||||
}
|
}
|
||||||
|
} else if strings.HasPrefix(r.Model, "gpt-5") {
|
||||||
|
return "developer"
|
||||||
}
|
}
|
||||||
return "system"
|
return "system"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
|
|||||||
common.SetContextKey(c, constant.ContextKeyChannelKey, key)
|
common.SetContextKey(c, constant.ContextKeyChannelKey, key)
|
||||||
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL())
|
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL())
|
||||||
|
|
||||||
|
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, false)
|
||||||
|
|
||||||
// TODO: api_version统一
|
// TODO: api_version统一
|
||||||
switch channel.Type {
|
switch channel.Type {
|
||||||
case constant.ChannelTypeAzure:
|
case constant.ChannelTypeAzure:
|
||||||
|
|||||||
@@ -64,6 +64,22 @@ var DB *gorm.DB
|
|||||||
|
|
||||||
var LOG_DB *gorm.DB
|
var LOG_DB *gorm.DB
|
||||||
|
|
||||||
|
// dropIndexIfExists drops a MySQL index only if it exists to avoid noisy 1091 errors
|
||||||
|
func dropIndexIfExists(tableName string, indexName string) {
|
||||||
|
if !common.UsingMySQL {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
// Check index existence via information_schema
|
||||||
|
err := DB.Raw(
|
||||||
|
"SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
|
||||||
|
tableName, indexName,
|
||||||
|
).Scan(&count).Error
|
||||||
|
if err == nil && count > 0 {
|
||||||
|
_ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func createRootAccountIfNeed() error {
|
func createRootAccountIfNeed() error {
|
||||||
var user User
|
var user User
|
||||||
//if user.Status != common.UserStatusEnabled {
|
//if user.Status != common.UserStatusEnabled {
|
||||||
@@ -235,6 +251,9 @@ func InitLogDB() (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateDB() error {
|
func migrateDB() error {
|
||||||
|
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
|
||||||
|
dropIndexIfExists("models", "uk_model_name")
|
||||||
|
dropIndexIfExists("vendors", "uk_vendor_name")
|
||||||
if !common.UsingPostgreSQL {
|
if !common.UsingPostgreSQL {
|
||||||
return migrateDBFast()
|
return migrateDBFast()
|
||||||
}
|
}
|
||||||
@@ -264,6 +283,10 @@ func migrateDB() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateDBFast() error {
|
func migrateDBFast() error {
|
||||||
|
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
|
||||||
|
dropIndexIfExists("models", "uk_model_name")
|
||||||
|
dropIndexIfExists("vendors", "uk_vendor_name")
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
migrations := []struct {
|
migrations := []struct {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type BoundChannel struct {
|
|||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,where:deleted_at IS NULL"`
|
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"`
|
||||||
Description string `json:"description,omitempty" gorm:"type:text"`
|
Description string `json:"description,omitempty" gorm:"type:text"`
|
||||||
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
|
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
|
||||||
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
|
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
|
||||||
@@ -44,7 +44,7 @@ type Model struct {
|
|||||||
Status int `json:"status" gorm:"default:1"`
|
Status int `json:"status" gorm:"default:1"`
|
||||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name,priority:2"`
|
||||||
|
|
||||||
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
|
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
|
||||||
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
|
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
|
|
||||||
type Vendor struct {
|
type Vendor struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,where:deleted_at IS NULL"`
|
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,priority:1"`
|
||||||
Description string `json:"description,omitempty" gorm:"type:text"`
|
Description string `json:"description,omitempty" gorm:"type:text"`
|
||||||
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
|
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
|
||||||
Status int `json:"status" gorm:"default:1"`
|
Status int `json:"status" gorm:"default:1"`
|
||||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name,priority:2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert 创建新的供应商记录
|
// Insert 创建新的供应商记录
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
|||||||
action = "batchEmbedContents"
|
action = "batchEmbedContents"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s/%s/models/%s:%s", info.BaseUrl, version, info.UpstreamModelName, action), nil
|
return fmt.Sprintf("%s/%s/models/%s:%s", info.BaseUrl, version, info.UpstreamModelName, action), nil
|
||||||
|
return fmt.Sprintf("%s/%s/models/%s:batchEmbedContents", info.BaseUrl, version, info.UpstreamModelName), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
action := "generateContent"
|
action := "generateContent"
|
||||||
@@ -163,29 +164,35 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
|||||||
if len(inputs) == 0 {
|
if len(inputs) == 0 {
|
||||||
return nil, errors.New("input is empty")
|
return nil, errors.New("input is empty")
|
||||||
}
|
}
|
||||||
|
// process all inputs
|
||||||
// only process the first input
|
geminiRequests := make([]map[string]interface{}, 0, len(inputs))
|
||||||
geminiRequest := dto.GeminiEmbeddingRequest{
|
for _, input := range inputs {
|
||||||
Content: dto.GeminiChatContent{
|
geminiRequest := map[string]interface{}{
|
||||||
Parts: []dto.GeminiPart{
|
"model": fmt.Sprintf("models/%s", info.UpstreamModelName),
|
||||||
{
|
"content": dto.GeminiChatContent{
|
||||||
Text: inputs[0],
|
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) {
|
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||||
|
|||||||
@@ -1071,7 +1071,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
|
|||||||
return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
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 {
|
if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
|
||||||
return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -1079,14 +1079,16 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
|
|||||||
// convert to openai format response
|
// convert to openai format response
|
||||||
openAIResponse := dto.OpenAIEmbeddingResponse{
|
openAIResponse := dto.OpenAIEmbeddingResponse{
|
||||||
Object: "list",
|
Object: "list",
|
||||||
Data: []dto.OpenAIEmbeddingResponseItem{
|
Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(geminiResponse.Embeddings)),
|
||||||
{
|
Model: info.UpstreamModelName,
|
||||||
Object: "embedding",
|
}
|
||||||
Embedding: geminiResponse.Embedding.Values,
|
|
||||||
Index: 0,
|
for i, embedding := range geminiResponse.Embeddings {
|
||||||
},
|
openAIResponse.Data = append(openAIResponse.Data, dto.OpenAIEmbeddingResponseItem{
|
||||||
},
|
Object: "embedding",
|
||||||
Model: info.UpstreamModelName,
|
Embedding: embedding.Values,
|
||||||
|
Index: i,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculate usage
|
// calculate usage
|
||||||
|
|||||||
@@ -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 {
|
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||||
channel.SetupApiRequestHeader(info, c, req)
|
channel.SetupApiRequestHeader(info, c, req)
|
||||||
token := getZhipuToken(info.ApiKey)
|
req.Set("Authorization", "Bearer "+info.ApiKey)
|
||||||
req.Set("Authorization", token)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,69 +1,10 @@
|
|||||||
package zhipu_4v
|
package zhipu_4v
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/golang-jwt/jwt"
|
|
||||||
"one-api/common"
|
|
||||||
"one-api/dto"
|
"one-api/dto"
|
||||||
"strings"
|
"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 {
|
func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest {
|
||||||
messages := make([]dto.Message, 0, len(request.Messages))
|
messages := make([]dto.Message, 0, len(request.Messages))
|
||||||
for _, message := range request.Messages {
|
for _, message := range request.Messages {
|
||||||
|
|||||||
@@ -140,10 +140,10 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
|
|||||||
returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
|
returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
includeUsage := false
|
includeUsage := true
|
||||||
// 判断用户是否需要返回使用情况
|
// 判断用户是否需要返回使用情况
|
||||||
if textRequest.StreamOptions != nil && textRequest.StreamOptions.IncludeUsage {
|
if textRequest.StreamOptions != nil {
|
||||||
includeUsage = true
|
includeUsage = textRequest.StreamOptions.IncludeUsage
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果不支持StreamOptions,将StreamOptions设置为nil
|
// 如果不支持StreamOptions,将StreamOptions设置为nil
|
||||||
@@ -158,9 +158,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if includeUsage {
|
relayInfo.ShouldIncludeUsage = includeUsage
|
||||||
relayInfo.ShouldIncludeUsage = true
|
|
||||||
}
|
|
||||||
|
|
||||||
adaptor := GetAdaptor(relayInfo.ApiType)
|
adaptor := GetAdaptor(relayInfo.ApiType)
|
||||||
if adaptor == nil {
|
if adaptor == nil {
|
||||||
@@ -201,6 +199,26 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
|
|||||||
Content: relayInfo.ChannelSetting.SystemPrompt,
|
Content: relayInfo.ChannelSetting.SystemPrompt,
|
||||||
}
|
}
|
||||||
request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
|
|||||||
other["is_model_mapped"] = true
|
other["is_model_mapped"] = true
|
||||||
other["upstream_model_name"] = relayInfo.UpstreamModelName
|
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 := make(map[string]interface{})
|
||||||
adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
|
adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
|
||||||
isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey)
|
isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey)
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
/*
|
||||||
|
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, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -15,16 +34,22 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Divider,
|
Divider,
|
||||||
|
Tooltip,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IconCode,
|
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconDelete,
|
IconDelete,
|
||||||
IconRefresh,
|
IconAlertTriangle,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// 唯一 ID 生成器,确保在组件生命周期内稳定且递增
|
||||||
|
const generateUniqueId = (() => {
|
||||||
|
let counter = 0;
|
||||||
|
return () => `kv_${counter++}`;
|
||||||
|
})();
|
||||||
|
|
||||||
const JSONEditor = ({
|
const JSONEditor = ({
|
||||||
value = '',
|
value = '',
|
||||||
onChange,
|
onChange,
|
||||||
@@ -43,24 +68,51 @@ const JSONEditor = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 初始化JSON数据
|
// 将对象转换为键值对数组(包含唯一ID)
|
||||||
const [jsonData, setJsonData] = useState(() => {
|
const objectToKeyValueArray = useCallback((obj, prevPairs = []) => {
|
||||||
// 初始化时解析JSON数据
|
if (!obj || typeof obj !== 'object') return [];
|
||||||
|
|
||||||
|
const entries = Object.entries(obj);
|
||||||
|
return entries.map(([key, value], index) => {
|
||||||
|
// 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定
|
||||||
|
const prev = prevPairs[index];
|
||||||
|
const shouldReuseId = prev && prev.key === key;
|
||||||
|
return {
|
||||||
|
id: shouldReuseId ? prev.id : generateUniqueId(),
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
|
||||||
|
const keyValueArrayToObject = useCallback((arr) => {
|
||||||
|
const result = {};
|
||||||
|
arr.forEach(item => {
|
||||||
|
if (item.key) {
|
||||||
|
result[item.key] = item.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化键值对数组
|
||||||
|
const [keyValuePairs, setKeyValuePairs] = useState(() => {
|
||||||
if (typeof value === 'string' && value.trim()) {
|
if (typeof value === 'string' && value.trim()) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
return parsed;
|
return objectToKeyValueArray(parsed);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {};
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (typeof value === 'object' && value !== null) {
|
||||||
return value;
|
return objectToKeyValueArray(value);
|
||||||
}
|
}
|
||||||
return {};
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// 手动模式下的本地文本缓冲,避免无效 JSON 时被外部值重置
|
// 手动模式下的本地文本缓冲
|
||||||
const [manualText, setManualText] = useState(() => {
|
const [manualText, setManualText] = useState(() => {
|
||||||
if (typeof value === 'string') return value;
|
if (typeof value === 'string') return value;
|
||||||
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
|
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||||
@@ -69,22 +121,38 @@ const JSONEditor = ({
|
|||||||
|
|
||||||
// 根据键数量决定默认编辑模式
|
// 根据键数量决定默认编辑模式
|
||||||
const [editMode, setEditMode] = useState(() => {
|
const [editMode, setEditMode] = useState(() => {
|
||||||
// 如果初始JSON数据的键数量大于10个,则默认使用手动模式
|
|
||||||
if (typeof value === 'string' && value.trim()) {
|
if (typeof value === 'string' && value.trim()) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
const keyCount = Object.keys(parsed).length;
|
const keyCount = Object.keys(parsed).length;
|
||||||
return keyCount > 10 ? 'manual' : 'visual';
|
return keyCount > 10 ? 'manual' : 'visual';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// JSON无效时默认显示手动编辑模式
|
|
||||||
return 'manual';
|
return 'manual';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'visual';
|
return 'visual';
|
||||||
});
|
});
|
||||||
|
|
||||||
const [jsonError, setJsonError] = useState('');
|
const [jsonError, setJsonError] = useState('');
|
||||||
|
|
||||||
// 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
|
// 计算重复的键
|
||||||
|
const duplicateKeys = useMemo(() => {
|
||||||
|
const keyCount = {};
|
||||||
|
const duplicates = new Set();
|
||||||
|
|
||||||
|
keyValuePairs.forEach(pair => {
|
||||||
|
if (pair.key) {
|
||||||
|
keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
|
||||||
|
if (keyCount[pair.key] > 1) {
|
||||||
|
duplicates.add(pair.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return duplicates;
|
||||||
|
}, [keyValuePairs]);
|
||||||
|
|
||||||
|
// 数据同步 - 当value变化时更新键值对数组
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
let parsed = {};
|
let parsed = {};
|
||||||
@@ -93,16 +161,20 @@ const JSONEditor = ({
|
|||||||
} else if (typeof value === 'object' && value !== null) {
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
parsed = value;
|
parsed = value;
|
||||||
}
|
}
|
||||||
setJsonData(parsed);
|
|
||||||
|
// 只在外部值真正改变时更新,避免循环更新
|
||||||
|
const currentObj = keyValueArrayToObject(keyValuePairs);
|
||||||
|
if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) {
|
||||||
|
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||||
|
}
|
||||||
setJsonError('');
|
setJsonError('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('JSON解析失败:', error.message);
|
console.log('JSON解析失败:', error.message);
|
||||||
setJsonError(error.message);
|
setJsonError(error.message);
|
||||||
// JSON格式错误时不更新jsonData
|
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
// 外部 value 变化时,若不在手动模式,则同步手动文本;在手动模式下不打断用户输入
|
// 外部 value 变化时,若不在手动模式,则同步手动文本
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editMode !== 'manual') {
|
if (editMode !== 'manual') {
|
||||||
if (typeof value === 'string') setManualText(value);
|
if (typeof value === 'string') setManualText(value);
|
||||||
@@ -112,45 +184,47 @@ const JSONEditor = ({
|
|||||||
}, [value, editMode]);
|
}, [value, editMode]);
|
||||||
|
|
||||||
// 处理可视化编辑的数据变化
|
// 处理可视化编辑的数据变化
|
||||||
const handleVisualChange = useCallback((newData) => {
|
const handleVisualChange = useCallback((newPairs) => {
|
||||||
setJsonData(newData);
|
setKeyValuePairs(newPairs);
|
||||||
setJsonError('');
|
const jsonObject = keyValueArrayToObject(newPairs);
|
||||||
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
|
const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2);
|
||||||
|
|
||||||
// 通过formApi设置值(如果提供的话)
|
setJsonError('');
|
||||||
|
|
||||||
|
// 通过formApi设置值
|
||||||
if (formApi && field) {
|
if (formApi && field) {
|
||||||
formApi.setValue(field, jsonString);
|
formApi.setValue(field, jsonString);
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange?.(jsonString);
|
onChange?.(jsonString);
|
||||||
}, [onChange, formApi, field]);
|
}, [onChange, formApi, field, keyValueArrayToObject]);
|
||||||
|
|
||||||
// 处理手动编辑的数据变化(无效 JSON 不阻断输入,也不立刻回传上游)
|
// 处理手动编辑的数据变化
|
||||||
const handleManualChange = useCallback((newValue) => {
|
const handleManualChange = useCallback((newValue) => {
|
||||||
setManualText(newValue);
|
setManualText(newValue);
|
||||||
if (newValue && newValue.trim()) {
|
if (newValue && newValue.trim()) {
|
||||||
try {
|
try {
|
||||||
JSON.parse(newValue);
|
const parsed = JSON.parse(newValue);
|
||||||
|
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||||
setJsonError('');
|
setJsonError('');
|
||||||
onChange?.(newValue);
|
onChange?.(newValue);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setJsonError(error.message);
|
setJsonError(error.message);
|
||||||
// 无效 JSON 时不回传,避免外部值把输入重置
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
setKeyValuePairs([]);
|
||||||
setJsonError('');
|
setJsonError('');
|
||||||
onChange?.('');
|
onChange?.('');
|
||||||
}
|
}
|
||||||
}, [onChange]);
|
}, [onChange, objectToKeyValueArray, keyValuePairs]);
|
||||||
|
|
||||||
// 切换编辑模式
|
// 切换编辑模式
|
||||||
const toggleEditMode = useCallback(() => {
|
const toggleEditMode = useCallback(() => {
|
||||||
if (editMode === 'visual') {
|
if (editMode === 'visual') {
|
||||||
// 从可视化模式切换到手动模式
|
const jsonObject = keyValueArrayToObject(keyValuePairs);
|
||||||
setManualText(Object.keys(jsonData).length === 0 ? '' : JSON.stringify(jsonData, null, 2));
|
setManualText(Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2));
|
||||||
setEditMode('manual');
|
setEditMode('manual');
|
||||||
} else {
|
} else {
|
||||||
// 从手动模式切换到可视化模式,需要验证JSON
|
|
||||||
try {
|
try {
|
||||||
let parsed = {};
|
let parsed = {};
|
||||||
if (manualText && manualText.trim()) {
|
if (manualText && manualText.trim()) {
|
||||||
@@ -160,98 +234,166 @@ const JSONEditor = ({
|
|||||||
} else if (typeof value === 'object' && value !== null) {
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
parsed = value;
|
parsed = value;
|
||||||
}
|
}
|
||||||
setJsonData(parsed);
|
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||||
setJsonError('');
|
setJsonError('');
|
||||||
setEditMode('visual');
|
setEditMode('visual');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setJsonError(error.message);
|
setJsonError(error.message);
|
||||||
// JSON格式错误时不切换模式
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editMode, value, manualText, jsonData]);
|
}, [editMode, value, manualText, keyValuePairs, keyValueArrayToObject, objectToKeyValueArray]);
|
||||||
|
|
||||||
// 添加键值对
|
// 添加键值对
|
||||||
const addKeyValue = useCallback(() => {
|
const addKeyValue = useCallback(() => {
|
||||||
const newData = { ...jsonData };
|
const newPairs = [...keyValuePairs];
|
||||||
const keys = Object.keys(newData);
|
const existingKeys = newPairs.map(p => p.key);
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
let newKey = `field_${counter}`;
|
let newKey = `field_${counter}`;
|
||||||
while (newData.hasOwnProperty(newKey)) {
|
while (existingKeys.includes(newKey)) {
|
||||||
counter += 1;
|
counter += 1;
|
||||||
newKey = `field_${counter}`;
|
newKey = `field_${counter}`;
|
||||||
}
|
}
|
||||||
newData[newKey] = '';
|
newPairs.push({
|
||||||
handleVisualChange(newData);
|
id: generateUniqueId(),
|
||||||
}, [jsonData, handleVisualChange]);
|
key: newKey,
|
||||||
|
value: ''
|
||||||
|
});
|
||||||
|
handleVisualChange(newPairs);
|
||||||
|
}, [keyValuePairs, handleVisualChange]);
|
||||||
|
|
||||||
// 删除键值对
|
// 删除键值对
|
||||||
const removeKeyValue = useCallback((keyToRemove) => {
|
const removeKeyValue = useCallback((id) => {
|
||||||
const newData = { ...jsonData };
|
const newPairs = keyValuePairs.filter(pair => pair.id !== id);
|
||||||
delete newData[keyToRemove];
|
handleVisualChange(newPairs);
|
||||||
handleVisualChange(newData);
|
}, [keyValuePairs, handleVisualChange]);
|
||||||
}, [jsonData, handleVisualChange]);
|
|
||||||
|
|
||||||
// 更新键名
|
// 更新键名
|
||||||
const updateKey = useCallback((oldKey, newKey) => {
|
const updateKey = useCallback((id, newKey) => {
|
||||||
if (oldKey === newKey || !newKey) return;
|
const newPairs = keyValuePairs.map(pair =>
|
||||||
const newData = {};
|
pair.id === id ? { ...pair, key: newKey } : pair
|
||||||
Object.entries(jsonData).forEach(([k, v]) => {
|
);
|
||||||
if (k === oldKey) {
|
handleVisualChange(newPairs);
|
||||||
newData[newKey] = v;
|
}, [keyValuePairs, handleVisualChange]);
|
||||||
} else {
|
|
||||||
newData[k] = v;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
handleVisualChange(newData);
|
|
||||||
}, [jsonData, handleVisualChange]);
|
|
||||||
|
|
||||||
// 更新值
|
// 更新值
|
||||||
const updateValue = useCallback((key, newValue) => {
|
const updateValue = useCallback((id, newValue) => {
|
||||||
const newData = { ...jsonData };
|
const newPairs = keyValuePairs.map(pair =>
|
||||||
newData[key] = newValue;
|
pair.id === id ? { ...pair, value: newValue } : pair
|
||||||
handleVisualChange(newData);
|
);
|
||||||
}, [jsonData, handleVisualChange]);
|
handleVisualChange(newPairs);
|
||||||
|
}, [keyValuePairs, handleVisualChange]);
|
||||||
|
|
||||||
// 填入模板
|
// 填入模板
|
||||||
const fillTemplate = useCallback(() => {
|
const fillTemplate = useCallback(() => {
|
||||||
if (template) {
|
if (template) {
|
||||||
const templateString = JSON.stringify(template, null, 2);
|
const templateString = JSON.stringify(template, null, 2);
|
||||||
|
|
||||||
// 通过formApi设置值(如果提供的话)
|
|
||||||
if (formApi && field) {
|
if (formApi && field) {
|
||||||
formApi.setValue(field, templateString);
|
formApi.setValue(field, templateString);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步内部与外部值,避免出现杂字符
|
|
||||||
setManualText(templateString);
|
setManualText(templateString);
|
||||||
setJsonData(template);
|
setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs));
|
||||||
onChange?.(templateString);
|
onChange?.(templateString);
|
||||||
|
|
||||||
// 清除错误状态
|
|
||||||
setJsonError('');
|
setJsonError('');
|
||||||
}
|
}
|
||||||
}, [template, onChange, editMode, formApi, field]);
|
}, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]);
|
||||||
|
|
||||||
// 渲染键值对编辑器
|
// 渲染值输入控件(支持嵌套)
|
||||||
const renderKeyValueEditor = () => {
|
const renderValueInput = (pairId, value) => {
|
||||||
if (typeof jsonData !== 'object' || jsonData === null) {
|
const valueType = typeof value;
|
||||||
|
|
||||||
|
if (valueType === 'boolean') {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-6 px-4">
|
<div className="flex items-center">
|
||||||
<div className="text-gray-400 mb-2">
|
<Switch
|
||||||
<IconCode size={32} />
|
checked={value}
|
||||||
</div>
|
onChange={(newValue) => updateValue(pairId, newValue)}
|
||||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
/>
|
||||||
{t('无效的JSON数据,请检查格式')}
|
<Text type="tertiary" className="ml-2">
|
||||||
|
{value ? t('true') : t('false')}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const entries = Object.entries(jsonData);
|
|
||||||
|
|
||||||
|
if (valueType === 'number') {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
value={value}
|
||||||
|
onChange={(newValue) => updateValue(pairId, newValue)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={t('输入数字')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueType === 'object' && value !== null) {
|
||||||
|
// 简化嵌套对象的处理,使用TextArea
|
||||||
|
return (
|
||||||
|
<TextArea
|
||||||
|
rows={2}
|
||||||
|
value={JSON.stringify(value, null, 2)}
|
||||||
|
onChange={(txt) => {
|
||||||
|
try {
|
||||||
|
const obj = txt.trim() ? JSON.parse(txt) : {};
|
||||||
|
updateValue(pairId, obj);
|
||||||
|
} catch {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('输入JSON对象')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字符串或其他原始类型
|
||||||
|
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 !== '') {
|
||||||
|
const num = Number(newValue);
|
||||||
|
// 检查是否为整数
|
||||||
|
if (Number.isInteger(num)) {
|
||||||
|
convertedValue = num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateValue(pairId, convertedValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染键值对编辑器
|
||||||
|
const renderKeyValueEditor = () => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{entries.length === 0 && (
|
{/* 重复键警告 */}
|
||||||
|
{duplicateKeys.size > 0 && (
|
||||||
|
<Banner
|
||||||
|
type="warning"
|
||||||
|
icon={<IconAlertTriangle />}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<Text strong>{t('存在重复的键名:')}</Text>
|
||||||
|
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="tertiary" size="small">
|
||||||
|
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="mb-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{keyValuePairs.length === 0 && (
|
||||||
<div className="text-center py-6 px-4">
|
<div className="text-center py-6 px-4">
|
||||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||||
{t('暂无数据,点击下方按钮添加键值对')}
|
{t('暂无数据,点击下方按钮添加键值对')}
|
||||||
@@ -259,29 +401,55 @@ const JSONEditor = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entries.map(([key, value], index) => (
|
{keyValuePairs.map((pair, index) => {
|
||||||
<Row key={index} gutter={8} align="middle">
|
const isDuplicate = duplicateKeys.has(pair.key);
|
||||||
<Col span={6}>
|
const isLastDuplicate = isDuplicate &&
|
||||||
<Input
|
keyValuePairs.slice(index + 1).every(p => p.key !== pair.key);
|
||||||
placeholder={t('键名')}
|
|
||||||
value={key}
|
return (
|
||||||
onChange={(newKey) => updateKey(key, newKey)}
|
<Row key={pair.id} gutter={8} align="middle">
|
||||||
/>
|
<Col span={6}>
|
||||||
</Col>
|
<div className="relative">
|
||||||
<Col span={16}>
|
<Input
|
||||||
{renderValueInput(key, value)}
|
placeholder={t('键名')}
|
||||||
</Col>
|
value={pair.key}
|
||||||
<Col span={2}>
|
onChange={(newKey) => updateKey(pair.id, newKey)}
|
||||||
<Button
|
status={isDuplicate ? 'warning' : undefined}
|
||||||
icon={<IconDelete />}
|
/>
|
||||||
type="danger"
|
{isDuplicate && (
|
||||||
theme="borderless"
|
<Tooltip
|
||||||
onClick={() => removeKeyValue(key)}
|
content={
|
||||||
style={{ width: '100%' }}
|
isLastDuplicate
|
||||||
/>
|
? t('这是重复键中的最后一个,其值将被使用')
|
||||||
</Col>
|
: t('重复的键名,此值将被后面的同名键覆盖')
|
||||||
</Row>
|
}
|
||||||
))}
|
>
|
||||||
|
<IconAlertTriangle
|
||||||
|
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||||
|
style={{
|
||||||
|
color: isLastDuplicate ? '#ff7d00' : '#faad14',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span={16}>
|
||||||
|
{renderValueInput(pair.id, pair.value)}
|
||||||
|
</Col>
|
||||||
|
<Col span={2}>
|
||||||
|
<Button
|
||||||
|
icon={<IconDelete />}
|
||||||
|
type="danger"
|
||||||
|
theme="borderless"
|
||||||
|
onClick={() => removeKeyValue(pair.id)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<div className="mt-2 flex justify-center">
|
<div className="mt-2 flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
@@ -297,249 +465,96 @@ const JSONEditor = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加嵌套对象
|
// 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
|
||||||
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 renderRegionEditor = () => {
|
||||||
const entries = Object.entries(jsonData);
|
const defaultPair = keyValuePairs.find(pair => pair.key === 'default');
|
||||||
const defaultEntry = entries.find(([key]) => key === 'default');
|
const modelPairs = keyValuePairs.filter(pair => pair.key !== 'default');
|
||||||
const modelEntries = entries.filter(([key]) => key !== 'default');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{/* 重复键警告 */}
|
||||||
|
{duplicateKeys.size > 0 && (
|
||||||
|
<Banner
|
||||||
|
type="warning"
|
||||||
|
icon={<IconAlertTriangle />}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<Text strong>{t('存在重复的键名:')}</Text>
|
||||||
|
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="tertiary" size="small">
|
||||||
|
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="mb-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 默认区域 */}
|
{/* 默认区域 */}
|
||||||
<Form.Slot label={t('默认区域')}>
|
<Form.Slot label={t('默认区域')}>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('默认区域,如: us-central1')}
|
placeholder={t('默认区域,如: us-central1')}
|
||||||
value={defaultEntry ? defaultEntry[1] : ''}
|
value={defaultPair ? defaultPair.value : ''}
|
||||||
onChange={(value) => updateValue('default', value)}
|
onChange={(value) => {
|
||||||
|
if (defaultPair) {
|
||||||
|
updateValue(defaultPair.id, value);
|
||||||
|
} else {
|
||||||
|
const newPairs = [...keyValuePairs, {
|
||||||
|
id: generateUniqueId(),
|
||||||
|
key: 'default',
|
||||||
|
value: value
|
||||||
|
}];
|
||||||
|
handleVisualChange(newPairs);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Slot>
|
</Form.Slot>
|
||||||
|
|
||||||
{/* 模型专用区域 */}
|
{/* 模型专用区域 */}
|
||||||
<Form.Slot label={t('模型专用区域')}>
|
<Form.Slot label={t('模型专用区域')}>
|
||||||
<div>
|
<div>
|
||||||
{modelEntries.map(([modelName, region], index) => (
|
{modelPairs.map((pair) => {
|
||||||
<Row key={index} gutter={8} align="middle" className="mb-2">
|
const isDuplicate = duplicateKeys.has(pair.key);
|
||||||
<Col span={10}>
|
return (
|
||||||
<Input
|
<Row key={pair.id} gutter={8} align="middle" className="mb-2">
|
||||||
placeholder={t('模型名称')}
|
<Col span={10}>
|
||||||
value={modelName}
|
<div className="relative">
|
||||||
onChange={(newKey) => updateKey(modelName, newKey)}
|
<Input
|
||||||
/>
|
placeholder={t('模型名称')}
|
||||||
</Col>
|
value={pair.key}
|
||||||
<Col span={12}>
|
onChange={(newKey) => updateKey(pair.id, newKey)}
|
||||||
<Input
|
status={isDuplicate ? 'warning' : undefined}
|
||||||
placeholder={t('区域')}
|
/>
|
||||||
value={region}
|
{isDuplicate && (
|
||||||
onChange={(newValue) => updateValue(modelName, newValue)}
|
<Tooltip content={t('重复的键名')}>
|
||||||
/>
|
<IconAlertTriangle
|
||||||
</Col>
|
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||||
<Col span={2}>
|
style={{ color: '#faad14', fontSize: '14px' }}
|
||||||
<Button
|
/>
|
||||||
icon={<IconDelete />}
|
</Tooltip>
|
||||||
type="danger"
|
)}
|
||||||
theme="borderless"
|
</div>
|
||||||
onClick={() => removeKeyValue(modelName)}
|
</Col>
|
||||||
style={{ width: '100%' }}
|
<Col span={12}>
|
||||||
/>
|
<Input
|
||||||
</Col>
|
placeholder={t('区域')}
|
||||||
</Row>
|
value={pair.value}
|
||||||
))}
|
onChange={(newValue) => updateValue(pair.id, newValue)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={2}>
|
||||||
|
<Button
|
||||||
|
icon={<IconDelete />}
|
||||||
|
type="danger"
|
||||||
|
theme="borderless"
|
||||||
|
onClick={() => removeKeyValue(pair.id)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<div className="mt-2 flex justify-center">
|
<div className="mt-2 flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
@@ -666,4 +681,4 @@ const JSONEditor = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default JSONEditor;
|
export default JSONEditor;
|
||||||
@@ -458,7 +458,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg" style={{ borderBottom: '1px solid var(--semi-color-border)' }}>
|
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
|
||||||
<NoticeModal
|
<NoticeModal
|
||||||
visible={noticeVisible}
|
visible={noticeVisible}
|
||||||
onClose={handleNoticeClose}
|
onClose={handleNoticeClose}
|
||||||
|
|||||||
@@ -128,18 +128,18 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
|||||||
|
|
||||||
const adminItems = useMemo(
|
const adminItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
|
||||||
text: t('模型管理'),
|
|
||||||
itemKey: 'models',
|
|
||||||
to: '/console/models',
|
|
||||||
className: isAdmin() ? '' : 'tableHiddle',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: t('渠道管理'),
|
text: t('渠道管理'),
|
||||||
itemKey: 'channel',
|
itemKey: 'channel',
|
||||||
to: '/channel',
|
to: '/channel',
|
||||||
className: isAdmin() ? '' : 'tableHiddle',
|
className: isAdmin() ? '' : 'tableHiddle',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: t('模型管理'),
|
||||||
|
itemKey: 'models',
|
||||||
|
to: '/console/models',
|
||||||
|
className: isAdmin() ? '' : 'tableHiddle',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: t('兑换码管理'),
|
text: t('兑换码管理'),
|
||||||
itemKey: 'redemption',
|
itemKey: 'redemption',
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ const EditChannelModal = (props) => {
|
|||||||
proxy: '',
|
proxy: '',
|
||||||
pass_through_body_enabled: false,
|
pass_through_body_enabled: false,
|
||||||
system_prompt: '',
|
system_prompt: '',
|
||||||
|
system_prompt_override: false,
|
||||||
};
|
};
|
||||||
const [batch, setBatch] = useState(false);
|
const [batch, setBatch] = useState(false);
|
||||||
const [multiToSingle, setMultiToSingle] = useState(false);
|
const [multiToSingle, setMultiToSingle] = useState(false);
|
||||||
@@ -340,12 +341,15 @@ const EditChannelModal = (props) => {
|
|||||||
data.proxy = parsedSettings.proxy || '';
|
data.proxy = parsedSettings.proxy || '';
|
||||||
data.pass_through_body_enabled = parsedSettings.pass_through_body_enabled || false;
|
data.pass_through_body_enabled = parsedSettings.pass_through_body_enabled || false;
|
||||||
data.system_prompt = parsedSettings.system_prompt || '';
|
data.system_prompt = parsedSettings.system_prompt || '';
|
||||||
|
data.system_prompt_override = parsedSettings.system_prompt_override || false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('解析渠道设置失败:', error);
|
console.error('解析渠道设置失败:', error);
|
||||||
data.force_format = false;
|
data.force_format = false;
|
||||||
data.thinking_to_content = false;
|
data.thinking_to_content = false;
|
||||||
data.proxy = '';
|
data.proxy = '';
|
||||||
data.pass_through_body_enabled = false;
|
data.pass_through_body_enabled = false;
|
||||||
|
data.system_prompt = '';
|
||||||
|
data.system_prompt_override = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
data.force_format = false;
|
data.force_format = false;
|
||||||
@@ -353,6 +357,7 @@ const EditChannelModal = (props) => {
|
|||||||
data.proxy = '';
|
data.proxy = '';
|
||||||
data.pass_through_body_enabled = false;
|
data.pass_through_body_enabled = false;
|
||||||
data.system_prompt = '';
|
data.system_prompt = '';
|
||||||
|
data.system_prompt_override = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputs(data);
|
setInputs(data);
|
||||||
@@ -372,6 +377,7 @@ const EditChannelModal = (props) => {
|
|||||||
proxy: data.proxy,
|
proxy: data.proxy,
|
||||||
pass_through_body_enabled: data.pass_through_body_enabled,
|
pass_through_body_enabled: data.pass_through_body_enabled,
|
||||||
system_prompt: data.system_prompt,
|
system_prompt: data.system_prompt,
|
||||||
|
system_prompt_override: data.system_prompt_override || false,
|
||||||
});
|
});
|
||||||
// console.log(data);
|
// console.log(data);
|
||||||
} else {
|
} else {
|
||||||
@@ -573,6 +579,7 @@ const EditChannelModal = (props) => {
|
|||||||
proxy: '',
|
proxy: '',
|
||||||
pass_through_body_enabled: false,
|
pass_through_body_enabled: false,
|
||||||
system_prompt: '',
|
system_prompt: '',
|
||||||
|
system_prompt_override: false,
|
||||||
});
|
});
|
||||||
// 重置密钥模式状态
|
// 重置密钥模式状态
|
||||||
setKeyMode('append');
|
setKeyMode('append');
|
||||||
@@ -721,6 +728,7 @@ const EditChannelModal = (props) => {
|
|||||||
proxy: localInputs.proxy || '',
|
proxy: localInputs.proxy || '',
|
||||||
pass_through_body_enabled: localInputs.pass_through_body_enabled || false,
|
pass_through_body_enabled: localInputs.pass_through_body_enabled || false,
|
||||||
system_prompt: localInputs.system_prompt || '',
|
system_prompt: localInputs.system_prompt || '',
|
||||||
|
system_prompt_override: localInputs.system_prompt_override || false,
|
||||||
};
|
};
|
||||||
localInputs.setting = JSON.stringify(channelExtraSettings);
|
localInputs.setting = JSON.stringify(channelExtraSettings);
|
||||||
|
|
||||||
@@ -730,6 +738,7 @@ const EditChannelModal = (props) => {
|
|||||||
delete localInputs.proxy;
|
delete localInputs.proxy;
|
||||||
delete localInputs.pass_through_body_enabled;
|
delete localInputs.pass_through_body_enabled;
|
||||||
delete localInputs.system_prompt;
|
delete localInputs.system_prompt;
|
||||||
|
delete localInputs.system_prompt_override;
|
||||||
|
|
||||||
let res;
|
let res;
|
||||||
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
|
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
|
||||||
@@ -1722,6 +1731,14 @@ const EditChannelModal = (props) => {
|
|||||||
showClear
|
showClear
|
||||||
extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')}
|
extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')}
|
||||||
/>
|
/>
|
||||||
|
<Form.Switch
|
||||||
|
field='system_prompt_override'
|
||||||
|
label={t('系统提示词拼接')}
|
||||||
|
checkedText={t('开')}
|
||||||
|
uncheckedText={t('关')}
|
||||||
|
onChange={(value) => handleChannelSettingsChange('system_prompt_override', value)}
|
||||||
|
extraText={t('如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面')}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const PricingSidebar = ({
|
|||||||
setViewMode,
|
setViewMode,
|
||||||
filterGroup,
|
filterGroup,
|
||||||
setFilterGroup,
|
setFilterGroup,
|
||||||
|
handleGroupClick,
|
||||||
filterQuotaType,
|
filterQuotaType,
|
||||||
setFilterQuotaType,
|
setFilterQuotaType,
|
||||||
filterEndpointType,
|
filterEndpointType,
|
||||||
@@ -126,7 +127,7 @@ const PricingSidebar = ({
|
|||||||
|
|
||||||
<PricingGroups
|
<PricingGroups
|
||||||
filterGroup={filterGroup}
|
filterGroup={filterGroup}
|
||||||
setFilterGroup={setFilterGroup}
|
setFilterGroup={handleGroupClick}
|
||||||
usableGroup={categoryProps.usableGroup}
|
usableGroup={categoryProps.usableGroup}
|
||||||
groupRatio={categoryProps.groupRatio}
|
groupRatio={categoryProps.groupRatio}
|
||||||
models={groupCountModels}
|
models={groupCountModels}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { stringToColor, calculateModelPrice, formatPriceInfo, getLobeHubIcon } f
|
|||||||
import PricingCardSkeleton from './PricingCardSkeleton';
|
import PricingCardSkeleton from './PricingCardSkeleton';
|
||||||
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
|
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
|
||||||
import { renderLimitedItems } from '../../../../common/ui/RenderUtils';
|
import { renderLimitedItems } from '../../../../common/ui/RenderUtils';
|
||||||
|
import { useIsMobile } from '../../../../../hooks/common/useIsMobile';
|
||||||
|
|
||||||
const CARD_STYLES = {
|
const CARD_STYLES = {
|
||||||
container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md",
|
container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md",
|
||||||
@@ -59,6 +60,7 @@ const PricingCardView = ({
|
|||||||
const startIndex = (currentPage - 1) * pageSize;
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize);
|
const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize);
|
||||||
const getModelKey = (model) => model.key ?? model.model_name ?? model.id;
|
const getModelKey = (model) => model.key ?? model.model_name ?? model.id;
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const handleCheckboxChange = (model, checked) => {
|
const handleCheckboxChange = (model, checked) => {
|
||||||
if (!setSelectedRowKeys) return;
|
if (!setSelectedRowKeys) return;
|
||||||
@@ -311,6 +313,8 @@ const PricingCardView = ({
|
|||||||
total={filteredModels.length}
|
total={filteredModels.length}
|
||||||
showSizeChanger={true}
|
showSizeChanger={true}
|
||||||
pageSizeOptions={[10, 20, 50, 100]}
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
size={isMobile ? 'small' : 'default'}
|
||||||
|
showQuickJumper={isMobile}
|
||||||
onPageChange={(page) => setCurrentPage(page)}
|
onPageChange={(page) => setCurrentPage(page)}
|
||||||
onPageSizeChange={(size) => {
|
onPageSizeChange={(size) => {
|
||||||
setPageSize(size);
|
setPageSize(size);
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ const { Text, Title } = Typography;
|
|||||||
// Example endpoint template for quick fill
|
// Example endpoint template for quick fill
|
||||||
const ENDPOINT_TEMPLATE = {
|
const ENDPOINT_TEMPLATE = {
|
||||||
openai: { path: '/v1/chat/completions', method: 'POST' },
|
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||||
|
'openai-response': { path: '/v1/responses', method: 'POST' },
|
||||||
anthropic: { path: '/v1/messages', 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' },
|
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ const { Text, Title } = Typography;
|
|||||||
// Example endpoint template for quick fill
|
// Example endpoint template for quick fill
|
||||||
const ENDPOINT_TEMPLATE = {
|
const ENDPOINT_TEMPLATE = {
|
||||||
openai: { path: '/v1/chat/completions', method: 'POST' },
|
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||||
|
'openai-response': { path: '/v1/responses', method: 'POST' },
|
||||||
anthropic: { path: '/v1/messages', 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' },
|
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ export const getTaskLogsColumns = ({
|
|||||||
copyText,
|
copyText,
|
||||||
openContentModal,
|
openContentModal,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
|
openVideoModal,
|
||||||
}) => {
|
}) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -342,7 +343,13 @@ export const getTaskLogsColumns = ({
|
|||||||
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
|
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
|
||||||
if (isSuccess && isVideoTask && isUrl) {
|
if (isSuccess && isVideoTask && isUrl) {
|
||||||
return (
|
return (
|
||||||
<a href={text} target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
openVideoModal(text);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('点击预览视频')}
|
{t('点击预览视频')}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const TaskLogsTable = (taskLogsData) => {
|
|||||||
handlePageSizeChange,
|
handlePageSizeChange,
|
||||||
copyText,
|
copyText,
|
||||||
openContentModal,
|
openContentModal,
|
||||||
|
openVideoModal,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
t,
|
t,
|
||||||
COLUMN_KEYS,
|
COLUMN_KEYS,
|
||||||
@@ -51,6 +52,7 @@ const TaskLogsTable = (taskLogsData) => {
|
|||||||
COLUMN_KEYS,
|
COLUMN_KEYS,
|
||||||
copyText,
|
copyText,
|
||||||
openContentModal,
|
openContentModal,
|
||||||
|
openVideoModal,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
@@ -58,6 +60,7 @@ const TaskLogsTable = (taskLogsData) => {
|
|||||||
COLUMN_KEYS,
|
COLUMN_KEYS,
|
||||||
copyText,
|
copyText,
|
||||||
openContentModal,
|
openContentModal,
|
||||||
|
openVideoModal,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,14 @@ const TaskLogsPage = () => {
|
|||||||
<>
|
<>
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<ColumnSelectorModal {...taskLogsData} />
|
<ColumnSelectorModal {...taskLogsData} />
|
||||||
<ContentModal {...taskLogsData} />
|
<ContentModal {...taskLogsData} isVideo={false} />
|
||||||
|
{/* 新增:视频预览弹窗 */}
|
||||||
|
<ContentModal
|
||||||
|
isModalOpen={taskLogsData.isVideoModalOpen}
|
||||||
|
setIsModalOpen={taskLogsData.setIsVideoModalOpen}
|
||||||
|
modalContent={taskLogsData.videoUrl}
|
||||||
|
isVideo={true}
|
||||||
|
/>
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<CardPro
|
<CardPro
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const ContentModal = ({
|
|||||||
isModalOpen,
|
isModalOpen,
|
||||||
setIsModalOpen,
|
setIsModalOpen,
|
||||||
modalContent,
|
modalContent,
|
||||||
|
isVideo,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -34,7 +35,11 @@ const ContentModal = ({
|
|||||||
bodyStyle={{ height: '400px', overflow: 'auto' }}
|
bodyStyle={{ height: '400px', overflow: 'auto' }}
|
||||||
width={800}
|
width={800}
|
||||||
>
|
>
|
||||||
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
|
{isVideo ? (
|
||||||
|
<video src={modalContent} controls style={{ width: '100%' }} autoPlay />
|
||||||
|
) : (
|
||||||
|
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Progress,
|
Progress,
|
||||||
Switch,
|
Popover,
|
||||||
|
Typography,
|
||||||
Input,
|
Input,
|
||||||
Modal
|
Modal
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
@@ -46,21 +47,22 @@ import {
|
|||||||
IconEyeClosed,
|
IconEyeClosed,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
// progress color helper
|
||||||
|
const getProgressColor = (pct) => {
|
||||||
|
if (pct === 100) return 'var(--semi-color-success)';
|
||||||
|
if (pct <= 10) return 'var(--semi-color-danger)';
|
||||||
|
if (pct <= 30) return 'var(--semi-color-warning)';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
// Render functions
|
// Render functions
|
||||||
function renderTimestamp(timestamp) {
|
function renderTimestamp(timestamp) {
|
||||||
return <>{timestamp2string(timestamp)}</>;
|
return <>{timestamp2string(timestamp)}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render status column with switch and progress bar
|
// Render status column only (no usage)
|
||||||
const renderStatus = (text, record, manageToken, t) => {
|
const renderStatus = (text, record, t) => {
|
||||||
const enabled = text === 1;
|
const enabled = text === 1;
|
||||||
const handleToggle = (checked) => {
|
|
||||||
if (checked) {
|
|
||||||
manageToken(record.id, 'enable', record);
|
|
||||||
} else {
|
|
||||||
manageToken(record.id, 'disable', record);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let tagColor = 'black';
|
let tagColor = 'black';
|
||||||
let tagText = t('未知状态');
|
let tagText = t('未知状态');
|
||||||
@@ -78,69 +80,11 @@ const renderStatus = (text, record, manageToken, t) => {
|
|||||||
tagText = t('已耗尽');
|
tagText = t('已耗尽');
|
||||||
}
|
}
|
||||||
|
|
||||||
const used = parseInt(record.used_quota) || 0;
|
return (
|
||||||
const remain = parseInt(record.remain_quota) || 0;
|
<Tag color={tagColor} shape='circle' size='small'>
|
||||||
const total = used + remain;
|
|
||||||
const percent = total > 0 ? (remain / total) * 100 : 0;
|
|
||||||
|
|
||||||
const getProgressColor = (pct) => {
|
|
||||||
if (pct === 100) return 'var(--semi-color-success)';
|
|
||||||
if (pct <= 10) return 'var(--semi-color-danger)';
|
|
||||||
if (pct <= 30) return 'var(--semi-color-warning)';
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const quotaSuffix = record.unlimited_quota ? (
|
|
||||||
<div className='text-xs'>{t('无限额度')}</div>
|
|
||||||
) : (
|
|
||||||
<div className='flex flex-col items-end'>
|
|
||||||
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
|
|
||||||
<Progress
|
|
||||||
percent={percent}
|
|
||||||
stroke={getProgressColor(percent)}
|
|
||||||
aria-label='quota usage'
|
|
||||||
format={() => `${percent.toFixed(0)}%`}
|
|
||||||
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<Tag
|
|
||||||
color={tagColor}
|
|
||||||
shape='circle'
|
|
||||||
size='large'
|
|
||||||
prefixIcon={
|
|
||||||
<Switch
|
|
||||||
size='small'
|
|
||||||
checked={enabled}
|
|
||||||
onChange={handleToggle}
|
|
||||||
aria-label='token status switch'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
suffixIcon={quotaSuffix}
|
|
||||||
>
|
|
||||||
{tagText}
|
{tagText}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
|
|
||||||
const tooltipContent = record.unlimited_quota ? (
|
|
||||||
<div className='text-xs'>
|
|
||||||
<div>{t('已用额度')}: {renderQuota(used)}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='text-xs'>
|
|
||||||
<div>{t('已用额度')}: {renderQuota(used)}</div>
|
|
||||||
<div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
|
|
||||||
<div>{t('总额度')}: {renderQuota(total)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip content={tooltipContent}>
|
|
||||||
{content}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render group column
|
// Render group column
|
||||||
@@ -292,35 +236,81 @@ const renderAllowIps = (text, t) => {
|
|||||||
return <Space wrap>{ipTags}</Space>;
|
return <Space wrap>{ipTags}</Space>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Render separate quota usage column
|
||||||
|
const renderQuotaUsage = (text, record, t) => {
|
||||||
|
const { Paragraph } = Typography;
|
||||||
|
const used = parseInt(record.used_quota) || 0;
|
||||||
|
const remain = parseInt(record.remain_quota) || 0;
|
||||||
|
const total = used + remain;
|
||||||
|
if (record.unlimited_quota) {
|
||||||
|
const popoverContent = (
|
||||||
|
<div className='text-xs p-2'>
|
||||||
|
<Paragraph copyable={{ content: renderQuota(used) }}>
|
||||||
|
{t('已用额度')}: {renderQuota(used)}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Popover content={popoverContent} position='top'>
|
||||||
|
<Tag color='white' shape='circle'>
|
||||||
|
{t('无限额度')}
|
||||||
|
</Tag>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const percent = total > 0 ? (remain / total) * 100 : 0;
|
||||||
|
const popoverContent = (
|
||||||
|
<div className='text-xs p-2'>
|
||||||
|
<Paragraph copyable={{ content: renderQuota(used) }}>
|
||||||
|
{t('已用额度')}: {renderQuota(used)}
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph copyable={{ content: renderQuota(remain) }}>
|
||||||
|
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph copyable={{ content: renderQuota(total) }}>
|
||||||
|
{t('总额度')}: {renderQuota(total)}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Popover content={popoverContent} position='top'>
|
||||||
|
<Tag color='white' shape='circle'>
|
||||||
|
<div className='flex flex-col items-end'>
|
||||||
|
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
|
||||||
|
<Progress
|
||||||
|
percent={percent}
|
||||||
|
stroke={getProgressColor(percent)}
|
||||||
|
aria-label='quota usage'
|
||||||
|
format={() => `${percent.toFixed(0)}%`}
|
||||||
|
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tag>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Render operations column
|
// Render operations column
|
||||||
const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => {
|
const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => {
|
||||||
let chats = localStorage.getItem('chats');
|
|
||||||
let chatsArray = [];
|
let chatsArray = [];
|
||||||
let shouldUseCustom = true;
|
try {
|
||||||
|
const raw = localStorage.getItem('chats');
|
||||||
if (shouldUseCustom) {
|
const parsed = JSON.parse(raw);
|
||||||
try {
|
if (Array.isArray(parsed)) {
|
||||||
chats = JSON.parse(chats);
|
for (let i = 0; i < parsed.length; i++) {
|
||||||
if (Array.isArray(chats)) {
|
const item = parsed[i];
|
||||||
for (let i = 0; i < chats.length; i++) {
|
const name = Object.keys(item)[0];
|
||||||
let chat = {};
|
if (!name) continue;
|
||||||
chat.node = 'item';
|
chatsArray.push({
|
||||||
for (let key in chats[i]) {
|
node: 'item',
|
||||||
if (chats[i].hasOwnProperty(key)) {
|
key: i,
|
||||||
chat.key = i;
|
name,
|
||||||
chat.name = key;
|
onClick: () => onOpenLink(name, item[name], record),
|
||||||
chat.onClick = () => {
|
});
|
||||||
onOpenLink(key, chats[i][key], record);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
chatsArray.push(chat);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
showError(t('聊天链接配置错误,请联系管理员'));
|
|
||||||
}
|
}
|
||||||
|
} catch (_) {
|
||||||
|
showError(t('聊天链接配置错误,请联系管理员'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -338,7 +328,7 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
|
|||||||
} else {
|
} else {
|
||||||
onOpenLink(
|
onOpenLink(
|
||||||
'default',
|
'default',
|
||||||
chats[0][Object.keys(chats[0])[0]],
|
chatsArray[0].name ? (parsed => parsed)(localStorage.getItem('chats')) : '',
|
||||||
record,
|
record,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -359,6 +349,29 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</SplitButtonGroup>
|
</SplitButtonGroup>
|
||||||
|
|
||||||
|
{record.status === 1 ? (
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
await manageToken(record.id, 'disable', record);
|
||||||
|
await refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('禁用')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
await manageToken(record.id, 'enable', record);
|
||||||
|
await refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('启用')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type='tertiary'
|
type='tertiary'
|
||||||
size="small"
|
size="small"
|
||||||
@@ -412,7 +425,12 @@ export const getTokensColumns = ({
|
|||||||
title: t('状态'),
|
title: t('状态'),
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
render: (text, record) => renderStatus(text, record, manageToken, t),
|
render: (text, record) => renderStatus(text, record, t),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('剩余额度/总额度'),
|
||||||
|
key: 'quota_usage',
|
||||||
|
render: (text, record) => renderQuotaUsage(text, record, t),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('分组'),
|
title: t('分组'),
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Notification, Button, Space, Toast, Typography, Select } from '@douyinfe/semi-ui';
|
||||||
|
import { API, showError, getModelCategories, selectFilter } from '../../../helpers';
|
||||||
import CardPro from '../../common/ui/CardPro';
|
import CardPro from '../../common/ui/CardPro';
|
||||||
import TokensTable from './TokensTable.jsx';
|
import TokensTable from './TokensTable.jsx';
|
||||||
import TokensActions from './TokensActions.jsx';
|
import TokensActions from './TokensActions.jsx';
|
||||||
@@ -28,9 +30,243 @@ import { useTokensData } from '../../../hooks/tokens/useTokensData';
|
|||||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||||
import { createCardProPagination } from '../../../helpers/utils';
|
import { createCardProPagination } from '../../../helpers/utils';
|
||||||
|
|
||||||
const TokensPage = () => {
|
function TokensPage() {
|
||||||
const tokensData = useTokensData();
|
// Define the function first, then pass it into the hook to avoid TDZ errors
|
||||||
|
const openFluentNotificationRef = useRef(null);
|
||||||
|
const tokensData = useTokensData((key) => openFluentNotificationRef.current?.(key));
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const latestRef = useRef({ tokens: [], selectedKeys: [], t: (k) => k, selectedModel: '', prefillKey: '' });
|
||||||
|
const [modelOptions, setModelOptions] = useState([]);
|
||||||
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
|
const [fluentNoticeOpen, setFluentNoticeOpen] = useState(false);
|
||||||
|
const [prefillKey, setPrefillKey] = useState('');
|
||||||
|
|
||||||
|
// Keep latest data for handlers inside notifications
|
||||||
|
useEffect(() => {
|
||||||
|
latestRef.current = {
|
||||||
|
tokens: tokensData.tokens,
|
||||||
|
selectedKeys: tokensData.selectedKeys,
|
||||||
|
t: tokensData.t,
|
||||||
|
selectedModel,
|
||||||
|
prefillKey,
|
||||||
|
};
|
||||||
|
}, [tokensData.tokens, tokensData.selectedKeys, tokensData.t, selectedModel, prefillKey]);
|
||||||
|
|
||||||
|
const loadModels = async () => {
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/user/models');
|
||||||
|
const { success, message, data } = res.data || {};
|
||||||
|
if (success) {
|
||||||
|
const categories = getModelCategories(tokensData.t);
|
||||||
|
const options = (data || []).map((model) => {
|
||||||
|
let icon = null;
|
||||||
|
for (const [key, category] of Object.entries(categories)) {
|
||||||
|
if (key !== 'all' && category.filter({ model_name: model })) {
|
||||||
|
icon = category.icon;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{icon}
|
||||||
|
{model}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
value: model,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setModelOptions(options);
|
||||||
|
} else {
|
||||||
|
showError(tokensData.t(message));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError(e.message || 'Failed to load models');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function openFluentNotification(key) {
|
||||||
|
const { t } = latestRef.current;
|
||||||
|
const SUPPRESS_KEY = 'fluent_notify_suppressed';
|
||||||
|
if (localStorage.getItem(SUPPRESS_KEY) === '1') return;
|
||||||
|
const container = document.getElementById('fluent-new-api-container');
|
||||||
|
if (!container) {
|
||||||
|
Toast.warning(t('未检测到 Fluent 容器,请确认扩展已启用'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPrefillKey(key || '');
|
||||||
|
setFluentNoticeOpen(true);
|
||||||
|
if (modelOptions.length === 0) {
|
||||||
|
// fire-and-forget; a later effect will refresh the notice content
|
||||||
|
loadModels()
|
||||||
|
}
|
||||||
|
Notification.info({
|
||||||
|
id: 'fluent-detected',
|
||||||
|
title: t('检测到 Fluent(流畅阅读)'),
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
{prefillKey
|
||||||
|
? t('已检测到 Fluent 扩展,已从操作中指定密钥,将使用该密钥进行填充。请选择模型后继续。')
|
||||||
|
: t('已检测到 Fluent 扩展,请选择模型后可一键填充当前选中令牌(或本页第一个令牌)。')}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Select
|
||||||
|
placeholder={t('请选择模型')}
|
||||||
|
optionList={modelOptions}
|
||||||
|
onChange={setSelectedModel}
|
||||||
|
filter={selectFilter}
|
||||||
|
style={{ width: 320 }}
|
||||||
|
showClear
|
||||||
|
searchable
|
||||||
|
emptyContent={t('暂无数据')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button theme="solid" type="primary" onClick={handlePrefillToFluent}>
|
||||||
|
{t('一键填充到 Fluent')}
|
||||||
|
</Button>
|
||||||
|
<Button type="warning" onClick={() => {
|
||||||
|
localStorage.setItem(SUPPRESS_KEY, '1');
|
||||||
|
Notification.close('fluent-detected');
|
||||||
|
Toast.info(t('已关闭后续提醒'));
|
||||||
|
}}>
|
||||||
|
{t('不再提醒')}
|
||||||
|
</Button>
|
||||||
|
<Button type="tertiary" onClick={() => Notification.close('fluent-detected')}>
|
||||||
|
{t('关闭')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// assign after definition so hook callback can call it safely
|
||||||
|
openFluentNotificationRef.current = openFluentNotification;
|
||||||
|
|
||||||
|
// Prefill to Fluent handler
|
||||||
|
const handlePrefillToFluent = () => {
|
||||||
|
const { tokens, selectedKeys, t, selectedModel: chosenModel, prefillKey: overrideKey } = latestRef.current;
|
||||||
|
const container = document.getElementById('fluent-new-api-container');
|
||||||
|
if (!container) {
|
||||||
|
Toast.error(t('未检测到 Fluent 容器'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chosenModel) {
|
||||||
|
Toast.warning(t('请选择模型'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = localStorage.getItem('status');
|
||||||
|
let serverAddress = '';
|
||||||
|
if (status) {
|
||||||
|
try {
|
||||||
|
status = JSON.parse(status);
|
||||||
|
serverAddress = status.server_address || '';
|
||||||
|
} catch (_) { }
|
||||||
|
}
|
||||||
|
if (!serverAddress) serverAddress = window.location.origin;
|
||||||
|
|
||||||
|
let apiKeyToUse = '';
|
||||||
|
if (overrideKey) {
|
||||||
|
apiKeyToUse = 'sk-' + overrideKey;
|
||||||
|
} else {
|
||||||
|
const token = (selectedKeys && selectedKeys.length === 1)
|
||||||
|
? selectedKeys[0]
|
||||||
|
: (tokens && tokens.length > 0 ? tokens[0] : null);
|
||||||
|
if (!token) {
|
||||||
|
Toast.warning(t('没有可用令牌用于填充'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiKeyToUse = 'sk-' + token.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: 'new-api',
|
||||||
|
baseUrl: serverAddress,
|
||||||
|
apiKey: apiKeyToUse,
|
||||||
|
model: chosenModel,
|
||||||
|
};
|
||||||
|
|
||||||
|
container.dispatchEvent(new CustomEvent('fluent:prefill', { detail: payload }));
|
||||||
|
Toast.success(t('已发送到 Fluent'));
|
||||||
|
Notification.close('fluent-detected');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show notification when Fluent container is available
|
||||||
|
useEffect(() => {
|
||||||
|
const onAppeared = () => {
|
||||||
|
openFluentNotification();
|
||||||
|
};
|
||||||
|
const onRemoved = () => {
|
||||||
|
setFluentNoticeOpen(false);
|
||||||
|
Notification.close('fluent-detected');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('fluent-container:appeared', onAppeared);
|
||||||
|
window.addEventListener('fluent-container:removed', onRemoved);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('fluent-container:appeared', onAppeared);
|
||||||
|
window.removeEventListener('fluent-container:removed', onRemoved);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// When modelOptions or language changes while the notice is open, refresh the content
|
||||||
|
useEffect(() => {
|
||||||
|
if (fluentNoticeOpen) {
|
||||||
|
openFluentNotification();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [modelOptions, selectedModel, tokensData.t, fluentNoticeOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selector = '#fluent-new-api-container';
|
||||||
|
const root = document.body || document.documentElement;
|
||||||
|
|
||||||
|
const existing = document.querySelector(selector);
|
||||||
|
if (existing) {
|
||||||
|
console.log('Fluent container detected (initial):', existing);
|
||||||
|
window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: existing }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOrContainsTarget = (node) => {
|
||||||
|
if (!(node && node.nodeType === 1)) return false;
|
||||||
|
if (node.id === 'fluent-new-api-container') return true;
|
||||||
|
return typeof node.querySelector === 'function' && !!node.querySelector(selector);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const m of mutations) {
|
||||||
|
// appeared
|
||||||
|
for (const added of m.addedNodes) {
|
||||||
|
if (isOrContainsTarget(added)) {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (el) {
|
||||||
|
console.log('Fluent container appeared:', el);
|
||||||
|
window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: el }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// removed
|
||||||
|
for (const removed of m.removedNodes) {
|
||||||
|
if (isOrContainsTarget(removed)) {
|
||||||
|
const elNow = document.querySelector(selector);
|
||||||
|
if (!elNow) {
|
||||||
|
console.log('Fluent container removed');
|
||||||
|
window.dispatchEvent(new CustomEvent('fluent-container:removed'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(root, { childList: true, subtree: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
// Edit state
|
// Edit state
|
||||||
@@ -119,6 +355,6 @@ const TokensPage = () => {
|
|||||||
</CardPro>
|
</CardPro>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default TokensPage;
|
export default TokensPage;
|
||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
getLogOther,
|
getLogOther,
|
||||||
renderModelTag,
|
renderModelTag,
|
||||||
renderClaudeLogContent,
|
renderClaudeLogContent,
|
||||||
renderClaudeModelPriceSimple,
|
|
||||||
renderLogContent,
|
renderLogContent,
|
||||||
renderModelPriceSimple,
|
renderModelPriceSimple,
|
||||||
renderAudioModelPrice,
|
renderAudioModelPrice,
|
||||||
@@ -538,7 +537,7 @@ export const getLogsColumns = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let content = other?.claude
|
let content = other?.claude
|
||||||
? renderClaudeModelPriceSimple(
|
? renderModelPriceSimple(
|
||||||
other.model_ratio,
|
other.model_ratio,
|
||||||
other.model_price,
|
other.model_price,
|
||||||
other.group_ratio,
|
other.group_ratio,
|
||||||
@@ -547,6 +546,10 @@ export const getLogsColumns = ({
|
|||||||
other.cache_ratio || 1.0,
|
other.cache_ratio || 1.0,
|
||||||
other.cache_creation_tokens || 0,
|
other.cache_creation_tokens || 0,
|
||||||
other.cache_creation_ratio || 1.0,
|
other.cache_creation_ratio || 1.0,
|
||||||
|
false,
|
||||||
|
1.0,
|
||||||
|
other?.is_system_prompt_overwritten,
|
||||||
|
'claude'
|
||||||
)
|
)
|
||||||
: renderModelPriceSimple(
|
: renderModelPriceSimple(
|
||||||
other.model_ratio,
|
other.model_ratio,
|
||||||
@@ -555,13 +558,19 @@ export const getLogsColumns = ({
|
|||||||
other?.user_group_ratio,
|
other?.user_group_ratio,
|
||||||
other.cache_tokens || 0,
|
other.cache_tokens || 0,
|
||||||
other.cache_ratio || 1.0,
|
other.cache_ratio || 1.0,
|
||||||
|
0,
|
||||||
|
1.0,
|
||||||
|
false,
|
||||||
|
1.0,
|
||||||
|
other?.is_system_prompt_overwritten,
|
||||||
|
'openai'
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Typography.Paragraph
|
<Typography.Paragraph
|
||||||
ellipsis={{
|
ellipsis={{
|
||||||
rows: 2,
|
rows: 3,
|
||||||
}}
|
}}
|
||||||
style={{ maxWidth: 240 }}
|
style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Progress,
|
Progress,
|
||||||
Switch,
|
Popover,
|
||||||
|
Typography,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
|
import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
|
||||||
|
|
||||||
@@ -89,7 +90,6 @@ const renderUsername = (text, record) => {
|
|||||||
* Render user statistics
|
* Render user statistics
|
||||||
*/
|
*/
|
||||||
const renderStatistics = (text, record, showEnableDisableModal, t) => {
|
const renderStatistics = (text, record, showEnableDisableModal, t) => {
|
||||||
const enabled = record.status === 1;
|
|
||||||
const isDeleted = record.DeletedAt !== null;
|
const isDeleted = record.DeletedAt !== null;
|
||||||
|
|
||||||
// Determine tag text & color like original status column
|
// Determine tag text & color like original status column
|
||||||
@@ -100,60 +100,17 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
|
|||||||
tagText = t('已注销');
|
tagText = t('已注销');
|
||||||
} else if (record.status === 1) {
|
} else if (record.status === 1) {
|
||||||
tagColor = 'green';
|
tagColor = 'green';
|
||||||
tagText = t('已激活');
|
tagText = t('已启用');
|
||||||
} else if (record.status === 2) {
|
} else if (record.status === 2) {
|
||||||
tagColor = 'red';
|
tagColor = 'red';
|
||||||
tagText = t('已封禁');
|
tagText = t('已禁用');
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggle = (checked) => {
|
|
||||||
if (checked) {
|
|
||||||
showEnableDisableModal(record, 'enable');
|
|
||||||
} else {
|
|
||||||
showEnableDisableModal(record, 'disable');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const used = parseInt(record.used_quota) || 0;
|
|
||||||
const remain = parseInt(record.quota) || 0;
|
|
||||||
const total = used + remain;
|
|
||||||
const percent = total > 0 ? (remain / total) * 100 : 0;
|
|
||||||
|
|
||||||
const getProgressColor = (pct) => {
|
|
||||||
if (pct === 100) return 'var(--semi-color-success)';
|
|
||||||
if (pct <= 10) return 'var(--semi-color-danger)';
|
|
||||||
if (pct <= 30) return 'var(--semi-color-warning)';
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const quotaSuffix = (
|
|
||||||
<div className='flex flex-col items-end'>
|
|
||||||
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
|
|
||||||
<Progress
|
|
||||||
percent={percent}
|
|
||||||
stroke={getProgressColor(percent)}
|
|
||||||
aria-label='quota usage'
|
|
||||||
format={() => `${percent.toFixed(0)}%`}
|
|
||||||
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<Tag
|
<Tag
|
||||||
color={tagColor}
|
color={tagColor}
|
||||||
shape='circle'
|
shape='circle'
|
||||||
size='large'
|
size='small'
|
||||||
prefixIcon={
|
|
||||||
<Switch
|
|
||||||
size='small'
|
|
||||||
checked={enabled}
|
|
||||||
onChange={handleToggle}
|
|
||||||
disabled={isDeleted}
|
|
||||||
aria-label='user status switch'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
suffixIcon={quotaSuffix}
|
|
||||||
>
|
>
|
||||||
{tagText}
|
{tagText}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -161,9 +118,6 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
|
|||||||
|
|
||||||
const tooltipContent = (
|
const tooltipContent = (
|
||||||
<div className='text-xs'>
|
<div className='text-xs'>
|
||||||
<div>{t('已用额度')}: {renderQuota(used)}</div>
|
|
||||||
<div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
|
|
||||||
<div>{t('总额度')}: {renderQuota(total)}</div>
|
|
||||||
<div>{t('调用次数')}: {renderNumber(record.request_count)}</div>
|
<div>{t('调用次数')}: {renderNumber(record.request_count)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -175,6 +129,43 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Render separate quota usage column
|
||||||
|
const renderQuotaUsage = (text, record, t) => {
|
||||||
|
const { Paragraph } = Typography;
|
||||||
|
const used = parseInt(record.used_quota) || 0;
|
||||||
|
const remain = parseInt(record.quota) || 0;
|
||||||
|
const total = used + remain;
|
||||||
|
const percent = total > 0 ? (remain / total) * 100 : 0;
|
||||||
|
const popoverContent = (
|
||||||
|
<div className='text-xs p-2'>
|
||||||
|
<Paragraph copyable={{ content: renderQuota(used) }}>
|
||||||
|
{t('已用额度')}: {renderQuota(used)}
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph copyable={{ content: renderQuota(remain) }}>
|
||||||
|
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph copyable={{ content: renderQuota(total) }}>
|
||||||
|
{t('总额度')}: {renderQuota(total)}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Popover content={popoverContent} position='top'>
|
||||||
|
<Tag color='white' shape='circle'>
|
||||||
|
<div className='flex flex-col items-end'>
|
||||||
|
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
|
||||||
|
<Progress
|
||||||
|
percent={percent}
|
||||||
|
aria-label='quota usage'
|
||||||
|
format={() => `${percent.toFixed(0)}%`}
|
||||||
|
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tag>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render invite information
|
* Render invite information
|
||||||
*/
|
*/
|
||||||
@@ -204,6 +195,7 @@ const renderOperations = (text, record, {
|
|||||||
setShowEditUser,
|
setShowEditUser,
|
||||||
showPromoteModal,
|
showPromoteModal,
|
||||||
showDemoteModal,
|
showDemoteModal,
|
||||||
|
showEnableDisableModal,
|
||||||
showDeleteModal,
|
showDeleteModal,
|
||||||
t
|
t
|
||||||
}) => {
|
}) => {
|
||||||
@@ -213,6 +205,22 @@ const renderOperations = (text, record, {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Space>
|
<Space>
|
||||||
|
{record.status === 1 ? (
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
size="small"
|
||||||
|
onClick={() => showEnableDisableModal(record, 'disable')}
|
||||||
|
>
|
||||||
|
{t('禁用')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => showEnableDisableModal(record, 'enable')}
|
||||||
|
>
|
||||||
|
{t('启用')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type='tertiary'
|
type='tertiary'
|
||||||
size="small"
|
size="small"
|
||||||
@@ -270,6 +278,16 @@ export const getUsersColumns = ({
|
|||||||
dataIndex: 'username',
|
dataIndex: 'username',
|
||||||
render: (text, record) => renderUsername(text, record),
|
render: (text, record) => renderUsername(text, record),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('状态'),
|
||||||
|
dataIndex: 'info',
|
||||||
|
render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('剩余额度/总额度'),
|
||||||
|
key: 'quota_usage',
|
||||||
|
render: (text, record) => renderQuotaUsage(text, record, t),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('分组'),
|
title: t('分组'),
|
||||||
dataIndex: 'group',
|
dataIndex: 'group',
|
||||||
@@ -284,11 +302,6 @@ export const getUsersColumns = ({
|
|||||||
return <div>{renderRole(text, t)}</div>;
|
return <div>{renderRole(text, t)}</div>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t('状态'),
|
|
||||||
dataIndex: 'info',
|
|
||||||
render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: t('邀请信息'),
|
title: t('邀请信息'),
|
||||||
dataIndex: 'invite',
|
dataIndex: 'invite',
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export const CHANNEL_OPTIONS = [
|
|||||||
{
|
{
|
||||||
value: 16,
|
value: 16,
|
||||||
color: 'violet',
|
color: 'violet',
|
||||||
label: '智谱 ChatGLM',
|
label: '智谱 ChatGLM(已经弃用,请使用智谱 GLM-4V)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 26,
|
value: 26,
|
||||||
|
|||||||
@@ -215,14 +215,16 @@ export async function getOAuthState() {
|
|||||||
export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
|
export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
|
||||||
const state = await getOAuthState();
|
const state = await getOAuthState();
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const redirect_uri = `${window.location.origin}/oauth/oidc`;
|
const url = new URL(auth_url);
|
||||||
const response_type = 'code';
|
url.searchParams.set('client_id', client_id);
|
||||||
const scope = 'openid profile email';
|
url.searchParams.set('redirect_uri', `${window.location.origin}/oauth/oidc`);
|
||||||
const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('scope', 'openid profile email');
|
||||||
|
url.searchParams.set('state', state);
|
||||||
if (openInNewTab) {
|
if (openInNewTab) {
|
||||||
window.open(url);
|
window.open(url.toString(), '_blank');
|
||||||
} else {
|
} else {
|
||||||
window.location.href = url;
|
window.location.href = url.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -953,6 +953,71 @@ function getEffectiveRatio(groupRatio, user_group_ratio) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared core for simple price rendering (used by OpenAI-like and Claude-like variants)
|
||||||
|
function renderPriceSimpleCore({
|
||||||
|
modelRatio,
|
||||||
|
modelPrice = -1,
|
||||||
|
groupRatio,
|
||||||
|
user_group_ratio,
|
||||||
|
cacheTokens = 0,
|
||||||
|
cacheRatio = 1.0,
|
||||||
|
cacheCreationTokens = 0,
|
||||||
|
cacheCreationRatio = 1.0,
|
||||||
|
image = false,
|
||||||
|
imageRatio = 1.0,
|
||||||
|
isSystemPromptOverride = false
|
||||||
|
}) {
|
||||||
|
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||||||
|
groupRatio,
|
||||||
|
user_group_ratio,
|
||||||
|
);
|
||||||
|
const finalGroupRatio = effectiveGroupRatio;
|
||||||
|
|
||||||
|
if (modelPrice !== -1) {
|
||||||
|
return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {
|
||||||
|
price: modelPrice,
|
||||||
|
ratioType: ratioLabel,
|
||||||
|
ratio: finalGroupRatio,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
// base: model ratio
|
||||||
|
parts.push(i18next.t('模型: {{ratio}}'));
|
||||||
|
|
||||||
|
// cache part (label differs when with image)
|
||||||
|
if (cacheTokens !== 0) {
|
||||||
|
parts.push(i18next.t('缓存: {{cacheRatio}}'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// cache creation part (Claude specific if passed)
|
||||||
|
if (cacheCreationTokens !== 0) {
|
||||||
|
parts.push(i18next.t('缓存创建: {{cacheCreationRatio}}'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// image part
|
||||||
|
if (image) {
|
||||||
|
parts.push(i18next.t('图片输入: {{imageRatio}}'));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`{{ratioType}}: {{groupRatio}}`);
|
||||||
|
|
||||||
|
let result = i18next.t(parts.join(' * '), {
|
||||||
|
ratio: modelRatio,
|
||||||
|
ratioType: ratioLabel,
|
||||||
|
groupRatio: finalGroupRatio,
|
||||||
|
cacheRatio: cacheRatio,
|
||||||
|
cacheCreationRatio: cacheCreationRatio,
|
||||||
|
imageRatio: imageRatio,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isSystemPromptOverride) {
|
||||||
|
result += '\n\r' + i18next.t('系统提示覆盖');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderModelPrice(
|
export function renderModelPrice(
|
||||||
inputTokens,
|
inputTokens,
|
||||||
completionTokens,
|
completionTokens,
|
||||||
@@ -1245,56 +1310,26 @@ export function renderModelPriceSimple(
|
|||||||
user_group_ratio,
|
user_group_ratio,
|
||||||
cacheTokens = 0,
|
cacheTokens = 0,
|
||||||
cacheRatio = 1.0,
|
cacheRatio = 1.0,
|
||||||
|
cacheCreationTokens = 0,
|
||||||
|
cacheCreationRatio = 1.0,
|
||||||
image = false,
|
image = false,
|
||||||
imageRatio = 1.0,
|
imageRatio = 1.0,
|
||||||
|
isSystemPromptOverride = false,
|
||||||
|
provider = 'openai',
|
||||||
) {
|
) {
|
||||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
|
return renderPriceSimpleCore({
|
||||||
groupRatio = effectiveGroupRatio;
|
modelRatio,
|
||||||
if (modelPrice !== -1) {
|
modelPrice,
|
||||||
return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {
|
groupRatio,
|
||||||
price: modelPrice,
|
user_group_ratio,
|
||||||
ratioType: ratioLabel,
|
cacheTokens,
|
||||||
ratio: groupRatio,
|
cacheRatio,
|
||||||
});
|
cacheCreationTokens,
|
||||||
} else {
|
cacheCreationRatio,
|
||||||
if (image && cacheTokens !== 0) {
|
image,
|
||||||
return i18next.t(
|
imageRatio,
|
||||||
'模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 缓存倍率: {{cacheRatio}} * 图片输入倍率: {{imageRatio}}',
|
isSystemPromptOverride
|
||||||
{
|
});
|
||||||
ratio: modelRatio,
|
|
||||||
ratioType: ratioLabel,
|
|
||||||
groupRatio: groupRatio,
|
|
||||||
cacheRatio: cacheRatio,
|
|
||||||
imageRatio: imageRatio,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else if (image) {
|
|
||||||
return i18next.t(
|
|
||||||
'模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 图片输入倍率: {{imageRatio}}',
|
|
||||||
{
|
|
||||||
ratio: modelRatio,
|
|
||||||
ratioType: ratioLabel,
|
|
||||||
groupRatio: groupRatio,
|
|
||||||
imageRatio: imageRatio,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else if (cacheTokens !== 0) {
|
|
||||||
return i18next.t(
|
|
||||||
'模型: {{ratio}} * 分组: {{groupRatio}} * 缓存: {{cacheRatio}}',
|
|
||||||
{
|
|
||||||
ratio: modelRatio,
|
|
||||||
groupRatio: groupRatio,
|
|
||||||
cacheRatio: cacheRatio,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return i18next.t('模型: {{ratio}} * {{ratioType}}:{{groupRatio}}', {
|
|
||||||
ratio: modelRatio,
|
|
||||||
ratioType: ratioLabel,
|
|
||||||
groupRatio: groupRatio,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderAudioModelPrice(
|
export function renderAudioModelPrice(
|
||||||
@@ -1635,46 +1670,7 @@ export function renderClaudeLogContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderClaudeModelPriceSimple(
|
// 已统一至 renderModelPriceSimple,若仍有遗留引用,请改为传入 provider='claude'
|
||||||
modelRatio,
|
|
||||||
modelPrice = -1,
|
|
||||||
groupRatio,
|
|
||||||
user_group_ratio,
|
|
||||||
cacheTokens = 0,
|
|
||||||
cacheRatio = 1.0,
|
|
||||||
cacheCreationTokens = 0,
|
|
||||||
cacheCreationRatio = 1.0,
|
|
||||||
) {
|
|
||||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
|
|
||||||
groupRatio = effectiveGroupRatio;
|
|
||||||
|
|
||||||
if (modelPrice !== -1) {
|
|
||||||
return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {
|
|
||||||
price: modelPrice,
|
|
||||||
ratioType: ratioLabel,
|
|
||||||
ratio: groupRatio,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (cacheTokens !== 0 || cacheCreationTokens !== 0) {
|
|
||||||
return i18next.t(
|
|
||||||
'模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 缓存: {{cacheRatio}}',
|
|
||||||
{
|
|
||||||
ratio: modelRatio,
|
|
||||||
ratioType: ratioLabel,
|
|
||||||
groupRatio: groupRatio,
|
|
||||||
cacheRatio: cacheRatio,
|
|
||||||
cacheCreationRatio: cacheCreationRatio,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return i18next.t('模型: {{ratio}} * {{ratioType}}: {{groupRatio}}', {
|
|
||||||
ratio: modelRatio,
|
|
||||||
ratioType: ratioLabel,
|
|
||||||
groupRatio: groupRatio,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。
|
* rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ export const useTaskLogsData = () => {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [modalContent, setModalContent] = useState('');
|
const [modalContent, setModalContent] = useState('');
|
||||||
|
|
||||||
|
// 新增:视频预览弹窗状态
|
||||||
|
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
||||||
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formApi, setFormApi] = useState(null);
|
const [formApi, setFormApi] = useState(null);
|
||||||
let now = new Date();
|
let now = new Date();
|
||||||
@@ -250,6 +254,12 @@ export const useTaskLogsData = () => {
|
|||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 新增:打开视频预览弹窗
|
||||||
|
const openVideoModal = (url) => {
|
||||||
|
setVideoUrl(url);
|
||||||
|
setIsVideoModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize data
|
// Initialize data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
|
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
|
||||||
@@ -271,6 +281,11 @@ export const useTaskLogsData = () => {
|
|||||||
setIsModalOpen,
|
setIsModalOpen,
|
||||||
modalContent,
|
modalContent,
|
||||||
|
|
||||||
|
// 新增:视频弹窗状态
|
||||||
|
isVideoModalOpen,
|
||||||
|
setIsVideoModalOpen,
|
||||||
|
videoUrl,
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
formApi,
|
formApi,
|
||||||
setFormApi,
|
setFormApi,
|
||||||
@@ -297,6 +312,7 @@ export const useTaskLogsData = () => {
|
|||||||
refresh,
|
refresh,
|
||||||
copyText,
|
copyText,
|
||||||
openContentModal,
|
openContentModal,
|
||||||
|
openVideoModal, // 新增
|
||||||
enrichLogs,
|
enrichLogs,
|
||||||
syncPageData,
|
syncPageData,
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
import { ITEMS_PER_PAGE } from '../../constants';
|
import { ITEMS_PER_PAGE } from '../../constants';
|
||||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||||
|
|
||||||
export const useTokensData = () => {
|
export const useTokensData = (openFluentNotification) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Basic state
|
// Basic state
|
||||||
@@ -121,6 +121,10 @@ export const useTokensData = () => {
|
|||||||
|
|
||||||
// Open link function for chat integrations
|
// Open link function for chat integrations
|
||||||
const onOpenLink = async (type, url, record) => {
|
const onOpenLink = async (type, url, record) => {
|
||||||
|
if (url && url.startsWith('fluent')) {
|
||||||
|
openFluentNotification(record.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let status = localStorage.getItem('status');
|
let status = localStorage.getItem('status');
|
||||||
let serverAddress = '';
|
let serverAddress = '';
|
||||||
if (status) {
|
if (status) {
|
||||||
|
|||||||
@@ -1804,5 +1804,11 @@
|
|||||||
"已选择 {{selected}} / {{total}}": "Selected {{selected}} / {{total}}",
|
"已选择 {{selected}} / {{total}}": "Selected {{selected}} / {{total}}",
|
||||||
"新获取的模型": "New models",
|
"新获取的模型": "New models",
|
||||||
"已有的模型": "Existing models",
|
"已有的模型": "Existing models",
|
||||||
"搜索模型": "Search models"
|
"搜索模型": "Search models",
|
||||||
|
"缓存: {{cacheRatio}}": "Cache: {{cacheRatio}}",
|
||||||
|
"缓存创建: {{cacheCreationRatio}}": "Cache creation: {{cacheCreationRatio}}",
|
||||||
|
"图片输入: {{imageRatio}}": "Image input: {{imageRatio}}",
|
||||||
|
"系统提示覆盖": "System prompt override",
|
||||||
|
"模型: {{ratio}}": "Model: {{ratio}}",
|
||||||
|
"专属倍率": "Exclusive group ratio"
|
||||||
}
|
}
|
||||||
@@ -655,7 +655,7 @@ html:not(.dark) .blur-ball-teal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pricing-search-header {
|
.pricing-search-header {
|
||||||
padding: 16px 24px;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid var(--semi-color-border);
|
border-bottom: 1px solid var(--semi-color-border);
|
||||||
background-color: var(--semi-color-bg-0);
|
background-color: var(--semi-color-bg-0);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user