mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-13 12:57:26 +00:00
Compare commits
11 Commits
v0.11.2-pa
...
v0.11.4-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bb2b6a6ae | ||
|
|
c706a5c29a | ||
|
|
9b394231c8 | ||
|
|
05452c1558 | ||
|
|
f9b5ecc955 | ||
|
|
d796578880 | ||
|
|
3c71e0cd09 | ||
|
|
8186ed0ea5 | ||
|
|
782124510a | ||
|
|
4d421f525e | ||
|
|
3cb0ca264f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.idea
|
||||
.vscode
|
||||
.zed
|
||||
.history
|
||||
upload
|
||||
*.exe
|
||||
*.db
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -17,10 +16,56 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var completionRatioMetaOptionKeys = []string{
|
||||
"ModelPrice",
|
||||
"ModelRatio",
|
||||
"CompletionRatio",
|
||||
"CacheRatio",
|
||||
"CreateCacheRatio",
|
||||
"ImageRatio",
|
||||
"AudioRatio",
|
||||
"AudioCompletionRatio",
|
||||
}
|
||||
|
||||
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := common.UnmarshalJsonStr(raw, &parsed); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for modelName := range parsed {
|
||||
modelNames[modelName] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func buildCompletionRatioMetaValue(optionValues map[string]string) string {
|
||||
modelNames := make(map[string]struct{})
|
||||
for _, key := range completionRatioMetaOptionKeys {
|
||||
collectModelNamesFromOptionValue(optionValues[key], modelNames)
|
||||
}
|
||||
|
||||
meta := make(map[string]ratio_setting.CompletionRatioInfo, len(modelNames))
|
||||
for modelName := range modelNames {
|
||||
meta[modelName] = ratio_setting.GetCompletionRatioInfo(modelName)
|
||||
}
|
||||
|
||||
jsonBytes, err := common.Marshal(meta)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func GetOptions(c *gin.Context) {
|
||||
var options []*model.Option
|
||||
optionValues := make(map[string]string)
|
||||
common.OptionMapRWMutex.Lock()
|
||||
for k, v := range common.OptionMap {
|
||||
value := common.Interface2String(v)
|
||||
if strings.HasSuffix(k, "Token") ||
|
||||
strings.HasSuffix(k, "Secret") ||
|
||||
strings.HasSuffix(k, "Key") ||
|
||||
@@ -30,10 +75,20 @@ func GetOptions(c *gin.Context) {
|
||||
}
|
||||
options = append(options, &model.Option{
|
||||
Key: k,
|
||||
Value: common.Interface2String(v),
|
||||
Value: value,
|
||||
})
|
||||
for _, optionKey := range completionRatioMetaOptionKeys {
|
||||
if optionKey == k {
|
||||
optionValues[k] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
options = append(options, &model.Option{
|
||||
Key: "CompletionRatioMeta",
|
||||
Value: buildCompletionRatioMetaValue(optionValues),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
@@ -49,7 +104,7 @@ type OptionUpdateRequest struct {
|
||||
|
||||
func UpdateOption(c *gin.Context) {
|
||||
var option OptionUpdateRequest
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&option)
|
||||
err := common.DecodeJson(c.Request.Body, &option)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
|
||||
@@ -341,6 +341,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
||||
if code < 100 || code > 599 {
|
||||
return true
|
||||
}
|
||||
if operation_setting.IsAlwaysSkipRetryCode(openaiErr.GetErrorCode()) {
|
||||
return false
|
||||
}
|
||||
return operation_setting.ShouldRetryByStatusCode(code)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,23 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func buildMaskedTokenResponse(token *model.Token) *model.Token {
|
||||
if token == nil {
|
||||
return nil
|
||||
}
|
||||
maskedToken := *token
|
||||
maskedToken.Key = token.GetMaskedKey()
|
||||
return &maskedToken
|
||||
}
|
||||
|
||||
func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token {
|
||||
maskedTokens := make([]*model.Token, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token))
|
||||
}
|
||||
return maskedTokens
|
||||
}
|
||||
|
||||
func GetAllTokens(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
@@ -24,9 +41,8 @@ func GetAllTokens(c *gin.Context) {
|
||||
}
|
||||
total, _ := model.CountUserTokens(userId)
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(tokens)
|
||||
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
return
|
||||
}
|
||||
|
||||
func SearchTokens(c *gin.Context) {
|
||||
@@ -42,9 +58,8 @@ func SearchTokens(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(tokens)
|
||||
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
return
|
||||
}
|
||||
|
||||
func GetToken(c *gin.Context) {
|
||||
@@ -59,12 +74,24 @@ func GetToken(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": token,
|
||||
common.ApiSuccess(c, buildMaskedTokenResponse(token))
|
||||
}
|
||||
|
||||
func GetTokenKey(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
userId := c.GetInt("id")
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
token, err := model.GetTokenByIds(id, userId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"key": token.GetFullKey(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetTokenStatus(c *gin.Context) {
|
||||
@@ -204,7 +231,6 @@ func AddToken(c *gin.Context) {
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func DeleteToken(c *gin.Context) {
|
||||
@@ -219,7 +245,6 @@ func DeleteToken(c *gin.Context) {
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func UpdateToken(c *gin.Context) {
|
||||
@@ -283,7 +308,7 @@ func UpdateToken(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": cleanToken,
|
||||
"data": buildMaskedTokenResponse(cleanToken),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
275
controller/token_test.go
Normal file
275
controller/token_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type tokenAPIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type tokenPageResponse struct {
|
||||
Items []tokenResponseItem `json:"items"`
|
||||
}
|
||||
|
||||
type tokenResponseItem struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type tokenKeyResponse struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func setupTokenControllerTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.UsingSQLite = true
|
||||
common.UsingMySQL = false
|
||||
common.UsingPostgreSQL = false
|
||||
common.RedisEnabled = false
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite db: %v", err)
|
||||
}
|
||||
model.DB = db
|
||||
model.LOG_DB = db
|
||||
|
||||
if err := db.AutoMigrate(&model.Token{}); err != nil {
|
||||
t.Fatalf("failed to migrate token table: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func seedToken(t *testing.T, db *gorm.DB, userID int, name string, rawKey string) *model.Token {
|
||||
t.Helper()
|
||||
|
||||
token := &model.Token{
|
||||
UserId: userID,
|
||||
Name: name,
|
||||
Key: rawKey,
|
||||
Status: common.TokenStatusEnabled,
|
||||
CreatedTime: 1,
|
||||
AccessedTime: 1,
|
||||
ExpiredTime: -1,
|
||||
RemainQuota: 100,
|
||||
UnlimitedQuota: true,
|
||||
Group: "default",
|
||||
}
|
||||
if err := db.Create(token).Error; err != nil {
|
||||
t.Fatalf("failed to create token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func newAuthenticatedContext(t *testing.T, method string, target string, body any, userID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
|
||||
var requestBody *bytes.Reader
|
||||
if body != nil {
|
||||
payload, err := common.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal request body: %v", err)
|
||||
}
|
||||
requestBody = bytes.NewReader(payload)
|
||||
} else {
|
||||
requestBody = bytes.NewReader(nil)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(method, target, requestBody)
|
||||
if body != nil {
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
ctx.Set("id", userID)
|
||||
return ctx, recorder
|
||||
}
|
||||
|
||||
func decodeAPIResponse(t *testing.T, recorder *httptest.ResponseRecorder) tokenAPIResponse {
|
||||
t.Helper()
|
||||
|
||||
var response tokenAPIResponse
|
||||
if err := common.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("failed to decode api response: %v", err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func TestGetAllTokensMasksKeyInResponse(t *testing.T) {
|
||||
db := setupTokenControllerTestDB(t)
|
||||
token := seedToken(t, db, 1, "list-token", "abcd1234efgh5678")
|
||||
seedToken(t, db, 2, "other-user-token", "zzzz1234yyyy5678")
|
||||
|
||||
ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/?p=1&size=10", nil, 1)
|
||||
GetAllTokens(ctx)
|
||||
|
||||
response := decodeAPIResponse(t, recorder)
|
||||
if !response.Success {
|
||||
t.Fatalf("expected success response, got message: %s", response.Message)
|
||||
}
|
||||
|
||||
var page tokenPageResponse
|
||||
if err := common.Unmarshal(response.Data, &page); err != nil {
|
||||
t.Fatalf("failed to decode token page response: %v", err)
|
||||
}
|
||||
if len(page.Items) != 1 {
|
||||
t.Fatalf("expected exactly one token, got %d", len(page.Items))
|
||||
}
|
||||
if page.Items[0].Key != token.GetMaskedKey() {
|
||||
t.Fatalf("expected masked key %q, got %q", token.GetMaskedKey(), page.Items[0].Key)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), token.Key) {
|
||||
t.Fatalf("list response leaked raw token key: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTokensMasksKeyInResponse(t *testing.T) {
|
||||
db := setupTokenControllerTestDB(t)
|
||||
token := seedToken(t, db, 1, "searchable-token", "ijkl1234mnop5678")
|
||||
|
||||
ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/search?keyword=searchable-token&p=1&size=10", nil, 1)
|
||||
SearchTokens(ctx)
|
||||
|
||||
response := decodeAPIResponse(t, recorder)
|
||||
if !response.Success {
|
||||
t.Fatalf("expected success response, got message: %s", response.Message)
|
||||
}
|
||||
|
||||
var page tokenPageResponse
|
||||
if err := common.Unmarshal(response.Data, &page); err != nil {
|
||||
t.Fatalf("failed to decode search response: %v", err)
|
||||
}
|
||||
if len(page.Items) != 1 {
|
||||
t.Fatalf("expected exactly one search result, got %d", len(page.Items))
|
||||
}
|
||||
if page.Items[0].Key != token.GetMaskedKey() {
|
||||
t.Fatalf("expected masked search key %q, got %q", token.GetMaskedKey(), page.Items[0].Key)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), token.Key) {
|
||||
t.Fatalf("search response leaked raw token key: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenMasksKeyInResponse(t *testing.T) {
|
||||
db := setupTokenControllerTestDB(t)
|
||||
token := seedToken(t, db, 1, "detail-token", "qrst1234uvwx5678")
|
||||
|
||||
ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/"+strconv.Itoa(token.Id), nil, 1)
|
||||
ctx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
|
||||
GetToken(ctx)
|
||||
|
||||
response := decodeAPIResponse(t, recorder)
|
||||
if !response.Success {
|
||||
t.Fatalf("expected success response, got message: %s", response.Message)
|
||||
}
|
||||
|
||||
var detail tokenResponseItem
|
||||
if err := common.Unmarshal(response.Data, &detail); err != nil {
|
||||
t.Fatalf("failed to decode token detail response: %v", err)
|
||||
}
|
||||
if detail.Key != token.GetMaskedKey() {
|
||||
t.Fatalf("expected masked detail key %q, got %q", token.GetMaskedKey(), detail.Key)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), token.Key) {
|
||||
t.Fatalf("detail response leaked raw token key: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
|
||||
db := setupTokenControllerTestDB(t)
|
||||
token := seedToken(t, db, 1, "editable-token", "yzab1234cdef5678")
|
||||
|
||||
body := map[string]any{
|
||||
"id": token.Id,
|
||||
"name": "updated-token",
|
||||
"expired_time": -1,
|
||||
"remain_quota": 100,
|
||||
"unlimited_quota": true,
|
||||
"model_limits_enabled": false,
|
||||
"model_limits": "",
|
||||
"group": "default",
|
||||
"cross_group_retry": false,
|
||||
}
|
||||
|
||||
ctx, recorder := newAuthenticatedContext(t, http.MethodPut, "/api/token/", body, 1)
|
||||
UpdateToken(ctx)
|
||||
|
||||
response := decodeAPIResponse(t, recorder)
|
||||
if !response.Success {
|
||||
t.Fatalf("expected success response, got message: %s", response.Message)
|
||||
}
|
||||
|
||||
var detail tokenResponseItem
|
||||
if err := common.Unmarshal(response.Data, &detail); err != nil {
|
||||
t.Fatalf("failed to decode token update response: %v", err)
|
||||
}
|
||||
if detail.Key != token.GetMaskedKey() {
|
||||
t.Fatalf("expected masked update key %q, got %q", token.GetMaskedKey(), detail.Key)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), token.Key) {
|
||||
t.Fatalf("update response leaked raw token key: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) {
|
||||
db := setupTokenControllerTestDB(t)
|
||||
token := seedToken(t, db, 1, "owned-token", "owner1234token5678")
|
||||
|
||||
authorizedCtx, authorizedRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/"+strconv.Itoa(token.Id)+"/key", nil, 1)
|
||||
authorizedCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
|
||||
GetTokenKey(authorizedCtx)
|
||||
|
||||
authorizedResponse := decodeAPIResponse(t, authorizedRecorder)
|
||||
if !authorizedResponse.Success {
|
||||
t.Fatalf("expected authorized key fetch to succeed, got message: %s", authorizedResponse.Message)
|
||||
}
|
||||
|
||||
var keyData tokenKeyResponse
|
||||
if err := common.Unmarshal(authorizedResponse.Data, &keyData); err != nil {
|
||||
t.Fatalf("failed to decode token key response: %v", err)
|
||||
}
|
||||
if keyData.Key != token.GetFullKey() {
|
||||
t.Fatalf("expected full key %q, got %q", token.GetFullKey(), keyData.Key)
|
||||
}
|
||||
|
||||
unauthorizedCtx, unauthorizedRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/"+strconv.Itoa(token.Id)+"/key", nil, 2)
|
||||
unauthorizedCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
|
||||
GetTokenKey(unauthorizedCtx)
|
||||
|
||||
unauthorizedResponse := decodeAPIResponse(t, unauthorizedRecorder)
|
||||
if unauthorizedResponse.Success {
|
||||
t.Fatalf("expected unauthorized key fetch to fail")
|
||||
}
|
||||
if strings.Contains(unauthorizedRecorder.Body.String(), token.Key) {
|
||||
t.Fatalf("unauthorized key response leaked raw token key: %s", unauthorizedRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,8 @@ func VideoProxy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
task, exists, err := model.GetByOnlyTaskId(taskID)
|
||||
userID := c.GetInt("id")
|
||||
task, exists, err := model.GetByTaskId(userID, taskID)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to query task %s: %s", taskID, err.Error()))
|
||||
videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to query task")
|
||||
|
||||
@@ -56,10 +56,10 @@ type GeneralOpenAIRequest struct {
|
||||
Tools []ToolCallRequest `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
FunctionCall json.RawMessage `json:"function_call,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
User json.RawMessage `json:"user,omitempty"`
|
||||
// ServiceTier specifies upstream service level and may affect billing.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_service_tier.
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
ServiceTier json.RawMessage `json:"service_tier,omitempty"`
|
||||
LogProbs *bool `json:"logprobs,omitempty"`
|
||||
TopLogProbs *int `json:"top_logprobs,omitempty"`
|
||||
Dimensions *int `json:"dimensions,omitempty"`
|
||||
@@ -67,7 +67,7 @@ type GeneralOpenAIRequest struct {
|
||||
Audio json.RawMessage `json:"audio,omitempty"`
|
||||
// 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户
|
||||
// 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤,可通过 allow_safety_identifier 开启
|
||||
SafetyIdentifier string `json:"safety_identifier,omitempty"`
|
||||
SafetyIdentifier json.RawMessage `json:"safety_identifier,omitempty"`
|
||||
// Whether or not to store the output of this chat completion request for use in our model distillation or evals products.
|
||||
// 是否存储此次请求数据供 OpenAI 用于评估和优化产品
|
||||
// 注意:默认允许透传,可通过 disable_store 禁用;禁用后可能导致 Codex 无法正常使用
|
||||
@@ -100,10 +100,10 @@ type GeneralOpenAIRequest struct {
|
||||
THINKING json.RawMessage `json:"thinking,omitempty"`
|
||||
// pplx Params
|
||||
SearchDomainFilter json.RawMessage `json:"search_domain_filter,omitempty"`
|
||||
SearchRecencyFilter string `json:"search_recency_filter,omitempty"`
|
||||
SearchRecencyFilter json.RawMessage `json:"search_recency_filter,omitempty"`
|
||||
ReturnImages *bool `json:"return_images,omitempty"`
|
||||
ReturnRelatedQuestions *bool `json:"return_related_questions,omitempty"`
|
||||
SearchMode string `json:"search_mode,omitempty"`
|
||||
SearchMode json.RawMessage `json:"search_mode,omitempty"`
|
||||
// Minimax
|
||||
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
|
||||
}
|
||||
@@ -836,7 +836,7 @@ type OpenAIResponsesRequest struct {
|
||||
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
|
||||
// SafetyIdentifier carries client identity for policy abuse detection.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_safety_identifier.
|
||||
SafetyIdentifier string `json:"safety_identifier,omitempty"`
|
||||
SafetyIdentifier json.RawMessage `json:"safety_identifier,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
@@ -844,8 +844,8 @@ type OpenAIResponsesRequest struct {
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Truncation string `json:"truncation,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Truncation json.RawMessage `json:"truncation,omitempty"`
|
||||
User json.RawMessage `json:"user,omitempty"`
|
||||
MaxToolCalls *uint `json:"max_tool_calls,omitempty"`
|
||||
Prompt json.RawMessage `json:"prompt,omitempty"`
|
||||
// qwen
|
||||
|
||||
@@ -25,6 +25,11 @@ type Pricing struct {
|
||||
ModelPrice float64 `json:"model_price"`
|
||||
OwnerBy string `json:"owner_by"`
|
||||
CompletionRatio float64 `json:"completion_ratio"`
|
||||
CacheRatio *float64 `json:"cache_ratio,omitempty"`
|
||||
CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"`
|
||||
ImageRatio *float64 `json:"image_ratio,omitempty"`
|
||||
AudioRatio *float64 `json:"audio_ratio,omitempty"`
|
||||
AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"`
|
||||
EnableGroup []string `json:"enable_groups"`
|
||||
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
|
||||
PricingVersion string `json:"pricing_version,omitempty"`
|
||||
@@ -297,12 +302,29 @@ func updatePricing() {
|
||||
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
|
||||
pricing.QuotaType = 0
|
||||
}
|
||||
if cacheRatio, ok := ratio_setting.GetCacheRatio(model); ok {
|
||||
pricing.CacheRatio = &cacheRatio
|
||||
}
|
||||
if createCacheRatio, ok := ratio_setting.GetCreateCacheRatio(model); ok {
|
||||
pricing.CreateCacheRatio = &createCacheRatio
|
||||
}
|
||||
if imageRatio, ok := ratio_setting.GetImageRatio(model); ok {
|
||||
pricing.ImageRatio = &imageRatio
|
||||
}
|
||||
if ratio_setting.ContainsAudioRatio(model) {
|
||||
audioRatio := ratio_setting.GetAudioRatio(model)
|
||||
pricing.AudioRatio = &audioRatio
|
||||
}
|
||||
if ratio_setting.ContainsAudioCompletionRatio(model) {
|
||||
audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(model)
|
||||
pricing.AudioCompletionRatio = &audioCompletionRatio
|
||||
}
|
||||
pricingMap = append(pricingMap, pricing)
|
||||
}
|
||||
|
||||
// 防止大更新后数据不通用
|
||||
if len(pricingMap) > 0 {
|
||||
pricingMap[0].PricingVersion = "82c4a357505fff6fee8462c3f7ec8a645bb95532669cb73b2cabee6a416ec24f"
|
||||
pricingMap[0].PricingVersion = "5a90f2b86c08bd983a9a2e6d66c255f4eaef9c4bc934386d2b6ae84ef0ff1f1f"
|
||||
}
|
||||
|
||||
// 刷新缓存映射,供高并发快速查询
|
||||
|
||||
@@ -35,6 +35,27 @@ func (token *Token) Clean() {
|
||||
token.Key = ""
|
||||
}
|
||||
|
||||
func MaskTokenKey(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
if len(key) <= 4 {
|
||||
return strings.Repeat("*", len(key))
|
||||
}
|
||||
if len(key) <= 8 {
|
||||
return key[:2] + "****" + key[len(key)-2:]
|
||||
}
|
||||
return key[:4] + "**********" + key[len(key)-4:]
|
||||
}
|
||||
|
||||
func (token *Token) GetFullKey() string {
|
||||
return token.Key
|
||||
}
|
||||
|
||||
func (token *Token) GetMaskedKey() string {
|
||||
return MaskTokenKey(token.Key)
|
||||
}
|
||||
|
||||
func (token *Token) GetIpLimits() []string {
|
||||
// delete empty spaces
|
||||
//split with \n
|
||||
@@ -201,7 +222,7 @@ func ValidateUserToken(key string) (token *Token, err error) {
|
||||
}
|
||||
keyPrefix := key[:3]
|
||||
keySuffix := key[len(key)-3:]
|
||||
return token, errors.New(fmt.Sprintf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota))
|
||||
return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package baidu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
@@ -12,16 +13,16 @@ type BaiduMessage struct {
|
||||
}
|
||||
|
||||
type BaiduChatRequest struct {
|
||||
Messages []BaiduMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
PenaltyScore float64 `json:"penalty_score,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
System string `json:"system,omitempty"`
|
||||
DisableSearch bool `json:"disable_search,omitempty"`
|
||||
EnableCitation bool `json:"enable_citation,omitempty"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
|
||||
UserId string `json:"user_id,omitempty"`
|
||||
Messages []BaiduMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
PenaltyScore float64 `json:"penalty_score,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
System string `json:"system,omitempty"`
|
||||
DisableSearch bool `json:"disable_search,omitempty"`
|
||||
EnableCitation bool `json:"enable_citation,omitempty"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
|
||||
UserId json.RawMessage `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
|
||||
@@ -17,7 +17,7 @@ type CozeEnterMessage struct {
|
||||
|
||||
type CozeChatRequest struct {
|
||||
BotId string `json:"bot_id"`
|
||||
UserId string `json:"user_id"`
|
||||
UserId json.RawMessage `json:"user_id"`
|
||||
AdditionalMessages []CozeEnterMessage `json:"additional_messages,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
CustomVariables json.RawMessage `json:"custom_variables,omitempty"`
|
||||
|
||||
@@ -34,8 +34,8 @@ func convertCozeChatRequest(c *gin.Context, request dto.GeneralOpenAIRequest) *C
|
||||
}
|
||||
}
|
||||
user := request.User
|
||||
if user == "" {
|
||||
user = helper.GetResponseID(c)
|
||||
if len(user) == 0 {
|
||||
user = json.RawMessage(helper.GetResponseID(c))
|
||||
}
|
||||
cozeRequest := &CozeChatRequest{
|
||||
BotId: c.GetString("bot_id"),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dify
|
||||
|
||||
import "github.com/QuantumNous/new-api/dto"
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
type DifyChatRequest struct {
|
||||
Inputs map[string]interface{} `json:"inputs"`
|
||||
|
||||
@@ -131,10 +131,16 @@ func requestOpenAI2Dify(c *gin.Context, info *relaycommon.RelayInfo, request dto
|
||||
}
|
||||
|
||||
user := request.User
|
||||
if user == "" {
|
||||
user = helper.GetResponseID(c)
|
||||
if len(user) == 0 {
|
||||
user = json.RawMessage(helper.GetResponseID(c))
|
||||
}
|
||||
difyReq.User = user
|
||||
var stringUser string
|
||||
err := json.Unmarshal(user, &stringUser)
|
||||
if err != nil {
|
||||
common.SysLog("failed to unmarshal user: " + err.Error())
|
||||
stringUser = helper.GetResponseID(c)
|
||||
}
|
||||
difyReq.User = stringUser
|
||||
|
||||
files := make([]DifyFile, 0)
|
||||
var content strings.Builder
|
||||
|
||||
@@ -248,6 +248,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
tokenRoute.GET("/", controller.GetAllTokens)
|
||||
tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens)
|
||||
tokenRoute.GET("/:id", controller.GetToken)
|
||||
tokenRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKey)
|
||||
tokenRoute.POST("/", controller.AddToken)
|
||||
tokenRoute.PUT("/", controller.UpdateToken)
|
||||
tokenRoute.DELETE("/:id", controller.DeleteToken)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
)
|
||||
|
||||
type StatusCodeRange struct {
|
||||
@@ -31,6 +33,10 @@ var alwaysSkipRetryStatusCodes = map[int]struct{}{
|
||||
524: {},
|
||||
}
|
||||
|
||||
var alwaysSkipRetryCodes = map[types.ErrorCode]struct{}{
|
||||
types.ErrorCodeBadResponseBody: {},
|
||||
}
|
||||
|
||||
func AutomaticDisableStatusCodesToString() string {
|
||||
return statusCodeRangesToString(AutomaticDisableStatusCodeRanges)
|
||||
}
|
||||
@@ -66,6 +72,11 @@ func IsAlwaysSkipRetryStatusCode(code int) bool {
|
||||
return exists
|
||||
}
|
||||
|
||||
func IsAlwaysSkipRetryCode(errorCode types.ErrorCode) bool {
|
||||
_, exists := alwaysSkipRetryCodes[errorCode]
|
||||
return exists
|
||||
}
|
||||
|
||||
func ShouldRetryByStatusCode(code int) bool {
|
||||
if IsAlwaysSkipRetryStatusCode(code) {
|
||||
return false
|
||||
|
||||
@@ -452,6 +452,44 @@ func GetCompletionRatio(name string) float64 {
|
||||
return hardCodedRatio
|
||||
}
|
||||
|
||||
type CompletionRatioInfo struct {
|
||||
Ratio float64 `json:"ratio"`
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
|
||||
func GetCompletionRatioInfo(name string) CompletionRatioInfo {
|
||||
name = FormatMatchingModelName(name)
|
||||
|
||||
if strings.Contains(name, "/") {
|
||||
if ratio, ok := completionRatioMap.Get(name); ok {
|
||||
return CompletionRatioInfo{
|
||||
Ratio: ratio,
|
||||
Locked: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hardCodedRatio, locked := getHardcodedCompletionModelRatio(name)
|
||||
if locked {
|
||||
return CompletionRatioInfo{
|
||||
Ratio: hardCodedRatio,
|
||||
Locked: true,
|
||||
}
|
||||
}
|
||||
|
||||
if ratio, ok := completionRatioMap.Get(name); ok {
|
||||
return CompletionRatioInfo{
|
||||
Ratio: ratio,
|
||||
Locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
return CompletionRatioInfo{
|
||||
Ratio: hardCodedRatio,
|
||||
Locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
|
||||
isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*")
|
||||
|
||||
2
web/i18next.config.js
vendored
2
web/i18next.config.js
vendored
@@ -21,7 +21,7 @@ import { defineConfig } from 'i18next-cli';
|
||||
|
||||
/** @type {import('i18next-cli').I18nextToolkitConfig} */
|
||||
export default defineConfig({
|
||||
locales: ['zh', 'en', 'fr', 'ru', 'ja', 'vi'],
|
||||
locales: ['zh-CN', 'zh-TW', 'en', 'fr', 'ru', 'ja', 'vi'],
|
||||
extract: {
|
||||
input: ['src/**/*.{js,jsx,ts,tsx}'],
|
||||
ignore: ['src/i18n/**/*'],
|
||||
|
||||
@@ -37,10 +37,11 @@ import {
|
||||
import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { normalizeLanguage } from '../../i18n/language';
|
||||
const { Sider, Content, Header } = Layout;
|
||||
|
||||
const PageLayout = () => {
|
||||
const [, userDispatch] = useContext(UserContext);
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [, statusDispatch] = useContext(StatusContext);
|
||||
const isMobile = useIsMobile();
|
||||
const [collapsed, , setCollapsed] = useSidebarCollapsed();
|
||||
@@ -113,11 +114,34 @@ const PageLayout = () => {
|
||||
linkElement.href = logo;
|
||||
}
|
||||
}
|
||||
const savedLang = localStorage.getItem('i18nextLng');
|
||||
if (savedLang) {
|
||||
i18n.changeLanguage(savedLang);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let preferredLang;
|
||||
|
||||
if (userState?.user?.setting) {
|
||||
try {
|
||||
const settings = JSON.parse(userState.user.setting);
|
||||
preferredLang = normalizeLanguage(settings.language);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}, [i18n]);
|
||||
|
||||
if (!preferredLang) {
|
||||
const savedLang = localStorage.getItem('i18nextLng');
|
||||
if (savedLang) {
|
||||
preferredLang = normalizeLanguage(savedLang);
|
||||
}
|
||||
}
|
||||
|
||||
if (preferredLang) {
|
||||
localStorage.setItem('i18nextLng', preferredLang);
|
||||
if (preferredLang !== i18n.language) {
|
||||
i18n.changeLanguage(preferredLang);
|
||||
}
|
||||
}
|
||||
}, [i18n, userState?.user?.setting]);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
|
||||
@@ -95,19 +95,19 @@ const RatioSetting = () => {
|
||||
|
||||
return (
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 模型倍率设置以及可视化编辑器 */}
|
||||
{/* 模型倍率设置以及价格编辑器 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs type='card'>
|
||||
<Tabs type='card' defaultActiveKey='visual'>
|
||||
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
|
||||
<ModelRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('分组倍率设置')} itemKey='group'>
|
||||
<Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
|
||||
<GroupRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
|
||||
<Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
|
||||
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'>
|
||||
<Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
|
||||
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Languages } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { API, showSuccess, showError } from "../../../../helpers";
|
||||
import { UserContext } from "../../../../context/User";
|
||||
import { normalizeLanguage } from "../../../../i18n/language";
|
||||
|
||||
// Language options with native names
|
||||
const languageOptions = [
|
||||
@@ -39,7 +40,7 @@ const PreferencesSettings = ({ t }) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [currentLanguage, setCurrentLanguage] = useState(
|
||||
i18n.language || "zh-CN",
|
||||
normalizeLanguage(i18n.language) || "zh-CN",
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -49,8 +50,7 @@ const PreferencesSettings = ({ t }) => {
|
||||
try {
|
||||
const settings = JSON.parse(userState.user.setting);
|
||||
if (settings.language) {
|
||||
// Normalize legacy "zh" to "zh-CN" for backward compatibility
|
||||
const lang = settings.language === "zh" ? "zh-CN" : settings.language;
|
||||
const lang = normalizeLanguage(settings.language);
|
||||
setCurrentLanguage(lang);
|
||||
// Sync i18n with saved preference
|
||||
if (i18n.language !== lang) {
|
||||
@@ -73,6 +73,7 @@ const PreferencesSettings = ({ t }) => {
|
||||
// Update language immediately for responsive UX
|
||||
setCurrentLanguage(lang);
|
||||
i18n.changeLanguage(lang);
|
||||
localStorage.setItem('i18nextLng', lang);
|
||||
|
||||
// Save to backend
|
||||
const res = await API.put("/api/user/self", {
|
||||
@@ -81,33 +82,38 @@ const PreferencesSettings = ({ t }) => {
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t("语言偏好已保存"));
|
||||
// Update user context with new setting
|
||||
// Keep backend preference, context state, and local cache aligned.
|
||||
let settings = {};
|
||||
if (userState?.user?.setting) {
|
||||
try {
|
||||
const settings = JSON.parse(userState.user.setting);
|
||||
settings.language = lang;
|
||||
userDispatch({
|
||||
type: "login",
|
||||
payload: {
|
||||
...userState.user,
|
||||
setting: JSON.stringify(settings),
|
||||
},
|
||||
});
|
||||
settings = JSON.parse(userState.user.setting) || {};
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
settings = {};
|
||||
}
|
||||
}
|
||||
settings.language = lang;
|
||||
const nextUser = {
|
||||
...userState.user,
|
||||
setting: JSON.stringify(settings),
|
||||
};
|
||||
userDispatch({
|
||||
type: "login",
|
||||
payload: nextUser,
|
||||
});
|
||||
localStorage.setItem("user", JSON.stringify(nextUser));
|
||||
} else {
|
||||
showError(res.data.message || t("保存失败"));
|
||||
// Revert on error
|
||||
setCurrentLanguage(previousLang);
|
||||
i18n.changeLanguage(previousLang);
|
||||
localStorage.setItem("i18nextLng", previousLang);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t("保存失败,请重试"));
|
||||
// Revert on error
|
||||
setCurrentLanguage(previousLang);
|
||||
i18n.changeLanguage(previousLang);
|
||||
localStorage.setItem("i18nextLng", previousLang);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
TextArea,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
|
||||
import { IconDelete, IconMenu, IconPlus } from '@douyinfe/semi-icons';
|
||||
import { copy, showError, showSuccess, verifyJSON } from '../../../../helpers';
|
||||
import {
|
||||
CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||
@@ -800,6 +800,38 @@ const normalizeOperation = (operation = {}) => ({
|
||||
|
||||
const createDefaultOperation = () => normalizeOperation({ mode: 'set' });
|
||||
|
||||
const reorderOperations = (
|
||||
sourceOperations = [],
|
||||
sourceId,
|
||||
targetId,
|
||||
position = 'before',
|
||||
) => {
|
||||
if (!sourceId || !targetId || sourceId === targetId) {
|
||||
return sourceOperations;
|
||||
}
|
||||
|
||||
const sourceIndex = sourceOperations.findIndex((item) => item.id === sourceId);
|
||||
|
||||
if (sourceIndex < 0) {
|
||||
return sourceOperations;
|
||||
}
|
||||
|
||||
const nextOperations = [...sourceOperations];
|
||||
const [moved] = nextOperations.splice(sourceIndex, 1);
|
||||
let insertIndex = nextOperations.findIndex((item) => item.id === targetId);
|
||||
|
||||
if (insertIndex < 0) {
|
||||
return sourceOperations;
|
||||
}
|
||||
|
||||
if (position === 'after') {
|
||||
insertIndex += 1;
|
||||
}
|
||||
|
||||
nextOperations.splice(insertIndex, 0, moved);
|
||||
return nextOperations;
|
||||
};
|
||||
|
||||
const getOperationSummary = (operation = {}, index = 0) => {
|
||||
const mode = operation.mode || 'set';
|
||||
const modeLabel = OPERATION_MODE_LABEL_MAP[mode] || mode;
|
||||
@@ -1037,6 +1069,9 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
const [operationSearch, setOperationSearch] = useState('');
|
||||
const [selectedOperationId, setSelectedOperationId] = useState('');
|
||||
const [expandedConditionMap, setExpandedConditionMap] = useState({});
|
||||
const [draggedOperationId, setDraggedOperationId] = useState('');
|
||||
const [dragOverOperationId, setDragOverOperationId] = useState('');
|
||||
const [dragOverPosition, setDragOverPosition] = useState('before');
|
||||
const [templateGroupKey, setTemplateGroupKey] = useState('basic');
|
||||
const [templatePresetKey, setTemplatePresetKey] = useState('operations_default');
|
||||
const [fieldGuideVisible, setFieldGuideVisible] = useState(false);
|
||||
@@ -1055,6 +1090,9 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
setOperationSearch('');
|
||||
setSelectedOperationId(nextState.operations[0]?.id || '');
|
||||
setExpandedConditionMap({});
|
||||
setDraggedOperationId('');
|
||||
setDragOverOperationId('');
|
||||
setDragOverPosition('before');
|
||||
if (nextState.visualMode === 'legacy') {
|
||||
setTemplateGroupKey('basic');
|
||||
setTemplatePresetKey('legacy_default');
|
||||
@@ -1583,6 +1621,67 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
setSelectedOperationId(created.id);
|
||||
};
|
||||
|
||||
const resetOperationDragState = useCallback(() => {
|
||||
setDraggedOperationId('');
|
||||
setDragOverOperationId('');
|
||||
setDragOverPosition('before');
|
||||
}, []);
|
||||
|
||||
const moveOperation = useCallback(
|
||||
(sourceId, targetId, position = 'before') => {
|
||||
if (!sourceId || !targetId || sourceId === targetId) {
|
||||
return;
|
||||
}
|
||||
setOperations((prev) =>
|
||||
reorderOperations(prev, sourceId, targetId, position),
|
||||
);
|
||||
setSelectedOperationId(sourceId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOperationDragStart = useCallback((event, operationId) => {
|
||||
setDraggedOperationId(operationId);
|
||||
setSelectedOperationId(operationId);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', operationId);
|
||||
}, []);
|
||||
|
||||
const handleOperationDragOver = useCallback(
|
||||
(event, operationId) => {
|
||||
event.preventDefault();
|
||||
if (!draggedOperationId || draggedOperationId === operationId) {
|
||||
return;
|
||||
}
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const position =
|
||||
event.clientY - rect.top > rect.height / 2 ? 'after' : 'before';
|
||||
setDragOverOperationId(operationId);
|
||||
setDragOverPosition(position);
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
},
|
||||
[draggedOperationId],
|
||||
);
|
||||
|
||||
const handleOperationDrop = useCallback(
|
||||
(event, operationId) => {
|
||||
event.preventDefault();
|
||||
const sourceId =
|
||||
draggedOperationId || event.dataTransfer.getData('text/plain');
|
||||
const position =
|
||||
dragOverOperationId === operationId ? dragOverPosition : 'before';
|
||||
moveOperation(sourceId, operationId, position);
|
||||
resetOperationDragState();
|
||||
},
|
||||
[
|
||||
dragOverOperationId,
|
||||
dragOverPosition,
|
||||
draggedOperationId,
|
||||
moveOperation,
|
||||
resetOperationDragState,
|
||||
],
|
||||
);
|
||||
|
||||
const duplicateOperation = (operationId) => {
|
||||
let insertedId = '';
|
||||
setOperations((prev) => {
|
||||
@@ -1941,14 +2040,31 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
);
|
||||
const isActive =
|
||||
operation.id === selectedOperationId;
|
||||
const isDragging =
|
||||
operation.id === draggedOperationId;
|
||||
const isDropTarget =
|
||||
operation.id === dragOverOperationId &&
|
||||
draggedOperationId &&
|
||||
draggedOperationId !== operation.id;
|
||||
return (
|
||||
<div
|
||||
key={operation.id}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
draggable={operations.length > 1}
|
||||
onClick={() =>
|
||||
setSelectedOperationId(operation.id)
|
||||
}
|
||||
onDragStart={(event) =>
|
||||
handleOperationDragStart(event, operation.id)
|
||||
}
|
||||
onDragOver={(event) =>
|
||||
handleOperationDragOver(event, operation.id)
|
||||
}
|
||||
onDrop={(event) =>
|
||||
handleOperationDrop(event, operation.id)
|
||||
}
|
||||
onDragEnd={resetOperationDragState}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === 'Enter' ||
|
||||
@@ -1966,35 +2082,53 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
border: isActive
|
||||
? '1px solid var(--semi-color-primary)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
boxShadow: isDropTarget
|
||||
? dragOverPosition === 'after'
|
||||
? 'inset 0 -3px 0 var(--semi-color-primary)'
|
||||
: 'inset 0 3px 0 var(--semi-color-primary)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div>
|
||||
<Text strong>{`#${index + 1}`}</Text>
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='block mt-1'
|
||||
<div className='flex items-start gap-2 min-w-0'>
|
||||
<div
|
||||
className='flex-shrink-0'
|
||||
style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
cursor: operations.length > 1 ? 'grab' : 'default',
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{getOperationSummary(operation, index)}
|
||||
</Text>
|
||||
{String(operation.description || '').trim() ? (
|
||||
<IconMenu />
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<Text strong>{`#${index + 1}`}</Text>
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='block mt-1'
|
||||
style={{
|
||||
lineHeight: 1.5,
|
||||
wordBreak: 'break-word',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{operation.description}
|
||||
{getOperationSummary(operation, index)}
|
||||
</Text>
|
||||
) : null}
|
||||
{String(operation.description || '').trim() ? (
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='block mt-1'
|
||||
style={{
|
||||
lineHeight: 1.5,
|
||||
wordBreak: 'break-word',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{operation.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Tag size='small' color='grey'>
|
||||
{(operation.conditions || []).length}
|
||||
|
||||
@@ -25,6 +25,7 @@ const PricingDisplaySettings = ({
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
siteDisplayType,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
@@ -34,11 +35,17 @@ const PricingDisplaySettings = ({
|
||||
loading = false,
|
||||
t,
|
||||
}) => {
|
||||
const supportsCurrencyDisplay = siteDisplayType !== 'TOKENS';
|
||||
|
||||
const items = [
|
||||
{
|
||||
value: 'recharge',
|
||||
label: t('充值价格显示'),
|
||||
},
|
||||
...(supportsCurrencyDisplay
|
||||
? [
|
||||
{
|
||||
value: 'recharge',
|
||||
label: t('充值价格显示'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: 'ratio',
|
||||
label: t('显示倍率'),
|
||||
@@ -78,7 +85,7 @@ const PricingDisplaySettings = ({
|
||||
|
||||
const getActiveValues = () => {
|
||||
const activeValues = [];
|
||||
if (showWithRecharge) activeValues.push('recharge');
|
||||
if (supportsCurrencyDisplay && showWithRecharge) activeValues.push('recharge');
|
||||
if (showRatio) activeValues.push('ratio');
|
||||
if (viewMode === 'table') activeValues.push('tableView');
|
||||
if (tokenUnit === 'K') activeValues.push('tokenUnit');
|
||||
@@ -98,7 +105,7 @@ const PricingDisplaySettings = ({
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{showWithRecharge && (
|
||||
{supportsCurrencyDisplay && showWithRecharge && (
|
||||
<SelectableButtonGroup
|
||||
title={t('货币单位')}
|
||||
items={currencyItems}
|
||||
|
||||
@@ -70,6 +70,7 @@ const PricingPage = () => {
|
||||
groupRatio={pricingData.groupRatio}
|
||||
usableGroup={pricingData.usableGroup}
|
||||
currency={pricingData.currency}
|
||||
siteDisplayType={pricingData.siteDisplayType}
|
||||
tokenUnit={pricingData.tokenUnit}
|
||||
displayPrice={pricingData.displayPrice}
|
||||
showRatio={allProps.showRatio}
|
||||
|
||||
@@ -40,6 +40,7 @@ const PricingTopSection = memo(
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
siteDisplayType,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
@@ -68,6 +69,7 @@ const PricingTopSection = memo(
|
||||
setShowWithRecharge={setShowWithRecharge}
|
||||
currency={currency}
|
||||
setCurrency={setCurrency}
|
||||
siteDisplayType={siteDisplayType}
|
||||
showRatio={showRatio}
|
||||
setShowRatio={setShowRatio}
|
||||
viewMode={viewMode}
|
||||
@@ -103,6 +105,7 @@ const PricingTopSection = memo(
|
||||
setShowWithRecharge={setShowWithRecharge}
|
||||
currency={currency}
|
||||
setCurrency={setCurrency}
|
||||
siteDisplayType={siteDisplayType}
|
||||
showRatio={showRatio}
|
||||
setShowRatio={setShowRatio}
|
||||
viewMode={viewMode}
|
||||
|
||||
@@ -35,6 +35,7 @@ const SearchActions = memo(
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
siteDisplayType,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
@@ -43,6 +44,8 @@ const SearchActions = memo(
|
||||
setTokenUnit,
|
||||
t,
|
||||
}) => {
|
||||
const supportsCurrencyDisplay = siteDisplayType !== 'TOKENS';
|
||||
|
||||
const handleCopyClick = useCallback(() => {
|
||||
if (copyText && selectedRowKeys.length > 0) {
|
||||
copyText(selectedRowKeys);
|
||||
@@ -91,16 +94,18 @@ const SearchActions = memo(
|
||||
<Divider layout='vertical' margin='8px' />
|
||||
|
||||
{/* 充值价格显示开关 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-600'>{t('充值价格显示')}</span>
|
||||
<Switch
|
||||
checked={showWithRecharge}
|
||||
onChange={setShowWithRecharge}
|
||||
/>
|
||||
</div>
|
||||
{supportsCurrencyDisplay && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-600'>{t('充值价格显示')}</span>
|
||||
<Switch
|
||||
checked={showWithRecharge}
|
||||
onChange={setShowWithRecharge}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 货币单位选择 */}
|
||||
{showWithRecharge && (
|
||||
{supportsCurrencyDisplay && showWithRecharge && (
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
|
||||
@@ -35,6 +35,7 @@ const ModelDetailSideSheet = ({
|
||||
modelData,
|
||||
groupRatio,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
@@ -92,6 +93,7 @@ const ModelDetailSideSheet = ({
|
||||
modelData={modelData}
|
||||
groupRatio={groupRatio}
|
||||
currency={currency}
|
||||
siteDisplayType={siteDisplayType}
|
||||
tokenUnit={tokenUnit}
|
||||
displayPrice={displayPrice}
|
||||
showRatio={showRatio}
|
||||
|
||||
@@ -32,6 +32,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
siteDisplayType,
|
||||
handleChange,
|
||||
setActiveKey,
|
||||
showRatio,
|
||||
@@ -77,6 +78,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
setShowWithRecharge={setShowWithRecharge}
|
||||
currency={currency}
|
||||
setCurrency={setCurrency}
|
||||
siteDisplayType={siteDisplayType}
|
||||
showRatio={showRatio}
|
||||
setShowRatio={setShowRatio}
|
||||
viewMode={viewMode}
|
||||
|
||||
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Typography, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { IconCoinMoneyStroked } from '@douyinfe/semi-icons';
|
||||
import { calculateModelPrice } from '../../../../../helpers';
|
||||
import { calculateModelPrice, getModelPriceItems } from '../../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -28,6 +28,7 @@ const ModelPricingTable = ({
|
||||
modelData,
|
||||
groupRatio,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
@@ -57,6 +58,7 @@ const ModelPricingTable = ({
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency,
|
||||
quotaDisplayType: siteDisplayType,
|
||||
})
|
||||
: { inputPrice: '-', outputPrice: '-', price: '-' };
|
||||
|
||||
@@ -74,12 +76,7 @@ const ModelPricingTable = ({
|
||||
: modelData?.quota_type === 1
|
||||
? t('按次计费')
|
||||
: '-',
|
||||
inputPrice: modelData?.quota_type === 0 ? priceData.inputPrice : '-',
|
||||
outputPrice:
|
||||
modelData?.quota_type === 0
|
||||
? priceData.completionPrice || priceData.outputPrice
|
||||
: '-',
|
||||
fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-',
|
||||
priceItems: getModelPriceItems(priceData, t, siteDisplayType),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -126,48 +123,22 @@ const ModelPricingTable = ({
|
||||
},
|
||||
});
|
||||
|
||||
// 根据计费类型添加价格列
|
||||
if (modelData?.quota_type === 0) {
|
||||
// 按量计费
|
||||
columns.push(
|
||||
{
|
||||
title: t('提示'),
|
||||
dataIndex: 'inputPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className='font-semibold text-orange-600'>{text}</div>
|
||||
<div className='text-xs text-gray-500'>
|
||||
/ {tokenUnit === 'K' ? '1K' : '1M'} tokens
|
||||
columns.push({
|
||||
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
|
||||
dataIndex: 'priceItems',
|
||||
render: (items) => (
|
||||
<div className='space-y-1'>
|
||||
{items.map((item) => (
|
||||
<div key={item.key}>
|
||||
<div className='font-semibold text-orange-600'>
|
||||
{item.label} {item.value}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('补全'),
|
||||
dataIndex: 'outputPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className='font-semibold text-orange-600'>{text}</div>
|
||||
<div className='text-xs text-gray-500'>
|
||||
/ {tokenUnit === 'K' ? '1K' : '1M'} tokens
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 按次计费
|
||||
columns.push({
|
||||
title: t('价格'),
|
||||
dataIndex: 'fixedPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className='font-semibold text-orange-600'>{text}</div>
|
||||
<div className='text-xs text-gray-500'>/ 次</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
<div className='text-xs text-gray-500'>{item.suffix}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Table
|
||||
|
||||
@@ -67,6 +67,7 @@ const PricingCardView = ({
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
@@ -246,6 +247,7 @@ const PricingCardView = ({
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency,
|
||||
quotaDisplayType: siteDisplayType,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -264,8 +266,8 @@ const PricingCardView = ({
|
||||
<h3 className='text-lg font-bold text-gray-900 truncate'>
|
||||
{model.model_name}
|
||||
</h3>
|
||||
<div className='flex items-center gap-3 text-xs mt-1'>
|
||||
{formatPriceInfo(priceData, t)}
|
||||
<div className='flex flex-col gap-1 text-xs mt-1'>
|
||||
{formatPriceInfo(priceData, t, siteDisplayType)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ const PricingTable = ({
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
searchValue,
|
||||
@@ -54,6 +55,7 @@ const PricingTable = ({
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
@@ -66,6 +68,7 @@ const PricingTable = ({
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
renderModelTag,
|
||||
stringToColor,
|
||||
calculateModelPrice,
|
||||
getModelPriceItems,
|
||||
getLobeHubIcon,
|
||||
} from '../../../../../helpers';
|
||||
import {
|
||||
@@ -108,6 +109,7 @@ export const getPricingTableColumns = ({
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
@@ -125,6 +127,7 @@ export const getPricingTableColumns = ({
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency,
|
||||
quotaDisplayType: siteDisplayType,
|
||||
});
|
||||
priceDataCache.set(record, cache);
|
||||
}
|
||||
@@ -226,31 +229,23 @@ export const getPricingTableColumns = ({
|
||||
};
|
||||
|
||||
const priceColumn = {
|
||||
title: t('模型价格'),
|
||||
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('模型价格'),
|
||||
dataIndex: 'model_price',
|
||||
...(isMobile ? {} : { fixed: 'right' }),
|
||||
render: (text, record, index) => {
|
||||
const priceData = getPriceData(record);
|
||||
const priceItems = getModelPriceItems(priceData, t, siteDisplayType);
|
||||
|
||||
if (priceData.isPerToken) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-gray-700'>
|
||||
{t('输入')} {priceData.inputPrice} / 1{priceData.unitLabel} tokens
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
{priceItems.map((item) => (
|
||||
<div key={item.key} className='text-gray-700'>
|
||||
{item.label} {item.value}
|
||||
{item.suffix}
|
||||
</div>
|
||||
<div className='text-gray-700'>
|
||||
{t('输出')} {priceData.completionPrice} / 1{priceData.unitLabel}{' '}
|
||||
tokens
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className='text-gray-700'>
|
||||
{t('模型价格')}:{priceData.price}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ const TokensActions = ({
|
||||
setShowEdit,
|
||||
batchCopyTokens,
|
||||
batchDeleteTokens,
|
||||
copyText,
|
||||
t,
|
||||
}) => {
|
||||
// Modal states
|
||||
@@ -99,8 +98,7 @@ const TokensActions = ({
|
||||
<CopyTokensModal
|
||||
visible={showCopyModal}
|
||||
onCancel={() => setShowCopyModal(false)}
|
||||
selectedKeys={selectedKeys}
|
||||
copyText={copyText}
|
||||
batchCopyTokens={batchCopyTokens}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
|
||||
@@ -108,17 +108,28 @@ const renderGroupColumn = (text, record, t) => {
|
||||
};
|
||||
|
||||
// Render token key column with show/hide and copy functionality
|
||||
const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
|
||||
const fullKey = 'sk-' + record.key;
|
||||
const maskedKey =
|
||||
'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4);
|
||||
const renderTokenKey = (
|
||||
text,
|
||||
record,
|
||||
showKeys,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
) => {
|
||||
const revealed = !!showKeys[record.id];
|
||||
const loading = !!loadingTokenKeys[record.id];
|
||||
const keyValue =
|
||||
revealed && resolvedTokenKeys[record.id]
|
||||
? resolvedTokenKeys[record.id]
|
||||
: record.key || '';
|
||||
const displayedKey = keyValue ? `sk-${keyValue}` : '';
|
||||
|
||||
return (
|
||||
<div className='w-[200px]'>
|
||||
<Input
|
||||
readOnly
|
||||
value={revealed ? fullKey : maskedKey}
|
||||
value={displayedKey}
|
||||
size='small'
|
||||
suffix={
|
||||
<div className='flex items-center'>
|
||||
@@ -127,10 +138,11 @@ const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
|
||||
size='small'
|
||||
type='tertiary'
|
||||
icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
|
||||
loading={loading}
|
||||
aria-label='toggle token visibility'
|
||||
onClick={(e) => {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setShowKeys((prev) => ({ ...prev, [record.id]: !revealed }));
|
||||
await toggleTokenVisibility(record);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -138,10 +150,11 @@ const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
|
||||
size='small'
|
||||
type='tertiary'
|
||||
icon={<IconCopy />}
|
||||
loading={loading}
|
||||
aria-label='copy token key'
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await copyText(fullKey);
|
||||
await copyTokenKey(record);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -427,8 +440,10 @@ const renderOperations = (
|
||||
export const getTokensColumns = ({
|
||||
t,
|
||||
showKeys,
|
||||
setShowKeys,
|
||||
copyText,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
manageToken,
|
||||
onOpenLink,
|
||||
setEditingToken,
|
||||
@@ -461,7 +476,15 @@ export const getTokensColumns = ({
|
||||
title: t('密钥'),
|
||||
key: 'token_key',
|
||||
render: (text, record) =>
|
||||
renderTokenKey(text, record, showKeys, setShowKeys, copyText),
|
||||
renderTokenKey(
|
||||
text,
|
||||
record,
|
||||
showKeys,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('可用模型'),
|
||||
|
||||
@@ -39,8 +39,10 @@ const TokensTable = (tokensData) => {
|
||||
rowSelection,
|
||||
handleRow,
|
||||
showKeys,
|
||||
setShowKeys,
|
||||
copyText,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
manageToken,
|
||||
onOpenLink,
|
||||
setEditingToken,
|
||||
@@ -54,8 +56,10 @@ const TokensTable = (tokensData) => {
|
||||
return getTokensColumns({
|
||||
t,
|
||||
showKeys,
|
||||
setShowKeys,
|
||||
copyText,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
manageToken,
|
||||
onOpenLink,
|
||||
setEditingToken,
|
||||
@@ -65,8 +69,10 @@ const TokensTable = (tokensData) => {
|
||||
}, [
|
||||
t,
|
||||
showKeys,
|
||||
setShowKeys,
|
||||
copyText,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
manageToken,
|
||||
onOpenLink,
|
||||
setEditingToken,
|
||||
|
||||
@@ -58,6 +58,7 @@ function TokensPage() {
|
||||
t: (k) => k,
|
||||
selectedModel: '',
|
||||
prefillKey: '',
|
||||
fetchTokenKey: async () => '',
|
||||
});
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
@@ -74,6 +75,7 @@ function TokensPage() {
|
||||
t: tokensData.t,
|
||||
selectedModel,
|
||||
prefillKey,
|
||||
fetchTokenKey: tokensData.fetchTokenKey,
|
||||
};
|
||||
}, [
|
||||
tokensData.tokens,
|
||||
@@ -81,6 +83,7 @@ function TokensPage() {
|
||||
tokensData.t,
|
||||
selectedModel,
|
||||
prefillKey,
|
||||
tokensData.fetchTokenKey,
|
||||
]);
|
||||
|
||||
const loadModels = async () => {
|
||||
@@ -198,13 +201,14 @@ function TokensPage() {
|
||||
openCCSwitchModalRef.current = openCCSwitchModal;
|
||||
|
||||
// Prefill to Fluent handler
|
||||
const handlePrefillToFluent = () => {
|
||||
const handlePrefillToFluent = async () => {
|
||||
const {
|
||||
tokens,
|
||||
selectedKeys,
|
||||
t,
|
||||
selectedModel: chosenModel,
|
||||
prefillKey: overrideKey,
|
||||
fetchTokenKey,
|
||||
} = latestRef.current;
|
||||
const container = document.getElementById('fluent-new-api-container');
|
||||
if (!container) {
|
||||
@@ -241,7 +245,11 @@ function TokensPage() {
|
||||
Toast.warning(t('没有可用令牌用于填充'));
|
||||
return;
|
||||
}
|
||||
apiKeyToUse = 'sk-' + token.key;
|
||||
try {
|
||||
apiKeyToUse = 'sk-' + (await fetchTokenKey(token));
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@@ -351,7 +359,6 @@ function TokensPage() {
|
||||
setShowEdit,
|
||||
batchCopyTokens,
|
||||
batchDeleteTokens,
|
||||
copyText,
|
||||
|
||||
// Filters state
|
||||
formInitValues,
|
||||
@@ -401,7 +408,6 @@ function TokensPage() {
|
||||
setShowEdit={setShowEdit}
|
||||
batchCopyTokens={batchCopyTokens}
|
||||
batchDeleteTokens={batchDeleteTokens}
|
||||
copyText={copyText}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
|
||||
@@ -116,8 +116,7 @@ export default function CCSwitchModal({
|
||||
Toast.warning(t('请选择主模型'));
|
||||
return;
|
||||
}
|
||||
const apiKey = 'sk-' + tokenKey;
|
||||
const url = buildCCSwitchURL(app, name, models, apiKey);
|
||||
const url = buildCCSwitchURL(app, name, models, 'sk-' + tokenKey);
|
||||
window.open(url, '_blank');
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -20,24 +20,21 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import { Modal, Button, Space } from '@douyinfe/semi-ui';
|
||||
|
||||
const CopyTokensModal = ({ visible, onCancel, selectedKeys, copyText, t }) => {
|
||||
const CopyTokensModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
batchCopyTokens,
|
||||
t,
|
||||
}) => {
|
||||
// Handle copy with name and key format
|
||||
const handleCopyWithName = async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content += selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
await batchCopyTokens('name+key');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
// Handle copy with key only format
|
||||
const handleCopyKeyOnly = async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content += 'sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
await batchCopyTokens('key-only');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
|
||||
@@ -337,6 +337,7 @@ export const getLogsColumns = ({
|
||||
showUserInfoFunc,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
isAdminUser,
|
||||
billingDisplayMode = 'price',
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -761,11 +762,10 @@ export const getLogsColumns = ({
|
||||
Boolean(other?.violation_fee_marker)
|
||||
) {
|
||||
const feeQuota = other?.fee_quota ?? record?.quota;
|
||||
const ratioText = formatRatio(other?.group_ratio);
|
||||
const summary = [
|
||||
t('违规扣费'),
|
||||
`${t('分组倍率')}:${ratioText}`,
|
||||
`${t('扣费')}:${renderQuota(feeQuota, 6)}`,
|
||||
`${t('分组倍率')}:${formatRatio(other?.group_ratio)}`,
|
||||
text ? `${t('详情')}:${text}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -808,6 +808,7 @@ export const getLogsColumns = ({
|
||||
1.0,
|
||||
other?.is_system_prompt_overwritten,
|
||||
'claude',
|
||||
billingDisplayMode,
|
||||
)
|
||||
: renderModelPriceSimple(
|
||||
other.model_ratio,
|
||||
@@ -826,6 +827,7 @@ export const getLogsColumns = ({
|
||||
1.0,
|
||||
other?.is_system_prompt_overwritten,
|
||||
'openai',
|
||||
billingDisplayMode,
|
||||
);
|
||||
return (
|
||||
<Typography.Paragraph
|
||||
|
||||
@@ -43,6 +43,7 @@ const LogsTable = (logsData) => {
|
||||
openChannelAffinityUsageCacheModal,
|
||||
hasExpandableRows,
|
||||
isAdminUser,
|
||||
billingDisplayMode,
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
} = logsData;
|
||||
@@ -56,6 +57,7 @@ const LogsTable = (logsData) => {
|
||||
showUserInfoFunc,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
isAdminUser,
|
||||
billingDisplayMode,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
@@ -64,6 +66,7 @@ const LogsTable = (logsData) => {
|
||||
showUserInfoFunc,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
isAdminUser,
|
||||
billingDisplayMode,
|
||||
]);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
|
||||
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
|
||||
import { Modal, Button, Checkbox, RadioGroup, Radio } from '@douyinfe/semi-ui';
|
||||
import { getLogsColumns } from '../UsageLogsColumnDefs';
|
||||
|
||||
const ColumnSelectorModal = ({
|
||||
@@ -28,12 +28,18 @@ const ColumnSelectorModal = ({
|
||||
handleColumnVisibilityChange,
|
||||
handleSelectAll,
|
||||
initDefaultColumns,
|
||||
billingDisplayMode,
|
||||
setBillingDisplayMode,
|
||||
COLUMN_KEYS,
|
||||
isAdminUser,
|
||||
copyText,
|
||||
showUserInfoFunc,
|
||||
t,
|
||||
}) => {
|
||||
const isTokensDisplay =
|
||||
typeof localStorage !== 'undefined' &&
|
||||
localStorage.getItem('quota_display_type') === 'TOKENS';
|
||||
|
||||
// Get all columns for display in selector
|
||||
const allColumns = getLogsColumns({
|
||||
t,
|
||||
@@ -41,6 +47,7 @@ const ColumnSelectorModal = ({
|
||||
copyText,
|
||||
showUserInfoFunc,
|
||||
isAdminUser,
|
||||
billingDisplayMode,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -61,6 +68,21 @@ const ColumnSelectorModal = ({
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>{t('计费显示模式')}</div>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={billingDisplayMode}
|
||||
onChange={(value) => setBillingDisplayMode(value)}
|
||||
>
|
||||
<Radio value='price'>
|
||||
{isTokensDisplay ? t('价格模式') : t('价格模式(默认)')}
|
||||
</Radio>
|
||||
<Radio value='ratio'>
|
||||
{isTokensDisplay ? t('倍率模式(默认)') : t('倍率模式')}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={Object.values(visibleColumns).every((v) => v === true)}
|
||||
indeterminate={
|
||||
|
||||
9
web/src/context/User/index.jsx
vendored
9
web/src/context/User/index.jsx
vendored
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { reducer, initialState } from './reducer';
|
||||
import { normalizeLanguage } from '../../i18n/language';
|
||||
|
||||
export const UserContext = React.createContext({
|
||||
state: initialState,
|
||||
@@ -35,8 +36,12 @@ export const UserProvider = ({ children }) => {
|
||||
if (state.user?.setting) {
|
||||
try {
|
||||
const settings = JSON.parse(state.user.setting);
|
||||
if (settings.language && settings.language !== i18n.language) {
|
||||
i18n.changeLanguage(settings.language);
|
||||
const normalizedLanguage = normalizeLanguage(settings.language);
|
||||
if (normalizedLanguage && normalizedLanguage !== i18n.language) {
|
||||
i18n.changeLanguage(normalizedLanguage);
|
||||
}
|
||||
if (normalizedLanguage) {
|
||||
localStorage.setItem('i18nextLng', normalizedLanguage);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
|
||||
1442
web/src/helpers/render.jsx
vendored
1442
web/src/helpers/render.jsx
vendored
File diff suppressed because it is too large
Load Diff
25
web/src/helpers/token.js
vendored
25
web/src/helpers/token.js
vendored
@@ -20,8 +20,22 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { API } from './api';
|
||||
|
||||
/**
|
||||
* 获取可用的token keys
|
||||
* @returns {Promise<string[]>} 返回active状态的token key数组
|
||||
* 按需获取单个令牌的真实 key
|
||||
* @param {number|string} tokenId
|
||||
* @returns {Promise<string>} 返回不带 sk- 前缀的真实 token key
|
||||
*/
|
||||
export async function fetchTokenKey(tokenId) {
|
||||
const response = await API.post(`/api/token/${tokenId}/key`);
|
||||
const { success, data, message } = response.data || {};
|
||||
if (!success || !data?.key) {
|
||||
throw new Error(message || 'Failed to fetch token key');
|
||||
}
|
||||
return data.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的 token keys
|
||||
* @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组
|
||||
*/
|
||||
export async function fetchTokenKeys() {
|
||||
try {
|
||||
@@ -31,7 +45,12 @@ export async function fetchTokenKeys() {
|
||||
|
||||
const tokenItems = Array.isArray(data) ? data : data.items || [];
|
||||
const activeTokens = tokenItems.filter((token) => token.status === 1);
|
||||
return activeTokens.map((token) => token.key);
|
||||
const keyResults = await Promise.allSettled(
|
||||
activeTokens.map((token) => fetchTokenKey(token.id)),
|
||||
);
|
||||
return keyResults
|
||||
.filter((result) => result.status === 'fulfilled' && result.value)
|
||||
.map((result) => result.value);
|
||||
} catch (error) {
|
||||
console.error('Error fetching token keys:', error);
|
||||
return [];
|
||||
|
||||
207
web/src/helpers/utils.jsx
vendored
207
web/src/helpers/utils.jsx
vendored
@@ -615,6 +615,7 @@ export const calculateModelPrice = ({
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency,
|
||||
quotaDisplayType = 'USD',
|
||||
precision = 4,
|
||||
}) => {
|
||||
// 1. 选择实际使用的分组
|
||||
@@ -647,20 +648,34 @@ export const calculateModelPrice = ({
|
||||
// 2. 根据计费类型计算价格
|
||||
if (record.quota_type === 0) {
|
||||
// 按量计费
|
||||
const isTokensDisplay = quotaDisplayType === 'TOKENS';
|
||||
const inputRatioPriceUSD = record.model_ratio * 2 * usedGroupRatio;
|
||||
const completionRatioPriceUSD =
|
||||
record.model_ratio * record.completion_ratio * 2 * usedGroupRatio;
|
||||
|
||||
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
|
||||
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
|
||||
const hasRatioValue = (value) =>
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
value !== '' &&
|
||||
Number.isFinite(Number(value));
|
||||
|
||||
const rawDisplayInput = displayPrice(inputRatioPriceUSD);
|
||||
const rawDisplayCompletion = displayPrice(completionRatioPriceUSD);
|
||||
const formatRatio = (value) =>
|
||||
hasRatioValue(value) ? Number(Number(value).toFixed(6)) : null;
|
||||
|
||||
const numInput =
|
||||
parseFloat(rawDisplayInput.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||
const numCompletion =
|
||||
parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||
if (isTokensDisplay) {
|
||||
return {
|
||||
inputRatio: formatRatio(record.model_ratio),
|
||||
completionRatio: formatRatio(record.completion_ratio),
|
||||
cacheRatio: formatRatio(record.cache_ratio),
|
||||
createCacheRatio: formatRatio(record.create_cache_ratio),
|
||||
imageRatio: formatRatio(record.image_ratio),
|
||||
audioInputRatio: formatRatio(record.audio_ratio),
|
||||
audioOutputRatio: formatRatio(record.audio_completion_ratio),
|
||||
isPerToken: true,
|
||||
isTokensDisplay: true,
|
||||
usedGroup,
|
||||
usedGroupRatio,
|
||||
};
|
||||
}
|
||||
|
||||
let symbol = '$';
|
||||
if (currency === 'CNY') {
|
||||
@@ -678,11 +693,45 @@ export const calculateModelPrice = ({
|
||||
symbol = '¤';
|
||||
}
|
||||
}
|
||||
|
||||
const formatTokenPrice = (priceUSD) => {
|
||||
const rawDisplayPrice = displayPrice(priceUSD);
|
||||
const numericPrice =
|
||||
parseFloat(rawDisplayPrice.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||
return `${symbol}${numericPrice.toFixed(precision)}`;
|
||||
};
|
||||
|
||||
const inputPrice = formatTokenPrice(inputRatioPriceUSD);
|
||||
const audioInputPrice = hasRatioValue(record.audio_ratio)
|
||||
? formatTokenPrice(inputRatioPriceUSD * Number(record.audio_ratio))
|
||||
: null;
|
||||
|
||||
return {
|
||||
inputPrice: `${symbol}${numInput.toFixed(precision)}`,
|
||||
completionPrice: `${symbol}${numCompletion.toFixed(precision)}`,
|
||||
inputPrice,
|
||||
completionPrice: formatTokenPrice(
|
||||
inputRatioPriceUSD * Number(record.completion_ratio),
|
||||
),
|
||||
cachePrice: hasRatioValue(record.cache_ratio)
|
||||
? formatTokenPrice(inputRatioPriceUSD * Number(record.cache_ratio))
|
||||
: null,
|
||||
createCachePrice: hasRatioValue(record.create_cache_ratio)
|
||||
? formatTokenPrice(inputRatioPriceUSD * Number(record.create_cache_ratio))
|
||||
: null,
|
||||
imagePrice: hasRatioValue(record.image_ratio)
|
||||
? formatTokenPrice(inputRatioPriceUSD * Number(record.image_ratio))
|
||||
: null,
|
||||
audioInputPrice,
|
||||
audioOutputPrice:
|
||||
audioInputPrice && hasRatioValue(record.audio_completion_ratio)
|
||||
? formatTokenPrice(
|
||||
inputRatioPriceUSD *
|
||||
Number(record.audio_ratio) *
|
||||
Number(record.audio_completion_ratio),
|
||||
)
|
||||
: null,
|
||||
unitLabel,
|
||||
isPerToken: true,
|
||||
isTokensDisplay: false,
|
||||
usedGroup,
|
||||
usedGroupRatio,
|
||||
};
|
||||
@@ -696,6 +745,7 @@ export const calculateModelPrice = ({
|
||||
return {
|
||||
price: displayVal,
|
||||
isPerToken: false,
|
||||
isTokensDisplay: false,
|
||||
usedGroup,
|
||||
usedGroupRatio,
|
||||
};
|
||||
@@ -705,31 +755,136 @@ export const calculateModelPrice = ({
|
||||
return {
|
||||
price: '-',
|
||||
isPerToken: false,
|
||||
isTokensDisplay: false,
|
||||
usedGroup,
|
||||
usedGroupRatio,
|
||||
};
|
||||
};
|
||||
|
||||
// 格式化价格信息(用于卡片视图)
|
||||
export const formatPriceInfo = (priceData, t) => {
|
||||
export const getModelPriceItems = (
|
||||
priceData,
|
||||
t,
|
||||
quotaDisplayType = 'USD',
|
||||
) => {
|
||||
if (priceData.isPerToken) {
|
||||
return (
|
||||
<>
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{t('输入')} {priceData.inputPrice}/{priceData.unitLabel}
|
||||
</span>
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{t('输出')} {priceData.completionPrice}/{priceData.unitLabel}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
if (quotaDisplayType === 'TOKENS' || priceData.isTokensDisplay) {
|
||||
return [
|
||||
{
|
||||
key: 'input-ratio',
|
||||
label: t('输入倍率'),
|
||||
value: priceData.inputRatio,
|
||||
suffix: 'x',
|
||||
},
|
||||
{
|
||||
key: 'completion-ratio',
|
||||
label: t('补全倍率'),
|
||||
value: priceData.completionRatio,
|
||||
suffix: 'x',
|
||||
},
|
||||
{
|
||||
key: 'cache-ratio',
|
||||
label: t('缓存读取倍率'),
|
||||
value: priceData.cacheRatio,
|
||||
suffix: 'x',
|
||||
},
|
||||
{
|
||||
key: 'create-cache-ratio',
|
||||
label: t('缓存创建倍率'),
|
||||
value: priceData.createCacheRatio,
|
||||
suffix: 'x',
|
||||
},
|
||||
{
|
||||
key: 'image-ratio',
|
||||
label: t('图片输入倍率'),
|
||||
value: priceData.imageRatio,
|
||||
suffix: 'x',
|
||||
},
|
||||
{
|
||||
key: 'audio-input-ratio',
|
||||
label: t('音频输入倍率'),
|
||||
value: priceData.audioInputRatio,
|
||||
suffix: 'x',
|
||||
},
|
||||
{
|
||||
key: 'audio-output-ratio',
|
||||
label: t('音频补全倍率'),
|
||||
value: priceData.audioOutputRatio,
|
||||
suffix: 'x',
|
||||
},
|
||||
].filter(
|
||||
(item) =>
|
||||
item.value !== null && item.value !== undefined && item.value !== '',
|
||||
);
|
||||
}
|
||||
|
||||
const unitSuffix = ` / 1${priceData.unitLabel} Tokens`;
|
||||
return [
|
||||
{
|
||||
key: 'input',
|
||||
label: t('输入价格'),
|
||||
value: priceData.inputPrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'completion',
|
||||
label: t('补全价格'),
|
||||
value: priceData.completionPrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'cache',
|
||||
label: t('缓存读取价格'),
|
||||
value: priceData.cachePrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'create-cache',
|
||||
label: t('缓存创建价格'),
|
||||
value: priceData.createCachePrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'image',
|
||||
label: t('图片输入价格'),
|
||||
value: priceData.imagePrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'audio-input',
|
||||
label: t('音频输入价格'),
|
||||
value: priceData.audioInputPrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'audio-output',
|
||||
label: t('音频补全价格'),
|
||||
value: priceData.audioOutputPrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'fixed',
|
||||
label: t('模型价格'),
|
||||
value: priceData.price,
|
||||
suffix: ` / ${t('次')}`,
|
||||
},
|
||||
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
|
||||
};
|
||||
|
||||
// 格式化价格信息(用于卡片视图)
|
||||
export const formatPriceInfo = (priceData, t, quotaDisplayType = 'USD') => {
|
||||
const items = getModelPriceItems(priceData, t, quotaDisplayType);
|
||||
return (
|
||||
<>
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{t('模型价格')} {priceData.price}
|
||||
</span>
|
||||
{items.map((item) => (
|
||||
<span key={item.key} style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{item.label} {item.value}
|
||||
{item.suffix}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
43
web/src/hooks/common/useHeaderBar.js
vendored
43
web/src/hooks/common/useHeaderBar.js
vendored
@@ -24,6 +24,7 @@ import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { useSetTheme, useTheme, useActualTheme } from '../../context/Theme';
|
||||
import { getLogo, getSystemName, API, showSuccess } from '../../helpers';
|
||||
import { normalizeLanguage } from '../../i18n/language';
|
||||
import { useIsMobile } from './useIsMobile';
|
||||
import { useSidebarCollapsed } from './useSidebarCollapsed';
|
||||
import { useMinimumLoadingTime } from './useMinimumLoadingTime';
|
||||
@@ -36,7 +37,7 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [currentLang, setCurrentLang] = useState(i18n.language);
|
||||
const [currentLang, setCurrentLang] = useState(normalizeLanguage(i18n.language));
|
||||
const location = useLocation();
|
||||
|
||||
const loading = statusState?.status === undefined;
|
||||
@@ -118,12 +119,13 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
// Language change effect
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng) => {
|
||||
setCurrentLang(lng);
|
||||
const normalizedLang = normalizeLanguage(lng);
|
||||
setCurrentLang(normalizedLang);
|
||||
try {
|
||||
const iframe = document.querySelector('iframe');
|
||||
const cw = iframe && iframe.contentWindow;
|
||||
if (cw) {
|
||||
cw.postMessage({ lang: lng }, '*');
|
||||
cw.postMessage({ lang: normalizedLang }, '*');
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore cross-origin or access errors
|
||||
@@ -148,7 +150,9 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const handleLanguageChange = useCallback(
|
||||
async (lang) => {
|
||||
// Change language immediately for responsive UX
|
||||
const previousLang = normalizeLanguage(i18n.language);
|
||||
i18n.changeLanguage(lang);
|
||||
localStorage.setItem('i18nextLng', lang);
|
||||
|
||||
// If user is logged in, save preference to backend
|
||||
if (userState?.user?.id) {
|
||||
@@ -157,25 +161,34 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
language: lang,
|
||||
});
|
||||
if (res.data.success) {
|
||||
// Update user context with new setting
|
||||
// Keep user preference and local cache in sync so route changes
|
||||
// don't reapply an older remembered language.
|
||||
let settings = {};
|
||||
if (userState?.user?.setting) {
|
||||
try {
|
||||
const settings = JSON.parse(userState.user.setting);
|
||||
settings.language = lang;
|
||||
userDispatch({
|
||||
type: 'login',
|
||||
payload: {
|
||||
...userState.user,
|
||||
setting: JSON.stringify(settings),
|
||||
},
|
||||
});
|
||||
settings = JSON.parse(userState.user.setting) || {};
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
settings = {};
|
||||
}
|
||||
}
|
||||
|
||||
settings.language = lang;
|
||||
const nextUser = {
|
||||
...userState.user,
|
||||
setting: JSON.stringify(settings),
|
||||
};
|
||||
|
||||
userDispatch({
|
||||
type: 'login',
|
||||
payload: nextUser,
|
||||
});
|
||||
localStorage.setItem('user', JSON.stringify(nextUser));
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors - language was already changed locally
|
||||
if (previousLang) {
|
||||
i18n.changeLanguage(previousLang);
|
||||
localStorage.setItem('i18nextLng', previousLang);
|
||||
}
|
||||
console.error('Failed to save language preference:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export const useModelPricingData = () => {
|
||||
[statusState],
|
||||
);
|
||||
|
||||
// 默认货币与站点展示类型同步(USD/CNY),TOKENS 时仍允许切换视图内货币
|
||||
// 默认货币与站点展示类型同步;TOKENS 由视图层走倍率展示
|
||||
const siteDisplayType = useMemo(
|
||||
() => statusState?.status?.quota_display_type || 'USD',
|
||||
[statusState],
|
||||
@@ -88,6 +88,13 @@ export const useModelPricingData = () => {
|
||||
}
|
||||
}, [siteDisplayType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (siteDisplayType === 'TOKENS') {
|
||||
setShowWithRecharge(false);
|
||||
setCurrency('USD');
|
||||
}
|
||||
}, [siteDisplayType]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
let result = models;
|
||||
|
||||
@@ -356,6 +363,7 @@ export const useModelPricingData = () => {
|
||||
setCurrentPage,
|
||||
currency,
|
||||
setCurrency,
|
||||
siteDisplayType,
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
tokenUnit,
|
||||
|
||||
150
web/src/hooks/tokens/useTokensData.jsx
vendored
150
web/src/hooks/tokens/useTokensData.jsx
vendored
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
|
||||
|
||||
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -54,6 +55,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
// UI state
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
|
||||
const [showKeys, setShowKeys] = useState({});
|
||||
const [resolvedTokenKeys, setResolvedTokenKeys] = useState({});
|
||||
const [loadingTokenKeys, setLoadingTokenKeys] = useState({});
|
||||
const keyRequestsRef = useRef({});
|
||||
|
||||
// Form state
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
@@ -87,6 +91,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
setTokenCount(payload.total || 0);
|
||||
setActivePage(payload.page || 1);
|
||||
setPageSize(payload.page_size || pageSize);
|
||||
setShowKeys({});
|
||||
};
|
||||
|
||||
// Load tokens function
|
||||
@@ -122,14 +127,86 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTokenKey = async (tokenOrId, options = {}) => {
|
||||
const { suppressError = false } = options;
|
||||
const tokenId =
|
||||
typeof tokenOrId === 'object' ? tokenOrId?.id : Number(tokenOrId);
|
||||
|
||||
if (!tokenId) {
|
||||
const error = new Error(t('令牌不存在'));
|
||||
if (!suppressError) {
|
||||
showError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (resolvedTokenKeys[tokenId]) {
|
||||
return resolvedTokenKeys[tokenId];
|
||||
}
|
||||
|
||||
if (keyRequestsRef.current[tokenId]) {
|
||||
return keyRequestsRef.current[tokenId];
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
setLoadingTokenKeys((prev) => ({ ...prev, [tokenId]: true }));
|
||||
try {
|
||||
const fullKey = await fetchTokenKeyById(tokenId);
|
||||
setResolvedTokenKeys((prev) => ({ ...prev, [tokenId]: fullKey }));
|
||||
return fullKey;
|
||||
} catch (error) {
|
||||
const normalizedError = new Error(
|
||||
error?.message || t('获取令牌密钥失败'),
|
||||
);
|
||||
if (!suppressError) {
|
||||
showError(normalizedError.message);
|
||||
}
|
||||
throw normalizedError;
|
||||
} finally {
|
||||
delete keyRequestsRef.current[tokenId];
|
||||
setLoadingTokenKeys((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[tokenId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
keyRequestsRef.current[tokenId] = request;
|
||||
return request;
|
||||
};
|
||||
|
||||
const toggleTokenVisibility = async (record) => {
|
||||
const tokenId = record?.id;
|
||||
if (!tokenId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showKeys[tokenId]) {
|
||||
setShowKeys((prev) => ({ ...prev, [tokenId]: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fullKey = await fetchTokenKey(record);
|
||||
if (fullKey) {
|
||||
setShowKeys((prev) => ({ ...prev, [tokenId]: true }));
|
||||
}
|
||||
};
|
||||
|
||||
const copyTokenKey = async (record) => {
|
||||
const fullKey = await fetchTokenKey(record);
|
||||
await copyText(`sk-${fullKey}`);
|
||||
};
|
||||
|
||||
// Open link function for chat integrations
|
||||
const onOpenLink = async (type, url, record) => {
|
||||
const fullKey = await fetchTokenKey(record);
|
||||
if (url && url.startsWith('ccswitch')) {
|
||||
openCCSwitchModal(record.key);
|
||||
openCCSwitchModal(fullKey);
|
||||
return;
|
||||
}
|
||||
if (url && url.startsWith('fluent')) {
|
||||
openFluentNotification(record.key);
|
||||
openFluentNotification(fullKey);
|
||||
return;
|
||||
}
|
||||
let status = localStorage.getItem('status');
|
||||
@@ -145,7 +222,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
let cherryConfig = {
|
||||
id: 'new-api',
|
||||
baseUrl: serverAddress,
|
||||
apiKey: 'sk-' + record.key,
|
||||
apiKey: `sk-${fullKey}`,
|
||||
};
|
||||
let encodedConfig = encodeURIComponent(
|
||||
encodeToBase64(JSON.stringify(cherryConfig)),
|
||||
@@ -155,7 +232,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
let aionuiConfig = {
|
||||
platform: 'new-api',
|
||||
baseUrl: serverAddress,
|
||||
apiKey: 'sk-' + record.key,
|
||||
apiKey: `sk-${fullKey}`,
|
||||
};
|
||||
let encodedConfig = encodeURIComponent(
|
||||
encodeToBase64(JSON.stringify(aionuiConfig)),
|
||||
@@ -164,7 +241,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
} else {
|
||||
let encodedServerAddress = encodeURIComponent(serverAddress);
|
||||
url = url.replaceAll('{address}', encodedServerAddress);
|
||||
url = url.replaceAll('{key}', 'sk-' + record.key);
|
||||
url = url.replaceAll('{key}', `sk-${fullKey}`);
|
||||
}
|
||||
|
||||
window.open(url, '_blank');
|
||||
@@ -314,48 +391,28 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
};
|
||||
|
||||
// Batch copy tokens
|
||||
const batchCopyTokens = (copyType) => {
|
||||
const batchCopyTokens = async (copyType) => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个令牌!'));
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.info({
|
||||
title: t('复制令牌'),
|
||||
icon: null,
|
||||
content: t('请选择你的复制方式'),
|
||||
footer: (
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
className='px-3 py-1 bg-gray-200 rounded'
|
||||
onClick={async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content +=
|
||||
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
Modal.destroyAll();
|
||||
}}
|
||||
>
|
||||
{t('名称+密钥')}
|
||||
</button>
|
||||
<button
|
||||
className='px-3 py-1 bg-blue-500 text-white rounded'
|
||||
onClick={async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content += 'sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
Modal.destroyAll();
|
||||
}}
|
||||
>
|
||||
{t('仅密钥')}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
try {
|
||||
const keys = await Promise.all(
|
||||
selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
|
||||
);
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
const fullKey = keys[i];
|
||||
if (copyType === 'name+key') {
|
||||
content += `${selectedKeys[i].name} sk-${fullKey}\n`;
|
||||
} else {
|
||||
content += `sk-${fullKey}\n`;
|
||||
}
|
||||
}
|
||||
await copyText(content);
|
||||
} catch (error) {
|
||||
showError(error?.message || t('复制令牌失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize data
|
||||
@@ -392,6 +449,8 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
setCompactMode,
|
||||
showKeys,
|
||||
setShowKeys,
|
||||
resolvedTokenKeys,
|
||||
loadingTokenKeys,
|
||||
|
||||
// Form state
|
||||
formApi,
|
||||
@@ -403,6 +462,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
loadTokens,
|
||||
refresh,
|
||||
copyText,
|
||||
fetchTokenKey,
|
||||
toggleTokenVisibility,
|
||||
copyTokenKey,
|
||||
onOpenLink,
|
||||
manageToken,
|
||||
searchTokens,
|
||||
|
||||
115
web/src/hooks/usage-logs/useUsageLogsData.jsx
vendored
115
web/src/hooks/usage-logs/useUsageLogsData.jsx
vendored
@@ -78,6 +78,9 @@ export const useLogsData = () => {
|
||||
const STORAGE_KEY = isAdminUser
|
||||
? 'logs-table-columns-admin'
|
||||
: 'logs-table-columns-user';
|
||||
const BILLING_DISPLAY_MODE_STORAGE_KEY = isAdminUser
|
||||
? 'logs-billing-display-mode-admin'
|
||||
: 'logs-billing-display-mode-user';
|
||||
|
||||
// Statistics state
|
||||
const [stat, setStat] = useState({
|
||||
@@ -102,50 +105,6 @@ export const useLogsData = () => {
|
||||
logType: '0',
|
||||
};
|
||||
|
||||
// Column visibility state
|
||||
const [visibleColumns, setVisibleColumns] = useState({});
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
|
||||
// Compact mode
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('logs');
|
||||
|
||||
// User info modal state
|
||||
const [showUserInfo, setShowUserInfoModal] = useState(false);
|
||||
const [userInfoData, setUserInfoData] = useState(null);
|
||||
|
||||
// Channel affinity usage cache stats modal state (admin only)
|
||||
const [
|
||||
showChannelAffinityUsageCacheModal,
|
||||
setShowChannelAffinityUsageCacheModal,
|
||||
] = useState(false);
|
||||
const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
|
||||
useState(null);
|
||||
|
||||
// Load saved column preferences from localStorage
|
||||
useEffect(() => {
|
||||
const savedColumns = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedColumns) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedColumns);
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
const merged = { ...defaults, ...parsed };
|
||||
|
||||
// For non-admin users, force-hide admin-only columns (does not touch admin settings)
|
||||
if (!isAdminUser) {
|
||||
merged[COLUMN_KEYS.CHANNEL] = false;
|
||||
merged[COLUMN_KEYS.USERNAME] = false;
|
||||
merged[COLUMN_KEYS.RETRY] = false;
|
||||
}
|
||||
setVisibleColumns(merged);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved column preferences', e);
|
||||
initDefaultColumns();
|
||||
}
|
||||
} else {
|
||||
initDefaultColumns();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get default column visibility based on user role
|
||||
const getDefaultColumnVisibility = () => {
|
||||
return {
|
||||
@@ -166,6 +125,63 @@ export const useLogsData = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const getInitialVisibleColumns = () => {
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
const savedColumns = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (!savedColumns) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(savedColumns);
|
||||
const merged = { ...defaults, ...parsed };
|
||||
|
||||
if (!isAdminUser) {
|
||||
merged[COLUMN_KEYS.CHANNEL] = false;
|
||||
merged[COLUMN_KEYS.USERNAME] = false;
|
||||
merged[COLUMN_KEYS.RETRY] = false;
|
||||
}
|
||||
|
||||
return merged;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved column preferences', e);
|
||||
return defaults;
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialBillingDisplayMode = () => {
|
||||
const savedMode = localStorage.getItem(BILLING_DISPLAY_MODE_STORAGE_KEY);
|
||||
if (savedMode === 'price' || savedMode === 'ratio') {
|
||||
return savedMode;
|
||||
}
|
||||
return localStorage.getItem('quota_display_type') === 'TOKENS'
|
||||
? 'ratio'
|
||||
: 'price';
|
||||
};
|
||||
|
||||
// Column visibility state
|
||||
const [visibleColumns, setVisibleColumns] = useState(getInitialVisibleColumns);
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
const [billingDisplayMode, setBillingDisplayMode] = useState(
|
||||
getInitialBillingDisplayMode,
|
||||
);
|
||||
|
||||
// Compact mode
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('logs');
|
||||
|
||||
// User info modal state
|
||||
const [showUserInfo, setShowUserInfoModal] = useState(false);
|
||||
const [userInfoData, setUserInfoData] = useState(null);
|
||||
|
||||
// Channel affinity usage cache stats modal state (admin only)
|
||||
const [
|
||||
showChannelAffinityUsageCacheModal,
|
||||
setShowChannelAffinityUsageCacheModal,
|
||||
] = useState(false);
|
||||
const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
|
||||
useState(null);
|
||||
|
||||
// Initialize default column visibility
|
||||
const initDefaultColumns = () => {
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
@@ -207,6 +223,10 @@ export const useLogsData = () => {
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(BILLING_DISPLAY_MODE_STORAGE_KEY, billingDisplayMode);
|
||||
}, [BILLING_DISPLAY_MODE_STORAGE_KEY, billingDisplayMode]);
|
||||
|
||||
// 获取表单值的辅助函数,确保所有值都是字符串
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
@@ -406,6 +426,7 @@ export const useLogsData = () => {
|
||||
other.cache_creation_ratio_1h ||
|
||||
other.cache_creation_ratio ||
|
||||
1.0,
|
||||
billingDisplayMode,
|
||||
)
|
||||
: renderLogContent(
|
||||
other?.model_ratio,
|
||||
@@ -420,6 +441,7 @@ export const useLogsData = () => {
|
||||
other.web_search_call_count || 0,
|
||||
other.file_search || false,
|
||||
other.file_search_call_count || 0,
|
||||
billingDisplayMode,
|
||||
),
|
||||
});
|
||||
if (logs[i]?.content) {
|
||||
@@ -473,6 +495,7 @@ export const useLogsData = () => {
|
||||
other?.user_group_ratio,
|
||||
other?.cache_tokens || 0,
|
||||
other?.cache_ratio || 1.0,
|
||||
billingDisplayMode,
|
||||
);
|
||||
} else if (other?.claude) {
|
||||
content = renderClaudeModelPrice(
|
||||
@@ -495,6 +518,7 @@ export const useLogsData = () => {
|
||||
other.cache_creation_ratio_1h ||
|
||||
other.cache_creation_ratio ||
|
||||
1.0,
|
||||
billingDisplayMode,
|
||||
);
|
||||
} else {
|
||||
content = renderModelPrice(
|
||||
@@ -521,6 +545,7 @@ export const useLogsData = () => {
|
||||
other?.audio_input_price || 0,
|
||||
other?.image_generation_call || false,
|
||||
other?.image_generation_call_price || 0,
|
||||
billingDisplayMode,
|
||||
);
|
||||
}
|
||||
expandDataLocal.push({
|
||||
@@ -764,6 +789,8 @@ export const useLogsData = () => {
|
||||
visibleColumns,
|
||||
showColumnSelector,
|
||||
setShowColumnSelector,
|
||||
billingDisplayMode,
|
||||
setBillingDisplayMode,
|
||||
handleColumnVisibilityChange,
|
||||
handleSelectAll,
|
||||
initDefaultColumns,
|
||||
|
||||
2
web/src/i18n/i18n.js
vendored
2
web/src/i18n/i18n.js
vendored
@@ -28,12 +28,14 @@ import zhTWTranslation from './locales/zh-TW.json';
|
||||
import ruTranslation from './locales/ru.json';
|
||||
import jaTranslation from './locales/ja.json';
|
||||
import viTranslation from './locales/vi.json';
|
||||
import { supportedLanguages } from './language';
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
load: 'currentOnly',
|
||||
supportedLngs: supportedLanguages,
|
||||
resources: {
|
||||
en: enTranslation,
|
||||
'zh-CN': zhCNTranslation,
|
||||
|
||||
61
web/src/i18n/language.js
vendored
Normal file
61
web/src/i18n/language.js
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
|
||||
export const supportedLanguages = [
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'en',
|
||||
'fr',
|
||||
'ru',
|
||||
'ja',
|
||||
'vi',
|
||||
];
|
||||
|
||||
export const normalizeLanguage = (language) => {
|
||||
if (!language) {
|
||||
return language;
|
||||
}
|
||||
|
||||
const normalized = language.trim().replace(/_/g, '-');
|
||||
const lower = normalized.toLowerCase();
|
||||
|
||||
if (
|
||||
lower === 'zh' ||
|
||||
lower === 'zh-cn' ||
|
||||
lower === 'zh-sg' ||
|
||||
lower.startsWith('zh-hans')
|
||||
) {
|
||||
return 'zh-CN';
|
||||
}
|
||||
|
||||
if (
|
||||
lower === 'zh-tw' ||
|
||||
lower === 'zh-hk' ||
|
||||
lower === 'zh-mo' ||
|
||||
lower.startsWith('zh-hant')
|
||||
) {
|
||||
return 'zh-TW';
|
||||
}
|
||||
|
||||
const matchedLanguage = supportedLanguages.find(
|
||||
(supportedLanguage) => supportedLanguage.toLowerCase() === lower,
|
||||
);
|
||||
|
||||
return matchedLanguage || normalized;
|
||||
};
|
||||
120
web/src/i18n/locales/en.json
vendored
120
web/src/i18n/locales/en.json
vendored
@@ -513,6 +513,8 @@
|
||||
"倍率信息": "Ratio information",
|
||||
"倍率是为了方便换算不同价格的模型": "The magnification is to facilitate the conversion of models with different prices.",
|
||||
"倍率模式": "Ratio Mode",
|
||||
"计费显示模式": "Billing Display Mode",
|
||||
"价格模式(默认)": "Price Mode (Default)",
|
||||
"倍率类型": "Ratio type",
|
||||
"偏好设置": "Preferences",
|
||||
"停止测试": "Stop Testing",
|
||||
@@ -909,6 +911,9 @@
|
||||
"图片生成调用:{{symbol}}{{price}} / 1次": "Image generation call: {{symbol}}{{price}} / 1 time",
|
||||
"图片输入: {{imageRatio}}": "Image input: {{imageRatio}}",
|
||||
"图片输入价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (图片倍率: {{imageRatio}})": "Image input price: {{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (Image ratio: {{imageRatio}})",
|
||||
"图片输入价格:{{symbol}}{{price}} / 1M tokens": "Image input price: {{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入价格 {{symbol}}{{price}} / 1M tokens": "Image input price {{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入 {{price}}": "Image input {{price}}",
|
||||
"图片输入倍率(仅部分模型支持该计费)": "Image input ratio (only supported by some models for billing)",
|
||||
"图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费": "Ratio settings related to image input, key is model name, value is ratio, only supported by some models for billing",
|
||||
"图生文": "Describe",
|
||||
@@ -1398,7 +1403,9 @@
|
||||
"按价格设置": "Set by price",
|
||||
"按倍率类型筛选": "Filter by ratio type",
|
||||
"按倍率设置": "Set by ratio",
|
||||
"按次计费": "Pay per view",
|
||||
"按次": "Per request",
|
||||
"按次 {{price}} / 次": "Per request {{price}} / request",
|
||||
"按次计费": "Pay per request",
|
||||
"按照如下格式输入:AccessKey|SecretAccessKey|Region": "Enter in the format: AccessKey|SecretAccessKey|Region",
|
||||
"按量计费": "Pay as you go",
|
||||
"按顺序替换content中的变量占位符": "Replace variable placeholders in content in order",
|
||||
@@ -1718,6 +1725,7 @@
|
||||
"未获取到授权码": "Authorization code not obtained",
|
||||
"未设置": "Not set",
|
||||
"未设置倍率模型": "Models without ratio settings",
|
||||
"未设置价格模型": "Models without price settings",
|
||||
"未设置路径": "",
|
||||
"未配置模型": "No model configured",
|
||||
"未配置的模型列表": "Models not configured",
|
||||
@@ -1785,7 +1793,10 @@
|
||||
"模型专用区域": "Model-specific area",
|
||||
"模型价格": "Model price",
|
||||
"模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}": "Model price {{symbol}}{{price}}, {{ratioType}} {{ratio}}",
|
||||
"模型价格 {{symbol}}{{price}} / 次": "Model price {{symbol}}{{price}} / request",
|
||||
"模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}": "Model price: {{symbol}}{{price}} * {{ratioType}}: {{ratio}} = {{symbol}}{{total}}",
|
||||
"模型价格:{{symbol}}{{price}} / 次": "Model price: {{symbol}}{{price}} / request",
|
||||
"输入 {{price}} / 1M tokens": "Input {{price}} / 1M tokens",
|
||||
"模型倍率": "Model ratio",
|
||||
"模型倍率 {{modelRatio}}": "Model ratio {{modelRatio}}",
|
||||
"模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}": "Model ratio {{modelRatio}}, cache ratio {{cacheRatio}}, completion ratio {{completionRatio}}, {{ratioType}} {{ratio}}",
|
||||
@@ -1845,7 +1856,7 @@
|
||||
"模板应用失败": "",
|
||||
"模板示例": "Template example",
|
||||
"模糊搜索模型名称": "Fuzzy search model name",
|
||||
"次": "times",
|
||||
"次": "request",
|
||||
"欢迎使用,请完成以下设置以开始使用系统": "Welcome! Please complete the following settings to start using the system",
|
||||
"欧元": "EUR",
|
||||
"正在加载可用部署位置...": "Loading available deployment locations...",
|
||||
@@ -2336,11 +2347,16 @@
|
||||
"统计次数": "Statistical count",
|
||||
"统计额度": "Statistical quota",
|
||||
"继续": "Continue",
|
||||
"缓存 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "Cache {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}",
|
||||
"缓存 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}} (倍率: {{ratio}})": "Cache {{tokens}} tokens / 1M tokens * {{symbol}}{{price}} (ratio: {{ratio}})",
|
||||
"缓存 Tokens": "Cache Tokens",
|
||||
"缓存: {{cacheRatio}}": "Cache: {{cacheRatio}}",
|
||||
"缓存价格:{{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})": "Cache price: {{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (Cache ratio: {{cacheRatio}})",
|
||||
"缓存价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})": "Cache price: {{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (Cache ratio: {{cacheRatio}})",
|
||||
"缓存读取价格:{{symbol}}{{price}} / 1M tokens": "Cache read price: {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存读取价格 {{symbol}}{{price}} / 1M tokens": "Cache read price {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存读取 {{price}}": "Cache read {{price}}",
|
||||
"缓存读取 {{price}}": "Cache read {{price}}",
|
||||
"缓存倍率": "Cache ratio",
|
||||
"缓存倍率 {{cacheRatio}}": "Cache ratio {{cacheRatio}}",
|
||||
"缓存写": "Cache Write",
|
||||
@@ -2350,8 +2366,20 @@
|
||||
"缓存创建: 1h {{cacheCreationRatio1h}}": "Cache creation: 1h {{cacheCreationRatio1h}}",
|
||||
"缓存创建: 5m {{cacheCreationRatio5m}}": "Cache creation: 5m {{cacheCreationRatio5m}}",
|
||||
"缓存创建: 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}": "Cache creation: 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}",
|
||||
"缓存创建 {{price}}": "Cache creation {{price}}",
|
||||
"5m缓存创建 {{price}}": "5m cache creation {{price}}",
|
||||
"1h缓存创建 {{price}}": "1h cache creation {{price}}",
|
||||
"缓存创建 {{price}}": "Cache creation {{price}}",
|
||||
"5m缓存创建 {{price}}": "5m cache creation {{price}}",
|
||||
"1h缓存创建 {{price}}": "1h cache creation {{price}}",
|
||||
"缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})": "Cache creation price: {{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (Cache creation ratio: {{cacheCreationRatio}})",
|
||||
"缓存创建价格:{{symbol}}{{price}} / 1M tokens": "Cache creation price: {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格 {{symbol}}{{price}} / 1M tokens": "Cache creation price {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格合计:5m {{symbol}}{{five}} + 1h {{symbol}}{{one}} = {{symbol}}{{total}} / 1M tokens": "Cache creation price total: 5m {{symbol}}{{five}} + 1h {{symbol}}{{one}} = {{symbol}}{{total}} / 1M tokens",
|
||||
"5m缓存创建价格:{{symbol}}{{price}} / 1M tokens": "5m cache creation price: {{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建价格 {{symbol}}{{price}} / 1M tokens": "5m cache creation price {{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格:{{symbol}}{{price}} / 1M tokens": "1h cache creation price: {{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格 {{symbol}}{{price}} / 1M tokens": "1h cache creation price {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建倍率": "Cache creation ratio",
|
||||
"缓存创建倍率 {{cacheCreationRatio}}": "Cache creation ratio {{cacheCreationRatio}}",
|
||||
"缓存创建倍率 1h {{cacheCreationRatio1h}}": "Cache creation multiplier 1h {{cacheCreationRatio1h}}",
|
||||
@@ -2468,8 +2496,16 @@
|
||||
"获得": "Received",
|
||||
"补全": "Completion",
|
||||
"补全 {{completion}} tokens / 1M tokens * {{symbol}}{{price}}": "Completion {{completion}} tokens / 1M tokens * {{symbol}}{{price}}",
|
||||
"模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "Model price {{symbol}}{{price}} / request * {{ratioType}} {{ratio}} = {{symbol}}{{total}}",
|
||||
"输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "Input {{input}} tokens / 1M tokens * {{symbol}}{{price}}",
|
||||
"图片输入 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "Image input {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}",
|
||||
"Web 搜索 {{count}} 次 * {{symbol}}{{price}} / 1K 次": "Web search {{count}} calls * {{symbol}}{{price}} / 1K calls",
|
||||
"文件搜索 {{count}} 次 * {{symbol}}{{price}} / 1K 次": "File search {{count}} calls * {{symbol}}{{price}} / 1K calls",
|
||||
"文字价格 {{textPrice}} + 音频价格 {{audioPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "Text price {{textPrice}} + Audio price {{audioPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}",
|
||||
"输入与缓存价格合计 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "Input and cache pricing subtotal * {{ratioType}} {{ratio}} = {{symbol}}{{total}}",
|
||||
"补全价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})": "Completion price: {{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (Completion ratio: {{completionRatio}})",
|
||||
"补全价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens": "Completion price: {{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens",
|
||||
"补全价格 {{symbol}}{{price}} / 1M tokens": "Completion price {{symbol}}{{price}} / 1M tokens",
|
||||
"补全倍率": "Completion ratio",
|
||||
"补全倍率值": "Completion Ratio Value",
|
||||
"补单": "Complete Order",
|
||||
@@ -2850,8 +2886,9 @@
|
||||
"输入 OIDC 的 Userinfo Endpoint": "Enter OIDC Userinfo Endpoint",
|
||||
"输入IP地址后回车,如:8.8.8.8": "Enter IP address and press Enter, e.g.: 8.8.8.8",
|
||||
"输入JSON对象": "Enter JSON Object",
|
||||
"输入价格": "Enter Price",
|
||||
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Enter price: {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
|
||||
"输入价格": "Input Price",
|
||||
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Input Price: {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
|
||||
"输入价格 {{symbol}}{{price}} / 1M tokens": "Input Price {{symbol}}{{price}} / 1M tokens",
|
||||
"输入你注册的 LinuxDO OAuth APP 的 ID": "Enter the ID of your registered LinuxDO OAuth APP",
|
||||
"输入你的账户名{{username}}以确认删除": "Enter your account name{{username}} to confirm deletion",
|
||||
"输入域名后回车": "Enter domain and press Enter",
|
||||
@@ -3183,6 +3220,79 @@
|
||||
"默认折叠侧边栏": "Default collapse sidebar",
|
||||
"默认测试模型": "Default Test Model",
|
||||
"默认用户消息": "Default User Message",
|
||||
"默认补全倍率": "Default completion ratio"
|
||||
"默认补全倍率": "Default completion ratio",
|
||||
"分组相关设置": "Group Related Settings",
|
||||
"保存分组相关设置": "Save Group Related Settings",
|
||||
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "This page only shows models without base pricing. After saving, configured models will be removed from this list automatically.",
|
||||
"没有未设置定价的模型": "No unpriced models",
|
||||
"当前没有未设置定价的模型": "There are currently no models without pricing",
|
||||
"模型计费编辑器": "Model Pricing Editor",
|
||||
"价格摘要": "Price Summary",
|
||||
"当前提示": "Current Notes",
|
||||
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "This editor uses prices by default and converts them back into the ratio JSON required by the backend when saved.",
|
||||
"当前未启用,需要时再打开即可。": "This field is currently disabled. Enable it when needed.",
|
||||
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "The fields below show which backend values will be written after saving, so you can keep them aligned with the raw JSON editors.",
|
||||
"补全价格已锁定": "Completion price is locked",
|
||||
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "Backend fixed ratio: {{ratio}}. This field only displays the converted price.",
|
||||
"这些价格都是可选项,不填也可以。": "All of these prices are optional and can be left empty.",
|
||||
"请先开启并填写音频输入价格。": "Enable and fill in the audio input price first.",
|
||||
"输入模型名称,例如 gpt-4.1": "Enter a model name, for example gpt-4.1",
|
||||
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "This model currently has both per-request pricing and ratio-based pricing. Saving will overwrite them according to the current billing mode.",
|
||||
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "This model has derived ratios without an explicit input ratio. Once you fill in the input price, they will be converted into price fields automatically.",
|
||||
"按量计费下需要先填写输入价格,才能保存其它价格项。": "For per-token billing, fill in the input price before saving other price fields.",
|
||||
"填写音频补全价格前,需要先填写音频输入价格。": "Fill in the audio input price before setting the audio completion price.",
|
||||
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "Model {{name}} is missing an input price, so the ratios for completion, cache, image, and audio pricing cannot be calculated.",
|
||||
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "Model {{name}} is missing an audio input price, so the audio completion ratio cannot be calculated.",
|
||||
"批量应用当前模型价格": "Batch Apply Current Model Pricing",
|
||||
"请先选择一个作为模板的模型": "Please select a model to use as the template first",
|
||||
"请先勾选需要批量设置的模型": "Please select the models you want to update in batch first",
|
||||
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "Applied the pricing configuration of model {{name}} to {{count}} models in batch",
|
||||
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "The pricing configuration of the currently edited model {{name}} will be applied to the {{count}} selected models.",
|
||||
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "Useful for pricing model variants together, for example syncing the pricing of gpt-5.1 to gpt-5.1-high, gpt-5.1-low, and similar models.",
|
||||
"已勾选": "Selected",
|
||||
"当前编辑": "Editing",
|
||||
"已勾选 {{count}} 个模型": "{{count}} models selected",
|
||||
"计费方式": "Billing Mode",
|
||||
"未设置价格": "Price not set",
|
||||
"保存预览": "Save Preview",
|
||||
"基础价格": "Base Pricing",
|
||||
"扩展价格": "Additional Pricing",
|
||||
"额外价格项": "Additional price items",
|
||||
"补全价格": "Completion Price",
|
||||
"缓存读取价格": "Input Cache Read Price",
|
||||
"缓存创建价格": "Input Cache Creation Price",
|
||||
"图片输入价格": "Image Input Price",
|
||||
"音频输入价格": "Audio Input Price",
|
||||
"音频输入价格:{{symbol}}{{price}} / 1M tokens": "Audio input price: {{symbol}}{{price}} / 1M tokens",
|
||||
"音频补全价格": "Audio Completion Price",
|
||||
"音频补全价格:{{symbol}}{{price}} / 1M tokens": "Audio completion price: {{symbol}}{{price}} / 1M tokens",
|
||||
"适合 MJ / 任务类等按次收费模型。": "Suitable for MJ and other task-based models billed per request.",
|
||||
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "This model's completion ratio is fixed to {{ratio}} by the backend. The completion price cannot be changed here.",
|
||||
"Web 搜索调用 {{webSearchCallCount}} 次": "Web search called {{webSearchCallCount}} times",
|
||||
"文件搜索调用 {{fileSearchCallCount}} 次": "File search called {{fileSearchCallCount}} times",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "Actual charge: {{symbol}}{{total}} (group pricing adjustment included)",
|
||||
"图片倍率 {{imageRatio}}": "Image ratio {{imageRatio}}",
|
||||
"音频倍率 {{audioRatio}}": "Audio ratio {{audioRatio}}",
|
||||
"普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Standard input: {{tokens}} / 1M * model ratio {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Cached input: {{tokens}} / 1M * model ratio {{modelRatio}} * cache ratio {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Image input: {{tokens}} / 1M * model ratio {{modelRatio}} * image ratio {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Audio input: {{tokens}} / 1M * model ratio {{modelRatio}} * audio ratio {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Output: {{tokens}} / 1M * model ratio {{modelRatio}} * completion ratio {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Web search: {{count}} / 1K * unit price {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "File search: {{count}} / 1K * unit price {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Image generation: 1 call * unit price {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:{{total}}": "Total: {{total}}",
|
||||
"模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}": "Model ratio {{modelRatio}}, completion ratio {{completionRatio}}, audio ratio {{audioRatio}}, audio completion ratio {{audioCompletionRatio}}, {{cachePart}}{{ratioType}} {{ratio}}",
|
||||
"文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Text output: {{tokens}} / 1M * model ratio {{modelRatio}} * completion ratio {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Audio output: {{tokens}} / 1M * model ratio {{modelRatio}} * audio ratio {{audioRatio}} * audio completion ratio {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}": "Total: text {{textTotal}} + audio {{audioTotal}} = {{total}}",
|
||||
"模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}": "Model ratio {{modelRatio}}, output ratio {{completionRatio}}, cache ratio {{cacheRatio}}, {{ratioType}} {{ratio}}",
|
||||
"缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}": "Cache creation ratio 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}",
|
||||
"缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Cache read: {{tokens}} / 1M * model ratio {{modelRatio}} * cache ratio {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Cache creation: {{tokens}} / 1M * model ratio {{modelRatio}} * cache creation ratio {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}": "5m cache creation: {{tokens}} / 1M * model ratio {{modelRatio}} * 5m cache creation ratio {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}": "1h cache creation: {{tokens}} / 1M * model ratio {{modelRatio}} * 1h cache creation ratio {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Output: {{tokens}} / 1M * model ratio {{modelRatio}} * output ratio {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"空": "Empty"
|
||||
}
|
||||
}
|
||||
|
||||
101
web/src/i18n/locales/fr.json
vendored
101
web/src/i18n/locales/fr.json
vendored
@@ -1398,7 +1398,8 @@
|
||||
"按价格设置": "Définir par prix",
|
||||
"按倍率类型筛选": "Filtrer par type de ratio",
|
||||
"按倍率设置": "Définir par ratio",
|
||||
"按次计费": "Paiement à la séance",
|
||||
"按次": "Par requête",
|
||||
"按次计费": "Paiement par requête",
|
||||
"按照如下格式输入:AccessKey|SecretAccessKey|Region": "Entrez au format : AccessKey|SecretAccessKey|Region",
|
||||
"按量计费": "Paiement à l'utilisation",
|
||||
"按顺序替换content中的变量占位符": "Remplacer les espaces réservés de variable dans le contenu dans l'ordre",
|
||||
@@ -1709,6 +1710,7 @@
|
||||
"未获取到授权码": "Code d'autorisation non obtenu",
|
||||
"未设置": "Non défini",
|
||||
"未设置倍率模型": "Modèles sans ratio",
|
||||
"未设置价格模型": "Modèles sans prix",
|
||||
"未设置路径": "",
|
||||
"未配置模型": "Aucun modèle configuré",
|
||||
"未配置的模型列表": "Modèles non configurés",
|
||||
@@ -1834,7 +1836,7 @@
|
||||
"模板应用失败": "",
|
||||
"模板示例": "Exemple de modèle",
|
||||
"模糊搜索模型名称": "Recherche floue de nom de modèle",
|
||||
"次": "Fois",
|
||||
"次": "requête",
|
||||
"欢迎使用,请完成以下设置以开始使用系统": "Bienvenue, veuillez compléter les paramètres suivants pour commencer à utiliser le système",
|
||||
"欧元": "Euro",
|
||||
"正在加载可用部署位置...": "Loading available deployment locations...",
|
||||
@@ -2832,8 +2834,8 @@
|
||||
"输入 OIDC 的 Userinfo Endpoint": "Saisir le point de terminaison des informations utilisateur OIDC",
|
||||
"输入IP地址后回车,如:8.8.8.8": "Saisissez l'adresse IP et appuyez sur Entrée, par exemple : 8.8.8.8",
|
||||
"输入JSON对象": "Saisir l'objet JSON",
|
||||
"输入价格": "Saisir le prix",
|
||||
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Saisir le prix : {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
|
||||
"输入价格": "Prix d'entrée",
|
||||
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Prix d'entrée : {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
|
||||
"输入你注册的 LinuxDO OAuth APP 的 ID": "Saisir l'ID de votre application OAuth LinuxDO enregistrée",
|
||||
"输入你的账户名{{username}}以确认删除": "Saisissez votre nom de compte{{username}}pour confirmer la suppression",
|
||||
"输入域名后回车": "Saisissez le domaine et appuyez sur Entrée",
|
||||
@@ -3152,6 +3154,95 @@
|
||||
"默认折叠侧边栏": "Réduire la barre latérale par défaut",
|
||||
"默认测试模型": "Modèle de test par défaut",
|
||||
"默认用户消息": "Bonjour",
|
||||
"默认补全倍率": "Taux de complétion par défaut"
|
||||
"默认补全倍率": "Taux de complétion par défaut",
|
||||
"分组相关设置": "Paramètres liés aux groupes",
|
||||
"保存分组相关设置": "Enregistrer les paramètres liés aux groupes",
|
||||
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "Cette page n'affiche que les modèles sans prix ou ratio de base. Après enregistrement, ils seront retirés automatiquement de cette liste.",
|
||||
"没有未设置定价的模型": "Aucun modèle sans prix",
|
||||
"当前没有未设置定价的模型": "Il n'y a actuellement aucun modèle sans prix",
|
||||
"模型计费编辑器": "Éditeur de tarification des modèles",
|
||||
"价格摘要": "Résumé des prix",
|
||||
"当前提示": "Informations actuelles",
|
||||
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "Cette interface utilise les prix par défaut et les reconvertit automatiquement en JSON de ratios requis par le backend lors de l'enregistrement.",
|
||||
"当前未启用,需要时再打开即可。": "Ce champ est actuellement désactivé. Activez-le si nécessaire.",
|
||||
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "Les champs backend écrits après l'enregistrement sont affichés ci-dessous afin de rester cohérents avec les éditeurs JSON bruts.",
|
||||
"补全价格已锁定": "Le prix de complétion est verrouillé",
|
||||
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "Ratio fixé par le backend : {{ratio}}. Ce champ affiche uniquement le prix converti.",
|
||||
"这些价格都是可选项,不填也可以。": "Tous ces prix sont optionnels et peuvent être laissés vides.",
|
||||
"请先开启并填写音频输入价格。": "Activez et renseignez d'abord le prix d'entrée audio.",
|
||||
"输入模型名称,例如 gpt-4.1": "Saisissez un nom de modèle, par exemple gpt-4.1",
|
||||
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "Ce modèle possède actuellement à la fois une tarification par requête et une configuration par ratio. L'enregistrement écrasera selon le mode de facturation actuel.",
|
||||
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "Ce modèle contient des ratios étendus sans ratio d'entrée explicite. Après saisie du prix d'entrée, ils seront convertis automatiquement en champs de prix.",
|
||||
"按量计费下需要先填写输入价格,才能保存其它价格项。": "En facturation au volume, il faut d'abord renseigner le prix d'entrée avant d'enregistrer les autres prix.",
|
||||
"填写音频补全价格前,需要先填写音频输入价格。": "Renseignez d'abord le prix d'entrée audio avant de définir le prix de complétion audio.",
|
||||
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "Le modèle {{name}} n'a pas de prix d'entrée, impossible de calculer les ratios correspondants pour la complétion, le cache, les images et l'audio.",
|
||||
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "Le modèle {{name}} n'a pas de prix d'entrée audio, impossible de calculer le ratio de complétion audio.",
|
||||
"批量应用当前模型价格": "Appliquer en lot le prix du modèle actuel",
|
||||
"请先选择一个作为模板的模型": "Veuillez d'abord choisir un modèle comme modèle de référence",
|
||||
"请先勾选需要批量设置的模型": "Veuillez d'abord sélectionner les modèles à configurer en lot",
|
||||
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "La configuration tarifaire du modèle {{name}} a été appliquée à {{count}} modèles en lot",
|
||||
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "La configuration tarifaire du modèle actuellement édité {{name}} sera appliquée aux {{count}} modèles sélectionnés.",
|
||||
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "Pratique pour tarifer ensemble des variantes d'un même modèle, par exemple synchroniser le prix de gpt-5.1 vers gpt-5.1-high, gpt-5.1-low et autres variantes similaires.",
|
||||
"已勾选": "Sélectionné",
|
||||
"当前编辑": "En cours d'édition",
|
||||
"已勾选 {{count}} 个模型": "{{count}} modèles sélectionnés",
|
||||
"计费方式": "Mode de facturation",
|
||||
"未设置价格": "Prix non défini",
|
||||
"保存预览": "Aperçu avant enregistrement",
|
||||
"基础价格": "Prix de base",
|
||||
"扩展价格": "Prix supplémentaires",
|
||||
"额外价格项": "Éléments de prix supplémentaires",
|
||||
"补全价格": "Prix de complétion",
|
||||
"缓存读取价格": "Prix de lecture du cache d'entrée",
|
||||
"缓存创建价格": "Prix de création du cache d'entrée",
|
||||
"图片输入价格": "Prix d'entrée image",
|
||||
"音频输入价格": "Prix d'entrée audio",
|
||||
"音频补全价格": "Prix de complétion audio",
|
||||
"适合 MJ / 任务类等按次收费模型。": "Convient aux modèles MJ et autres modèles facturés à la requête.",
|
||||
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "Le ratio de complétion de ce modèle est fixé à {{ratio}} par le backend. Le prix de complétion ne peut pas être modifié ici.",
|
||||
"计费显示模式": "Mode d'affichage de la facturation",
|
||||
"价格模式(默认)": "Mode prix (par défaut)",
|
||||
"模型价格 {{symbol}}{{price}} / 次": "Prix du modèle {{symbol}}{{price}} / requête",
|
||||
"模型价格:{{symbol}}{{price}} / 次": "Prix du modèle : {{symbol}}{{price}} / requête",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "Montant facturé réel : {{symbol}}{{total}} (ajustement tarifaire de groupe inclus)",
|
||||
"缓存读取价格:{{symbol}}{{price}} / 1M tokens": "Prix de lecture du cache : {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存读取价格 {{symbol}}{{price}} / 1M tokens": "Prix de lecture du cache {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格:{{symbol}}{{price}} / 1M tokens": "Prix de création du cache : {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格 {{symbol}}{{price}} / 1M tokens": "Prix de création du cache {{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建价格:{{symbol}}{{price}} / 1M tokens": "Prix de création du cache 5m : {{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建价格 {{symbol}}{{price}} / 1M tokens": "Prix de création du cache 5m {{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格:{{symbol}}{{price}} / 1M tokens": "Prix de création du cache 1h : {{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格 {{symbol}}{{price}} / 1M tokens": "Prix de création du cache 1h {{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入价格:{{symbol}}{{price}} / 1M tokens": "Prix d'entrée image : {{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入价格 {{symbol}}{{price}} / 1M tokens": "Prix d'entrée image {{symbol}}{{price}} / 1M tokens",
|
||||
"输入价格 {{symbol}}{{price}} / 1M tokens": "Prix d'entrée {{symbol}}{{price}} / 1M tokens",
|
||||
"补全价格 {{symbol}}{{price}} / 1M tokens": "Prix de complétion {{symbol}}{{price}} / 1M tokens",
|
||||
"音频输入价格:{{symbol}}{{price}} / 1M tokens": "Prix d'entrée audio : {{symbol}}{{price}} / 1M tokens",
|
||||
"音频补全价格:{{symbol}}{{price}} / 1M tokens": "Prix de complétion audio : {{symbol}}{{price}} / 1M tokens",
|
||||
"Web 搜索调用 {{webSearchCallCount}} 次": "Recherche Web appelée {{webSearchCallCount}} fois",
|
||||
"文件搜索调用 {{fileSearchCallCount}} 次": "Recherche de fichier appelée {{fileSearchCallCount}} fois",
|
||||
"图片倍率 {{imageRatio}}": "Ratio d'image {{imageRatio}}",
|
||||
"音频倍率 {{audioRatio}}": "Ratio audio {{audioRatio}}",
|
||||
"普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Entrée standard : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Entrée en cache : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio du cache {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Entrée d'image : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio d'image {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Entrée audio : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio audio {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Sortie : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio de complétion {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Recherche Web : {{count}} / 1K * prix unitaire {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Recherche de fichier : {{count}} / 1K * prix unitaire {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Génération d'image : 1 appel * prix unitaire {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:{{total}}": "Total : {{total}}",
|
||||
"模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}": "Ratio du modèle {{modelRatio}}, ratio de complétion {{completionRatio}}, ratio audio {{audioRatio}}, ratio de complétion audio {{audioCompletionRatio}}, {{cachePart}}{{ratioType}} {{ratio}}",
|
||||
"文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Sortie texte : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio de complétion {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Sortie audio : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio audio {{audioRatio}} * ratio de complétion audio {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}": "Total : partie texte {{textTotal}} + partie audio {{audioTotal}} = {{total}}",
|
||||
"模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}": "Ratio du modèle {{modelRatio}}, ratio de sortie {{completionRatio}}, ratio du cache {{cacheRatio}}, {{ratioType}} {{ratio}}",
|
||||
"缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}": "Ratio de création du cache 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}",
|
||||
"缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Lecture du cache : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio du cache {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Création du cache : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio de création du cache {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}": "Création du cache 5m : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio de création du cache 5m {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}": "Création du cache 1h : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio de création du cache 1h {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Sortie : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio de sortie {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"空": "Vide"
|
||||
}
|
||||
}
|
||||
|
||||
99
web/src/i18n/locales/ja.json
vendored
99
web/src/i18n/locales/ja.json
vendored
@@ -1381,7 +1381,8 @@
|
||||
"按价格设置": "料金設定",
|
||||
"按倍率类型筛选": "倍率タイプで絞り込み",
|
||||
"按倍率设置": "倍率設定",
|
||||
"按次计费": "リクエスト課金",
|
||||
"按次": "リクエストごと",
|
||||
"按次计费": "リクエストごとの課金",
|
||||
"按照如下格式输入:AccessKey|SecretAccessKey|Region": "Enter in the format: AccessKey|SecretAccessKey|Region",
|
||||
"按量计费": "従量課金",
|
||||
"按顺序替换content中的变量占位符": "content内の変数プレースホルダーを順番に置換します",
|
||||
@@ -1692,6 +1693,7 @@
|
||||
"未获取到授权码": "認可コードの取得に失敗しました",
|
||||
"未设置": "未設定",
|
||||
"未设置倍率模型": "倍率が未設定のモデル",
|
||||
"未设置价格模型": "価格が未設定のモデル",
|
||||
"未设置路径": "",
|
||||
"未配置模型": "未設定モデル",
|
||||
"未配置的模型列表": "未設定のモデルリスト",
|
||||
@@ -1817,7 +1819,7 @@
|
||||
"模板应用失败": "",
|
||||
"模板示例": "テンプレートサンプル",
|
||||
"模糊搜索模型名称": "モデル名であいまい検索",
|
||||
"次": "回",
|
||||
"次": "リクエスト",
|
||||
"欢迎使用,请完成以下设置以开始使用系统": "ようこそ。システムを利用開始するには、以下の設定を完了してください",
|
||||
"欧元": "EUR",
|
||||
"正在加载可用部署位置...": "Loading available deployment locations...",
|
||||
@@ -2813,7 +2815,7 @@
|
||||
"输入 OIDC 的 Userinfo Endpoint": "OIDCのUserinfo Endpointを入力してください",
|
||||
"输入IP地址后回车,如:8.8.8.8": "IPアドレスを入力してEnter(例:8.8.8.8)",
|
||||
"输入JSON对象": "JSONオブジェクトを入力してください",
|
||||
"输入价格": "料金を入力してください",
|
||||
"输入价格": "入力価格",
|
||||
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "入力料金:{{symbol}}{{price}} / 1M tokens{{audioPrice}}",
|
||||
"输入你注册的 LinuxDO OAuth APP 的 ID": "登録したLinuxDO OAuth APPのIDを入力してください",
|
||||
"输入你的账户名{{username}}以确认删除": "削除確認: アカウント名{{username}}を入力してください",
|
||||
@@ -3133,6 +3135,95 @@
|
||||
"默认折叠侧边栏": "サイドバーをデフォルトで折りたたむ",
|
||||
"默认测试模型": "デフォルトテストモデル",
|
||||
"默认用户消息": "こんにちは",
|
||||
"默认补全倍率": "デフォルト補完倍率"
|
||||
"默认补全倍率": "デフォルト補完倍率",
|
||||
"分组相关设置": "グループ関連設定",
|
||||
"保存分组相关设置": "グループ関連設定を保存",
|
||||
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "このページには価格または基本倍率が未設定のモデルのみ表示され、設定後は一覧から自動的に消えます。",
|
||||
"没有未设置定价的模型": "価格未設定のモデルはありません",
|
||||
"当前没有未设置定价的模型": "現在、価格未設定のモデルはありません",
|
||||
"模型计费编辑器": "モデル料金エディタ",
|
||||
"价格摘要": "価格概要",
|
||||
"当前提示": "現在のヒント",
|
||||
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "この画面では価格を基準に入力し、保存時にバックエンドが必要とする倍率 JSON に自動変換されます。",
|
||||
"当前未启用,需要时再打开即可。": "この項目は現在無効です。必要なときに有効にしてください。",
|
||||
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "保存後にこのモデルでどのバックエンド項目に書き込まれるかを以下に表示します。元の JSON エディタとの整合確認に便利です。",
|
||||
"补全价格已锁定": "補完価格はロックされています",
|
||||
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "バックエンド固定倍率: {{ratio}}。この項目は変換後の価格表示のみです。",
|
||||
"这些价格都是可选项,不填也可以。": "これらの価格はすべて任意項目で、未入力でも構いません。",
|
||||
"请先开启并填写音频输入价格。": "先に音声入力価格を有効にして入力してください。",
|
||||
"输入模型名称,例如 gpt-4.1": "モデル名を入力してください。例: gpt-4.1",
|
||||
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "このモデルには従量価格と倍率設定が同時に存在しています。保存すると現在の課金方式に従って上書きされます。",
|
||||
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "このモデルには入力倍率が明示されていない拡張倍率があります。入力価格を設定すると価格項目へ自動換算されます。",
|
||||
"按量计费下需要先填写输入价格,才能保存其它价格项。": "従量課金では、他の価格項目を保存する前に入力価格を設定する必要があります。",
|
||||
"填写音频补全价格前,需要先填写音频输入价格。": "音声補完価格を入力する前に、先に音声入力価格を入力してください。",
|
||||
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "モデル {{name}} に入力価格がないため、補完・キャッシュ・画像・音声価格に対応する倍率を計算できません。",
|
||||
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "モデル {{name}} に音声入力価格がないため、音声補完倍率を計算できません。",
|
||||
"批量应用当前模型价格": "現在のモデル価格を一括適用",
|
||||
"请先选择一个作为模板的模型": "まずテンプレートとして使うモデルを選択してください",
|
||||
"请先勾选需要批量设置的模型": "一括設定したいモデルを先に選択してください",
|
||||
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "モデル {{name}} の価格設定を {{count}} 個のモデルに一括適用しました",
|
||||
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "現在編集中のモデル {{name}} の価格設定を、選択済みの {{count}} 個のモデルに一括適用します。",
|
||||
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "同系列モデルをまとめて価格設定するのに適しています。例えば gpt-5.1 の価格を gpt-5.1-high、gpt-5.1-low などへ一括同期できます。",
|
||||
"已勾选": "選択済み",
|
||||
"当前编辑": "編集中",
|
||||
"已勾选 {{count}} 个模型": "{{count}} 個のモデルを選択済み",
|
||||
"计费方式": "課金方式",
|
||||
"未设置价格": "価格未設定",
|
||||
"保存预览": "保存プレビュー",
|
||||
"基础价格": "基本価格",
|
||||
"扩展价格": "追加価格",
|
||||
"额外价格项": "追加価格項目",
|
||||
"补全价格": "補完価格",
|
||||
"缓存读取价格": "入力キャッシュ読み取り価格",
|
||||
"缓存创建价格": "入力キャッシュ作成価格",
|
||||
"图片输入价格": "画像入力価格",
|
||||
"音频输入价格": "音声入力価格",
|
||||
"音频补全价格": "音声補完価格",
|
||||
"适合 MJ / 任务类等按次收费模型。": "MJ やその他のリクエスト単位課金モデルに適しています。",
|
||||
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "このモデルの補完倍率はバックエンドで {{ratio}} に固定されています。ここでは補完価格を変更できません。",
|
||||
"计费显示模式": "課金表示モード",
|
||||
"价格模式(默认)": "価格モード(デフォルト)",
|
||||
"模型价格 {{symbol}}{{price}} / 次": "モデル価格 {{symbol}}{{price}} / リクエスト",
|
||||
"模型价格:{{symbol}}{{price}} / 次": "モデル価格:{{symbol}}{{price}} / リクエスト",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "実際の請求額:{{symbol}}{{total}}(グループ価格調整込み)",
|
||||
"缓存读取价格:{{symbol}}{{price}} / 1M tokens": "キャッシュ読み取り価格:{{symbol}}{{price}} / 1M tokens",
|
||||
"缓存读取价格 {{symbol}}{{price}} / 1M tokens": "キャッシュ読み取り価格 {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格:{{symbol}}{{price}} / 1M tokens": "キャッシュ作成価格:{{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格 {{symbol}}{{price}} / 1M tokens": "キャッシュ作成価格 {{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建价格:{{symbol}}{{price}} / 1M tokens": "5m キャッシュ作成価格:{{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建价格 {{symbol}}{{price}} / 1M tokens": "5m キャッシュ作成価格 {{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格:{{symbol}}{{price}} / 1M tokens": "1h キャッシュ作成価格:{{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格 {{symbol}}{{price}} / 1M tokens": "1h キャッシュ作成価格 {{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入价格:{{symbol}}{{price}} / 1M tokens": "画像入力価格:{{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入价格 {{symbol}}{{price}} / 1M tokens": "画像入力価格 {{symbol}}{{price}} / 1M tokens",
|
||||
"输入价格 {{symbol}}{{price}} / 1M tokens": "入力価格 {{symbol}}{{price}} / 1M tokens",
|
||||
"补全价格 {{symbol}}{{price}} / 1M tokens": "補完価格 {{symbol}}{{price}} / 1M tokens",
|
||||
"音频输入价格:{{symbol}}{{price}} / 1M tokens": "音声入力価格:{{symbol}}{{price}} / 1M tokens",
|
||||
"音频补全价格:{{symbol}}{{price}} / 1M tokens": "音声補完価格:{{symbol}}{{price}} / 1M tokens",
|
||||
"Web 搜索调用 {{webSearchCallCount}} 次": "Web 検索呼び出し {{webSearchCallCount}} 回",
|
||||
"文件搜索调用 {{fileSearchCallCount}} 次": "ファイル検索呼び出し {{fileSearchCallCount}} 回",
|
||||
"图片倍率 {{imageRatio}}": "画像倍率 {{imageRatio}}",
|
||||
"音频倍率 {{audioRatio}}": "音声倍率 {{audioRatio}}",
|
||||
"普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "通常入力: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "キャッシュ入力: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * キャッシュ倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "画像入力: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * 画像倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音声入力: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * 音声倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "出力: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * 補完倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Web 検索: {{count}} / 1K * 単価 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "ファイル検索: {{count}} / 1K * 単価 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "画像生成: 1 回 * 単価 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:{{total}}": "合計: {{total}}",
|
||||
"模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}": "モデル倍率 {{modelRatio}}、補完倍率 {{completionRatio}}、音声倍率 {{audioRatio}}、音声補完倍率 {{audioCompletionRatio}}、{{cachePart}}{{ratioType}} {{ratio}}",
|
||||
"文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "テキスト出力: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * 補完倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音声出力: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * 音声倍率 {{audioRatio}} * 音声補完倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}": "合計: テキスト部分 {{textTotal}} + 音声部分 {{audioTotal}} = {{total}}",
|
||||
"模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}": "モデル倍率 {{modelRatio}}、出力倍率 {{completionRatio}}、キャッシュ倍率 {{cacheRatio}}、{{ratioType}} {{ratio}}",
|
||||
"缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}": "キャッシュ作成倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}",
|
||||
"缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "キャッシュ読み取り: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * キャッシュ倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "キャッシュ作成: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * キャッシュ作成倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}": "5m キャッシュ作成: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * 5m キャッシュ作成倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}": "1h キャッシュ作成: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * 1h キャッシュ作成倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "出力: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * 出力倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"空": "空"
|
||||
}
|
||||
}
|
||||
|
||||
99
web/src/i18n/locales/ru.json
vendored
99
web/src/i18n/locales/ru.json
vendored
@@ -1410,7 +1410,8 @@
|
||||
"按价格设置": "Настроить по цене",
|
||||
"按倍率类型筛选": "Фильтровать по типу коэффициента",
|
||||
"按倍率设置": "Настроить по множителю",
|
||||
"按次计费": "Оплата за использование",
|
||||
"按次": "За запрос",
|
||||
"按次计费": "Оплата за запрос",
|
||||
"按照如下格式输入:AccessKey|SecretAccessKey|Region": "Введите в формате: AccessKey|SecretAccessKey|Region",
|
||||
"按量计费": "Оплата по объему",
|
||||
"按顺序替换content中的变量占位符": "Последовательно заменять переменные-заполнители в content",
|
||||
@@ -1721,6 +1722,7 @@
|
||||
"未获取到授权码": "Код авторизации не получен",
|
||||
"未设置": "Не настроено",
|
||||
"未设置倍率模型": "Модели с неустановленным множителем",
|
||||
"未设置价格模型": "Модели с неустановленной ценой",
|
||||
"未设置路径": "",
|
||||
"未配置模型": "Ненастроенные модели",
|
||||
"未配置的模型列表": "Список ненастроенных моделей",
|
||||
@@ -1846,7 +1848,7 @@
|
||||
"模板应用失败": "",
|
||||
"模板示例": "Пример шаблона",
|
||||
"模糊搜索模型名称": "Нечеткий поиск по названию модели",
|
||||
"次": "раз",
|
||||
"次": "запрос",
|
||||
"欢迎使用,请完成以下设置以开始使用系统": "Добро пожаловать, пожалуйста, выполните следующие настройки, чтобы начать использовать систему",
|
||||
"欧元": "Евро",
|
||||
"正在加载可用部署位置...": "Loading available deployment locations...",
|
||||
@@ -2846,7 +2848,7 @@
|
||||
"输入 OIDC 的 Userinfo Endpoint": "Введите Userinfo Endpoint OIDC",
|
||||
"输入IP地址后回车,如:8.8.8.8": "Введите IP-адрес и нажмите Enter, например: 8.8.8.8",
|
||||
"输入JSON对象": "Введите JSON-объект",
|
||||
"输入价格": "Введите цену",
|
||||
"输入价格": "Цена ввода",
|
||||
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Цена ввода: {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
|
||||
"输入你注册的 LinuxDO OAuth APP 的 ID": "Введите ID вашего зарегистрированного LinuxDO OAuth APP",
|
||||
"输入你的账户名{{username}}以确认删除": "Введите имя вашей учётной записи {{username}} для подтверждения удаления",
|
||||
@@ -3166,6 +3168,95 @@
|
||||
"默认折叠侧边栏": "Сворачивать боковую панель по умолчанию",
|
||||
"默认测试模型": "Модель для тестирования по умолчанию",
|
||||
"默认用户消息": "Здравствуйте",
|
||||
"默认补全倍率": "Default completion ratio"
|
||||
"默认补全倍率": "Default completion ratio",
|
||||
"分组相关设置": "Настройки, связанные с группами",
|
||||
"保存分组相关设置": "Сохранить настройки, связанные с группами",
|
||||
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "На этой странице показаны только модели без цены или базового коэффициента. После сохранения они будут автоматически удалены из списка.",
|
||||
"没有未设置定价的模型": "Нет моделей без цены",
|
||||
"当前没有未设置定价的模型": "Сейчас нет моделей без цены",
|
||||
"模型计费编辑器": "Редактор тарификации моделей",
|
||||
"价格摘要": "Сводка цен",
|
||||
"当前提示": "Текущие подсказки",
|
||||
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "В этом интерфейсе значения по умолчанию задаются через цены, а при сохранении они автоматически преобразуются в JSON коэффициентов, требуемый backend.",
|
||||
"当前未启用,需要时再打开即可。": "Это поле сейчас отключено. Включите его при необходимости.",
|
||||
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "Ниже показано, какие backend-поля будут записаны после сохранения, чтобы их было удобно сверять с редакторами исходного JSON.",
|
||||
"补全价格已锁定": "Цена завершения заблокирована",
|
||||
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "Фиксированный backend-коэффициент: {{ratio}}. Это поле только показывает вычисленную цену.",
|
||||
"这些价格都是可选项,不填也可以。": "Все эти цены необязательны и могут быть оставлены пустыми.",
|
||||
"请先开启并填写音频输入价格。": "Сначала включите и заполните цену аудио-ввода.",
|
||||
"输入模型名称,例如 gpt-4.1": "Введите имя модели, например gpt-4.1",
|
||||
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "У этой модели одновременно задана цена за запрос и конфигурация коэффициентов. При сохранении данные будут перезаписаны согласно текущему режиму тарификации.",
|
||||
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "У этой модели есть дополнительные коэффициенты без явно заданного входного коэффициента; после ввода входной цены они будут автоматически преобразованы в ценовые поля.",
|
||||
"按量计费下需要先填写输入价格,才能保存其它价格项。": "При тарификации по объему сначала нужно указать входную цену, чтобы сохранить остальные ценовые поля.",
|
||||
"填写音频补全价格前,需要先填写音频输入价格。": "Перед указанием цены аудио-завершения сначала задайте цену аудио-ввода.",
|
||||
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "У модели {{name}} отсутствует входная цена, поэтому невозможно вычислить коэффициенты для завершения, кэша, изображений и аудио.",
|
||||
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "У модели {{name}} отсутствует цена аудио-ввода, поэтому невозможно вычислить коэффициент аудио-завершения.",
|
||||
"批量应用当前模型价格": "Массово применить цену текущей модели",
|
||||
"请先选择一个作为模板的模型": "Сначала выберите модель-шаблон",
|
||||
"请先勾选需要批量设置的模型": "Сначала отметьте модели для массовой настройки",
|
||||
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "Ценовая конфигурация модели {{name}} массово применена к {{count}} моделям",
|
||||
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "Ценовая конфигурация редактируемой модели {{name}} будет применена к {{count}} выбранным моделям.",
|
||||
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "Подходит для совместной настройки цен вариантов одной модели, например синхронизации цены gpt-5.1 с gpt-5.1-high, gpt-5.1-low и похожими моделями.",
|
||||
"已勾选": "Выбрано",
|
||||
"当前编辑": "Текущее редактирование",
|
||||
"已勾选 {{count}} 个模型": "Выбрано моделей: {{count}}",
|
||||
"计费方式": "Режим тарификации",
|
||||
"未设置价格": "Цена не задана",
|
||||
"保存预览": "Предпросмотр сохранения",
|
||||
"基础价格": "Базовые цены",
|
||||
"扩展价格": "Дополнительные цены",
|
||||
"额外价格项": "Дополнительные ценовые позиции",
|
||||
"补全价格": "Цена завершения",
|
||||
"缓存读取价格": "Цена чтения входного кеша",
|
||||
"缓存创建价格": "Цена создания входного кеша",
|
||||
"图片输入价格": "Цена входного изображения",
|
||||
"音频输入价格": "Цена входного аудио",
|
||||
"音频补全价格": "Цена завершения аудио",
|
||||
"适合 MJ / 任务类等按次收费模型。": "Подходит для MJ и других моделей с тарификацией за запрос.",
|
||||
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "Коэффициент завершения для этой модели зафиксирован на уровне {{ratio}} на бэкенде. Цену завершения нельзя изменить здесь.",
|
||||
"计费显示模式": "Режим отображения тарификации",
|
||||
"价格模式(默认)": "Режим цен (по умолчанию)",
|
||||
"模型价格 {{symbol}}{{price}} / 次": "Цена модели {{symbol}}{{price}} / запрос",
|
||||
"模型价格:{{symbol}}{{price}} / 次": "Цена модели: {{symbol}}{{price}} / запрос",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "Фактическое списание: {{symbol}}{{total}} (включая групповую ценовую корректировку)",
|
||||
"缓存读取价格:{{symbol}}{{price}} / 1M tokens": "Цена чтения кеша: {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存读取价格 {{symbol}}{{price}} / 1M tokens": "Цена чтения кеша {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格:{{symbol}}{{price}} / 1M tokens": "Цена создания кеша: {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格 {{symbol}}{{price}} / 1M tokens": "Цена создания кеша {{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建价格:{{symbol}}{{price}} / 1M tokens": "Цена создания кеша 5m: {{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建价格 {{symbol}}{{price}} / 1M tokens": "Цена создания кеша 5m {{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格:{{symbol}}{{price}} / 1M tokens": "Цена создания кеша 1h: {{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格 {{symbol}}{{price}} / 1M tokens": "Цена создания кеша 1h {{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入价格:{{symbol}}{{price}} / 1M tokens": "Цена входного изображения: {{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入价格 {{symbol}}{{price}} / 1M tokens": "Цена входного изображения {{symbol}}{{price}} / 1M tokens",
|
||||
"输入价格 {{symbol}}{{price}} / 1M tokens": "Цена ввода {{symbol}}{{price}} / 1M tokens",
|
||||
"补全价格 {{symbol}}{{price}} / 1M tokens": "Цена завершения {{symbol}}{{price}} / 1M tokens",
|
||||
"音频输入价格:{{symbol}}{{price}} / 1M tokens": "Цена входного аудио: {{symbol}}{{price}} / 1M tokens",
|
||||
"音频补全价格:{{symbol}}{{price}} / 1M tokens": "Цена завершения аудио: {{symbol}}{{price}} / 1M tokens",
|
||||
"Web 搜索调用 {{webSearchCallCount}} 次": "Web-поиск вызван {{webSearchCallCount}} раз",
|
||||
"文件搜索调用 {{fileSearchCallCount}} 次": "Поиск файлов вызван {{fileSearchCallCount}} раз",
|
||||
"图片倍率 {{imageRatio}}": "Коэффициент изображения {{imageRatio}}",
|
||||
"音频倍率 {{audioRatio}}": "Аудио-коэффициент {{audioRatio}}",
|
||||
"普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Обычный ввод: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Кэшированный ввод: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * коэффициент кэша {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Ввод изображения: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * коэффициент изображения {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Аудиоввод: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * аудио-коэффициент {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Вывод: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * коэффициент завершения {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Web-поиск: {{count}} / 1K * цена за единицу {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Поиск файлов: {{count}} / 1K * цена за единицу {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Генерация изображения: 1 вызов * цена за единицу {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:{{total}}": "Итого: {{total}}",
|
||||
"模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}": "Коэффициент модели {{modelRatio}}, коэффициент завершения {{completionRatio}}, аудио-коэффициент {{audioRatio}}, коэффициент аудиозавершения {{audioCompletionRatio}}, {{cachePart}}{{ratioType}} {{ratio}}",
|
||||
"文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Текстовый вывод: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * коэффициент завершения {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Аудиовывод: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * аудио-коэффициент {{audioRatio}} * коэффициент аудиозавершения {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}": "Итого: текстовая часть {{textTotal}} + аудиочасть {{audioTotal}} = {{total}}",
|
||||
"模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}": "Коэффициент модели {{modelRatio}}, коэффициент вывода {{completionRatio}}, коэффициент кэша {{cacheRatio}}, {{ratioType}} {{ratio}}",
|
||||
"缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}": "Коэффициент создания кэша 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}",
|
||||
"缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Чтение кэша: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * коэффициент кэша {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Создание кэша: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * коэффициент создания кэша {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}": "Создание кэша 5m: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * коэффициент создания кэша 5m {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}": "Создание кэша 1h: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * коэффициент создания кэша 1h {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Вывод: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * коэффициент вывода {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"空": "Пусто"
|
||||
}
|
||||
}
|
||||
|
||||
101
web/src/i18n/locales/vi.json
vendored
101
web/src/i18n/locales/vi.json
vendored
@@ -1382,7 +1382,8 @@
|
||||
"按价格设置": "Đặt theo giá",
|
||||
"按倍率类型筛选": "Lọc theo loại tỷ lệ",
|
||||
"按倍率设置": "Đặt theo tỷ lệ",
|
||||
"按次计费": "Trả tiền cho mỗi lần xem",
|
||||
"按次": "Theo lượt gọi",
|
||||
"按次计费": "Tính phí theo lượt gọi",
|
||||
"按照如下格式输入:AccessKey|SecretAccessKey|Region": "Enter in the format: AccessKey|SecretAccessKey|Region",
|
||||
"按量计费": "Trả tiền theo mức sử dụng",
|
||||
"按顺序替换content中的变量占位符": "Thay thế các trình giữ chỗ biến trong nội dung theo thứ tự",
|
||||
@@ -1693,6 +1694,7 @@
|
||||
"未获取到授权码": "Không lấy được mã ủy quyền",
|
||||
"未设置": "Chưa thiết lập",
|
||||
"未设置倍率模型": "Mô hình không có cài đặt tỷ lệ",
|
||||
"未设置价格模型": "Mô hình chưa thiết lập giá",
|
||||
"未设置路径": "",
|
||||
"未配置模型": "Không có mô hình được cấu hình",
|
||||
"未配置的模型列表": "Mô hình chưa được cấu hình",
|
||||
@@ -1838,7 +1840,7 @@
|
||||
"模板示例": "Ví dụ mẫu",
|
||||
"模糊匹配": "Khớp mờ",
|
||||
"模糊搜索模型名称": "Tìm kiếm mờ tên mô hình",
|
||||
"次": "lần",
|
||||
"次": "lượt",
|
||||
"欢迎使用,请完成以下设置以开始使用系统": "Chào mừng! Vui lòng hoàn tất các cài đặt sau để bắt đầu sử dụng hệ thống",
|
||||
"欢迎回来": "Chào mừng trở lại",
|
||||
"欢迎回来!": "Chào mừng trở lại!",
|
||||
@@ -3291,9 +3293,9 @@
|
||||
"输入 OIDC 的 Userinfo Endpoint": "Nhập Userinfo Endpoint của OIDC",
|
||||
"输入IP地址后回车,如:8.8.8.8": "Nhập địa chỉ IP và nhấn Enter, ví dụ: 8.8.8.8",
|
||||
"输入JSON对象": "Nhập đối tượng JSON",
|
||||
"输入价格": "Nhập giá",
|
||||
"输入价格": "Giá đầu vào",
|
||||
"输入价格:{{symbol}}{{price}} / 1M tokens": "Giá đầu vào: {{symbol}}{{price}} / 1M tokens",
|
||||
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Nhập giá: {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
|
||||
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Giá đầu vào: {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
|
||||
"输入你注册的 LinuxDO OAuth APP 的 ID": "Nhập ID của LinuxDO OAuth APP bạn đã đăng ký",
|
||||
"输入你的账户名{{username}}以确认删除": "Nhập tên tài khoản của bạn {{username}} để xác nhận xóa",
|
||||
"输入倍率": "Tỷ lệ đầu vào",
|
||||
@@ -3705,6 +3707,95 @@
|
||||
"默认折叠侧边栏": "Mặc định thu gọn thanh bên",
|
||||
"默认测试模型": "Mô hình kiểm tra mặc định",
|
||||
"默认用户消息": "Xin chào",
|
||||
"默认补全倍率": "Tỷ lệ hoàn thành mặc định"
|
||||
"默认补全倍率": "Tỷ lệ hoàn thành mặc định",
|
||||
"分组相关设置": "Cài đặt liên quan đến nhóm",
|
||||
"保存分组相关设置": "Lưu cài đặt liên quan đến nhóm",
|
||||
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "Trang này chỉ hiển thị các mô hình chưa thiết lập giá hoặc tỷ lệ cơ bản. Sau khi lưu, chúng sẽ tự động biến mất khỏi danh sách.",
|
||||
"没有未设置定价的模型": "Không có mô hình chưa thiết lập giá",
|
||||
"当前没有未设置定价的模型": "Hiện không có mô hình nào chưa thiết lập giá",
|
||||
"模型计费编辑器": "Trình chỉnh sửa giá mô hình",
|
||||
"价格摘要": "Tóm tắt giá",
|
||||
"当前提示": "Gợi ý hiện tại",
|
||||
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "Giao diện này mặc định nhập theo giá, khi lưu sẽ tự động quy đổi lại thành JSON tỷ lệ mà backend yêu cầu.",
|
||||
"当前未启用,需要时再打开即可。": "Trường này hiện đang tắt. Hãy bật khi cần.",
|
||||
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "Bên dưới hiển thị các trường backend sẽ được ghi sau khi lưu, giúp bạn dễ đối chiếu với ô chỉnh sửa JSON gốc.",
|
||||
"补全价格已锁定": "Giá hoàn thành đã bị khóa",
|
||||
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "Tỷ lệ cố định từ backend: {{ratio}}. Trường này chỉ hiển thị giá sau khi quy đổi.",
|
||||
"这些价格都是可选项,不填也可以。": "Tất cả các mức giá này đều là tùy chọn và có thể để trống.",
|
||||
"请先开启并填写音频输入价格。": "Hãy bật và điền giá đầu vào âm thanh trước.",
|
||||
"输入模型名称,例如 gpt-4.1": "Nhập tên mô hình, ví dụ gpt-4.1",
|
||||
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "Mô hình này hiện đồng thời có giá theo lượt gọi và cấu hình tỷ lệ. Khi lưu, dữ liệu sẽ bị ghi đè theo chế độ tính phí hiện tại.",
|
||||
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "Mô hình này có các tỷ lệ mở rộng mà chưa đặt rõ tỷ lệ đầu vào; sau khi điền giá đầu vào, chúng sẽ tự động được quy đổi thành trường giá.",
|
||||
"按量计费下需要先填写输入价格,才能保存其它价格项。": "Ở chế độ tính phí theo lượng, cần điền giá đầu vào trước thì mới lưu được các mục giá khác.",
|
||||
"填写音频补全价格前,需要先填写音频输入价格。": "Trước khi nhập giá hoàn thành âm thanh, hãy nhập giá đầu vào âm thanh trước.",
|
||||
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "Mô hình {{name}} thiếu giá đầu vào, nên không thể tính tỷ lệ tương ứng cho giá hoàn thành, bộ nhớ đệm, hình ảnh và âm thanh.",
|
||||
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "Mô hình {{name}} thiếu giá đầu vào âm thanh, nên không thể tính tỷ lệ hoàn thành âm thanh.",
|
||||
"批量应用当前模型价格": "Áp dụng hàng loạt giá của mô hình hiện tại",
|
||||
"请先选择一个作为模板的模型": "Vui lòng chọn trước một mô hình làm mẫu",
|
||||
"请先勾选需要批量设置的模型": "Vui lòng chọn các mô hình cần thiết lập hàng loạt trước",
|
||||
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "Đã áp dụng hàng loạt cấu hình giá của mô hình {{name}} cho {{count}} mô hình",
|
||||
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "Cấu hình giá của mô hình đang chỉnh sửa {{name}} sẽ được áp dụng hàng loạt cho {{count}} mô hình đã chọn.",
|
||||
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "Phù hợp để định giá cùng lúc các biến thể cùng dòng, ví dụ đồng bộ giá của gpt-5.1 sang gpt-5.1-high, gpt-5.1-low và các mô hình tương tự.",
|
||||
"已勾选": "Đã chọn",
|
||||
"当前编辑": "Đang chỉnh sửa",
|
||||
"已勾选 {{count}} 个模型": "Đã chọn {{count}} mô hình",
|
||||
"计费方式": "Chế độ tính phí",
|
||||
"未设置价格": "Chưa thiết lập giá",
|
||||
"保存预览": "Xem trước khi lưu",
|
||||
"基础价格": "Giá cơ bản",
|
||||
"扩展价格": "Giá mở rộng",
|
||||
"额外价格项": "Mục giá bổ sung",
|
||||
"补全价格": "Giá hoàn thành",
|
||||
"缓存读取价格": "Giá đọc bộ nhớ đệm đầu vào",
|
||||
"缓存创建价格": "Giá tạo bộ nhớ đệm đầu vào",
|
||||
"图片输入价格": "Giá đầu vào hình ảnh",
|
||||
"音频输入价格": "Giá đầu vào âm thanh",
|
||||
"音频补全价格": "Giá hoàn thành âm thanh",
|
||||
"适合 MJ / 任务类等按次收费模型。": "Phù hợp cho MJ và các mô hình tính phí theo lượt gọi tương tự.",
|
||||
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "Tỷ lệ hoàn thành của mô hình này được backend cố định ở {{ratio}}. Không thể chỉnh giá hoàn thành tại đây.",
|
||||
"计费显示模式": "Chế độ hiển thị tính phí",
|
||||
"价格模式(默认)": "Chế độ giá (mặc định)",
|
||||
"模型价格 {{symbol}}{{price}} / 次": "Giá mô hình {{symbol}}{{price}} / lượt gọi",
|
||||
"模型价格:{{symbol}}{{price}} / 次": "Giá mô hình: {{symbol}}{{price}} / lượt gọi",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "Khoản phí thực tế: {{symbol}}{{total}} (đã bao gồm điều chỉnh giá theo nhóm)",
|
||||
"缓存读取价格:{{symbol}}{{price}} / 1M tokens": "Giá đọc bộ nhớ đệm: {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存读取价格 {{symbol}}{{price}} / 1M tokens": "Giá đọc bộ nhớ đệm {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格:{{symbol}}{{price}} / 1M tokens": "Giá tạo bộ nhớ đệm: {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格 {{symbol}}{{price}} / 1M tokens": "Giá tạo bộ nhớ đệm {{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建价格:{{symbol}}{{price}} / 1M tokens": "Giá tạo bộ nhớ đệm 5m: {{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建价格 {{symbol}}{{price}} / 1M tokens": "Giá tạo bộ nhớ đệm 5m {{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格:{{symbol}}{{price}} / 1M tokens": "Giá tạo bộ nhớ đệm 1h: {{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格 {{symbol}}{{price}} / 1M tokens": "Giá tạo bộ nhớ đệm 1h {{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入价格:{{symbol}}{{price}} / 1M tokens": "Giá đầu vào hình ảnh: {{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入价格 {{symbol}}{{price}} / 1M tokens": "Giá đầu vào hình ảnh {{symbol}}{{price}} / 1M tokens",
|
||||
"输入价格 {{symbol}}{{price}} / 1M tokens": "Giá đầu vào {{symbol}}{{price}} / 1M tokens",
|
||||
"补全价格 {{symbol}}{{price}} / 1M tokens": "Giá hoàn thành {{symbol}}{{price}} / 1M tokens",
|
||||
"音频输入价格:{{symbol}}{{price}} / 1M tokens": "Giá đầu vào âm thanh: {{symbol}}{{price}} / 1M tokens",
|
||||
"音频补全价格:{{symbol}}{{price}} / 1M tokens": "Giá hoàn thành âm thanh: {{symbol}}{{price}} / 1M tokens",
|
||||
"Web 搜索调用 {{webSearchCallCount}} 次": "Đã gọi tìm kiếm Web {{webSearchCallCount}} lần",
|
||||
"文件搜索调用 {{fileSearchCallCount}} 次": "Đã gọi tìm kiếm tệp {{fileSearchCallCount}} lần",
|
||||
"图片倍率 {{imageRatio}}": "Hệ số hình ảnh {{imageRatio}}",
|
||||
"音频倍率 {{audioRatio}}": "Hệ số âm thanh {{audioRatio}}",
|
||||
"普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Đầu vào thường: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Đầu vào bộ nhớ đệm: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số bộ nhớ đệm {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Đầu vào hình ảnh: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số hình ảnh {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Đầu vào âm thanh: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số âm thanh {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Đầu ra: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số hoàn thành {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Tìm kiếm Web: {{count}} / 1K * đơn giá {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Tìm kiếm tệp: {{count}} / 1K * đơn giá {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Tạo ảnh: 1 lần gọi * đơn giá {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:{{total}}": "Tổng cộng: {{total}}",
|
||||
"模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}": "Hệ số mô hình {{modelRatio}}, hệ số hoàn thành {{completionRatio}}, hệ số âm thanh {{audioRatio}}, hệ số hoàn thành âm thanh {{audioCompletionRatio}}, {{cachePart}}{{ratioType}} {{ratio}}",
|
||||
"文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Đầu ra văn bản: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số hoàn thành {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Đầu ra âm thanh: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số âm thanh {{audioRatio}} * hệ số hoàn thành âm thanh {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}": "Tổng cộng: phần văn bản {{textTotal}} + phần âm thanh {{audioTotal}} = {{total}}",
|
||||
"模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}": "Hệ số mô hình {{modelRatio}}, hệ số đầu ra {{completionRatio}}, hệ số bộ nhớ đệm {{cacheRatio}}, {{ratioType}} {{ratio}}",
|
||||
"缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}": "Hệ số tạo bộ nhớ đệm 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}",
|
||||
"缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Đọc bộ nhớ đệm: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số bộ nhớ đệm {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Tạo bộ nhớ đệm: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số tạo bộ nhớ đệm {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}": "Tạo bộ nhớ đệm 5m: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số tạo bộ nhớ đệm 5m {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}": "Tạo bộ nhớ đệm 1h: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số tạo bộ nhớ đệm 1h {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Đầu ra: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số đầu ra {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"空": "Trống"
|
||||
}
|
||||
}
|
||||
|
||||
97
web/src/i18n/locales/zh-CN.json
vendored
97
web/src/i18n/locales/zh-CN.json
vendored
@@ -1103,6 +1103,8 @@
|
||||
"按价格设置": "按价格设置",
|
||||
"按倍率类型筛选": "按倍率类型筛选",
|
||||
"按倍率设置": "按倍率设置",
|
||||
"按次": "按次",
|
||||
"按次 {{price}} / 次": "按次 {{price}} / 次",
|
||||
"按次计费": "按次计费",
|
||||
"按照如下格式输入:AccessKey|SecretAccessKey|Region": "按照如下格式输入:AccessKey|SecretAccessKey|Region",
|
||||
"按量计费": "按量计费",
|
||||
@@ -1368,6 +1370,7 @@
|
||||
"未获取到授权码": "未获取到授权码",
|
||||
"未设置": "未设置",
|
||||
"未设置倍率模型": "未设置倍率模型",
|
||||
"未设置价格模型": "未设置价格模型",
|
||||
"未配置模型": "未配置模型",
|
||||
"未配置的模型列表": "未配置的模型列表",
|
||||
"本地": "本地",
|
||||
@@ -2813,6 +2816,98 @@
|
||||
"缓存写": "缓存写",
|
||||
"写": "写",
|
||||
"根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。": "根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。",
|
||||
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加"
|
||||
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加",
|
||||
"分组相关设置": "分组相关设置",
|
||||
"保存分组相关设置": "保存分组相关设置",
|
||||
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出",
|
||||
"没有未设置定价的模型": "没有未设置定价的模型",
|
||||
"当前没有未设置定价的模型": "当前没有未设置定价的模型",
|
||||
"模型计费编辑器": "模型计费编辑器",
|
||||
"价格摘要": "价格摘要",
|
||||
"当前提示": "当前提示",
|
||||
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。",
|
||||
"当前未启用,需要时再打开即可。": "当前未启用,需要时再打开即可。",
|
||||
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。",
|
||||
"补全价格已锁定": "补全价格已锁定",
|
||||
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。",
|
||||
"这些价格都是可选项,不填也可以。": "这些价格都是可选项,不填也可以。",
|
||||
"请先开启并填写音频输入价格。": "请先开启并填写音频输入价格。",
|
||||
"输入模型名称,例如 gpt-4.1": "输入模型名称,例如 gpt-4.1",
|
||||
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。",
|
||||
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。",
|
||||
"按量计费下需要先填写输入价格,才能保存其它价格项。": "按量计费下需要先填写输入价格,才能保存其它价格项。",
|
||||
"填写音频补全价格前,需要先填写音频输入价格。": "填写音频补全价格前,需要先填写音频输入价格。",
|
||||
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率",
|
||||
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率",
|
||||
"批量应用当前模型价格": "批量应用当前模型价格",
|
||||
"请先选择一个作为模板的模型": "请先选择一个作为模板的模型",
|
||||
"请先勾选需要批量设置的模型": "请先勾选需要批量设置的模型",
|
||||
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型",
|
||||
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。",
|
||||
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。",
|
||||
"已勾选": "已勾选",
|
||||
"当前编辑": "当前编辑",
|
||||
"已勾选 {{count}} 个模型": "已勾选 {{count}} 个模型",
|
||||
"基础价格": "基础价格",
|
||||
"扩展价格": "扩展价格",
|
||||
"额外价格项": "额外价格项",
|
||||
"补全价格": "补全价格",
|
||||
"缓存读取价格": "缓存读取价格",
|
||||
"缓存创建价格": "缓存创建价格",
|
||||
"图片输入价格": "图片输入价格",
|
||||
"音频输入价格": "音频输入价格",
|
||||
"音频补全价格": "音频补全价格",
|
||||
"适合 MJ / 任务类等按次收费模型。": "适合 MJ / 任务类等按次收费模型。",
|
||||
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。",
|
||||
"计费显示模式": "计费显示模式",
|
||||
"价格模式(默认)": "价格模式(默认)",
|
||||
"模型价格 {{symbol}}{{price}} / 次": "模型价格 {{symbol}}{{price}} / 次",
|
||||
"模型价格:{{symbol}}{{price}} / 次": "模型价格:{{symbol}}{{price}} / 次",
|
||||
"输入 {{price}} / 1M tokens": "输入 {{price}} / 1M tokens",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)",
|
||||
"缓存读取价格:{{symbol}}{{price}} / 1M tokens": "缓存读取价格:{{symbol}}{{price}} / 1M tokens",
|
||||
"缓存读取价格 {{symbol}}{{price}} / 1M tokens": "缓存读取价格 {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存读取 {{price}}": "缓存读取 {{price}}",
|
||||
"缓存创建价格:{{symbol}}{{price}} / 1M tokens": "缓存创建价格:{{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格 {{symbol}}{{price}} / 1M tokens": "缓存创建价格 {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建 {{price}}": "缓存创建 {{price}}",
|
||||
"5m缓存创建价格:{{symbol}}{{price}} / 1M tokens": "5m缓存创建价格:{{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建价格 {{symbol}}{{price}} / 1M tokens": "5m缓存创建价格 {{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建 {{price}}": "5m缓存创建 {{price}}",
|
||||
"1h缓存创建价格:{{symbol}}{{price}} / 1M tokens": "1h缓存创建价格:{{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格 {{symbol}}{{price}} / 1M tokens": "1h缓存创建价格 {{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建 {{price}}": "1h缓存创建 {{price}}",
|
||||
"图片输入价格:{{symbol}}{{price}} / 1M tokens": "图片输入价格:{{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入价格 {{symbol}}{{price}} / 1M tokens": "图片输入价格 {{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入 {{price}}": "图片输入 {{price}}",
|
||||
"输入价格 {{symbol}}{{price}} / 1M tokens": "输入价格 {{symbol}}{{price}} / 1M tokens",
|
||||
"补全价格 {{symbol}}{{price}} / 1M tokens": "补全价格 {{symbol}}{{price}} / 1M tokens",
|
||||
"音频输入价格:{{symbol}}{{price}} / 1M tokens": "音频输入价格:{{symbol}}{{price}} / 1M tokens",
|
||||
"音频补全价格:{{symbol}}{{price}} / 1M tokens": "音频补全价格:{{symbol}}{{price}} / 1M tokens",
|
||||
"Web 搜索调用 {{webSearchCallCount}} 次": "Web 搜索调用 {{webSearchCallCount}} 次",
|
||||
"文件搜索调用 {{fileSearchCallCount}} 次": "文件搜索调用 {{fileSearchCallCount}} 次",
|
||||
"图片倍率 {{imageRatio}}": "图片倍率 {{imageRatio}}",
|
||||
"音频倍率 {{audioRatio}}": "音频倍率 {{audioRatio}}",
|
||||
"普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:{{total}}": "合计:{{total}}",
|
||||
"模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}": "模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}",
|
||||
"文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}": "合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}",
|
||||
"模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}": "模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}",
|
||||
"缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}": "缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}",
|
||||
"缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}": "5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}": "1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"空": "空"
|
||||
}
|
||||
}
|
||||
|
||||
97
web/src/i18n/locales/zh-TW.json
vendored
97
web/src/i18n/locales/zh-TW.json
vendored
@@ -1106,6 +1106,8 @@
|
||||
"按价格设置": "按價格設定",
|
||||
"按倍率类型筛选": "按倍率類型篩選",
|
||||
"按倍率设置": "按倍率設定",
|
||||
"按次": "按次",
|
||||
"按次 {{price}} / 次": "按次 {{price}} / 次",
|
||||
"按次计费": "按次計費",
|
||||
"按照如下格式输入:AccessKey|SecretAccessKey|Region": "按照如下格式輸入:AccessKey|SecretAccessKey|Region",
|
||||
"按量计费": "按量計費",
|
||||
@@ -1372,6 +1374,7 @@
|
||||
"未获取到授权码": "未獲取到授權碼",
|
||||
"未设置": "未設定",
|
||||
"未设置倍率模型": "未設定倍率模型",
|
||||
"未设置价格模型": "未設定價格模型",
|
||||
"未配置模型": "未設定模型",
|
||||
"未配置的模型列表": "未設定的模型列表",
|
||||
"本地": "本地",
|
||||
@@ -2806,6 +2809,98 @@
|
||||
"自动生成:": "自動生成:",
|
||||
"请先填写服务器地址,以自动生成完整的端点 URL": "請先填寫伺服器位址,以自動生成完整的端點 URL",
|
||||
"端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "端點 URL 必須是完整位址(以 http:// 或 https:// 開頭)",
|
||||
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "未匹配到模型,按下 Enter 鍵可將「{{name}}」作為自訂模型名稱新增"
|
||||
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "未匹配到模型,按下 Enter 鍵可將「{{name}}」作為自訂模型名稱新增",
|
||||
"分组相关设置": "分組相關設定",
|
||||
"保存分组相关设置": "保存分組相關設定",
|
||||
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "此頁面僅顯示未設定價格或基礎倍率的模型,設定後會自動從列表中移出",
|
||||
"没有未设置定价的模型": "沒有未設定定價的模型",
|
||||
"当前没有未设置定价的模型": "目前沒有未設定定價的模型",
|
||||
"模型计费编辑器": "模型計費編輯器",
|
||||
"价格摘要": "價格摘要",
|
||||
"当前提示": "目前提示",
|
||||
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "這個介面預設按價格填寫,儲存時會自動換算回後端需要的倍率 JSON。",
|
||||
"当前未启用,需要时再打开即可。": "目前未啟用,需要時再開啟即可。",
|
||||
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "下方會顯示此模型儲存後將寫入哪些後端欄位,方便與原始 JSON 編輯框保持一致。",
|
||||
"补全价格已锁定": "補全價格已鎖定",
|
||||
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "後端固定倍率:{{ratio}}。此欄位僅展示換算後的價格。",
|
||||
"这些价格都是可选项,不填也可以。": "這些價格都是可選項,不填也可以。",
|
||||
"请先开启并填写音频输入价格。": "請先開啟並填寫音訊輸入價格。",
|
||||
"输入模型名称,例如 gpt-4.1": "輸入模型名稱,例如 gpt-4.1",
|
||||
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "目前模型同時存在按次價格與倍率配置,儲存時會依目前計費方式覆蓋。",
|
||||
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "目前模型存在未明確設定輸入倍率的擴展倍率;填寫輸入價格後會自動換算為價格欄位。",
|
||||
"按量计费下需要先填写输入价格,才能保存其它价格项。": "按量計費下需要先填寫輸入價格,才能儲存其它價格項。",
|
||||
"填写音频补全价格前,需要先填写音频输入价格。": "填寫音訊補全價格前,需要先填寫音訊輸入價格。",
|
||||
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "模型 {{name}} 缺少輸入價格,無法計算補全、快取、圖片與音訊價格對應的倍率",
|
||||
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "模型 {{name}} 缺少音訊輸入價格,無法計算音訊補全倍率",
|
||||
"批量应用当前模型价格": "批量套用目前模型價格",
|
||||
"请先选择一个作为模板的模型": "請先選擇一個作為範本的模型",
|
||||
"请先勾选需要批量设置的模型": "請先勾選需要批量設定的模型",
|
||||
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "已將模型 {{name}} 的價格配置批量套用到 {{count}} 個模型",
|
||||
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "會把目前編輯中的模型 {{name}} 的價格配置,批量套用到已勾選的 {{count}} 個模型。",
|
||||
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "適合同系列模型一起定價,例如把 gpt-5.1 的價格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。",
|
||||
"已勾选": "已勾選",
|
||||
"当前编辑": "目前編輯",
|
||||
"已勾选 {{count}} 个模型": "已勾選 {{count}} 個模型",
|
||||
"基础价格": "基礎價格",
|
||||
"扩展价格": "擴展價格",
|
||||
"额外价格项": "額外價格項",
|
||||
"补全价格": "補全價格",
|
||||
"缓存读取价格": "快取讀取價格",
|
||||
"缓存创建价格": "快取建立價格",
|
||||
"图片输入价格": "圖片輸入價格",
|
||||
"音频输入价格": "音訊輸入價格",
|
||||
"音频补全价格": "音訊補全價格",
|
||||
"适合 MJ / 任务类等按次收费模型。": "適合 MJ / 任務類等按次收費模型。",
|
||||
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "該模型補全倍率由後端固定為 {{ratio}}。補全價格不能在這裡修改。",
|
||||
"计费显示模式": "計費顯示模式",
|
||||
"价格模式(默认)": "價格模式(預設)",
|
||||
"模型价格 {{symbol}}{{price}} / 次": "模型價格 {{symbol}}{{price}} / 次",
|
||||
"模型价格:{{symbol}}{{price}} / 次": "模型價格:{{symbol}}{{price}} / 次",
|
||||
"输入 {{price}} / 1M tokens": "輸入 {{price}} / 1M tokens",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "實際結算金額:{{symbol}}{{total}}(已包含分組價格調整)",
|
||||
"缓存读取价格:{{symbol}}{{price}} / 1M tokens": "快取讀取價格:{{symbol}}{{price}} / 1M tokens",
|
||||
"缓存读取价格 {{symbol}}{{price}} / 1M tokens": "快取讀取價格 {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存读取 {{price}}": "快取讀取 {{price}}",
|
||||
"缓存创建价格:{{symbol}}{{price}} / 1M tokens": "快取建立價格:{{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建价格 {{symbol}}{{price}} / 1M tokens": "快取建立價格 {{symbol}}{{price}} / 1M tokens",
|
||||
"缓存创建 {{price}}": "快取建立 {{price}}",
|
||||
"5m缓存创建价格:{{symbol}}{{price}} / 1M tokens": "5m快取建立價格:{{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建价格 {{symbol}}{{price}} / 1M tokens": "5m快取建立價格 {{symbol}}{{price}} / 1M tokens",
|
||||
"5m缓存创建 {{price}}": "5m快取建立 {{price}}",
|
||||
"1h缓存创建价格:{{symbol}}{{price}} / 1M tokens": "1h快取建立價格:{{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建价格 {{symbol}}{{price}} / 1M tokens": "1h快取建立價格 {{symbol}}{{price}} / 1M tokens",
|
||||
"1h缓存创建 {{price}}": "1h快取建立 {{price}}",
|
||||
"图片输入价格:{{symbol}}{{price}} / 1M tokens": "圖片輸入價格:{{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入价格 {{symbol}}{{price}} / 1M tokens": "圖片輸入價格 {{symbol}}{{price}} / 1M tokens",
|
||||
"图片输入 {{price}}": "圖片輸入 {{price}}",
|
||||
"输入价格 {{symbol}}{{price}} / 1M tokens": "輸入價格 {{symbol}}{{price}} / 1M tokens",
|
||||
"补全价格 {{symbol}}{{price}} / 1M tokens": "補全價格 {{symbol}}{{price}} / 1M tokens",
|
||||
"音频输入价格:{{symbol}}{{price}} / 1M tokens": "音訊輸入價格:{{symbol}}{{price}} / 1M tokens",
|
||||
"音频补全价格:{{symbol}}{{price}} / 1M tokens": "音訊補全價格:{{symbol}}{{price}} / 1M tokens",
|
||||
"Web 搜索调用 {{webSearchCallCount}} 次": "Web 搜尋呼叫 {{webSearchCallCount}} 次",
|
||||
"文件搜索调用 {{fileSearchCallCount}} 次": "檔案搜尋呼叫 {{fileSearchCallCount}} 次",
|
||||
"图片倍率 {{imageRatio}}": "圖片倍率 {{imageRatio}}",
|
||||
"音频倍率 {{audioRatio}}": "音訊倍率 {{audioRatio}}",
|
||||
"普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "普通輸入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "快取輸入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 快取倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "圖片輸入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 圖片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音訊輸入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音訊倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "輸出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 補全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Web 搜尋:{{count}} / 1K * 單價 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "檔案搜尋:{{count}} / 1K * 單價 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "圖片生成:1 次 * 單價 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:{{total}}": "合計:{{total}}",
|
||||
"模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}": "模型倍率 {{modelRatio}},補全倍率 {{completionRatio}},音訊倍率 {{audioRatio}},音訊補全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}",
|
||||
"文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "文字輸出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 補全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音訊輸出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音訊倍率 {{audioRatio}} * 音訊補全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}": "合計:文字部分 {{textTotal}} + 音訊部分 {{audioTotal}} = {{total}}",
|
||||
"模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}": "模型倍率 {{modelRatio}},輸出倍率 {{completionRatio}},快取倍率 {{cacheRatio}},{{ratioType}} {{ratio}}",
|
||||
"缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}": "快取建立倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}",
|
||||
"缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "快取讀取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 快取倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "快取建立:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 快取建立倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}": "5m快取建立:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m快取建立倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}": "1h快取建立:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h快取建立倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "輸出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 輸出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"空": "空"
|
||||
}
|
||||
}
|
||||
|
||||
3083
web/src/i18n/locales/zh.json
vendored
3083
web/src/i18n/locales/zh.json
vendored
File diff suppressed because it is too large
Load Diff
@@ -265,7 +265,7 @@ export default function GroupRatioSettings(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
<Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>
|
||||
<Button onClick={onSubmit}>{t('保存分组相关设置')}</Button>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,47 +18,13 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Form,
|
||||
Space,
|
||||
Typography,
|
||||
Radio,
|
||||
Notification,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDelete,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSave,
|
||||
IconBolt,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { API, showError } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ModelPricingEditor from './components/ModelPricingEditor';
|
||||
|
||||
export default function ModelRatioNotSetEditor(props) {
|
||||
const { t } = useTranslation();
|
||||
const [models, setModels] = useState([]);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [batchVisible, setBatchVisible] = useState(false);
|
||||
const [currentModel, setCurrentModel] = useState(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [enabledModels, setEnabledModels] = useState([]);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [batchFillType, setBatchFillType] = useState('ratio');
|
||||
const [batchFillValue, setBatchFillValue] = useState('');
|
||||
const [batchRatioValue, setBatchRatioValue] = useState('');
|
||||
const [batchCompletionRatioValue, setBatchCompletionRatioValue] =
|
||||
useState('');
|
||||
const { Text } = Typography;
|
||||
// 定义可选的每页显示条数
|
||||
const pageSizeOptions = [10, 20, 50, 100];
|
||||
|
||||
const getAllEnabledModels = async () => {
|
||||
try {
|
||||
@@ -79,540 +45,20 @@ export default function ModelRatioNotSetEditor(props) {
|
||||
// 获取所有启用的模型
|
||||
getAllEnabledModels();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const modelPrice = JSON.parse(props.options.ModelPrice || '{}');
|
||||
const modelRatio = JSON.parse(props.options.ModelRatio || '{}');
|
||||
const completionRatio = JSON.parse(props.options.CompletionRatio || '{}');
|
||||
|
||||
// 找出所有未设置价格和倍率的模型
|
||||
const unsetModels = enabledModels.filter((modelName) => {
|
||||
const hasPrice = modelPrice[modelName] !== undefined;
|
||||
const hasRatio = modelRatio[modelName] !== undefined;
|
||||
|
||||
// 如果模型没有价格或者没有倍率设置,则显示
|
||||
return !hasPrice && !hasRatio;
|
||||
});
|
||||
|
||||
// 创建模型数据
|
||||
const modelData = unsetModels.map((name) => ({
|
||||
name,
|
||||
price: modelPrice[name] || '',
|
||||
ratio: modelRatio[name] || '',
|
||||
completionRatio: completionRatio[name] || '',
|
||||
}));
|
||||
|
||||
setModels(modelData);
|
||||
// 清空选择
|
||||
setSelectedRowKeys([]);
|
||||
} catch (error) {
|
||||
console.error(t('JSON解析错误:'), error);
|
||||
}
|
||||
}, [props.options, enabledModels]);
|
||||
|
||||
// 首先声明分页相关的工具函数
|
||||
const getPagedData = (data, currentPage, pageSize) => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
return data.slice(start, end);
|
||||
};
|
||||
|
||||
// 处理页面大小变化
|
||||
const handlePageSizeChange = (size) => {
|
||||
setPageSize(size);
|
||||
// 重新计算当前页,避免数据丢失
|
||||
const totalPages = Math.ceil(filteredModels.length / size);
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages || 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 在 return 语句之前,先处理过滤和分页逻辑
|
||||
const filteredModels = models.filter((model) =>
|
||||
searchText ? model.name.includes(searchText) : true,
|
||||
);
|
||||
|
||||
// 然后基于过滤后的数据计算分页数据
|
||||
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
|
||||
|
||||
const SubmitData = async () => {
|
||||
setLoading(true);
|
||||
const output = {
|
||||
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
|
||||
ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
|
||||
CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
|
||||
};
|
||||
|
||||
try {
|
||||
// 数据转换 - 只处理已修改的模型
|
||||
models.forEach((model) => {
|
||||
// 只有当用户设置了值时才更新
|
||||
if (model.price !== '') {
|
||||
// 如果价格不为空,则转换为浮点数,忽略倍率参数
|
||||
output.ModelPrice[model.name] = parseFloat(model.price);
|
||||
} else {
|
||||
if (model.ratio !== '')
|
||||
output.ModelRatio[model.name] = parseFloat(model.ratio);
|
||||
if (model.completionRatio !== '')
|
||||
output.CompletionRatio[model.name] = parseFloat(
|
||||
model.completionRatio,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 准备API请求数组
|
||||
const finalOutput = {
|
||||
ModelPrice: JSON.stringify(output.ModelPrice, null, 2),
|
||||
ModelRatio: JSON.stringify(output.ModelRatio, null, 2),
|
||||
CompletionRatio: JSON.stringify(output.CompletionRatio, null, 2),
|
||||
};
|
||||
|
||||
const requestQueue = Object.entries(finalOutput).map(([key, value]) => {
|
||||
return API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
// 批量处理请求
|
||||
const results = await Promise.all(requestQueue);
|
||||
|
||||
// 验证结果
|
||||
if (requestQueue.length === 1) {
|
||||
if (results.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (results.includes(undefined)) {
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
}
|
||||
|
||||
// 检查每个请求的结果
|
||||
for (const res of results) {
|
||||
if (!res.data.success) {
|
||||
return showError(res.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
// 重新获取未设置的模型
|
||||
getAllEnabledModels();
|
||||
} catch (error) {
|
||||
console.error(t('保存失败:'), error);
|
||||
showError(t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: t('模型固定价格'),
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={t('按量计费')}
|
||||
onChange={(value) => updateModel(record.name, 'price', value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('模型倍率'),
|
||||
dataIndex: 'ratio',
|
||||
key: 'ratio',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={record.price !== '' ? t('模型倍率') : t('输入模型倍率')}
|
||||
disabled={record.price !== ''}
|
||||
onChange={(value) => updateModel(record.name, 'ratio', value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('补全倍率'),
|
||||
dataIndex: 'completionRatio',
|
||||
key: 'completionRatio',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={record.price !== '' ? t('补全倍率') : t('输入补全倍率')}
|
||||
disabled={record.price !== ''}
|
||||
onChange={(value) =>
|
||||
updateModel(record.name, 'completionRatio', value)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const updateModel = (name, field, value) => {
|
||||
if (value !== '' && isNaN(value)) {
|
||||
showError(t('请输入数字'));
|
||||
return;
|
||||
}
|
||||
setModels((prev) =>
|
||||
prev.map((model) =>
|
||||
model.name === name ? { ...model, [field]: value } : model,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const addModel = (values) => {
|
||||
// 检查模型名称是否存在, 如果存在则拒绝添加
|
||||
if (models.some((model) => model.name === values.name)) {
|
||||
showError(t('模型名称已存在'));
|
||||
return;
|
||||
}
|
||||
setModels((prev) => [
|
||||
{
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
setVisible(false);
|
||||
showSuccess(t('添加成功'));
|
||||
};
|
||||
|
||||
// 批量填充功能
|
||||
const handleBatchFill = () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
showError(t('请先选择需要批量设置的模型'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchFillType === 'bothRatio') {
|
||||
if (batchRatioValue === '' || batchCompletionRatioValue === '') {
|
||||
showError(t('请输入模型倍率和补全倍率'));
|
||||
return;
|
||||
}
|
||||
if (isNaN(batchRatioValue) || isNaN(batchCompletionRatioValue)) {
|
||||
showError(t('请输入有效的数字'));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (batchFillValue === '') {
|
||||
showError(t('请输入填充值'));
|
||||
return;
|
||||
}
|
||||
if (isNaN(batchFillValue)) {
|
||||
showError(t('请输入有效的数字'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据选择的类型批量更新模型
|
||||
setModels((prev) =>
|
||||
prev.map((model) => {
|
||||
if (selectedRowKeys.includes(model.name)) {
|
||||
if (batchFillType === 'price') {
|
||||
return {
|
||||
...model,
|
||||
price: batchFillValue,
|
||||
ratio: '',
|
||||
completionRatio: '',
|
||||
};
|
||||
} else if (batchFillType === 'ratio') {
|
||||
return {
|
||||
...model,
|
||||
price: '',
|
||||
ratio: batchFillValue,
|
||||
};
|
||||
} else if (batchFillType === 'completionRatio') {
|
||||
return {
|
||||
...model,
|
||||
price: '',
|
||||
completionRatio: batchFillValue,
|
||||
};
|
||||
} else if (batchFillType === 'bothRatio') {
|
||||
return {
|
||||
...model,
|
||||
price: '',
|
||||
ratio: batchRatioValue,
|
||||
completionRatio: batchCompletionRatioValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
return model;
|
||||
}),
|
||||
);
|
||||
|
||||
setBatchVisible(false);
|
||||
Notification.success({
|
||||
title: t('批量设置成功'),
|
||||
content: t('已为 {{count}} 个模型设置{{type}}', {
|
||||
count: selectedRowKeys.length,
|
||||
type:
|
||||
batchFillType === 'price'
|
||||
? t('固定价格')
|
||||
: batchFillType === 'ratio'
|
||||
? t('模型倍率')
|
||||
: batchFillType === 'completionRatio'
|
||||
? t('补全倍率')
|
||||
: t('模型倍率和补全倍率'),
|
||||
}),
|
||||
duration: 3,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchTypeChange = (value) => {
|
||||
console.log(t('Changing batch type to:'), value);
|
||||
setBatchFillType(value);
|
||||
|
||||
// 切换类型时清空对应的值
|
||||
if (value !== 'bothRatio') {
|
||||
setBatchFillValue('');
|
||||
} else {
|
||||
setBatchRatioValue('');
|
||||
setBatchCompletionRatioValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedKeys) => {
|
||||
setSelectedRowKeys(selectedKeys);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space className='mt-2'>
|
||||
<Button icon={<IconPlus />} onClick={() => setVisible(true)}>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<IconBolt />}
|
||||
type='secondary'
|
||||
onClick={() => setBatchVisible(true)}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
{t('批量设置')} ({selectedRowKeys.length})
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconSave />}
|
||||
onClick={SubmitData}
|
||||
loading={loading}
|
||||
>
|
||||
{t('应用更改')}
|
||||
</Button>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
value={searchText}
|
||||
onChange={(value) => {
|
||||
setSearchText(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Text>
|
||||
{t('此页面仅显示未设置价格或倍率的模型,设置后将自动从列表中移除')}
|
||||
</Text>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pagedData}
|
||||
rowSelection={rowSelection}
|
||||
rowKey='name'
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: filteredModels.length,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
pageSizeOptions: pageSizeOptions,
|
||||
showTotal: true,
|
||||
showSizeChanger: true,
|
||||
}}
|
||||
empty={
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
{t('没有未设置的模型')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{/* 添加模型弹窗 */}
|
||||
<Modal
|
||||
title={t('添加模型')}
|
||||
visible={visible}
|
||||
onCancel={() => setVisible(false)}
|
||||
onOk={() => {
|
||||
currentModel && addModel(currentModel);
|
||||
}}
|
||||
>
|
||||
<Form>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('模型名称')}
|
||||
placeholder='strawberry'
|
||||
required
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({ ...prev, name: value }))
|
||||
}
|
||||
/>
|
||||
<Form.Switch
|
||||
field='priceMode'
|
||||
label={
|
||||
<>
|
||||
{t('定价模式')}:
|
||||
{currentModel?.priceMode ? t('固定价格') : t('倍率模式')}
|
||||
</>
|
||||
}
|
||||
onChange={(checked) => {
|
||||
setCurrentModel((prev) => ({
|
||||
...prev,
|
||||
price: '',
|
||||
ratio: '',
|
||||
completionRatio: '',
|
||||
priceMode: checked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{currentModel?.priceMode ? (
|
||||
<Form.Input
|
||||
field='price'
|
||||
label={t('固定价格(每次)')}
|
||||
placeholder={t('输入每次价格')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({ ...prev, price: value }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Form.Input
|
||||
field='ratio'
|
||||
label={t('模型倍率')}
|
||||
placeholder={t('输入模型倍率')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({ ...prev, ratio: value }))
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field='completionRatio'
|
||||
label={t('补全倍率')}
|
||||
placeholder={t('输入补全价格')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({
|
||||
...prev,
|
||||
completionRatio: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 批量设置弹窗 */}
|
||||
<Modal
|
||||
title={t('批量设置模型参数')}
|
||||
visible={batchVisible}
|
||||
onCancel={() => setBatchVisible(false)}
|
||||
onOk={handleBatchFill}
|
||||
width={500}
|
||||
>
|
||||
<Form>
|
||||
<Form.Section text={t('设置类型')}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Space>
|
||||
<Radio
|
||||
checked={batchFillType === 'price'}
|
||||
onChange={() => handleBatchTypeChange('price')}
|
||||
>
|
||||
{t('固定价格')}
|
||||
</Radio>
|
||||
<Radio
|
||||
checked={batchFillType === 'ratio'}
|
||||
onChange={() => handleBatchTypeChange('ratio')}
|
||||
>
|
||||
{t('模型倍率')}
|
||||
</Radio>
|
||||
<Radio
|
||||
checked={batchFillType === 'completionRatio'}
|
||||
onChange={() => handleBatchTypeChange('completionRatio')}
|
||||
>
|
||||
{t('补全倍率')}
|
||||
</Radio>
|
||||
<Radio
|
||||
checked={batchFillType === 'bothRatio'}
|
||||
onChange={() => handleBatchTypeChange('bothRatio')}
|
||||
>
|
||||
{t('模型倍率和补全倍率同时设置')}
|
||||
</Radio>
|
||||
</Space>
|
||||
</div>
|
||||
</Form.Section>
|
||||
|
||||
{batchFillType === 'bothRatio' ? (
|
||||
<>
|
||||
<Form.Input
|
||||
field='batchRatioValue'
|
||||
label={t('模型倍率值')}
|
||||
placeholder={t('请输入模型倍率')}
|
||||
value={batchRatioValue}
|
||||
onChange={(value) => setBatchRatioValue(value)}
|
||||
/>
|
||||
<Form.Input
|
||||
field='batchCompletionRatioValue'
|
||||
label={t('补全倍率值')}
|
||||
placeholder={t('请输入补全倍率')}
|
||||
value={batchCompletionRatioValue}
|
||||
onChange={(value) => setBatchCompletionRatioValue(value)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Form.Input
|
||||
field='batchFillValue'
|
||||
label={
|
||||
batchFillType === 'price'
|
||||
? t('固定价格值')
|
||||
: batchFillType === 'ratio'
|
||||
? t('模型倍率值')
|
||||
: t('补全倍率值')
|
||||
}
|
||||
placeholder={t('请输入数值')}
|
||||
value={batchFillValue}
|
||||
onChange={(value) => setBatchFillValue(value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text type='tertiary'>
|
||||
{t('将为选中的 ')} <Text strong>{selectedRowKeys.length}</Text>{' '}
|
||||
{t(' 个模型设置相同的值')}
|
||||
</Text>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type='tertiary'>
|
||||
{t('当前设置类型: ')}{' '}
|
||||
<Text strong>
|
||||
{batchFillType === 'price'
|
||||
? t('固定价格')
|
||||
: batchFillType === 'ratio'
|
||||
? t('模型倍率')
|
||||
: batchFillType === 'completionRatio'
|
||||
? t('补全倍率')
|
||||
: t('模型倍率和补全倍率')}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
<ModelPricingEditor
|
||||
options={props.options}
|
||||
refresh={props.refresh}
|
||||
candidateModelNames={enabledModels}
|
||||
filterMode='unset'
|
||||
allowAddModel={false}
|
||||
allowDeleteModel={false}
|
||||
showConflictFilter={false}
|
||||
listDescription={t(
|
||||
'此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出',
|
||||
)}
|
||||
emptyTitle={t('没有未设置定价的模型')}
|
||||
emptyDescription={t('当前没有未设置定价的模型')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,741 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Form,
|
||||
Space,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Checkbox,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDelete,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSave,
|
||||
IconEdit,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess, getQuotaPerUnit } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React from 'react';
|
||||
import ModelPricingEditor from './components/ModelPricingEditor';
|
||||
|
||||
export default function ModelSettingsVisualEditor(props) {
|
||||
const { t } = useTranslation();
|
||||
const [models, setModels] = useState([]);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [currentModel, setCurrentModel] = useState(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pricingMode, setPricingMode] = useState('per-token'); // 'per-token' or 'per-request'
|
||||
const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' or 'token-price'
|
||||
const [conflictOnly, setConflictOnly] = useState(false);
|
||||
const formRef = useRef(null);
|
||||
const pageSize = 10;
|
||||
const quotaPerUnit = getQuotaPerUnit();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const modelPrice = JSON.parse(props.options.ModelPrice || '{}');
|
||||
const modelRatio = JSON.parse(props.options.ModelRatio || '{}');
|
||||
const completionRatio = JSON.parse(props.options.CompletionRatio || '{}');
|
||||
|
||||
// 合并所有模型名称
|
||||
const modelNames = new Set([
|
||||
...Object.keys(modelPrice),
|
||||
...Object.keys(modelRatio),
|
||||
...Object.keys(completionRatio),
|
||||
]);
|
||||
|
||||
const modelData = Array.from(modelNames).map((name) => {
|
||||
const price = modelPrice[name] === undefined ? '' : modelPrice[name];
|
||||
const ratio = modelRatio[name] === undefined ? '' : modelRatio[name];
|
||||
const comp =
|
||||
completionRatio[name] === undefined ? '' : completionRatio[name];
|
||||
|
||||
return {
|
||||
name,
|
||||
price,
|
||||
ratio,
|
||||
completionRatio: comp,
|
||||
hasConflict: price !== '' && (ratio !== '' || comp !== ''),
|
||||
};
|
||||
});
|
||||
|
||||
setModels(modelData);
|
||||
} catch (error) {
|
||||
console.error('JSON解析错误:', error);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
// 首先声明分页相关的工具函数
|
||||
const getPagedData = (data, currentPage, pageSize) => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
return data.slice(start, end);
|
||||
};
|
||||
|
||||
// 在 return 语句之前,先处理过滤和分页逻辑
|
||||
const filteredModels = models.filter((model) => {
|
||||
const keywordMatch = searchText ? model.name.includes(searchText) : true;
|
||||
const conflictMatch = conflictOnly ? model.hasConflict : true;
|
||||
return keywordMatch && conflictMatch;
|
||||
});
|
||||
|
||||
// 然后基于过滤后的数据计算分页数据
|
||||
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
|
||||
|
||||
const SubmitData = async () => {
|
||||
setLoading(true);
|
||||
const output = {
|
||||
ModelPrice: {},
|
||||
ModelRatio: {},
|
||||
CompletionRatio: {},
|
||||
};
|
||||
let currentConvertModelName = '';
|
||||
|
||||
try {
|
||||
// 数据转换
|
||||
models.forEach((model) => {
|
||||
currentConvertModelName = model.name;
|
||||
if (model.price !== '') {
|
||||
// 如果价格不为空,则转换为浮点数,忽略倍率参数
|
||||
output.ModelPrice[model.name] = parseFloat(model.price);
|
||||
} else {
|
||||
if (model.ratio !== '')
|
||||
output.ModelRatio[model.name] = parseFloat(model.ratio);
|
||||
if (model.completionRatio !== '')
|
||||
output.CompletionRatio[model.name] = parseFloat(
|
||||
model.completionRatio,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 准备API请求数组
|
||||
const finalOutput = {
|
||||
ModelPrice: JSON.stringify(output.ModelPrice, null, 2),
|
||||
ModelRatio: JSON.stringify(output.ModelRatio, null, 2),
|
||||
CompletionRatio: JSON.stringify(output.CompletionRatio, null, 2),
|
||||
};
|
||||
|
||||
const requestQueue = Object.entries(finalOutput).map(([key, value]) => {
|
||||
return API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
// 批量处理请求
|
||||
const results = await Promise.all(requestQueue);
|
||||
|
||||
// 验证结果
|
||||
if (requestQueue.length === 1) {
|
||||
if (results.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (results.includes(undefined)) {
|
||||
return showError('部分保存失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 检查每个请求的结果
|
||||
for (const res of results) {
|
||||
if (!res.data.success) {
|
||||
return showError(res.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess('保存成功');
|
||||
props.refresh();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
showError('保存失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<span>
|
||||
{text}
|
||||
{record.hasConflict && (
|
||||
<Tag color='red' shape='circle' className='ml-2'>
|
||||
{t('矛盾')}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('模型固定价格'),
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={t('按量计费')}
|
||||
onChange={(value) => updateModel(record.name, 'price', value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('模型倍率'),
|
||||
dataIndex: 'ratio',
|
||||
key: 'ratio',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={record.price !== '' ? t('模型倍率') : t('默认补全倍率')}
|
||||
disabled={record.price !== ''}
|
||||
onChange={(value) => updateModel(record.name, 'ratio', value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('补全倍率'),
|
||||
dataIndex: 'completionRatio',
|
||||
key: 'completionRatio',
|
||||
render: (text, record) => (
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={record.price !== '' ? t('补全倍率') : t('默认补全倍率')}
|
||||
disabled={record.price !== ''}
|
||||
onChange={(value) =>
|
||||
updateModel(record.name, 'completionRatio', value)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconEdit />}
|
||||
onClick={() => editModel(record)}
|
||||
></Button>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
onClick={() => deleteModel(record.name)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const updateModel = (name, field, value) => {
|
||||
if (isNaN(value)) {
|
||||
showError('请输入数字');
|
||||
return;
|
||||
}
|
||||
setModels((prev) =>
|
||||
prev.map((model) => {
|
||||
if (model.name !== name) return model;
|
||||
const updated = { ...model, [field]: value };
|
||||
updated.hasConflict =
|
||||
updated.price !== '' &&
|
||||
(updated.ratio !== '' || updated.completionRatio !== '');
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteModel = (name) => {
|
||||
setModels((prev) => prev.filter((model) => model.name !== name));
|
||||
};
|
||||
|
||||
const calculateRatioFromTokenPrice = (tokenPrice) => {
|
||||
return tokenPrice / 2;
|
||||
};
|
||||
|
||||
const calculateCompletionRatioFromPrices = (
|
||||
modelTokenPrice,
|
||||
completionTokenPrice,
|
||||
) => {
|
||||
if (!modelTokenPrice || modelTokenPrice === '0') {
|
||||
showError('模型价格不能为0');
|
||||
return '';
|
||||
}
|
||||
return completionTokenPrice / modelTokenPrice;
|
||||
};
|
||||
|
||||
const handleTokenPriceChange = (value) => {
|
||||
// Use a temporary variable to hold the new state
|
||||
let newState = {
|
||||
...(currentModel || {}),
|
||||
tokenPrice: value,
|
||||
ratio: 0,
|
||||
};
|
||||
|
||||
if (!isNaN(value) && value !== '') {
|
||||
const tokenPrice = parseFloat(value);
|
||||
const ratio = calculateRatioFromTokenPrice(tokenPrice);
|
||||
newState.ratio = ratio;
|
||||
}
|
||||
|
||||
// Set the state with the complete updated object
|
||||
setCurrentModel(newState);
|
||||
};
|
||||
|
||||
const handleCompletionTokenPriceChange = (value) => {
|
||||
// Use a temporary variable to hold the new state
|
||||
let newState = {
|
||||
...(currentModel || {}),
|
||||
completionTokenPrice: value,
|
||||
completionRatio: 0,
|
||||
};
|
||||
|
||||
if (!isNaN(value) && value !== '' && currentModel?.tokenPrice) {
|
||||
const completionTokenPrice = parseFloat(value);
|
||||
const modelTokenPrice = parseFloat(currentModel.tokenPrice);
|
||||
|
||||
if (modelTokenPrice > 0) {
|
||||
const completionRatio = calculateCompletionRatioFromPrices(
|
||||
modelTokenPrice,
|
||||
completionTokenPrice,
|
||||
);
|
||||
newState.completionRatio = completionRatio;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the state with the complete updated object
|
||||
setCurrentModel(newState);
|
||||
};
|
||||
|
||||
const addOrUpdateModel = (values) => {
|
||||
// Check if we're editing an existing model or adding a new one
|
||||
const existingModelIndex = models.findIndex(
|
||||
(model) => model.name === values.name,
|
||||
);
|
||||
|
||||
if (existingModelIndex >= 0) {
|
||||
// Update existing model
|
||||
setModels((prev) =>
|
||||
prev.map((model, index) => {
|
||||
if (index !== existingModelIndex) return model;
|
||||
const updated = {
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
};
|
||||
updated.hasConflict =
|
||||
updated.price !== '' &&
|
||||
(updated.ratio !== '' || updated.completionRatio !== '');
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
setVisible(false);
|
||||
showSuccess(t('更新成功'));
|
||||
} else {
|
||||
// Add new model
|
||||
// Check if model name already exists
|
||||
if (models.some((model) => model.name === values.name)) {
|
||||
showError(t('模型名称已存在'));
|
||||
return;
|
||||
}
|
||||
|
||||
setModels((prev) => {
|
||||
const newModel = {
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
};
|
||||
newModel.hasConflict =
|
||||
newModel.price !== '' &&
|
||||
(newModel.ratio !== '' || newModel.completionRatio !== '');
|
||||
return [newModel, ...prev];
|
||||
});
|
||||
setVisible(false);
|
||||
showSuccess(t('添加成功'));
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTokenPriceFromRatio = (ratio) => {
|
||||
return ratio * 2;
|
||||
};
|
||||
|
||||
const resetModalState = () => {
|
||||
setCurrentModel(null);
|
||||
setPricingMode('per-token');
|
||||
setPricingSubMode('ratio');
|
||||
setIsEditMode(false);
|
||||
};
|
||||
|
||||
const editModel = (record) => {
|
||||
setIsEditMode(true);
|
||||
// Determine which pricing mode to use based on the model's current configuration
|
||||
let initialPricingMode = 'per-token';
|
||||
let initialPricingSubMode = 'ratio';
|
||||
|
||||
if (record.price !== '') {
|
||||
initialPricingMode = 'per-request';
|
||||
} else {
|
||||
initialPricingMode = 'per-token';
|
||||
// We default to ratio mode, but could set to token-price if needed
|
||||
}
|
||||
|
||||
// Set the pricing modes for the form
|
||||
setPricingMode(initialPricingMode);
|
||||
setPricingSubMode(initialPricingSubMode);
|
||||
|
||||
// Create a copy of the model data to avoid modifying the original
|
||||
const modelCopy = { ...record };
|
||||
|
||||
// If the model has ratio data and we want to populate token price fields
|
||||
if (record.ratio) {
|
||||
modelCopy.tokenPrice = calculateTokenPriceFromRatio(
|
||||
parseFloat(record.ratio),
|
||||
).toString();
|
||||
|
||||
if (record.completionRatio) {
|
||||
modelCopy.completionTokenPrice = (
|
||||
parseFloat(modelCopy.tokenPrice) * parseFloat(record.completionRatio)
|
||||
).toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Set the current model
|
||||
setCurrentModel(modelCopy);
|
||||
|
||||
// Open the modal
|
||||
setVisible(true);
|
||||
|
||||
// Use setTimeout to ensure the form is rendered before setting values
|
||||
setTimeout(() => {
|
||||
if (formRef.current) {
|
||||
// Update the form fields based on pricing mode
|
||||
const formValues = {
|
||||
name: modelCopy.name,
|
||||
};
|
||||
|
||||
if (initialPricingMode === 'per-request') {
|
||||
formValues.priceInput = modelCopy.price;
|
||||
} else if (initialPricingMode === 'per-token') {
|
||||
formValues.ratioInput = modelCopy.ratio;
|
||||
formValues.completionRatioInput = modelCopy.completionRatio;
|
||||
formValues.modelTokenPrice = modelCopy.tokenPrice;
|
||||
formValues.completionTokenPrice = modelCopy.completionTokenPrice;
|
||||
}
|
||||
|
||||
formRef.current.setValues(formValues);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space className='mt-2'>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={() => {
|
||||
resetModalState();
|
||||
setVisible(true);
|
||||
}}
|
||||
>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
<Button type='primary' icon={<IconSave />} onClick={SubmitData}>
|
||||
{t('应用更改')}
|
||||
</Button>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
value={searchText}
|
||||
onChange={(value) => {
|
||||
setSearchText(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
showClear
|
||||
/>
|
||||
<Checkbox
|
||||
checked={conflictOnly}
|
||||
onChange={(e) => {
|
||||
setConflictOnly(e.target.checked);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
{t('仅显示矛盾倍率')}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pagedData}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: filteredModels.length,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
showTotal: true,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={isEditMode ? t('编辑模型') : t('添加模型')}
|
||||
visible={visible}
|
||||
onCancel={() => {
|
||||
resetModalState();
|
||||
setVisible(false);
|
||||
}}
|
||||
onOk={() => {
|
||||
if (currentModel) {
|
||||
// If we're in token price mode, make sure ratio values are properly set
|
||||
const valuesToSave = { ...currentModel };
|
||||
|
||||
if (
|
||||
pricingMode === 'per-token' &&
|
||||
pricingSubMode === 'token-price' &&
|
||||
currentModel.tokenPrice
|
||||
) {
|
||||
// Calculate and set ratio from token price
|
||||
const tokenPrice = parseFloat(currentModel.tokenPrice);
|
||||
valuesToSave.ratio = (tokenPrice / 2).toString();
|
||||
|
||||
// Calculate and set completion ratio if both token prices are available
|
||||
if (
|
||||
currentModel.completionTokenPrice &&
|
||||
currentModel.tokenPrice
|
||||
) {
|
||||
const completionPrice = parseFloat(
|
||||
currentModel.completionTokenPrice,
|
||||
);
|
||||
const modelPrice = parseFloat(currentModel.tokenPrice);
|
||||
if (modelPrice > 0) {
|
||||
valuesToSave.completionRatio = (
|
||||
completionPrice / modelPrice
|
||||
).toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear price if we're in per-token mode
|
||||
if (pricingMode === 'per-token') {
|
||||
valuesToSave.price = '';
|
||||
} else {
|
||||
// Clear ratios if we're in per-request mode
|
||||
valuesToSave.ratio = '';
|
||||
valuesToSave.completionRatio = '';
|
||||
}
|
||||
|
||||
addOrUpdateModel(valuesToSave);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form getFormApi={(api) => (formRef.current = api)}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('模型名称')}
|
||||
placeholder='strawberry'
|
||||
required
|
||||
disabled={isEditMode}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({ ...prev, name: value }))
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Section text={t('定价模式')}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={pricingMode}
|
||||
onChange={(e) => {
|
||||
const newMode = e.target.value;
|
||||
const oldMode = pricingMode;
|
||||
setPricingMode(newMode);
|
||||
|
||||
// Instead of resetting all values, convert between modes
|
||||
if (currentModel) {
|
||||
const updatedModel = { ...currentModel };
|
||||
|
||||
// Update formRef with converted values
|
||||
if (formRef.current) {
|
||||
const formValues = {
|
||||
name: updatedModel.name,
|
||||
};
|
||||
|
||||
if (newMode === 'per-request') {
|
||||
formValues.priceInput = updatedModel.price || '';
|
||||
} else if (newMode === 'per-token') {
|
||||
formValues.ratioInput = updatedModel.ratio || '';
|
||||
formValues.completionRatioInput =
|
||||
updatedModel.completionRatio || '';
|
||||
formValues.modelTokenPrice =
|
||||
updatedModel.tokenPrice || '';
|
||||
formValues.completionTokenPrice =
|
||||
updatedModel.completionTokenPrice || '';
|
||||
}
|
||||
|
||||
formRef.current.setValues(formValues);
|
||||
}
|
||||
|
||||
// Update the model state
|
||||
setCurrentModel(updatedModel);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Radio value='per-token'>{t('按量计费')}</Radio>
|
||||
<Radio value='per-request'>{t('按次计费')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</Form.Section>
|
||||
|
||||
{pricingMode === 'per-token' && (
|
||||
<>
|
||||
<Form.Section text={t('价格设置方式')}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={pricingSubMode}
|
||||
onChange={(e) => {
|
||||
const newSubMode = e.target.value;
|
||||
const oldSubMode = pricingSubMode;
|
||||
setPricingSubMode(newSubMode);
|
||||
|
||||
// Handle conversion between submodes
|
||||
if (currentModel) {
|
||||
const updatedModel = { ...currentModel };
|
||||
|
||||
// Convert between ratio and token price
|
||||
if (
|
||||
oldSubMode === 'ratio' &&
|
||||
newSubMode === 'token-price'
|
||||
) {
|
||||
if (updatedModel.ratio) {
|
||||
updatedModel.tokenPrice =
|
||||
calculateTokenPriceFromRatio(
|
||||
parseFloat(updatedModel.ratio),
|
||||
).toString();
|
||||
|
||||
if (updatedModel.completionRatio) {
|
||||
updatedModel.completionTokenPrice = (
|
||||
parseFloat(updatedModel.tokenPrice) *
|
||||
parseFloat(updatedModel.completionRatio)
|
||||
).toString();
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
oldSubMode === 'token-price' &&
|
||||
newSubMode === 'ratio'
|
||||
) {
|
||||
// Ratio values should already be calculated by the handlers
|
||||
}
|
||||
|
||||
// Update the form values
|
||||
if (formRef.current) {
|
||||
const formValues = {};
|
||||
|
||||
if (newSubMode === 'ratio') {
|
||||
formValues.ratioInput = updatedModel.ratio || '';
|
||||
formValues.completionRatioInput =
|
||||
updatedModel.completionRatio || '';
|
||||
} else if (newSubMode === 'token-price') {
|
||||
formValues.modelTokenPrice =
|
||||
updatedModel.tokenPrice || '';
|
||||
formValues.completionTokenPrice =
|
||||
updatedModel.completionTokenPrice || '';
|
||||
}
|
||||
|
||||
formRef.current.setValues(formValues);
|
||||
}
|
||||
|
||||
setCurrentModel(updatedModel);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Radio value='ratio'>{t('按倍率设置')}</Radio>
|
||||
<Radio value='token-price'>{t('按价格设置')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</Form.Section>
|
||||
|
||||
{pricingSubMode === 'ratio' && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='ratioInput'
|
||||
label={t('模型倍率')}
|
||||
placeholder={t('输入模型倍率')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({
|
||||
...(prev || {}),
|
||||
ratio: value,
|
||||
}))
|
||||
}
|
||||
initValue={currentModel?.ratio || ''}
|
||||
/>
|
||||
<Form.Input
|
||||
field='completionRatioInput'
|
||||
label={t('补全倍率')}
|
||||
placeholder={t('输入补全倍率')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({
|
||||
...(prev || {}),
|
||||
completionRatio: value,
|
||||
}))
|
||||
}
|
||||
initValue={currentModel?.completionRatio || ''}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pricingSubMode === 'token-price' && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='modelTokenPrice'
|
||||
label={t('输入价格')}
|
||||
onChange={(value) => {
|
||||
handleTokenPriceChange(value);
|
||||
}}
|
||||
initValue={currentModel?.tokenPrice || ''}
|
||||
suffix={t('$/1M tokens')}
|
||||
/>
|
||||
<Form.Input
|
||||
field='completionTokenPrice'
|
||||
label={t('输出价格')}
|
||||
onChange={(value) => {
|
||||
handleCompletionTokenPriceChange(value);
|
||||
}}
|
||||
initValue={currentModel?.completionTokenPrice || ''}
|
||||
suffix={t('$/1M tokens')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pricingMode === 'per-request' && (
|
||||
<Form.Input
|
||||
field='priceInput'
|
||||
label={t('固定价格(每次)')}
|
||||
placeholder={t('输入每次价格')}
|
||||
onChange={(value) =>
|
||||
setCurrentModel((prev) => ({
|
||||
...(prev || {}),
|
||||
price: value,
|
||||
}))
|
||||
}
|
||||
initValue={currentModel?.price || ''}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
return <ModelPricingEditor options={props.options} refresh={props.refresh} />;
|
||||
}
|
||||
|
||||
739
web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
Normal file
739
web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
Normal file
@@ -0,0 +1,739 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDelete,
|
||||
IconPlus,
|
||||
IconSave,
|
||||
IconSearch,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
PRICE_SUFFIX,
|
||||
buildSummaryText,
|
||||
hasValue,
|
||||
useModelPricingEditorState,
|
||||
} from '../hooks/useModelPricingEditorState';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text } = Typography;
|
||||
const EMPTY_CANDIDATE_MODEL_NAMES = [];
|
||||
|
||||
const PriceInput = ({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
suffix = PRICE_SUFFIX,
|
||||
disabled = false,
|
||||
extraText = '',
|
||||
headerAction = null,
|
||||
hidden = false,
|
||||
}) => (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className='mb-1 font-medium text-gray-700 flex items-center justify-between gap-3'>
|
||||
<span>{label}</span>
|
||||
{headerAction}
|
||||
</div>
|
||||
{!hidden ? (
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
suffix={suffix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
{extraText ? (
|
||||
<div className='mt-1 text-xs text-gray-500'>{extraText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ModelPricingEditor({
|
||||
options,
|
||||
refresh,
|
||||
candidateModelNames = EMPTY_CANDIDATE_MODEL_NAMES,
|
||||
filterMode = 'all',
|
||||
allowAddModel = true,
|
||||
allowDeleteModel = true,
|
||||
showConflictFilter = true,
|
||||
listDescription = '',
|
||||
emptyTitle = '',
|
||||
emptyDescription = '',
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [addVisible, setAddVisible] = useState(false);
|
||||
const [batchVisible, setBatchVisible] = useState(false);
|
||||
const [newModelName, setNewModelName] = useState('');
|
||||
|
||||
const {
|
||||
selectedModel,
|
||||
selectedModelName,
|
||||
selectedModelNames,
|
||||
setSelectedModelName,
|
||||
setSelectedModelNames,
|
||||
searchText,
|
||||
setSearchText,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
loading,
|
||||
conflictOnly,
|
||||
setConflictOnly,
|
||||
filteredModels,
|
||||
pagedData,
|
||||
selectedWarnings,
|
||||
previewRows,
|
||||
isOptionalFieldEnabled,
|
||||
handleOptionalFieldToggle,
|
||||
handleNumericFieldChange,
|
||||
handleBillingModeChange,
|
||||
handleSubmit,
|
||||
addModel,
|
||||
deleteModel,
|
||||
applySelectedModelPricing,
|
||||
} = useModelPricingEditorState({
|
||||
options,
|
||||
refresh,
|
||||
t,
|
||||
candidateModelNames,
|
||||
filterMode,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={() => setSelectedModelName(record.name)}
|
||||
style={{
|
||||
padding: 0,
|
||||
color:
|
||||
record.name === selectedModelName
|
||||
? 'var(--semi-color-primary)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
{selectedModelNames.includes(record.name) ? (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('已勾选')}
|
||||
</Tag>
|
||||
) : null}
|
||||
{record.hasConflict ? (
|
||||
<Tag color='red' shape='circle'>
|
||||
{t('矛盾')}
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('计费方式'),
|
||||
dataIndex: 'billingMode',
|
||||
key: 'billingMode',
|
||||
render: (_, record) => (
|
||||
<Tag color={record.billingMode === 'per-request' ? 'teal' : 'violet'}>
|
||||
{record.billingMode === 'per-request'
|
||||
? t('按次计费')
|
||||
: t('按量计费')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('价格摘要'),
|
||||
dataIndex: 'summary',
|
||||
key: 'summary',
|
||||
render: (_, record) => buildSummaryText(record, t),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
{allowDeleteModel ? (
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
icon={<IconDelete />}
|
||||
onClick={() => deleteModel(record.name)}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
allowDeleteModel,
|
||||
deleteModel,
|
||||
selectedModelName,
|
||||
selectedModelNames,
|
||||
setSelectedModelName,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAddModel = () => {
|
||||
if (addModel(newModelName)) {
|
||||
setNewModelName('');
|
||||
setAddVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedModelNames,
|
||||
onChange: (selectedRowKeys) => setSelectedModelNames(selectedRowKeys),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space wrap className='mt-2'>
|
||||
{allowAddModel ? (
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={() => setAddVisible(true)}
|
||||
style={isMobile ? { width: '100%' } : undefined}
|
||||
>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
style={isMobile ? { width: '100%' } : undefined}
|
||||
>
|
||||
{t('应用更改')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedModel || selectedModelNames.length === 0}
|
||||
onClick={() => setBatchVisible(true)}
|
||||
style={isMobile ? { width: '100%' } : undefined}
|
||||
>
|
||||
{t('批量应用当前模型价格')}
|
||||
{selectedModelNames.length > 0 ? ` (${selectedModelNames.length})` : ''}
|
||||
</Button>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
value={searchText}
|
||||
onChange={(value) => setSearchText(value)}
|
||||
style={{ width: isMobile ? '100%' : 220 }}
|
||||
showClear
|
||||
/>
|
||||
{showConflictFilter ? (
|
||||
<Checkbox
|
||||
checked={conflictOnly}
|
||||
onChange={(event) => setConflictOnly(event.target.checked)}
|
||||
>
|
||||
{t('仅显示矛盾倍率')}
|
||||
</Checkbox>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{listDescription ? (
|
||||
<div className='text-sm text-gray-500'>{listDescription}</div>
|
||||
) : null}
|
||||
{selectedModelNames.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
background: 'var(--semi-color-primary-light-default)',
|
||||
border: '1px solid var(--semi-color-primary)',
|
||||
color: 'var(--semi-color-primary)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t('已勾选 {{count}} 个模型', { count: selectedModelNames.length })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: isMobile
|
||||
? 'minmax(0, 1fr)'
|
||||
: 'minmax(360px, 1.1fr) minmax(420px, 1fr)',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
bodyStyle={{ padding: 0 }}
|
||||
style={isMobile ? { order: 2 } : undefined}
|
||||
>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pagedData}
|
||||
rowKey='name'
|
||||
rowSelection={rowSelection}
|
||||
pagination={{
|
||||
currentPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: filteredModels.length,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
showTotal: true,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
empty={
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
{emptyTitle || t('暂无模型')}
|
||||
</div>
|
||||
}
|
||||
onRow={(record) => ({
|
||||
style: {
|
||||
background: selectedModelNames.includes(record.name)
|
||||
? 'var(--semi-color-success-light-default)'
|
||||
: record.name === selectedModelName
|
||||
? 'var(--semi-color-primary-light-default)'
|
||||
: undefined,
|
||||
boxShadow: selectedModelNames.includes(record.name)
|
||||
? 'inset 4px 0 0 var(--semi-color-success)'
|
||||
: record.name === selectedModelName
|
||||
? 'inset 4px 0 0 var(--semi-color-primary)'
|
||||
: undefined,
|
||||
transition: 'background 0.2s ease, box-shadow 0.2s ease',
|
||||
},
|
||||
onClick: () => setSelectedModelName(record.name),
|
||||
})}
|
||||
scroll={isMobile ? { x: 720 } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={isMobile ? { order: 1 } : undefined}
|
||||
title={selectedModel ? selectedModel.name : t('模型计费编辑器')}
|
||||
headerExtraContent={
|
||||
selectedModel ? (
|
||||
<Tag color='blue'>
|
||||
{selectedModel.billingMode === 'per-request'
|
||||
? t('按次计费')
|
||||
: t('按量计费')}
|
||||
</Tag>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!selectedModel ? (
|
||||
<Empty
|
||||
title={emptyTitle || t('暂无模型')}
|
||||
description={
|
||||
emptyDescription || t('请先新增模型或从左侧列表选择一个模型')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div className='mb-4'>
|
||||
<div className='mb-2 font-medium text-gray-700'>
|
||||
{t('计费方式')}
|
||||
</div>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={selectedModel.billingMode}
|
||||
onChange={(event) => handleBillingModeChange(event.target.value)}
|
||||
>
|
||||
<Radio value='per-token'>{t('按量计费')}</Radio>
|
||||
<Radio value='per-request'>{t('按次计费')}</Radio>
|
||||
</RadioGroup>
|
||||
<div className='mt-2 text-xs text-gray-500'>
|
||||
{t(
|
||||
'这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedWarnings.length > 0 ? (
|
||||
<Card
|
||||
bodyStyle={{ padding: 12 }}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--semi-color-warning-light-default)',
|
||||
}}
|
||||
>
|
||||
<div className='font-medium mb-2'>{t('当前提示')}</div>
|
||||
{selectedWarnings.map((warning) => (
|
||||
<div key={warning} className='text-sm text-gray-700 mb-1'>
|
||||
{warning}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{selectedModel.billingMode === 'per-request' ? (
|
||||
<PriceInput
|
||||
label={t('固定价格')}
|
||||
value={selectedModel.fixedPrice}
|
||||
placeholder={t('输入每次调用价格')}
|
||||
suffix={t('$/次')}
|
||||
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
|
||||
extraText={t('适合 MJ / 任务类等按次收费模型。')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Card
|
||||
bodyStyle={{ padding: 16 }}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
>
|
||||
<div className='font-medium mb-3'>{t('基础价格')}</div>
|
||||
<PriceInput
|
||||
label={t('输入价格')}
|
||||
value={selectedModel.inputPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) => handleNumericFieldChange('inputPrice', value)}
|
||||
/>
|
||||
{selectedModel.completionRatioLocked ? (
|
||||
<Banner
|
||||
type='warning'
|
||||
bordered
|
||||
fullMode={false}
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: 12 }}
|
||||
title={t('补全价格已锁定')}
|
||||
description={t(
|
||||
'该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。',
|
||||
{
|
||||
ratio: selectedModel.lockedCompletionRatio || '-',
|
||||
},
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<PriceInput
|
||||
label={t('补全价格')}
|
||||
value={selectedModel.completionPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('completionPrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'completionPrice',
|
||||
)}
|
||||
disabled={selectedModel.completionRatioLocked}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('completionPrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={
|
||||
!isOptionalFieldEnabled(selectedModel, 'completionPrice')
|
||||
}
|
||||
disabled={
|
||||
!hasValue(selectedModel.inputPrice) ||
|
||||
selectedModel.completionRatioLocked
|
||||
}
|
||||
extraText={
|
||||
selectedModel.completionRatioLocked
|
||||
? t(
|
||||
'后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。',
|
||||
{
|
||||
ratio: selectedModel.lockedCompletionRatio || '-',
|
||||
},
|
||||
)
|
||||
: !isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'completionPrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('缓存读取价格')}
|
||||
value={selectedModel.cachePrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) => handleNumericFieldChange('cachePrice', value)}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(selectedModel, 'cachePrice')}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('cachePrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={!isOptionalFieldEnabled(selectedModel, 'cachePrice')}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(selectedModel, 'cachePrice')
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('缓存创建价格')}
|
||||
value={selectedModel.createCachePrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('createCachePrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'createCachePrice',
|
||||
)}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('createCachePrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={
|
||||
!isOptionalFieldEnabled(selectedModel, 'createCachePrice')
|
||||
}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'createCachePrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
bodyStyle={{ padding: 16 }}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
>
|
||||
<div className='mb-3'>
|
||||
<div className='font-medium'>{t('扩展价格')}</div>
|
||||
<div className='text-xs text-gray-500 mt-1'>
|
||||
{t('这些价格都是可选项,不填也可以。')}
|
||||
</div>
|
||||
</div>
|
||||
<PriceInput
|
||||
label={t('图片输入价格')}
|
||||
value={selectedModel.imagePrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) => handleNumericFieldChange('imagePrice', value)}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(selectedModel, 'imagePrice')}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('imagePrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={!isOptionalFieldEnabled(selectedModel, 'imagePrice')}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(selectedModel, 'imagePrice')
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('音频输入价格')}
|
||||
value={selectedModel.audioInputPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('audioInputPrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('audioInputPrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={!isOptionalFieldEnabled(selectedModel, 'audioInputPrice')}
|
||||
disabled={!hasValue(selectedModel.inputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<PriceInput
|
||||
label={t('音频补全价格')}
|
||||
value={selectedModel.audioOutputPrice}
|
||||
placeholder={t('输入 $/1M tokens')}
|
||||
onChange={(value) =>
|
||||
handleNumericFieldChange('audioOutputPrice', value)
|
||||
}
|
||||
headerAction={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioOutputPrice',
|
||||
)}
|
||||
disabled={!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)}
|
||||
onChange={(checked) =>
|
||||
handleOptionalFieldToggle('audioOutputPrice', checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
hidden={
|
||||
!isOptionalFieldEnabled(selectedModel, 'audioOutputPrice')
|
||||
}
|
||||
disabled={!hasValue(selectedModel.audioInputPrice)}
|
||||
extraText={
|
||||
!isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioInputPrice',
|
||||
)
|
||||
? t('请先开启并填写音频输入价格。')
|
||||
: !isOptionalFieldEnabled(
|
||||
selectedModel,
|
||||
'audioOutputPrice',
|
||||
)
|
||||
? t('当前未启用,需要时再打开即可。')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card
|
||||
bodyStyle={{ padding: 16 }}
|
||||
style={{ background: 'var(--semi-color-fill-0)' }}
|
||||
>
|
||||
<div className='font-medium mb-3'>{t('保存预览')}</div>
|
||||
<div className='text-xs text-gray-500 mb-3'>
|
||||
{t(
|
||||
'下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。',
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(140px, 180px) 1fr',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{previewRows.map((row) => (
|
||||
<React.Fragment key={row.key}>
|
||||
<Text strong>{row.label}</Text>
|
||||
<Text>{row.value}</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
{allowAddModel ? (
|
||||
<Modal
|
||||
title={t('添加模型')}
|
||||
visible={addVisible}
|
||||
onCancel={() => {
|
||||
setAddVisible(false);
|
||||
setNewModelName('');
|
||||
}}
|
||||
onOk={handleAddModel}
|
||||
>
|
||||
<Input
|
||||
value={newModelName}
|
||||
placeholder={t('输入模型名称,例如 gpt-4.1')}
|
||||
onChange={(value) => setNewModelName(value)}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
title={t('批量应用当前模型价格')}
|
||||
visible={batchVisible}
|
||||
onCancel={() => setBatchVisible(false)}
|
||||
onOk={() => {
|
||||
if (applySelectedModelPricing()) {
|
||||
setBatchVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='text-sm text-gray-600'>
|
||||
{selectedModel
|
||||
? t(
|
||||
'将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。',
|
||||
{
|
||||
name: selectedModel.name,
|
||||
count: selectedModelNames.length,
|
||||
},
|
||||
)
|
||||
: t('请先选择一个作为模板的模型')}
|
||||
</div>
|
||||
{selectedModel ? (
|
||||
<div className='text-xs text-gray-500 mt-3'>
|
||||
{t(
|
||||
'适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。',
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
937
web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
Normal file
937
web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
Normal file
@@ -0,0 +1,937 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
export const PAGE_SIZE = 10;
|
||||
export const PRICE_SUFFIX = '$/1M tokens';
|
||||
const EMPTY_CANDIDATE_MODEL_NAMES = [];
|
||||
|
||||
const EMPTY_MODEL = {
|
||||
name: '',
|
||||
billingMode: 'per-token',
|
||||
fixedPrice: '',
|
||||
inputPrice: '',
|
||||
completionPrice: '',
|
||||
lockedCompletionRatio: '',
|
||||
completionRatioLocked: false,
|
||||
cachePrice: '',
|
||||
createCachePrice: '',
|
||||
imagePrice: '',
|
||||
audioInputPrice: '',
|
||||
audioOutputPrice: '',
|
||||
rawRatios: {
|
||||
modelRatio: '',
|
||||
completionRatio: '',
|
||||
cacheRatio: '',
|
||||
createCacheRatio: '',
|
||||
imageRatio: '',
|
||||
audioRatio: '',
|
||||
audioCompletionRatio: '',
|
||||
},
|
||||
hasConflict: false,
|
||||
};
|
||||
|
||||
const NUMERIC_INPUT_REGEX = /^(\d+(\.\d*)?|\.\d*)?$/;
|
||||
|
||||
export const hasValue = (value) =>
|
||||
value !== '' && value !== null && value !== undefined && value !== false;
|
||||
|
||||
const toNumericString = (value) => {
|
||||
if (!hasValue(value) && value !== 0) {
|
||||
return '';
|
||||
}
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? String(num) : '';
|
||||
};
|
||||
|
||||
const toNumberOrNull = (value) => {
|
||||
if (!hasValue(value) && value !== 0) {
|
||||
return null;
|
||||
}
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
};
|
||||
|
||||
const formatNumber = (value) => {
|
||||
const num = toNumberOrNull(value);
|
||||
if (num === null) {
|
||||
return '';
|
||||
}
|
||||
return parseFloat(num.toFixed(12)).toString();
|
||||
};
|
||||
|
||||
const parseOptionJSON = (rawValue) => {
|
||||
if (!rawValue || rawValue.trim() === '') {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch (error) {
|
||||
console.error('JSON解析错误:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const ratioToBasePrice = (ratio) => {
|
||||
const num = toNumberOrNull(ratio);
|
||||
if (num === null) return '';
|
||||
return formatNumber(num * 2);
|
||||
};
|
||||
|
||||
const normalizeCompletionRatioMeta = (rawMeta) => {
|
||||
if (!rawMeta || typeof rawMeta !== 'object' || Array.isArray(rawMeta)) {
|
||||
return {
|
||||
locked: false,
|
||||
ratio: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
locked: Boolean(rawMeta.locked),
|
||||
ratio: toNumericString(rawMeta.ratio),
|
||||
};
|
||||
};
|
||||
|
||||
const buildModelState = (name, sourceMaps) => {
|
||||
const modelRatio = toNumericString(sourceMaps.ModelRatio[name]);
|
||||
const completionRatio = toNumericString(sourceMaps.CompletionRatio[name]);
|
||||
const completionRatioMeta = normalizeCompletionRatioMeta(
|
||||
sourceMaps.CompletionRatioMeta?.[name],
|
||||
);
|
||||
const cacheRatio = toNumericString(sourceMaps.CacheRatio[name]);
|
||||
const createCacheRatio = toNumericString(sourceMaps.CreateCacheRatio[name]);
|
||||
const imageRatio = toNumericString(sourceMaps.ImageRatio[name]);
|
||||
const audioRatio = toNumericString(sourceMaps.AudioRatio[name]);
|
||||
const audioCompletionRatio = toNumericString(
|
||||
sourceMaps.AudioCompletionRatio[name],
|
||||
);
|
||||
const fixedPrice = toNumericString(sourceMaps.ModelPrice[name]);
|
||||
const inputPrice = ratioToBasePrice(modelRatio);
|
||||
const inputPriceNumber = toNumberOrNull(inputPrice);
|
||||
const audioInputPrice =
|
||||
inputPriceNumber !== null && hasValue(audioRatio)
|
||||
? formatNumber(inputPriceNumber * Number(audioRatio))
|
||||
: '';
|
||||
|
||||
return {
|
||||
...EMPTY_MODEL,
|
||||
name,
|
||||
billingMode: hasValue(fixedPrice) ? 'per-request' : 'per-token',
|
||||
fixedPrice,
|
||||
inputPrice,
|
||||
completionRatioLocked: completionRatioMeta.locked,
|
||||
lockedCompletionRatio: completionRatioMeta.ratio,
|
||||
completionPrice:
|
||||
inputPriceNumber !== null &&
|
||||
hasValue(completionRatioMeta.locked ? completionRatioMeta.ratio : completionRatio)
|
||||
? formatNumber(
|
||||
inputPriceNumber *
|
||||
Number(
|
||||
completionRatioMeta.locked
|
||||
? completionRatioMeta.ratio
|
||||
: completionRatio,
|
||||
),
|
||||
)
|
||||
: '',
|
||||
cachePrice:
|
||||
inputPriceNumber !== null && hasValue(cacheRatio)
|
||||
? formatNumber(inputPriceNumber * Number(cacheRatio))
|
||||
: '',
|
||||
createCachePrice:
|
||||
inputPriceNumber !== null && hasValue(createCacheRatio)
|
||||
? formatNumber(inputPriceNumber * Number(createCacheRatio))
|
||||
: '',
|
||||
imagePrice:
|
||||
inputPriceNumber !== null && hasValue(imageRatio)
|
||||
? formatNumber(inputPriceNumber * Number(imageRatio))
|
||||
: '',
|
||||
audioInputPrice,
|
||||
audioOutputPrice:
|
||||
toNumberOrNull(audioInputPrice) !== null && hasValue(audioCompletionRatio)
|
||||
? formatNumber(Number(audioInputPrice) * Number(audioCompletionRatio))
|
||||
: '',
|
||||
rawRatios: {
|
||||
modelRatio,
|
||||
completionRatio,
|
||||
cacheRatio,
|
||||
createCacheRatio,
|
||||
imageRatio,
|
||||
audioRatio,
|
||||
audioCompletionRatio,
|
||||
},
|
||||
hasConflict:
|
||||
hasValue(fixedPrice) &&
|
||||
[
|
||||
modelRatio,
|
||||
completionRatio,
|
||||
cacheRatio,
|
||||
createCacheRatio,
|
||||
imageRatio,
|
||||
audioRatio,
|
||||
audioCompletionRatio,
|
||||
].some(hasValue),
|
||||
};
|
||||
};
|
||||
|
||||
export const isBasePricingUnset = (model) =>
|
||||
!hasValue(model.fixedPrice) && !hasValue(model.inputPrice);
|
||||
|
||||
export const getModelWarnings = (model, t) => {
|
||||
if (!model) {
|
||||
return [];
|
||||
}
|
||||
const warnings = [];
|
||||
const hasDerivedPricing = [
|
||||
model.inputPrice,
|
||||
model.completionPrice,
|
||||
model.cachePrice,
|
||||
model.createCachePrice,
|
||||
model.imagePrice,
|
||||
model.audioInputPrice,
|
||||
model.audioOutputPrice,
|
||||
].some(hasValue);
|
||||
|
||||
if (model.hasConflict) {
|
||||
warnings.push(t('当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。'));
|
||||
}
|
||||
|
||||
if (
|
||||
!hasValue(model.inputPrice) &&
|
||||
[
|
||||
model.rawRatios.completionRatio,
|
||||
model.rawRatios.cacheRatio,
|
||||
model.rawRatios.createCacheRatio,
|
||||
model.rawRatios.imageRatio,
|
||||
model.rawRatios.audioRatio,
|
||||
model.rawRatios.audioCompletionRatio,
|
||||
].some(hasValue)
|
||||
) {
|
||||
warnings.push(
|
||||
t('当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。'),
|
||||
);
|
||||
}
|
||||
|
||||
if (model.billingMode === 'per-token' && hasDerivedPricing && !hasValue(model.inputPrice)) {
|
||||
warnings.push(t('按量计费下需要先填写输入价格,才能保存其它价格项。'));
|
||||
}
|
||||
|
||||
if (
|
||||
model.billingMode === 'per-token' &&
|
||||
hasValue(model.audioOutputPrice) &&
|
||||
!hasValue(model.audioInputPrice)
|
||||
) {
|
||||
warnings.push(t('填写音频补全价格前,需要先填写音频输入价格。'));
|
||||
}
|
||||
|
||||
return warnings;
|
||||
};
|
||||
|
||||
export const buildSummaryText = (model, t) => {
|
||||
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
|
||||
return `${t('按次')} $${model.fixedPrice} / ${t('次')}`;
|
||||
}
|
||||
|
||||
if (hasValue(model.inputPrice)) {
|
||||
const extraCount = [
|
||||
model.completionPrice,
|
||||
model.cachePrice,
|
||||
model.createCachePrice,
|
||||
model.imagePrice,
|
||||
model.audioInputPrice,
|
||||
model.audioOutputPrice,
|
||||
].filter(hasValue).length;
|
||||
const extraLabel =
|
||||
extraCount > 0 ? `,${t('额外价格项')} ${extraCount}` : '';
|
||||
return `${t('输入')} $${model.inputPrice}${extraLabel}`;
|
||||
}
|
||||
|
||||
return t('未设置价格');
|
||||
};
|
||||
|
||||
export const buildOptionalFieldToggles = (model) => ({
|
||||
completionPrice: model.completionRatioLocked || hasValue(model.completionPrice),
|
||||
cachePrice: hasValue(model.cachePrice),
|
||||
createCachePrice: hasValue(model.createCachePrice),
|
||||
imagePrice: hasValue(model.imagePrice),
|
||||
audioInputPrice: hasValue(model.audioInputPrice),
|
||||
audioOutputPrice: hasValue(model.audioOutputPrice),
|
||||
});
|
||||
|
||||
const serializeModel = (model, t) => {
|
||||
const result = {
|
||||
ModelPrice: null,
|
||||
ModelRatio: null,
|
||||
CompletionRatio: null,
|
||||
CacheRatio: null,
|
||||
CreateCacheRatio: null,
|
||||
ImageRatio: null,
|
||||
AudioRatio: null,
|
||||
AudioCompletionRatio: null,
|
||||
};
|
||||
|
||||
if (model.billingMode === 'per-request') {
|
||||
if (hasValue(model.fixedPrice)) {
|
||||
result.ModelPrice = Number(model.fixedPrice);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const inputPrice = toNumberOrNull(model.inputPrice);
|
||||
const completionPrice = toNumberOrNull(model.completionPrice);
|
||||
const cachePrice = toNumberOrNull(model.cachePrice);
|
||||
const createCachePrice = toNumberOrNull(model.createCachePrice);
|
||||
const imagePrice = toNumberOrNull(model.imagePrice);
|
||||
const audioInputPrice = toNumberOrNull(model.audioInputPrice);
|
||||
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
|
||||
|
||||
const hasDependentPrice = [
|
||||
completionPrice,
|
||||
cachePrice,
|
||||
createCachePrice,
|
||||
imagePrice,
|
||||
audioInputPrice,
|
||||
audioOutputPrice,
|
||||
].some((value) => value !== null);
|
||||
|
||||
if (inputPrice === null) {
|
||||
if (hasDependentPrice) {
|
||||
throw new Error(
|
||||
t('模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率', {
|
||||
name: model.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasValue(model.rawRatios.modelRatio)) {
|
||||
result.ModelRatio = Number(model.rawRatios.modelRatio);
|
||||
}
|
||||
if (hasValue(model.rawRatios.completionRatio)) {
|
||||
result.CompletionRatio = Number(model.rawRatios.completionRatio);
|
||||
}
|
||||
if (hasValue(model.rawRatios.cacheRatio)) {
|
||||
result.CacheRatio = Number(model.rawRatios.cacheRatio);
|
||||
}
|
||||
if (hasValue(model.rawRatios.createCacheRatio)) {
|
||||
result.CreateCacheRatio = Number(model.rawRatios.createCacheRatio);
|
||||
}
|
||||
if (hasValue(model.rawRatios.imageRatio)) {
|
||||
result.ImageRatio = Number(model.rawRatios.imageRatio);
|
||||
}
|
||||
if (hasValue(model.rawRatios.audioRatio)) {
|
||||
result.AudioRatio = Number(model.rawRatios.audioRatio);
|
||||
}
|
||||
if (hasValue(model.rawRatios.audioCompletionRatio)) {
|
||||
result.AudioCompletionRatio = Number(model.rawRatios.audioCompletionRatio);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
result.ModelRatio = inputPrice / 2;
|
||||
|
||||
if (!model.completionRatioLocked && completionPrice !== null) {
|
||||
result.CompletionRatio = completionPrice / inputPrice;
|
||||
} else if (
|
||||
model.completionRatioLocked &&
|
||||
hasValue(model.rawRatios.completionRatio)
|
||||
) {
|
||||
result.CompletionRatio = Number(model.rawRatios.completionRatio);
|
||||
}
|
||||
if (cachePrice !== null) {
|
||||
result.CacheRatio = cachePrice / inputPrice;
|
||||
}
|
||||
if (createCachePrice !== null) {
|
||||
result.CreateCacheRatio = createCachePrice / inputPrice;
|
||||
}
|
||||
if (imagePrice !== null) {
|
||||
result.ImageRatio = imagePrice / inputPrice;
|
||||
}
|
||||
if (audioInputPrice !== null) {
|
||||
result.AudioRatio = audioInputPrice / inputPrice;
|
||||
}
|
||||
if (audioOutputPrice !== null) {
|
||||
if (audioInputPrice === null || audioInputPrice === 0) {
|
||||
throw new Error(
|
||||
t('模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率', {
|
||||
name: model.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
result.AudioCompletionRatio = audioOutputPrice / audioInputPrice;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const buildPreviewRows = (model, t) => {
|
||||
if (!model) return [];
|
||||
|
||||
if (model.billingMode === 'per-request') {
|
||||
return [
|
||||
{
|
||||
key: 'ModelPrice',
|
||||
label: 'ModelPrice',
|
||||
value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const inputPrice = toNumberOrNull(model.inputPrice);
|
||||
if (inputPrice === null) {
|
||||
return [
|
||||
{
|
||||
key: 'ModelRatio',
|
||||
label: 'ModelRatio',
|
||||
value: hasValue(model.rawRatios.modelRatio)
|
||||
? model.rawRatios.modelRatio
|
||||
: t('空'),
|
||||
},
|
||||
{
|
||||
key: 'CompletionRatio',
|
||||
label: 'CompletionRatio',
|
||||
value: hasValue(model.rawRatios.completionRatio)
|
||||
? model.rawRatios.completionRatio
|
||||
: t('空'),
|
||||
},
|
||||
{
|
||||
key: 'CacheRatio',
|
||||
label: 'CacheRatio',
|
||||
value: hasValue(model.rawRatios.cacheRatio)
|
||||
? model.rawRatios.cacheRatio
|
||||
: t('空'),
|
||||
},
|
||||
{
|
||||
key: 'CreateCacheRatio',
|
||||
label: 'CreateCacheRatio',
|
||||
value: hasValue(model.rawRatios.createCacheRatio)
|
||||
? model.rawRatios.createCacheRatio
|
||||
: t('空'),
|
||||
},
|
||||
{
|
||||
key: 'ImageRatio',
|
||||
label: 'ImageRatio',
|
||||
value: hasValue(model.rawRatios.imageRatio)
|
||||
? model.rawRatios.imageRatio
|
||||
: t('空'),
|
||||
},
|
||||
{
|
||||
key: 'AudioRatio',
|
||||
label: 'AudioRatio',
|
||||
value: hasValue(model.rawRatios.audioRatio)
|
||||
? model.rawRatios.audioRatio
|
||||
: t('空'),
|
||||
},
|
||||
{
|
||||
key: 'AudioCompletionRatio',
|
||||
label: 'AudioCompletionRatio',
|
||||
value: hasValue(model.rawRatios.audioCompletionRatio)
|
||||
? model.rawRatios.audioCompletionRatio
|
||||
: t('空'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const completionPrice = toNumberOrNull(model.completionPrice);
|
||||
const cachePrice = toNumberOrNull(model.cachePrice);
|
||||
const createCachePrice = toNumberOrNull(model.createCachePrice);
|
||||
const imagePrice = toNumberOrNull(model.imagePrice);
|
||||
const audioInputPrice = toNumberOrNull(model.audioInputPrice);
|
||||
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'ModelRatio',
|
||||
label: 'ModelRatio',
|
||||
value: formatNumber(inputPrice / 2),
|
||||
},
|
||||
{
|
||||
key: 'CompletionRatio',
|
||||
label: 'CompletionRatio',
|
||||
value: model.completionRatioLocked
|
||||
? `${model.lockedCompletionRatio || t('空')} (${t('后端固定')})`
|
||||
: completionPrice !== null
|
||||
? formatNumber(completionPrice / inputPrice)
|
||||
: t('空'),
|
||||
},
|
||||
{
|
||||
key: 'CacheRatio',
|
||||
label: 'CacheRatio',
|
||||
value: cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'),
|
||||
},
|
||||
{
|
||||
key: 'CreateCacheRatio',
|
||||
label: 'CreateCacheRatio',
|
||||
value:
|
||||
createCachePrice !== null
|
||||
? formatNumber(createCachePrice / inputPrice)
|
||||
: t('空'),
|
||||
},
|
||||
{
|
||||
key: 'ImageRatio',
|
||||
label: 'ImageRatio',
|
||||
value: imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'),
|
||||
},
|
||||
{
|
||||
key: 'AudioRatio',
|
||||
label: 'AudioRatio',
|
||||
value:
|
||||
audioInputPrice !== null
|
||||
? formatNumber(audioInputPrice / inputPrice)
|
||||
: t('空'),
|
||||
},
|
||||
{
|
||||
key: 'AudioCompletionRatio',
|
||||
label: 'AudioCompletionRatio',
|
||||
value:
|
||||
audioOutputPrice !== null && audioInputPrice !== null && audioInputPrice !== 0
|
||||
? formatNumber(audioOutputPrice / audioInputPrice)
|
||||
: t('空'),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export function useModelPricingEditorState({
|
||||
options,
|
||||
refresh,
|
||||
t,
|
||||
candidateModelNames = EMPTY_CANDIDATE_MODEL_NAMES,
|
||||
filterMode = 'all',
|
||||
}) {
|
||||
const [models, setModels] = useState([]);
|
||||
const [initialVisibleModelNames, setInitialVisibleModelNames] = useState([]);
|
||||
const [selectedModelName, setSelectedModelName] = useState('');
|
||||
const [selectedModelNames, setSelectedModelNames] = useState([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [conflictOnly, setConflictOnly] = useState(false);
|
||||
const [optionalFieldToggles, setOptionalFieldToggles] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
const sourceMaps = {
|
||||
ModelPrice: parseOptionJSON(options.ModelPrice),
|
||||
ModelRatio: parseOptionJSON(options.ModelRatio),
|
||||
CompletionRatio: parseOptionJSON(options.CompletionRatio),
|
||||
CompletionRatioMeta: parseOptionJSON(options.CompletionRatioMeta),
|
||||
CacheRatio: parseOptionJSON(options.CacheRatio),
|
||||
CreateCacheRatio: parseOptionJSON(options.CreateCacheRatio),
|
||||
ImageRatio: parseOptionJSON(options.ImageRatio),
|
||||
AudioRatio: parseOptionJSON(options.AudioRatio),
|
||||
AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio),
|
||||
};
|
||||
|
||||
const names = new Set([
|
||||
...candidateModelNames,
|
||||
...Object.keys(sourceMaps.ModelPrice),
|
||||
...Object.keys(sourceMaps.ModelRatio),
|
||||
...Object.keys(sourceMaps.CompletionRatio),
|
||||
...Object.keys(sourceMaps.CompletionRatioMeta),
|
||||
...Object.keys(sourceMaps.CacheRatio),
|
||||
...Object.keys(sourceMaps.CreateCacheRatio),
|
||||
...Object.keys(sourceMaps.ImageRatio),
|
||||
...Object.keys(sourceMaps.AudioRatio),
|
||||
...Object.keys(sourceMaps.AudioCompletionRatio),
|
||||
]);
|
||||
|
||||
const nextModels = Array.from(names)
|
||||
.map((name) => buildModelState(name, sourceMaps))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
setModels(nextModels);
|
||||
setInitialVisibleModelNames(
|
||||
filterMode === 'unset'
|
||||
? nextModels
|
||||
.filter((model) => isBasePricingUnset(model))
|
||||
.map((model) => model.name)
|
||||
: nextModels.map((model) => model.name),
|
||||
);
|
||||
setOptionalFieldToggles(
|
||||
nextModels.reduce((acc, model) => {
|
||||
acc[model.name] = buildOptionalFieldToggles(model);
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
setSelectedModelName((previous) => {
|
||||
if (previous && nextModels.some((model) => model.name === previous)) {
|
||||
return previous;
|
||||
}
|
||||
const nextVisibleModels =
|
||||
filterMode === 'unset'
|
||||
? nextModels.filter((model) => isBasePricingUnset(model))
|
||||
: nextModels;
|
||||
return nextVisibleModels[0]?.name || '';
|
||||
});
|
||||
}, [candidateModelNames, filterMode, options]);
|
||||
|
||||
const visibleModels = useMemo(() => {
|
||||
return filterMode === 'unset'
|
||||
? models.filter((model) => initialVisibleModelNames.includes(model.name))
|
||||
: models;
|
||||
}, [filterMode, initialVisibleModelNames, models]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
return visibleModels.filter((model) => {
|
||||
const keyword = searchText.trim().toLowerCase();
|
||||
const keywordMatch = keyword
|
||||
? model.name.toLowerCase().includes(keyword)
|
||||
: true;
|
||||
const conflictMatch = conflictOnly ? model.hasConflict : true;
|
||||
return keywordMatch && conflictMatch;
|
||||
});
|
||||
}, [conflictOnly, searchText, visibleModels]);
|
||||
|
||||
const pagedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredModels.slice(start, start + PAGE_SIZE);
|
||||
}, [currentPage, filteredModels]);
|
||||
|
||||
const selectedModel = useMemo(
|
||||
() => visibleModels.find((model) => model.name === selectedModelName) || null,
|
||||
[selectedModelName, visibleModels],
|
||||
);
|
||||
|
||||
const selectedWarnings = useMemo(
|
||||
() => getModelWarnings(selectedModel, t),
|
||||
[selectedModel, t],
|
||||
);
|
||||
|
||||
const previewRows = useMemo(
|
||||
() => buildPreviewRows(selectedModel, t),
|
||||
[selectedModel, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchText, conflictOnly, filterMode, candidateModelNames]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedModelNames((previous) =>
|
||||
previous.filter((name) => visibleModels.some((model) => model.name === name)),
|
||||
);
|
||||
}, [visibleModels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleModels.length === 0) {
|
||||
setSelectedModelName('');
|
||||
return;
|
||||
}
|
||||
if (!visibleModels.some((model) => model.name === selectedModelName)) {
|
||||
setSelectedModelName(visibleModels[0].name);
|
||||
}
|
||||
}, [selectedModelName, visibleModels]);
|
||||
|
||||
const upsertModel = (name, updater) => {
|
||||
setModels((previous) =>
|
||||
previous.map((model) => {
|
||||
if (model.name !== name) return model;
|
||||
return typeof updater === 'function' ? updater(model) : updater;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const isOptionalFieldEnabled = (model, field) => {
|
||||
if (!model) return false;
|
||||
const modelToggles = optionalFieldToggles[model.name];
|
||||
if (modelToggles && typeof modelToggles[field] === 'boolean') {
|
||||
return modelToggles[field];
|
||||
}
|
||||
return buildOptionalFieldToggles(model)[field];
|
||||
};
|
||||
|
||||
const updateOptionalFieldToggle = (modelName, field, checked) => {
|
||||
setOptionalFieldToggles((prev) => ({
|
||||
...prev,
|
||||
[modelName]: {
|
||||
...(prev[modelName] || {}),
|
||||
[field]: checked,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleOptionalFieldToggle = (field, checked) => {
|
||||
if (!selectedModel) return;
|
||||
|
||||
updateOptionalFieldToggle(selectedModel.name, field, checked);
|
||||
|
||||
if (checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
upsertModel(selectedModel.name, (model) => {
|
||||
const nextModel = { ...model, [field]: '' };
|
||||
|
||||
if (field === 'audioInputPrice') {
|
||||
nextModel.audioOutputPrice = '';
|
||||
setOptionalFieldToggles((prev) => ({
|
||||
...prev,
|
||||
[selectedModel.name]: {
|
||||
...(prev[selectedModel.name] || {}),
|
||||
audioInputPrice: false,
|
||||
audioOutputPrice: false,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return nextModel;
|
||||
});
|
||||
};
|
||||
|
||||
const fillDerivedPricesFromBase = (model, nextInputPrice) => {
|
||||
const baseNumber = toNumberOrNull(nextInputPrice);
|
||||
if (baseNumber === null) {
|
||||
return model;
|
||||
}
|
||||
|
||||
return {
|
||||
...model,
|
||||
completionPrice:
|
||||
model.completionRatioLocked && hasValue(model.lockedCompletionRatio)
|
||||
? formatNumber(baseNumber * Number(model.lockedCompletionRatio))
|
||||
: !hasValue(model.completionPrice) &&
|
||||
hasValue(model.rawRatios.completionRatio)
|
||||
? formatNumber(baseNumber * Number(model.rawRatios.completionRatio))
|
||||
: model.completionPrice,
|
||||
cachePrice:
|
||||
!hasValue(model.cachePrice) && hasValue(model.rawRatios.cacheRatio)
|
||||
? formatNumber(baseNumber * Number(model.rawRatios.cacheRatio))
|
||||
: model.cachePrice,
|
||||
createCachePrice:
|
||||
!hasValue(model.createCachePrice) &&
|
||||
hasValue(model.rawRatios.createCacheRatio)
|
||||
? formatNumber(baseNumber * Number(model.rawRatios.createCacheRatio))
|
||||
: model.createCachePrice,
|
||||
imagePrice:
|
||||
!hasValue(model.imagePrice) && hasValue(model.rawRatios.imageRatio)
|
||||
? formatNumber(baseNumber * Number(model.rawRatios.imageRatio))
|
||||
: model.imagePrice,
|
||||
audioInputPrice:
|
||||
!hasValue(model.audioInputPrice) && hasValue(model.rawRatios.audioRatio)
|
||||
? formatNumber(baseNumber * Number(model.rawRatios.audioRatio))
|
||||
: model.audioInputPrice,
|
||||
audioOutputPrice:
|
||||
!hasValue(model.audioOutputPrice) &&
|
||||
hasValue(model.rawRatios.audioRatio) &&
|
||||
hasValue(model.rawRatios.audioCompletionRatio)
|
||||
? formatNumber(
|
||||
baseNumber *
|
||||
Number(model.rawRatios.audioRatio) *
|
||||
Number(model.rawRatios.audioCompletionRatio),
|
||||
)
|
||||
: model.audioOutputPrice,
|
||||
};
|
||||
};
|
||||
|
||||
const handleNumericFieldChange = (field, value) => {
|
||||
if (!selectedModel || !NUMERIC_INPUT_REGEX.test(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
upsertModel(selectedModel.name, (model) => {
|
||||
const updatedModel = { ...model, [field]: value };
|
||||
|
||||
if (field === 'inputPrice') {
|
||||
return fillDerivedPricesFromBase(updatedModel, value);
|
||||
}
|
||||
|
||||
return updatedModel;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBillingModeChange = (value) => {
|
||||
if (!selectedModel) return;
|
||||
upsertModel(selectedModel.name, (model) => ({
|
||||
...model,
|
||||
billingMode: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const addModel = (modelName) => {
|
||||
const trimmedName = modelName.trim();
|
||||
if (!trimmedName) {
|
||||
showError(t('请输入模型名称'));
|
||||
return false;
|
||||
}
|
||||
if (models.some((model) => model.name === trimmedName)) {
|
||||
showError(t('模型名称已存在'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextModel = {
|
||||
...EMPTY_MODEL,
|
||||
name: trimmedName,
|
||||
rawRatios: { ...EMPTY_MODEL.rawRatios },
|
||||
};
|
||||
|
||||
setModels((previous) => [nextModel, ...previous]);
|
||||
setOptionalFieldToggles((prev) => ({
|
||||
...prev,
|
||||
[trimmedName]: buildOptionalFieldToggles(nextModel),
|
||||
}));
|
||||
setSelectedModelName(trimmedName);
|
||||
setCurrentPage(1);
|
||||
return true;
|
||||
};
|
||||
|
||||
const deleteModel = (name) => {
|
||||
const nextModels = models.filter((model) => model.name !== name);
|
||||
setModels(nextModels);
|
||||
setOptionalFieldToggles((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
setSelectedModelNames((previous) => previous.filter((item) => item !== name));
|
||||
if (selectedModelName === name) {
|
||||
setSelectedModelName(nextModels[0]?.name || '');
|
||||
}
|
||||
};
|
||||
|
||||
const applySelectedModelPricing = () => {
|
||||
if (!selectedModel) {
|
||||
showError(t('请先选择一个作为模板的模型'));
|
||||
return false;
|
||||
}
|
||||
if (selectedModelNames.length === 0) {
|
||||
showError(t('请先勾选需要批量设置的模型'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceToggles = optionalFieldToggles[selectedModel.name] || {};
|
||||
|
||||
setModels((previous) =>
|
||||
previous.map((model) => {
|
||||
if (!selectedModelNames.includes(model.name)) {
|
||||
return model;
|
||||
}
|
||||
|
||||
const nextModel = {
|
||||
...model,
|
||||
billingMode: selectedModel.billingMode,
|
||||
fixedPrice: selectedModel.fixedPrice,
|
||||
inputPrice: selectedModel.inputPrice,
|
||||
completionPrice: selectedModel.completionPrice,
|
||||
cachePrice: selectedModel.cachePrice,
|
||||
createCachePrice: selectedModel.createCachePrice,
|
||||
imagePrice: selectedModel.imagePrice,
|
||||
audioInputPrice: selectedModel.audioInputPrice,
|
||||
audioOutputPrice: selectedModel.audioOutputPrice,
|
||||
};
|
||||
|
||||
if (
|
||||
nextModel.billingMode === 'per-token' &&
|
||||
nextModel.completionRatioLocked &&
|
||||
hasValue(nextModel.inputPrice) &&
|
||||
hasValue(nextModel.lockedCompletionRatio)
|
||||
) {
|
||||
nextModel.completionPrice = formatNumber(
|
||||
Number(nextModel.inputPrice) * Number(nextModel.lockedCompletionRatio),
|
||||
);
|
||||
}
|
||||
|
||||
return nextModel;
|
||||
}),
|
||||
);
|
||||
|
||||
setOptionalFieldToggles((previous) => {
|
||||
const next = { ...previous };
|
||||
selectedModelNames.forEach((modelName) => {
|
||||
const targetModel = models.find((item) => item.name === modelName);
|
||||
next[modelName] = {
|
||||
completionPrice: targetModel?.completionRatioLocked
|
||||
? true
|
||||
: Boolean(sourceToggles.completionPrice),
|
||||
cachePrice: Boolean(sourceToggles.cachePrice),
|
||||
createCachePrice: Boolean(sourceToggles.createCachePrice),
|
||||
imagePrice: Boolean(sourceToggles.imagePrice),
|
||||
audioInputPrice: Boolean(sourceToggles.audioInputPrice),
|
||||
audioOutputPrice:
|
||||
Boolean(sourceToggles.audioInputPrice) &&
|
||||
Boolean(sourceToggles.audioOutputPrice),
|
||||
};
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
showSuccess(
|
||||
t('已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型', {
|
||||
name: selectedModel.name,
|
||||
count: selectedModelNames.length,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const output = {
|
||||
ModelPrice: {},
|
||||
ModelRatio: {},
|
||||
CompletionRatio: {},
|
||||
CacheRatio: {},
|
||||
CreateCacheRatio: {},
|
||||
ImageRatio: {},
|
||||
AudioRatio: {},
|
||||
AudioCompletionRatio: {},
|
||||
};
|
||||
|
||||
for (const model of models) {
|
||||
const serialized = serializeModel(model, t);
|
||||
Object.entries(serialized).forEach(([key, value]) => {
|
||||
if (value !== null) {
|
||||
output[key][model.name] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const requestQueue = Object.entries(output).map(([key, value]) =>
|
||||
API.put('/api/option/', {
|
||||
key,
|
||||
value: JSON.stringify(value, null, 2),
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.all(requestQueue);
|
||||
for (const res of results) {
|
||||
if (!res?.data?.success) {
|
||||
throw new Error(res?.data?.message || t('保存失败,请重试'));
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(t('保存成功'));
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
showError(error.message || t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
models,
|
||||
selectedModel,
|
||||
selectedModelName,
|
||||
selectedModelNames,
|
||||
setSelectedModelName,
|
||||
setSelectedModelNames,
|
||||
searchText,
|
||||
setSearchText,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
loading,
|
||||
conflictOnly,
|
||||
setConflictOnly,
|
||||
filteredModels,
|
||||
pagedData,
|
||||
selectedWarnings,
|
||||
previewRows,
|
||||
isOptionalFieldEnabled,
|
||||
handleOptionalFieldToggle,
|
||||
handleNumericFieldChange,
|
||||
handleBillingModeChange,
|
||||
handleSubmit,
|
||||
addModel,
|
||||
deleteModel,
|
||||
applySelectedModelPricing,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user