💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM)

Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe
configuration `general_setting.quota_display_type`, and wire it through the
backend and frontend.

Backend
- Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected
  registration via `config.GlobalConfig.Register("general_setting", ...)`.
  Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`.
- Expose `quota_display_type` in `/api/status` and keep legacy
  `display_in_currency` for backward compatibility.
- Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When
  CNY is selected, convert using `operation_setting.USDExchangeRate`.
- Controllers:
  - `billing`: compute subscription/usage amounts based on the selected type
    (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens).
  - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as
    token-count for TOKENS; adjust min topup and pay money accordingly.
  - `misc`: include `quota_display_type` in status payload.
- Compatibility: in `model/option.UpdateOption`, map updates to
  `DisplayInCurrencyEnabled` → `general_setting.quota_display_type`
  (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`.

Frontend
- Settings: replace the “display in currency” switch with a Select
  (`general_setting.quota_display_type`) offering USD / CNY / Tokens.
  Provide fallback mapping from legacy `DisplayInCurrencyEnabled`.
- Persist `quota_display_type` to localStorage (keep `display_in_currency`
  for legacy components).
- Rendering helpers: base all quota/price rendering on `quota_display_type`;
  use `usd_exchange_rate` for CNY symbol/values.
- Pricing page: default view currency follows site display type (USD/CNY),
  while TOKENS mode still allows per-view currency toggling when needed.

Notes
- No database migrations required.
- Legacy clients remain functional via compatibility fields.
This commit is contained in:
t0ng7u
2025-09-29 23:23:31 +08:00
parent 9f989fc7ef
commit 39a868faea
46 changed files with 1268 additions and 601 deletions

View File

@@ -19,6 +19,7 @@ var TopUpLink = ""
// var ChatLink = "" // var ChatLink = ""
// var ChatLink2 = "" // var ChatLink2 = ""
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制
var DisplayInCurrencyEnabled = true var DisplayInCurrencyEnabled = true
var DisplayTokenStatEnabled = true var DisplayTokenStatEnabled = true
var DrawingEnabled = true var DrawingEnabled = true

View File

@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
var UsingMySQL = false var UsingMySQL = false
var UsingClickHouse = false var UsingClickHouse = false
var SQLitePath = "one-api.db?_busy_timeout=30000" var SQLitePath = "one-api.db?_busy_timeout=30000"

View File

@@ -5,6 +5,7 @@ import (
"one-api/common" "one-api/common"
"one-api/dto" "one-api/dto"
"one-api/model" "one-api/model"
"one-api/setting/operation_setting"
) )
func GetSubscription(c *gin.Context) { func GetSubscription(c *gin.Context) {
@@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) {
} }
quota := remainQuota + usedQuota quota := remainQuota + usedQuota
amount := float64(quota) amount := float64(quota)
if common.DisplayInCurrencyEnabled { // OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值:
amount /= common.QuotaPerUnit // 我们将其解释为以“站点展示类型”为准:
// - USD: 直接除以 QuotaPerUnit
// - CNY: 先转 USD 再乘汇率
// - TOKENS: 直接使用 tokens 数量
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
case operation_setting.QuotaDisplayTypeTokens:
// amount 保持 tokens 数值
default:
amount = amount / common.QuotaPerUnit
} }
if token != nil && token.UnlimitedQuota { if token != nil && token.UnlimitedQuota {
amount = 100000000 amount = 100000000
@@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) {
return return
} }
amount := float64(quota) amount := float64(quota)
if common.DisplayInCurrencyEnabled { switch operation_setting.GetQuotaDisplayType() {
amount /= common.QuotaPerUnit case operation_setting.QuotaDisplayTypeCNY:
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
case operation_setting.QuotaDisplayTypeTokens:
// tokens 保持原值
default:
amount = amount / common.QuotaPerUnit
} }
usage := OpenAIUsageResponse{ usage := OpenAIUsageResponse{
Object: "list", Object: "list",

View File

@@ -64,18 +64,22 @@ func GetStatus(c *gin.Context) {
"top_up_link": common.TopUpLink, "top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink, "docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit, "quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled, // 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
"enable_batch_update": common.BatchUpdateEnabled, "display_in_currency": operation_setting.IsCurrencyDisplay(),
"enable_drawing": common.DrawingEnabled, "quota_display_type": operation_setting.GetQuotaDisplayType(),
"enable_task": common.TaskEnabled, "custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
"enable_data_export": common.DataExportEnabled, "custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
"data_export_default_time": common.DataExportDefaultTime, "enable_batch_update": common.BatchUpdateEnabled,
"default_collapse_sidebar": common.DefaultCollapseSidebar, "enable_drawing": common.DrawingEnabled,
"mj_notify_enabled": setting.MjNotifyEnabled, "enable_task": common.TaskEnabled,
"chats": setting.Chats, "enable_data_export": common.DataExportEnabled,
"demo_site_enabled": operation_setting.DemoSiteEnabled, "data_export_default_time": common.DataExportDefaultTime,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled, "default_collapse_sidebar": common.DefaultCollapseSidebar,
"default_use_auto_group": setting.DefaultUseAutoGroup, "mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"usd_exchange_rate": operation_setting.USDExchangeRate, "usd_exchange_rate": operation_setting.USDExchangeRate,
"price": operation_setting.Price, "price": operation_setting.Price,

View File

@@ -178,4 +178,4 @@ func boolToString(b bool) string {
return "true" return "true"
} }
return "false" return "false"
} }

View File

@@ -86,8 +86,9 @@ func GetEpayClient() *epay.Client {
func getPayMoney(amount int64, group string) float64 { func getPayMoney(amount int64, group string) float64 {
dAmount := decimal.NewFromInt(amount) dAmount := decimal.NewFromInt(amount)
// 充值金额以“展示类型”为准:
if !common.DisplayInCurrencyEnabled { // - USD/CNY: 前端传 amount 为金额单位TOKENS: 前端传 tokens需要换成 USD 金额
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
dAmount = dAmount.Div(dQuotaPerUnit) dAmount = dAmount.Div(dQuotaPerUnit)
} }
@@ -115,7 +116,7 @@ func getPayMoney(amount int64, group string) float64 {
func getMinTopup() int64 { func getMinTopup() int64 {
minTopup := operation_setting.MinTopUp minTopup := operation_setting.MinTopUp
if !common.DisplayInCurrencyEnabled { if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dMinTopup := decimal.NewFromInt(int64(minTopup)) dMinTopup := decimal.NewFromInt(int64(minTopup))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart()) minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
@@ -176,7 +177,7 @@ func RequestEpay(c *gin.Context) {
return return
} }
amount := req.Amount amount := req.Amount
if !common.DisplayInCurrencyEnabled { if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dAmount := decimal.NewFromInt(int64(amount)) dAmount := decimal.NewFromInt(int64(amount))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
amount = dAmount.Div(dQuotaPerUnit).IntPart() amount = dAmount.Div(dQuotaPerUnit).IntPart()

View File

@@ -258,7 +258,7 @@ func GetChargedAmount(count float64, user model.User) float64 {
func getStripePayMoney(amount float64, group string) float64 { func getStripePayMoney(amount float64, group string) float64 {
originalAmount := amount originalAmount := amount
if !common.DisplayInCurrencyEnabled { if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
amount = amount / common.QuotaPerUnit amount = amount / common.QuotaPerUnit
} }
// Using float64 for monetary calculations is acceptable here due to the small amounts involved // Using float64 for monetary calculations is acceptable here due to the small amounts involved
@@ -279,7 +279,7 @@ func getStripePayMoney(amount float64, group string) float64 {
func getStripeMinTopup() int64 { func getStripeMinTopup() int64 {
minTopup := setting.StripeMinTopUp minTopup := setting.StripeMinTopUp
if !common.DisplayInCurrencyEnabled { if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
minTopup = minTopup * int(common.QuotaPerUnit) minTopup = minTopup * int(common.QuotaPerUnit)
} }
return int64(minTopup) return int64(minTopup)

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"log" "log"
"one-api/common" "one-api/common"
"one-api/setting/operation_setting"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -92,18 +93,55 @@ func logHelper(ctx context.Context, level string, msg string) {
} }
func LogQuota(quota int) string { func LogQuota(quota int) string {
if common.DisplayInCurrencyEnabled { // 新逻辑:根据额度展示类型输出
return fmt.Sprintf("%.6f 额度", float64(quota)/common.QuotaPerUnit) q := float64(quota)
} else { switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
usd := q / common.QuotaPerUnit
cny := usd * operation_setting.USDExchangeRate
return fmt.Sprintf("¥%.6f 额度", cny)
case operation_setting.QuotaDisplayTypeCustom:
usd := q / common.QuotaPerUnit
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
if symbol == "" {
symbol = "¤"
}
if rate <= 0 {
rate = 1
}
v := usd * rate
return fmt.Sprintf("%s%.6f 额度", symbol, v)
case operation_setting.QuotaDisplayTypeTokens:
return fmt.Sprintf("%d 点额度", quota) return fmt.Sprintf("%d 点额度", quota)
default: // USD
return fmt.Sprintf("%.6f 额度", q/common.QuotaPerUnit)
} }
} }
func FormatQuota(quota int) string { func FormatQuota(quota int) string {
if common.DisplayInCurrencyEnabled { q := float64(quota)
return fmt.Sprintf("%.6f", float64(quota)/common.QuotaPerUnit) switch operation_setting.GetQuotaDisplayType() {
} else { case operation_setting.QuotaDisplayTypeCNY:
usd := q / common.QuotaPerUnit
cny := usd * operation_setting.USDExchangeRate
return fmt.Sprintf("¥%.6f", cny)
case operation_setting.QuotaDisplayTypeCustom:
usd := q / common.QuotaPerUnit
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
if symbol == "" {
symbol = "¤"
}
if rate <= 0 {
rate = 1
}
v := usd * rate
return fmt.Sprintf("%s%.6f", symbol, v)
case operation_setting.QuotaDisplayTypeTokens:
return fmt.Sprintf("%d", quota) return fmt.Sprintf("%d", quota)
default:
return fmt.Sprintf("%.6f", q/common.QuotaPerUnit)
} }
} }

View File

@@ -240,7 +240,15 @@ func updateOptionMap(key string, value string) (err error) {
case "LogConsumeEnabled": case "LogConsumeEnabled":
common.LogConsumeEnabled = boolValue common.LogConsumeEnabled = boolValue
case "DisplayInCurrencyEnabled": case "DisplayInCurrencyEnabled":
common.DisplayInCurrencyEnabled = boolValue // 兼容旧字段:同步到新配置 general_setting.quota_display_type运行时生效
// true -> USD, false -> TOKENS
newVal := "USD"
if !boolValue {
newVal = "TOKENS"
}
if cfg := config.GlobalConfig.Get("general_setting"); cfg != nil {
_ = config.UpdateConfigFromMap(cfg, map[string]string{"quota_display_type": newVal})
}
case "DisplayTokenStatEnabled": case "DisplayTokenStatEnabled":
common.DisplayTokenStatEnabled = boolValue common.DisplayTokenStatEnabled = boolValue
case "DrawingEnabled": case "DrawingEnabled":

View File

@@ -18,7 +18,9 @@ import (
type Adaptor struct { type Adaptor struct {
} }
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { return nil, errors.New("not implemented") } func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
openaiAdaptor := openai.Adaptor{} openaiAdaptor := openai.Adaptor{}
@@ -33,17 +35,25 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest)) return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
} }
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { return nil, errors.New("not implemented") } func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) { func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
} }
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil } if info.RelayMode == relayconstant.RelayModeEmbeddings {
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil } return info.ChannelBaseUrl + "/api/embed", nil
return info.ChannelBaseUrl + "/api/chat", nil }
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
return info.ChannelBaseUrl + "/api/generate", nil
}
return info.ChannelBaseUrl + "/api/chat", nil
} }
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
@@ -53,7 +63,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
} }
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
if request == nil { return nil, errors.New("request is nil") } if request == nil {
return nil, errors.New("request is nil")
}
// decide generate or chat // decide generate or chat
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
return openAIToGenerate(c, request) return openAIToGenerate(c, request)
@@ -69,7 +81,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
return requestOpenAI2Embeddings(request), nil return requestOpenAI2Embeddings(request), nil
} }
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { return nil, errors.New("not implemented") } func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
return channel.DoApiRequest(a, c, info, requestBody) return channel.DoApiRequest(a, c, info, requestBody)

View File

@@ -5,12 +5,12 @@ import (
) )
type OllamaChatMessage struct { type OllamaChatMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
Images []string `json:"images,omitempty"` Images []string `json:"images,omitempty"`
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"` ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"` ToolName string `json:"tool_name,omitempty"`
Thinking json.RawMessage `json:"thinking,omitempty"` Thinking json.RawMessage `json:"thinking,omitempty"`
} }
type OllamaToolFunction struct { type OllamaToolFunction struct {
@@ -20,7 +20,7 @@ type OllamaToolFunction struct {
} }
type OllamaTool struct { type OllamaTool struct {
Type string `json:"type"` Type string `json:"type"`
Function OllamaToolFunction `json:"function"` Function OllamaToolFunction `json:"function"`
} }
@@ -43,28 +43,27 @@ type OllamaChatRequest struct {
} }
type OllamaGenerateRequest struct { type OllamaGenerateRequest struct {
Model string `json:"model"` Model string `json:"model"`
Prompt string `json:"prompt,omitempty"` Prompt string `json:"prompt,omitempty"`
Suffix string `json:"suffix,omitempty"` Suffix string `json:"suffix,omitempty"`
Images []string `json:"images,omitempty"` Images []string `json:"images,omitempty"`
Format interface{} `json:"format,omitempty"` Format interface{} `json:"format,omitempty"`
Stream bool `json:"stream,omitempty"` Stream bool `json:"stream,omitempty"`
Options map[string]any `json:"options,omitempty"` Options map[string]any `json:"options,omitempty"`
KeepAlive interface{} `json:"keep_alive,omitempty"` KeepAlive interface{} `json:"keep_alive,omitempty"`
Think json.RawMessage `json:"think,omitempty"` Think json.RawMessage `json:"think,omitempty"`
} }
type OllamaEmbeddingRequest struct { type OllamaEmbeddingRequest struct {
Model string `json:"model"` Model string `json:"model"`
Input interface{} `json:"input"` Input interface{} `json:"input"`
Options map[string]any `json:"options,omitempty"` Options map[string]any `json:"options,omitempty"`
Dimensions int `json:"dimensions,omitempty"` Dimensions int `json:"dimensions,omitempty"`
} }
type OllamaEmbeddingResponse struct { type OllamaEmbeddingResponse struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Model string `json:"model"` Model string `json:"model"`
Embeddings [][]float64 `json:"embeddings"` Embeddings [][]float64 `json:"embeddings"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"` PromptEvalCount int `json:"prompt_eval_count,omitempty"`
} }

View File

@@ -35,13 +35,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
} }
// options mapping // options mapping
if r.Temperature != nil { chatReq.Options["temperature"] = r.Temperature } if r.Temperature != nil {
if r.TopP != 0 { chatReq.Options["top_p"] = r.TopP } chatReq.Options["temperature"] = r.Temperature
if r.TopK != 0 { chatReq.Options["top_k"] = r.TopK } }
if r.FrequencyPenalty != 0 { chatReq.Options["frequency_penalty"] = r.FrequencyPenalty } if r.TopP != 0 {
if r.PresencePenalty != 0 { chatReq.Options["presence_penalty"] = r.PresencePenalty } chatReq.Options["top_p"] = r.TopP
if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) } }
if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) } if r.TopK != 0 {
chatReq.Options["top_k"] = r.TopK
}
if r.FrequencyPenalty != 0 {
chatReq.Options["frequency_penalty"] = r.FrequencyPenalty
}
if r.PresencePenalty != 0 {
chatReq.Options["presence_penalty"] = r.PresencePenalty
}
if r.Seed != 0 {
chatReq.Options["seed"] = int(r.Seed)
}
if mt := r.GetMaxTokens(); mt != 0 {
chatReq.Options["num_predict"] = int(mt)
}
if r.Stop != nil { if r.Stop != nil {
switch v := r.Stop.(type) { switch v := r.Stop.(type) {
@@ -50,21 +64,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
case []string: case []string:
chatReq.Options["stop"] = v chatReq.Options["stop"] = v
case []any: case []any:
arr := make([]string,0,len(v)) arr := make([]string, 0, len(v))
for _, i := range v { if s,ok:=i.(string); ok { arr = append(arr,s) } } for _, i := range v {
if len(arr)>0 { chatReq.Options["stop"] = arr } if s, ok := i.(string); ok {
arr = append(arr, s)
}
}
if len(arr) > 0 {
chatReq.Options["stop"] = arr
}
} }
} }
if len(r.Tools) > 0 { if len(r.Tools) > 0 {
tools := make([]OllamaTool,0,len(r.Tools)) tools := make([]OllamaTool, 0, len(r.Tools))
for _, t := range r.Tools { for _, t := range r.Tools {
tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}}) tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}})
} }
chatReq.Tools = tools chatReq.Tools = tools
} }
chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages)) chatReq.Messages = make([]OllamaChatMessage, 0, len(r.Messages))
for _, m := range r.Messages { for _, m := range r.Messages {
var textBuilder strings.Builder var textBuilder strings.Builder
var images []string var images []string
@@ -79,14 +99,20 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
var base64Data string var base64Data string
if strings.HasPrefix(img.Url, "http") { if strings.HasPrefix(img.Url, "http") {
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat") fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
if err != nil { return nil, err } if err != nil {
return nil, err
}
base64Data = fileData.Base64Data base64Data = fileData.Base64Data
} else if strings.HasPrefix(img.Url, "data:") { } else if strings.HasPrefix(img.Url, "data:") {
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) { base64Data = img.Url[idx+1:] } if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) {
base64Data = img.Url[idx+1:]
}
} else { } else {
base64Data = img.Url base64Data = img.Url
} }
if base64Data != "" { images = append(images, base64Data) } if base64Data != "" {
images = append(images, base64Data)
}
} }
} else if part.Type == dto.ContentTypeText { } else if part.Type == dto.ContentTypeText {
textBuilder.WriteString(part.Text) textBuilder.WriteString(part.Text)
@@ -94,16 +120,24 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
} }
} }
cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()} cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
if len(images)>0 { cm.Images = images } if len(images) > 0 {
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name } cm.Images = images
}
if m.Role == "tool" && m.Name != nil {
cm.ToolName = *m.Name
}
if m.ToolCalls != nil && len(m.ToolCalls) > 0 { if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
parsed := m.ParseToolCalls() parsed := m.ParseToolCalls()
if len(parsed) > 0 { if len(parsed) > 0 {
calls := make([]OllamaToolCall,0,len(parsed)) calls := make([]OllamaToolCall, 0, len(parsed))
for _, tc := range parsed { for _, tc := range parsed {
var args interface{} var args interface{}
if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) } if tc.Function.Arguments != "" {
if args==nil { args = map[string]any{} } _ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
}
if args == nil {
args = map[string]any{}
}
oc := OllamaToolCall{} oc := OllamaToolCall{}
oc.Function.Name = tc.Function.Name oc.Function.Name = tc.Function.Name
oc.Function.Arguments = args oc.Function.Arguments = args
@@ -132,28 +166,67 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
gen.Prompt = v gen.Prompt = v
case []any: case []any:
var sb strings.Builder var sb strings.Builder
for _, it := range v { if s,ok:=it.(string); ok { sb.WriteString(s) } } for _, it := range v {
if s, ok := it.(string); ok {
sb.WriteString(s)
}
}
gen.Prompt = sb.String() gen.Prompt = sb.String()
default: default:
gen.Prompt = fmt.Sprintf("%v", r.Prompt) gen.Prompt = fmt.Sprintf("%v", r.Prompt)
} }
} }
if r.Suffix != nil { if s,ok:=r.Suffix.(string); ok { gen.Suffix = s } } if r.Suffix != nil {
if r.ResponseFormat != nil { if s, ok := r.Suffix.(string); ok {
if r.ResponseFormat.Type == "json" { gen.Format = "json" } else if r.ResponseFormat.Type == "json_schema" { var schema any; _ = json.Unmarshal(r.ResponseFormat.JsonSchema,&schema); gen.Format=schema } gen.Suffix = s
}
}
if r.ResponseFormat != nil {
if r.ResponseFormat.Type == "json" {
gen.Format = "json"
} else if r.ResponseFormat.Type == "json_schema" {
var schema any
_ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema)
gen.Format = schema
}
}
if r.Temperature != nil {
gen.Options["temperature"] = r.Temperature
}
if r.TopP != 0 {
gen.Options["top_p"] = r.TopP
}
if r.TopK != 0 {
gen.Options["top_k"] = r.TopK
}
if r.FrequencyPenalty != 0 {
gen.Options["frequency_penalty"] = r.FrequencyPenalty
}
if r.PresencePenalty != 0 {
gen.Options["presence_penalty"] = r.PresencePenalty
}
if r.Seed != 0 {
gen.Options["seed"] = int(r.Seed)
}
if mt := r.GetMaxTokens(); mt != 0 {
gen.Options["num_predict"] = int(mt)
} }
if r.Temperature != nil { gen.Options["temperature"] = r.Temperature }
if r.TopP != 0 { gen.Options["top_p"] = r.TopP }
if r.TopK != 0 { gen.Options["top_k"] = r.TopK }
if r.FrequencyPenalty != 0 { gen.Options["frequency_penalty"] = r.FrequencyPenalty }
if r.PresencePenalty != 0 { gen.Options["presence_penalty"] = r.PresencePenalty }
if r.Seed != 0 { gen.Options["seed"] = int(r.Seed) }
if mt := r.GetMaxTokens(); mt != 0 { gen.Options["num_predict"] = int(mt) }
if r.Stop != nil { if r.Stop != nil {
switch v := r.Stop.(type) { switch v := r.Stop.(type) {
case string: gen.Options["stop"] = []string{v} case string:
case []string: gen.Options["stop"] = v gen.Options["stop"] = []string{v}
case []any: arr:=make([]string,0,len(v)); for _,i:= range v { if s,ok:=i.(string); ok { arr=append(arr,s) } }; if len(arr)>0 { gen.Options["stop"]=arr } case []string:
gen.Options["stop"] = v
case []any:
arr := make([]string, 0, len(v))
for _, i := range v {
if s, ok := i.(string); ok {
arr = append(arr, s)
}
}
if len(arr) > 0 {
gen.Options["stop"] = arr
}
} }
} }
return gen, nil return gen, nil
@@ -161,30 +234,51 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest { func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest {
opts := map[string]any{} opts := map[string]any{}
if r.Temperature != nil { opts["temperature"] = r.Temperature } if r.Temperature != nil {
if r.TopP != 0 { opts["top_p"] = r.TopP } opts["temperature"] = r.Temperature
if r.FrequencyPenalty != 0 { opts["frequency_penalty"] = r.FrequencyPenalty } }
if r.PresencePenalty != 0 { opts["presence_penalty"] = r.PresencePenalty } if r.TopP != 0 {
if r.Seed != 0 { opts["seed"] = int(r.Seed) } opts["top_p"] = r.TopP
if r.Dimensions != 0 { opts["dimensions"] = r.Dimensions } }
if r.FrequencyPenalty != 0 {
opts["frequency_penalty"] = r.FrequencyPenalty
}
if r.PresencePenalty != 0 {
opts["presence_penalty"] = r.PresencePenalty
}
if r.Seed != 0 {
opts["seed"] = int(r.Seed)
}
if r.Dimensions != 0 {
opts["dimensions"] = r.Dimensions
}
input := r.ParseInput() input := r.ParseInput()
if len(input)==1 { return &OllamaEmbeddingRequest{Model:r.Model, Input: input[0], Options: opts, Dimensions:r.Dimensions} } if len(input) == 1 {
return &OllamaEmbeddingRequest{Model:r.Model, Input: input, Options: opts, Dimensions:r.Dimensions} return &OllamaEmbeddingRequest{Model: r.Model, Input: input[0], Options: opts, Dimensions: r.Dimensions}
}
return &OllamaEmbeddingRequest{Model: r.Model, Input: input, Options: opts, Dimensions: r.Dimensions}
} }
func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
var oResp OllamaEmbeddingResponse var oResp OllamaEmbeddingResponse
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp) service.CloseResponseBodyGracefully(resp)
if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if err = common.Unmarshal(body, &oResp); err != nil {
if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
data := make([]dto.OpenAIEmbeddingResponseItem,0,len(oResp.Embeddings)) }
for i, emb := range oResp.Embeddings { data = append(data, dto.OpenAIEmbeddingResponseItem{Index:i,Object:"embedding",Embedding:emb}) } if oResp.Error != "" {
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens:0, TotalTokens: oResp.PromptEvalCount} return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
embResp := &dto.OpenAIEmbeddingResponse{Object:"list", Data:data, Model: info.UpstreamModelName, Usage:*usage} }
data := make([]dto.OpenAIEmbeddingResponseItem, 0, len(oResp.Embeddings))
for i, emb := range oResp.Embeddings {
data = append(data, dto.OpenAIEmbeddingResponseItem{Index: i, Object: "embedding", Embedding: emb})
}
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens: 0, TotalTokens: oResp.PromptEvalCount}
embResp := &dto.OpenAIEmbeddingResponse{Object: "list", Data: data, Model: info.UpstreamModelName, Usage: *usage}
out, _ := common.Marshal(embResp) out, _ := common.Marshal(embResp)
service.IOCopyBytesGracefully(c, resp, out) service.IOCopyBytesGracefully(c, resp, out)
return usage, nil return usage, nil
} }

View File

@@ -1,210 +1,278 @@
package ollama package ollama
import ( import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/dto" "one-api/dto"
"one-api/logger" "one-api/logger"
relaycommon "one-api/relay/common" relaycommon "one-api/relay/common"
"one-api/relay/helper" "one-api/relay/helper"
"one-api/service" "one-api/service"
"one-api/types" "one-api/types"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type ollamaChatStreamChunk struct { type ollamaChatStreamChunk struct {
Model string `json:"model"` Model string `json:"model"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
// chat // chat
Message *struct { Message *struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Thinking json.RawMessage `json:"thinking"` Thinking json.RawMessage `json:"thinking"`
ToolCalls []struct { ToolCalls []struct {
Function struct { Function struct {
Name string `json:"name"` Name string `json:"name"`
Arguments interface{} `json:"arguments"` Arguments interface{} `json:"arguments"`
} `json:"function"` } `json:"function"`
} `json:"tool_calls"` } `json:"tool_calls"`
} `json:"message"` } `json:"message"`
// generate // generate
Response string `json:"response"` Response string `json:"response"`
Done bool `json:"done"` Done bool `json:"done"`
DoneReason string `json:"done_reason"` DoneReason string `json:"done_reason"`
TotalDuration int64 `json:"total_duration"` TotalDuration int64 `json:"total_duration"`
LoadDuration int64 `json:"load_duration"` LoadDuration int64 `json:"load_duration"`
PromptEvalCount int `json:"prompt_eval_count"` PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"` EvalCount int `json:"eval_count"`
PromptEvalDuration int64 `json:"prompt_eval_duration"` PromptEvalDuration int64 `json:"prompt_eval_duration"`
EvalDuration int64 `json:"eval_duration"` EvalDuration int64 `json:"eval_duration"`
} }
func toUnix(ts string) int64 { func toUnix(ts string) int64 {
if ts == "" { return time.Now().Unix() } if ts == "" {
// try time.RFC3339 or with nanoseconds return time.Now().Unix()
t, err := time.Parse(time.RFC3339Nano, ts) }
if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() } // try time.RFC3339 or with nanoseconds
return t.Unix() t, err := time.Parse(time.RFC3339Nano, ts)
if err != nil {
t2, err2 := time.Parse(time.RFC3339, ts)
if err2 == nil {
return t2.Unix()
}
return time.Now().Unix()
}
return t.Unix()
} }
func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) } if resp == nil || resp.Body == nil {
defer service.CloseResponseBodyGracefully(resp) return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest)
}
defer service.CloseResponseBodyGracefully(resp)
helper.SetEventStreamHeaders(c) helper.SetEventStreamHeaders(c)
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
usage := &dto.Usage{} usage := &dto.Usage{}
var model = info.UpstreamModelName var model = info.UpstreamModelName
var responseId = common.GetUUID() var responseId = common.GetUUID()
var created = time.Now().Unix() var created = time.Now().Unix()
var toolCallIndex int var toolCallIndex int
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil) start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) } if data, err := common.Marshal(start); err == nil {
_ = helper.StringData(c, string(data))
}
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" { continue } if line == "" {
var chunk ollamaChatStreamChunk continue
if err := json.Unmarshal([]byte(line), &chunk); err != nil { }
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line) var chunk ollamaChatStreamChunk
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) if err := json.Unmarshal([]byte(line), &chunk); err != nil {
} logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
if chunk.Model != "" { model = chunk.Model } return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
created = toUnix(chunk.CreatedAt) }
if chunk.Model != "" {
model = chunk.Model
}
created = toUnix(chunk.CreatedAt)
if !chunk.Done { if !chunk.Done {
// delta content // delta content
var content string var content string
if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response } if chunk.Message != nil {
delta := dto.ChatCompletionsStreamResponse{ content = chunk.Message.Content
Id: responseId, } else {
Object: "chat.completion.chunk", content = chunk.Response
Created: created, }
Model: model, delta := dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{ { Id: responseId,
Index: 0, Object: "chat.completion.chunk",
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" }, Created: created,
} }, Model: model,
} Choices: []dto.ChatCompletionsStreamResponseChoice{{
if content != "" { delta.Choices[0].Delta.SetContentString(content) } Index: 0,
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 { Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"},
raw := strings.TrimSpace(string(chunk.Message.Thinking)) }},
if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) } }
} if content != "" {
// tool calls delta.Choices[0].Delta.SetContentString(content)
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 { }
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls)) if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
for _, tc := range chunk.Message.ToolCalls { raw := strings.TrimSpace(string(chunk.Message.Thinking))
// arguments -> string if raw != "" && raw != "null" {
argBytes, _ := json.Marshal(tc.Function.Arguments) delta.Choices[0].Delta.SetReasoningContent(raw)
toolId := fmt.Sprintf("call_%d", toolCallIndex) }
tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}} }
tr.SetIndex(toolCallIndex) // tool calls
toolCallIndex++ if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr) delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 0, len(chunk.Message.ToolCalls))
} for _, tc := range chunk.Message.ToolCalls {
} // arguments -> string
if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) } argBytes, _ := json.Marshal(tc.Function.Arguments)
continue toolId := fmt.Sprintf("call_%d", toolCallIndex)
} tr := dto.ToolCallResponse{ID: toolId, Type: "function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
// done frame tr.SetIndex(toolCallIndex)
// finalize once and break loop toolCallIndex++
usage.PromptTokens = chunk.PromptEvalCount delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
usage.CompletionTokens = chunk.EvalCount }
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens }
finishReason := chunk.DoneReason if data, err := common.Marshal(delta); err == nil {
if finishReason == "" { finishReason = "stop" } _ = helper.StringData(c, string(data))
// emit stop delta }
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil { continue
if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) } }
} // done frame
// emit usage frame // finalize once and break loop
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil { usage.PromptTokens = chunk.PromptEvalCount
if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) } usage.CompletionTokens = chunk.EvalCount
} usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
// send [DONE] finishReason := chunk.DoneReason
helper.Done(c) if finishReason == "" {
break finishReason = "stop"
} }
if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) } // emit stop delta
return usage, nil if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
if data, err := common.Marshal(stop); err == nil {
_ = helper.StringData(c, string(data))
}
}
// emit usage frame
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
if data, err := common.Marshal(final); err == nil {
_ = helper.StringData(c, string(data))
}
}
// send [DONE]
helper.Done(c)
break
}
if err := scanner.Err(); err != nil && err != io.EOF {
logger.LogError(c, "ollama stream scan error: "+err.Error())
}
return usage, nil
} }
// non-stream handler for chat/generate // non-stream handler for chat/generate
func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } if err != nil {
service.CloseResponseBodyGracefully(resp) return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
raw := string(body) }
if common.DebugEnabled { println("ollama non-stream raw resp:", raw) } service.CloseResponseBodyGracefully(resp)
raw := string(body)
if common.DebugEnabled {
println("ollama non-stream raw resp:", raw)
}
lines := strings.Split(raw, "\n") lines := strings.Split(raw, "\n")
var ( var (
aggContent strings.Builder aggContent strings.Builder
reasoningBuilder strings.Builder reasoningBuilder strings.Builder
lastChunk ollamaChatStreamChunk lastChunk ollamaChatStreamChunk
parsedAny bool parsedAny bool
) )
for _, ln := range lines { for _, ln := range lines {
ln = strings.TrimSpace(ln) ln = strings.TrimSpace(ln)
if ln == "" { continue } if ln == "" {
var ck ollamaChatStreamChunk continue
if err := json.Unmarshal([]byte(ln), &ck); err != nil { }
if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } var ck ollamaChatStreamChunk
continue if err := json.Unmarshal([]byte(ln), &ck); err != nil {
} if len(lines) == 1 {
parsedAny = true return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
lastChunk = ck }
if ck.Message != nil && len(ck.Message.Thinking) > 0 { continue
raw := strings.TrimSpace(string(ck.Message.Thinking)) }
if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } parsedAny = true
} lastChunk = ck
if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) } if ck.Message != nil && len(ck.Message.Thinking) > 0 {
} raw := strings.TrimSpace(string(ck.Message.Thinking))
if raw != "" && raw != "null" {
reasoningBuilder.WriteString(raw)
}
}
if ck.Message != nil && ck.Message.Content != "" {
aggContent.WriteString(ck.Message.Content)
} else if ck.Response != "" {
aggContent.WriteString(ck.Response)
}
}
if !parsedAny { if !parsedAny {
var single ollamaChatStreamChunk var single ollamaChatStreamChunk
if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if err := json.Unmarshal(body, &single); err != nil {
lastChunk = single return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
if single.Message != nil { }
if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } } lastChunk = single
aggContent.WriteString(single.Message.Content) if single.Message != nil {
} else { aggContent.WriteString(single.Response) } if len(single.Message.Thinking) > 0 {
} raw := strings.TrimSpace(string(single.Message.Thinking))
if raw != "" && raw != "null" {
reasoningBuilder.WriteString(raw)
}
}
aggContent.WriteString(single.Message.Content)
} else {
aggContent.WriteString(single.Response)
}
}
model := lastChunk.Model model := lastChunk.Model
if model == "" { model = info.UpstreamModelName } if model == "" {
created := toUnix(lastChunk.CreatedAt) model = info.UpstreamModelName
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount} }
content := aggContent.String() created := toUnix(lastChunk.CreatedAt)
finishReason := lastChunk.DoneReason usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
if finishReason == "" { finishReason = "stop" } content := aggContent.String()
finishReason := lastChunk.DoneReason
if finishReason == "" {
finishReason = "stop"
}
msg := dto.Message{Role: "assistant", Content: contentPtr(content)} msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc } if rc := reasoningBuilder.String(); rc != "" {
full := dto.OpenAITextResponse{ msg.ReasoningContent = rc
Id: common.GetUUID(), }
Model: model, full := dto.OpenAITextResponse{
Object: "chat.completion", Id: common.GetUUID(),
Created: created, Model: model,
Choices: []dto.OpenAITextResponseChoice{ { Object: "chat.completion",
Index: 0, Created: created,
Message: msg, Choices: []dto.OpenAITextResponseChoice{{
FinishReason: finishReason, Index: 0,
} }, Message: msg,
Usage: *usage, FinishReason: finishReason,
} }},
out, _ := common.Marshal(full) Usage: *usage,
service.IOCopyBytesGracefully(c, resp, out) }
return usage, nil out, _ := common.Marshal(full)
service.IOCopyBytesGracefully(c, resp, out)
return usage, nil
} }
func contentPtr(s string) *string { if s=="" { return nil }; return &s } func contentPtr(s string) *string {
if s == "" {
return nil
}
return &s
}

View File

@@ -187,7 +187,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
} }
req.Set("Authorization", "Bearer "+accessToken) req.Set("Authorization", "Bearer "+accessToken)
} }
if a.AccountCredentials.ProjectID != "" { if a.AccountCredentials.ProjectID != "" {
req.Set("x-goog-user-project", a.AccountCredentials.ProjectID) req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
} }
return nil return nil

View File

@@ -2,17 +2,34 @@ package operation_setting
import "one-api/setting/config" import "one-api/setting/config"
// 额度展示类型
const (
QuotaDisplayTypeUSD = "USD"
QuotaDisplayTypeCNY = "CNY"
QuotaDisplayTypeTokens = "TOKENS"
QuotaDisplayTypeCustom = "CUSTOM"
)
type GeneralSetting struct { type GeneralSetting struct {
DocsLink string `json:"docs_link"` DocsLink string `json:"docs_link"`
PingIntervalEnabled bool `json:"ping_interval_enabled"` PingIntervalEnabled bool `json:"ping_interval_enabled"`
PingIntervalSeconds int `json:"ping_interval_seconds"` PingIntervalSeconds int `json:"ping_interval_seconds"`
// 当前站点额度展示类型USD / CNY / TOKENS
QuotaDisplayType string `json:"quota_display_type"`
// 自定义货币符号,用于 CUSTOM 展示类型
CustomCurrencySymbol string `json:"custom_currency_symbol"`
// 自定义货币与美元汇率1 USD = X Custom
CustomCurrencyExchangeRate float64 `json:"custom_currency_exchange_rate"`
} }
// 默认配置 // 默认配置
var generalSetting = GeneralSetting{ var generalSetting = GeneralSetting{
DocsLink: "https://docs.newapi.pro", DocsLink: "https://docs.newapi.pro",
PingIntervalEnabled: false, PingIntervalEnabled: false,
PingIntervalSeconds: 60, PingIntervalSeconds: 60,
QuotaDisplayType: QuotaDisplayTypeUSD,
CustomCurrencySymbol: "¤",
CustomCurrencyExchangeRate: 1.0,
} }
func init() { func init() {
@@ -23,3 +40,52 @@ func init() {
func GetGeneralSetting() *GeneralSetting { func GetGeneralSetting() *GeneralSetting {
return &generalSetting return &generalSetting
} }
// IsCurrencyDisplay 是否以货币形式展示(美元或人民币)
func IsCurrencyDisplay() bool {
return generalSetting.QuotaDisplayType != QuotaDisplayTypeTokens
}
// IsCNYDisplay 是否以人民币展示
func IsCNYDisplay() bool {
return generalSetting.QuotaDisplayType == QuotaDisplayTypeCNY
}
// GetQuotaDisplayType 返回额度展示类型
func GetQuotaDisplayType() string {
return generalSetting.QuotaDisplayType
}
// GetCurrencySymbol 返回当前展示类型对应符号
func GetCurrencySymbol() string {
switch generalSetting.QuotaDisplayType {
case QuotaDisplayTypeUSD:
return "$"
case QuotaDisplayTypeCNY:
return "¥"
case QuotaDisplayTypeCustom:
if generalSetting.CustomCurrencySymbol != "" {
return generalSetting.CustomCurrencySymbol
}
return "¤"
default:
return ""
}
}
// GetUsdToCurrencyRate 返回 1 USD = X <currency> 的 XTOKENS 不适用)
func GetUsdToCurrencyRate(usdToCny float64) float64 {
switch generalSetting.QuotaDisplayType {
case QuotaDisplayTypeUSD:
return 1
case QuotaDisplayTypeCNY:
return usdToCny
case QuotaDisplayTypeCustom:
if generalSetting.CustomCurrencyExchangeRate > 0 {
return generalSetting.CustomCurrencyExchangeRate
}
return 1
default:
return 1
}
}

View File

@@ -10,7 +10,7 @@
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用" content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用"
/> />
<title>New API</title> <title>New API</title>
<analytics></analytics> <analytics></analytics>
</head> </head>
<body> <body>

View File

@@ -6,4 +6,4 @@
} }
}, },
"include": ["src/**/*"] "include": ["src/**/*"]
} }

View File

@@ -135,7 +135,9 @@ const TwoFactorAuthModal = ({
autoFocus autoFocus
/> />
<Typography.Text type='tertiary' size='small' className='mt-2 block'> <Typography.Text type='tertiary' size='small' className='mt-2 block'>
{t('支持6位TOTP验证码或8位备用码可到`个人设置-安全设置-两步验证设置`配置或查看。')} {t(
'支持6位TOTP验证码或8位备用码可到`个人设置-安全设置-两步验证设置`配置或查看。',
)}
</Typography.Text> </Typography.Text>
</div> </div>
</div> </div>

View File

@@ -42,7 +42,7 @@ const OperationSetting = () => {
QuotaPerUnit: 0, QuotaPerUnit: 0,
USDExchangeRate: 0, USDExchangeRate: 0,
RetryTimes: 0, RetryTimes: 0,
DisplayInCurrencyEnabled: false, 'general_setting.quota_display_type': 'USD',
DisplayTokenStatEnabled: false, DisplayTokenStatEnabled: false,
DefaultCollapseSidebar: false, DefaultCollapseSidebar: false,
DemoSiteEnabled: false, DemoSiteEnabled: false,

View File

@@ -45,7 +45,6 @@ import { useTranslation } from 'react-i18next';
const SystemSetting = () => { const SystemSetting = () => {
const { t } = useTranslation(); const { t } = useTranslation();
let [inputs, setInputs] = useState({ let [inputs, setInputs] = useState({
PasswordLoginEnabled: '', PasswordLoginEnabled: '',
PasswordRegisterEnabled: '', PasswordRegisterEnabled: '',
EmailVerificationEnabled: '', EmailVerificationEnabled: '',
@@ -188,7 +187,9 @@ const SystemSetting = () => {
setInputs(newInputs); setInputs(newInputs);
setOriginInputs(newInputs); setOriginInputs(newInputs);
// 同步模式布尔到本地状态 // 同步模式布尔到本地状态
if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') { if (
typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined'
) {
setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']); setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
} }
if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') { if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
@@ -695,14 +696,17 @@ const SystemSetting = () => {
noLabel noLabel
extraText={t('SSRF防护开关详细说明')} extraText={t('SSRF防护开关详细说明')}
onChange={(e) => onChange={(e) =>
handleCheckboxChange('fetch_setting.enable_ssrf_protection', e) handleCheckboxChange(
'fetch_setting.enable_ssrf_protection',
e,
)
} }
> >
{t('启用SSRF防护推荐开启以保护服务器安全')} {t('启用SSRF防护推荐开启以保护服务器安全')}
</Form.Checkbox> </Form.Checkbox>
</Col> </Col>
</Row> </Row>
<Row <Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
@@ -713,14 +717,19 @@ const SystemSetting = () => {
noLabel noLabel
extraText={t('私有IP访问详细说明')} extraText={t('私有IP访问详细说明')}
onChange={(e) => onChange={(e) =>
handleCheckboxChange('fetch_setting.allow_private_ip', e) handleCheckboxChange(
'fetch_setting.allow_private_ip',
e,
)
} }
> >
{t('允许访问私有IP地址127.0.0.1、192.168.x.x等内网地址')} {t(
'允许访问私有IP地址127.0.0.1、192.168.x.x等内网地址',
)}
</Form.Checkbox> </Form.Checkbox>
</Col> </Col>
</Row> </Row>
<Row <Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
@@ -731,7 +740,10 @@ const SystemSetting = () => {
noLabel noLabel
extraText={t('域名IP过滤详细说明')} extraText={t('域名IP过滤详细说明')}
onChange={(e) => onChange={(e) =>
handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e) handleCheckboxChange(
'fetch_setting.apply_ip_filter_for_domain',
e,
)
} }
style={{ marginBottom: 8 }} style={{ marginBottom: 8 }}
> >
@@ -740,17 +752,23 @@ const SystemSetting = () => {
<Text strong> <Text strong>
{t(domainFilterMode ? '域名白名单' : '域名黑名单')} {t(domainFilterMode ? '域名白名单' : '域名黑名单')}
</Text> </Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> <Text
{t('支持通配符格式example.com, *.api.example.com')} type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t(
'支持通配符格式example.com, *.api.example.com',
)}
</Text> </Text>
<Radio.Group <Radio.Group
type='button' type='button'
value={domainFilterMode ? 'whitelist' : 'blacklist'} value={domainFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => { onChange={(val) => {
const selected = val && val.target ? val.target.value : val; const selected =
val && val.target ? val.target.value : val;
const isWhitelist = selected === 'whitelist'; const isWhitelist = selected === 'whitelist';
setDomainFilterMode(isWhitelist); setDomainFilterMode(isWhitelist);
setInputs(prev => ({ setInputs((prev) => ({
...prev, ...prev,
'fetch_setting.domain_filter_mode': isWhitelist, 'fetch_setting.domain_filter_mode': isWhitelist,
})); }));
@@ -765,9 +783,9 @@ const SystemSetting = () => {
onChange={(value) => { onChange={(value) => {
setDomainList(value); setDomainList(value);
// 触发Form的onChange事件 // 触发Form的onChange事件
setInputs(prev => ({ setInputs((prev) => ({
...prev, ...prev,
'fetch_setting.domain_list': value 'fetch_setting.domain_list': value,
})); }));
}} }}
placeholder={t('输入域名后回车example.com')} placeholder={t('输入域名后回车example.com')}
@@ -784,17 +802,21 @@ const SystemSetting = () => {
<Text strong> <Text strong>
{t(ipFilterMode ? 'IP白名单' : 'IP黑名单')} {t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
</Text> </Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> <Text
type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t('支持CIDR格式8.8.8.8, 192.168.1.0/24')} {t('支持CIDR格式8.8.8.8, 192.168.1.0/24')}
</Text> </Text>
<Radio.Group <Radio.Group
type='button' type='button'
value={ipFilterMode ? 'whitelist' : 'blacklist'} value={ipFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => { onChange={(val) => {
const selected = val && val.target ? val.target.value : val; const selected =
val && val.target ? val.target.value : val;
const isWhitelist = selected === 'whitelist'; const isWhitelist = selected === 'whitelist';
setIpFilterMode(isWhitelist); setIpFilterMode(isWhitelist);
setInputs(prev => ({ setInputs((prev) => ({
...prev, ...prev,
'fetch_setting.ip_filter_mode': isWhitelist, 'fetch_setting.ip_filter_mode': isWhitelist,
})); }));
@@ -809,9 +831,9 @@ const SystemSetting = () => {
onChange={(value) => { onChange={(value) => {
setIpList(value); setIpList(value);
// 触发Form的onChange事件 // 触发Form的onChange事件
setInputs(prev => ({ setInputs((prev) => ({
...prev, ...prev,
'fetch_setting.ip_list': value 'fetch_setting.ip_list': value,
})); }));
}} }}
placeholder={t('输入IP地址后回车8.8.8.8')} placeholder={t('输入IP地址后回车8.8.8.8')}
@@ -826,7 +848,10 @@ const SystemSetting = () => {
> >
<Col xs={24} sm={24} md={24} lg={24} xl={24}> <Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Text strong>{t('允许的端口')}</Text> <Text strong>{t('允许的端口')}</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> <Text
type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t('支持单个端口和端口范围80, 443, 8000-8999')} {t('支持单个端口和端口范围80, 443, 8000-8999')}
</Text> </Text>
<TagInput <TagInput
@@ -834,15 +859,18 @@ const SystemSetting = () => {
onChange={(value) => { onChange={(value) => {
setAllowedPorts(value); setAllowedPorts(value);
// 触发Form的onChange事件 // 触发Form的onChange事件
setInputs(prev => ({ setInputs((prev) => ({
...prev, ...prev,
'fetch_setting.allowed_ports': value 'fetch_setting.allowed_ports': value,
})); }));
}} }}
placeholder={t('输入端口后回车80 或 8000-8999')} placeholder={t('输入端口后回车80 或 8000-8999')}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> <Text
type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t('端口配置详细说明')} {t('端口配置详细说明')}
</Text> </Text>
</Col> </Col>

View File

@@ -85,7 +85,8 @@ const AccountManagement = ({
); );
}; };
const isBound = (accountId) => Boolean(accountId); const isBound = (accountId) => Boolean(accountId);
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false); const [showTelegramBindModal, setShowTelegramBindModal] =
React.useState(false);
return ( return (
<Card className='!rounded-2xl'> <Card className='!rounded-2xl'>
@@ -226,7 +227,8 @@ const AccountManagement = ({
onGitHubOAuthClicked(status.github_client_id) onGitHubOAuthClicked(status.github_client_id)
} }
disabled={ disabled={
isBound(userState.user?.github_id) || !status.github_oauth isBound(userState.user?.github_id) ||
!status.github_oauth
} }
> >
{status.github_oauth ? t('绑定') : t('未启用')} {status.github_oauth ? t('绑定') : t('未启用')}
@@ -384,7 +386,8 @@ const AccountManagement = ({
onLinuxDOOAuthClicked(status.linuxdo_client_id) onLinuxDOOAuthClicked(status.linuxdo_client_id)
} }
disabled={ disabled={
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth isBound(userState.user?.linux_do_id) ||
!status.linuxdo_oauth
} }
> >
{status.linuxdo_oauth ? t('绑定') : t('未启用')} {status.linuxdo_oauth ? t('绑定') : t('未启用')}

View File

@@ -87,23 +87,7 @@ const REGION_EXAMPLE = {
// 支持并且已适配通过接口获取模型列表的渠道类型 // 支持并且已适配通过接口获取模型列表的渠道类型
const MODEL_FETCHABLE_TYPES = new Set([ const MODEL_FETCHABLE_TYPES = new Set([
1, 1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43,
4,
14,
34,
17,
26,
24,
47,
25,
20,
23,
31,
35,
40,
42,
48,
43,
]); ]);
function type2secretPrompt(type) { function type2secretPrompt(type) {
@@ -348,7 +332,10 @@ const EditChannelModal = (props) => {
break; break;
case 45: case 45:
localModels = getChannelModels(value); localModels = getChannelModels(value);
setInputs((prevInputs) => ({ ...prevInputs, base_url: 'https://ark.cn-beijing.volces.com' })); setInputs((prevInputs) => ({
...prevInputs,
base_url: 'https://ark.cn-beijing.volces.com',
}));
break; break;
default: default:
localModels = getChannelModels(value); localModels = getChannelModels(value);
@@ -442,7 +429,8 @@ const EditChannelModal = (props) => {
// 读取 Vertex 密钥格式 // 读取 Vertex 密钥格式
data.vertex_key_type = parsedSettings.vertex_key_type || 'json'; data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
// 读取企业账户设置 // 读取企业账户设置
data.is_enterprise_account = parsedSettings.openrouter_enterprise === true; data.is_enterprise_account =
parsedSettings.openrouter_enterprise === true;
} catch (error) { } catch (error) {
console.error('解析其他设置失败:', error); console.error('解析其他设置失败:', error);
data.azure_responses_version = ''; data.azure_responses_version = '';
@@ -868,7 +856,10 @@ const EditChannelModal = (props) => {
showInfo(t('请至少选择一个模型!')); showInfo(t('请至少选择一个模型!'));
return; return;
} }
if (localInputs.type === 45 && (!localInputs.base_url || localInputs.base_url.trim() === '')) { if (
localInputs.type === 45 &&
(!localInputs.base_url || localInputs.base_url.trim() === '')
) {
showInfo(t('请输入API地址')); showInfo(t('请输入API地址'));
return; return;
} }
@@ -912,7 +903,8 @@ const EditChannelModal = (props) => {
} }
} }
// 设置企业账户标识无论是true还是false都要传到后端 // 设置企业账户标识无论是true还是false都要传到后端
settings.openrouter_enterprise = localInputs.is_enterprise_account === true; settings.openrouter_enterprise =
localInputs.is_enterprise_account === true;
localInputs.settings = JSON.stringify(settings); localInputs.settings = JSON.stringify(settings);
} }
@@ -1318,7 +1310,9 @@ const EditChannelModal = (props) => {
setIsEnterpriseAccount(value); setIsEnterpriseAccount(value);
handleInputChange('is_enterprise_account', value); handleInputChange('is_enterprise_account', value);
}} }}
extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')} extraText={t(
'企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选',
)}
initValue={inputs.is_enterprise_account} initValue={inputs.is_enterprise_account}
/> />
)} )}
@@ -1944,27 +1938,27 @@ const EditChannelModal = (props) => {
)} )}
{inputs.type === 45 && ( {inputs.type === 45 && (
<div> <div>
<Form.Select <Form.Select
field='base_url' field='base_url'
label={t('API地址')} label={t('API地址')}
placeholder={t('请选择API地址')} placeholder={t('请选择API地址')}
onChange={(value) => onChange={(value) =>
handleInputChange('base_url', value) handleInputChange('base_url', value)
} }
optionList={[ optionList={[
{ {
value: 'https://ark.cn-beijing.volces.com', value: 'https://ark.cn-beijing.volces.com',
label: 'https://ark.cn-beijing.volces.com' label: 'https://ark.cn-beijing.volces.com',
}, },
{ {
value: 'https://ark.ap-southeast.bytepluses.com', value: 'https://ark.ap-southeast.bytepluses.com',
label: 'https://ark.ap-southeast.bytepluses.com' label: 'https://ark.ap-southeast.bytepluses.com',
} },
]} ]}
defaultValue='https://ark.cn-beijing.volces.com' defaultValue='https://ark.cn-beijing.volces.com'
/> />
</div> </div>
)} )}
</Card> </Card>
)} )}

View File

@@ -56,10 +56,10 @@ const MjLogsFilters = ({
showClear showClear
pure pure
size='small' size='small'
presets={DATE_RANGE_PRESETS.map(preset => ({ presets={DATE_RANGE_PRESETS.map((preset) => ({
text: t(preset.text), text: t(preset.text),
start: preset.start(), start: preset.start(),
end: preset.end() end: preset.end(),
}))} }))}
/> />
</div> </div>

View File

@@ -56,6 +56,7 @@ const PricingDisplaySettings = ({
const currencyItems = [ const currencyItems = [
{ value: 'USD', label: 'USD ($)' }, { value: 'USD', label: 'USD ($)' },
{ value: 'CNY', label: 'CNY (¥)' }, { value: 'CNY', label: 'CNY (¥)' },
{ value: 'CUSTOM', label: t('自定义货币') },
]; ];
const handleChange = (value) => { const handleChange = (value) => {

View File

@@ -107,6 +107,7 @@ const SearchActions = memo(
optionList={[ optionList={[
{ value: 'USD', label: 'USD' }, { value: 'USD', label: 'USD' },
{ value: 'CNY', label: 'CNY' }, { value: 'CNY', label: 'CNY' },
{ value: 'CUSTOM', label: t('自定义货币') },
]} ]}
/> />
)} )}

View File

@@ -36,8 +36,9 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { import {
TASK_ACTION_FIRST_TAIL_GENERATE, TASK_ACTION_FIRST_TAIL_GENERATE,
TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE, TASK_ACTION_GENERATE,
TASK_ACTION_TEXT_GENERATE TASK_ACTION_REFERENCE_GENERATE,
TASK_ACTION_TEXT_GENERATE,
} from '../../../constants/common.constant'; } from '../../../constants/common.constant';
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants'; import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';

View File

@@ -56,10 +56,10 @@ const TaskLogsFilters = ({
showClear showClear
pure pure
size='small' size='small'
presets={DATE_RANGE_PRESETS.map(preset => ({ presets={DATE_RANGE_PRESETS.map((preset) => ({
text: t(preset.text), text: t(preset.text),
start: preset.start(), start: preset.start(),
end: preset.end() end: preset.end(),
}))} }))}
/> />
</div> </div>

View File

@@ -60,38 +60,54 @@ const ContentModal = ({
if (videoError) { if (videoError) {
return ( return (
<div style={{ textAlign: 'center', padding: '40px' }}> <div style={{ textAlign: 'center', padding: '40px' }}>
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}> <Text
type='tertiary'
style={{ display: 'block', marginBottom: '16px' }}
>
视频无法在当前浏览器中播放这可能是由于 视频无法在当前浏览器中播放这可能是由于
</Text> </Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}> <Text
type='tertiary'
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
>
视频服务商的跨域限制 视频服务商的跨域限制
</Text> </Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}> <Text
type='tertiary'
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
>
需要特定的请求头或认证 需要特定的请求头或认证
</Text> </Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}> <Text
type='tertiary'
style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}
>
防盗链保护机制 防盗链保护机制
</Text> </Text>
<div style={{ marginTop: '20px' }}> <div style={{ marginTop: '20px' }}>
<Button <Button
icon={<IconExternalOpen />} icon={<IconExternalOpen />}
onClick={handleOpenInNewTab} onClick={handleOpenInNewTab}
style={{ marginRight: '8px' }} style={{ marginRight: '8px' }}
> >
在新标签页中打开 在新标签页中打开
</Button> </Button>
<Button <Button icon={<IconCopy />} onClick={handleCopyUrl}>
icon={<IconCopy />}
onClick={handleCopyUrl}
>
复制链接 复制链接
</Button> </Button>
</div> </div>
<div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}> <div
<Text style={{
type="tertiary" marginTop: '16px',
padding: '8px',
backgroundColor: '#f8f9fa',
borderRadius: '4px',
}}
>
<Text
type='tertiary'
style={{ fontSize: '10px', wordBreak: 'break-all' }} style={{ fontSize: '10px', wordBreak: 'break-all' }}
> >
{modalContent} {modalContent}
@@ -104,22 +120,24 @@ const ContentModal = ({
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{isLoading && ( {isLoading && (
<div style={{ <div
position: 'absolute', style={{
top: '50%', position: 'absolute',
left: '50%', top: '50%',
transform: 'translate(-50%, -50%)', left: '50%',
zIndex: 10 transform: 'translate(-50%, -50%)',
}}> zIndex: 10,
<Spin size="large" /> }}
>
<Spin size='large' />
</div> </div>
)} )}
<video <video
src={modalContent} src={modalContent}
controls controls
style={{ width: '100%' }} style={{ width: '100%' }}
autoPlay autoPlay
crossOrigin="anonymous" crossOrigin='anonymous'
onError={handleVideoError} onError={handleVideoError}
onLoadedData={handleVideoLoaded} onLoadedData={handleVideoLoaded}
onLoadStart={() => setIsLoading(true)} onLoadStart={() => setIsLoading(true)}
@@ -134,10 +152,10 @@ const ContentModal = ({
onOk={() => setIsModalOpen(false)} onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)} onCancel={() => setIsModalOpen(false)}
closable={null} closable={null}
bodyStyle={{ bodyStyle={{
height: isVideo ? '450px' : '400px', height: isVideo ? '450px' : '400px',
overflow: 'auto', overflow: 'auto',
padding: isVideo && videoError ? '0' : '24px' padding: isVideo && videoError ? '0' : '24px',
}} }}
width={800} width={800}
> >

View File

@@ -57,10 +57,10 @@ const LogsFilters = ({
showClear showClear
pure pure
size='small' size='small'
presets={DATE_RANGE_PRESETS.map(preset => ({ presets={DATE_RANGE_PRESETS.map((preset) => ({
text: t(preset.text), text: t(preset.text),
start: preset.start(), start: preset.start(),
end: preset.end() end: preset.end(),
}))} }))}
/> />
</div> </div>

View File

@@ -30,7 +30,8 @@ import {
Space, Space,
Row, Row,
Col, Col,
Spin, Tooltip Spin,
Tooltip,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'; import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react'; import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
@@ -266,7 +267,8 @@ const RechargeCard = ({
{payMethods && payMethods.length > 0 ? ( {payMethods && payMethods.length > 0 ? (
<Space wrap> <Space wrap>
{payMethods.map((payMethod) => { {payMethods.map((payMethod) => {
const minTopupVal = Number(payMethod.min_topup) || 0; const minTopupVal =
Number(payMethod.min_topup) || 0;
const isStripe = payMethod.type === 'stripe'; const isStripe = payMethod.type === 'stripe';
const disabled = const disabled =
(!enableOnlineTopUp && !isStripe) || (!enableOnlineTopUp && !isStripe) ||
@@ -280,7 +282,9 @@ const RechargeCard = ({
type='tertiary' type='tertiary'
onClick={() => preTopUp(payMethod.type)} onClick={() => preTopUp(payMethod.type)}
disabled={disabled} disabled={disabled}
loading={paymentLoading && payWay === payMethod.type} loading={
paymentLoading && payWay === payMethod.type
}
icon={ icon={
payMethod.type === 'alipay' ? ( payMethod.type === 'alipay' ? (
<SiAlipay size={18} color='#1677FF' /> <SiAlipay size={18} color='#1677FF' />
@@ -291,7 +295,10 @@ const RechargeCard = ({
) : ( ) : (
<CreditCard <CreditCard
size={18} size={18}
color={payMethod.color || 'var(--semi-color-text-2)'} color={
payMethod.color ||
'var(--semi-color-text-2)'
}
/> />
) )
} }
@@ -301,12 +308,22 @@ const RechargeCard = ({
</Button> </Button>
); );
return disabled && minTopupVal > Number(topUpCount || 0) ? ( return disabled &&
<Tooltip content={t('此支付方式最低充值金额为') + ' ' + minTopupVal} key={payMethod.type}> minTopupVal > Number(topUpCount || 0) ? (
<Tooltip
content={
t('此支付方式最低充值金额为') +
' ' +
minTopupVal
}
key={payMethod.type}
>
{buttonEl} {buttonEl}
</Tooltip> </Tooltip>
) : ( ) : (
<React.Fragment key={payMethod.type}>{buttonEl}</React.Fragment> <React.Fragment key={payMethod.type}>
{buttonEl}
</React.Fragment>
); );
})} })}
</Space> </Space>
@@ -324,23 +341,27 @@ const RechargeCard = ({
<Form.Slot label={t('选择充值额度')}> <Form.Slot label={t('选择充值额度')}>
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'> <div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
{presetAmounts.map((preset, index) => { {presetAmounts.map((preset, index) => {
const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0; const discount =
preset.discount ||
topupInfo?.discount?.[preset.value] ||
1.0;
const originalPrice = preset.value * priceRatio; const originalPrice = preset.value * priceRatio;
const discountedPrice = originalPrice * discount; const discountedPrice = originalPrice * discount;
const hasDiscount = discount < 1.0; const hasDiscount = discount < 1.0;
const actualPay = discountedPrice; const actualPay = discountedPrice;
const save = originalPrice - discountedPrice; const save = originalPrice - discountedPrice;
return ( return (
<Card <Card
key={index} key={index}
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
border: selectedPreset === preset.value border:
? '2px solid var(--semi-color-primary)' selectedPreset === preset.value
: '1px solid var(--semi-color-border)', ? '2px solid var(--semi-color-primary)'
: '1px solid var(--semi-color-border)',
height: '100%', height: '100%',
width: '100%' width: '100%',
}} }}
bodyStyle={{ padding: '12px' }} bodyStyle={{ padding: '12px' }}
onClick={() => { onClick={() => {
@@ -352,24 +373,35 @@ const RechargeCard = ({
}} }}
> >
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Typography.Title heading={6} style={{ margin: '0 0 8px 0' }}> <Typography.Title
heading={6}
style={{ margin: '0 0 8px 0' }}
>
<Coins size={18} /> <Coins size={18} />
{formatLargeNumber(preset.value)} {formatLargeNumber(preset.value)}
{hasDiscount && ( {hasDiscount && (
<Tag style={{ marginLeft: 4 }} color="green"> <Tag style={{ marginLeft: 4 }} color='green'>
{t('折').includes('off') ? {t('折').includes('off')
((1 - parseFloat(discount)) * 100).toFixed(1) : ? (
(discount * 10).toFixed(1)}{t('折')} (1 - parseFloat(discount)) *
</Tag> 100
).toFixed(1)
: (discount * 10).toFixed(1)}
{t('折')}
</Tag>
)} )}
</Typography.Title> </Typography.Title>
<div style={{ <div
color: 'var(--semi-color-text-2)', style={{
fontSize: '12px', color: 'var(--semi-color-text-2)',
margin: '4px 0' fontSize: '12px',
}}> margin: '4px 0',
}}
>
{t('实付')} {actualPay.toFixed(2)} {t('实付')} {actualPay.toFixed(2)}
{hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`} {hasDiscount
? `${t('节省')} ${save.toFixed(2)}`
: `${t('节省')} 0.00`}
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -80,11 +80,11 @@ const TopUp = () => {
// 预设充值额度选项 // 预设充值额度选项
const [presetAmounts, setPresetAmounts] = useState([]); const [presetAmounts, setPresetAmounts] = useState([]);
const [selectedPreset, setSelectedPreset] = useState(null); const [selectedPreset, setSelectedPreset] = useState(null);
// 充值配置信息 // 充值配置信息
const [topupInfo, setTopupInfo] = useState({ const [topupInfo, setTopupInfo] = useState({
amount_options: [], amount_options: [],
discount: {} discount: {},
}); });
const topUp = async () => { const topUp = async () => {
@@ -262,9 +262,9 @@ const TopUp = () => {
if (success) { if (success) {
setTopupInfo({ setTopupInfo({
amount_options: data.amount_options || [], amount_options: data.amount_options || [],
discount: data.discount || {} discount: data.discount || {},
}); });
// 处理支付方式 // 处理支付方式
let payMethods = data.pay_methods || []; let payMethods = data.pay_methods || [];
try { try {
@@ -280,10 +280,15 @@ const TopUp = () => {
payMethods = payMethods.map((method) => { payMethods = payMethods.map((method) => {
// 规范化最小充值数 // 规范化最小充值数
const normalizedMinTopup = Number(method.min_topup); const normalizedMinTopup = Number(method.min_topup);
method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0; method.min_topup = Number.isFinite(normalizedMinTopup)
? normalizedMinTopup
: 0;
// Stripe 的最小充值从后端字段回填 // Stripe 的最小充值从后端字段回填
if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) { if (
method.type === 'stripe' &&
(!method.min_topup || method.min_topup <= 0)
) {
const stripeMin = Number(data.stripe_min_topup); const stripeMin = Number(data.stripe_min_topup);
if (Number.isFinite(stripeMin)) { if (Number.isFinite(stripeMin)) {
method.min_topup = stripeMin; method.min_topup = stripeMin;
@@ -313,7 +318,11 @@ const TopUp = () => {
setPayMethods(payMethods); setPayMethods(payMethods);
const enableStripeTopUp = data.enable_stripe_topup || false; const enableStripeTopUp = data.enable_stripe_topup || false;
const enableOnlineTopUp = data.enable_online_topup || false; const enableOnlineTopUp = data.enable_online_topup || false;
const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1; const minTopUpValue = enableOnlineTopUp
? data.min_topup
: enableStripeTopUp
? data.stripe_min_topup
: 1;
setEnableOnlineTopUp(enableOnlineTopUp); setEnableOnlineTopUp(enableOnlineTopUp);
setEnableStripeTopUp(enableStripeTopUp); setEnableStripeTopUp(enableStripeTopUp);
setMinTopUp(minTopUpValue); setMinTopUp(minTopUpValue);
@@ -330,12 +339,12 @@ const TopUp = () => {
console.log('解析支付方式失败:', e); console.log('解析支付方式失败:', e);
setPayMethods([]); setPayMethods([]);
} }
// 如果有自定义充值数量选项,使用它们替换默认的预设选项 // 如果有自定义充值数量选项,使用它们替换默认的预设选项
if (data.amount_options && data.amount_options.length > 0) { if (data.amount_options && data.amount_options.length > 0) {
const customPresets = data.amount_options.map(amount => ({ const customPresets = data.amount_options.map((amount) => ({
value: amount, value: amount,
discount: data.discount[amount] || 1.0 discount: data.discount[amount] || 1.0,
})); }));
setPresetAmounts(customPresets); setPresetAmounts(customPresets);
} }
@@ -483,7 +492,7 @@ const TopUp = () => {
const selectPresetAmount = (preset) => { const selectPresetAmount = (preset) => {
setTopUpCount(preset.value); setTopUpCount(preset.value);
setSelectedPreset(preset.value); setSelectedPreset(preset.value);
// 计算实际支付金额,考虑折扣 // 计算实际支付金额,考虑折扣
const discount = preset.discount || topupInfo.discount[preset.value] || 1.0; const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
const discountedAmount = preset.value * priceRatio * discount; const discountedAmount = preset.value * priceRatio * discount;

View File

@@ -40,9 +40,10 @@ const PaymentConfirmModal = ({
amountNumber, amountNumber,
discountRate, discountRate,
}) => { }) => {
const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; const hasDiscount =
const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0; discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0; const originalAmount = hasDiscount ? amountNumber / discountRate : 0;
const discountAmount = hasDiscount ? originalAmount - amountNumber : 0;
return ( return (
<Modal <Modal
title={ title={

View File

@@ -24,26 +24,26 @@ export const DATE_RANGE_PRESETS = [
{ {
text: '今天', text: '今天',
start: () => dayjs().startOf('day').toDate(), start: () => dayjs().startOf('day').toDate(),
end: () => dayjs().endOf('day').toDate() end: () => dayjs().endOf('day').toDate(),
}, },
{ {
text: '近 7 天', text: '近 7 天',
start: () => dayjs().subtract(6, 'day').startOf('day').toDate(), start: () => dayjs().subtract(6, 'day').startOf('day').toDate(),
end: () => dayjs().endOf('day').toDate() end: () => dayjs().endOf('day').toDate(),
}, },
{ {
text: '本周', text: '本周',
start: () => dayjs().startOf('week').toDate(), start: () => dayjs().startOf('week').toDate(),
end: () => dayjs().endOf('week').toDate() end: () => dayjs().endOf('week').toDate(),
}, },
{ {
text: '近 30 天', text: '近 30 天',
start: () => dayjs().subtract(29, 'day').startOf('day').toDate(), start: () => dayjs().subtract(29, 'day').startOf('day').toDate(),
end: () => dayjs().endOf('day').toDate() end: () => dayjs().endOf('day').toDate(),
}, },
{ {
text: '本月', text: '本月',
start: () => dayjs().startOf('month').toDate(), start: () => dayjs().startOf('month').toDate(),
end: () => dayjs().endOf('month').toDate() end: () => dayjs().endOf('month').toDate(),
}, },
]; ];

View File

@@ -131,13 +131,11 @@ export const buildApiPayload = (
seed: 'seed', seed: 'seed',
}; };
Object.entries(parameterMappings).forEach(([key, param]) => { Object.entries(parameterMappings).forEach(([key, param]) => {
const enabled = parameterEnabled[key]; const enabled = parameterEnabled[key];
const value = inputs[param]; const value = inputs[param];
const hasValue = value !== undefined && value !== null; const hasValue = value !== undefined && value !== null;
if (enabled && hasValue) { if (enabled && hasValue) {
payload[param] = value; payload[param] = value;
} }

View File

@@ -23,7 +23,9 @@ export function setStatusData(data) {
localStorage.setItem('logo', data.logo); localStorage.setItem('logo', data.logo);
localStorage.setItem('footer_html', data.footer_html); localStorage.setItem('footer_html', data.footer_html);
localStorage.setItem('quota_per_unit', data.quota_per_unit); localStorage.setItem('quota_per_unit', data.quota_per_unit);
// 兼容:保留旧字段,同时写入新的额度展示类型
localStorage.setItem('display_in_currency', data.display_in_currency); localStorage.setItem('display_in_currency', data.display_in_currency);
localStorage.setItem('quota_display_type', data.quota_display_type || 'USD');
localStorage.setItem('enable_drawing', data.enable_drawing); localStorage.setItem('enable_drawing', data.enable_drawing);
localStorage.setItem('enable_task', data.enable_task); localStorage.setItem('enable_task', data.enable_task);
localStorage.setItem('enable_data_export', data.enable_data_export); localStorage.setItem('enable_data_export', data.enable_data_export);

View File

@@ -830,12 +830,25 @@ export function renderQuotaNumberWithDigit(num, digits = 2) {
if (typeof num !== 'number' || isNaN(num)) { if (typeof num !== 'number' || isNaN(num)) {
return 0; return 0;
} }
let displayInCurrency = localStorage.getItem('display_in_currency'); const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
num = num.toFixed(digits); num = num.toFixed(digits);
if (displayInCurrency) { if (quotaDisplayType === 'CNY') {
return '¥' + num;
} else if (quotaDisplayType === 'USD') {
return '$' + num; return '$' + num;
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbol = '¤';
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || symbol;
}
} catch (e) {}
return symbol + num;
} else {
return num;
} }
return num;
} }
export function renderNumberWithPoint(num) { export function renderNumberWithPoint(num) {
@@ -887,33 +900,67 @@ export function getQuotaWithUnit(quota, digits = 6) {
} }
export function renderQuotaWithAmount(amount) { export function renderQuotaWithAmount(amount) {
let displayInCurrency = localStorage.getItem('display_in_currency'); const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
displayInCurrency = displayInCurrency === 'true'; if (quotaDisplayType === 'TOKENS') {
if (displayInCurrency) {
return '$' + amount;
} else {
return renderNumber(renderUnitWithQuota(amount)); return renderNumber(renderUnitWithQuota(amount));
} }
if (quotaDisplayType === 'CNY') {
return '¥' + amount;
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbol = '¤';
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || symbol;
}
} catch (e) {}
return symbol + amount;
}
return '$' + amount;
} }
export function renderQuota(quota, digits = 2) { export function renderQuota(quota, digits = 2) {
let quotaPerUnit = localStorage.getItem('quota_per_unit'); let quotaPerUnit = localStorage.getItem('quota_per_unit');
let displayInCurrency = localStorage.getItem('display_in_currency'); const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
quotaPerUnit = parseFloat(quotaPerUnit); quotaPerUnit = parseFloat(quotaPerUnit);
displayInCurrency = displayInCurrency === 'true'; if (quotaDisplayType === 'TOKENS') {
if (displayInCurrency) { return renderNumber(quota);
const result = quota / quotaPerUnit;
const fixedResult = result.toFixed(digits);
// 如果 toFixed 后结果为 0 但原始值不为 0显示最小值
if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
const minValue = Math.pow(10, -digits);
return '$' + minValue.toFixed(digits);
}
return '$' + fixedResult;
} }
return renderNumber(quota); const resultUSD = quota / quotaPerUnit;
let symbol = '$';
let value = resultUSD;
if (quotaDisplayType === 'CNY') {
const statusStr = localStorage.getItem('status');
let usdRate = 1;
try {
if (statusStr) {
const s = JSON.parse(statusStr);
usdRate = s?.usd_exchange_rate || 1;
}
} catch (e) {}
value = resultUSD * usdRate;
symbol = '¥';
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbolCustom = '¤';
let rate = 1;
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbolCustom = s?.custom_currency_symbol || symbolCustom;
rate = s?.custom_currency_exchange_rate || rate;
}
} catch (e) {}
value = resultUSD * rate;
symbol = symbolCustom;
}
const fixedResult = value.toFixed(digits);
if (parseFloat(fixedResult) === 0 && quota > 0 && value > 0) {
const minValue = Math.pow(10, -digits);
return symbol + minValue.toFixed(digits);
}
return symbol + fixedResult;
} }
function isValidGroupRatio(ratio) { function isValidGroupRatio(ratio) {
@@ -1072,7 +1119,7 @@ export function renderModelPrice(
(completionTokens / 1000000) * completionRatioPrice * groupRatio + (completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
(imageGenerationCallPrice * groupRatio); imageGenerationCallPrice * groupRatio;
return ( return (
<> <>
@@ -1510,9 +1557,8 @@ export function renderAudioModelPrice(
} }
export function renderQuotaWithPrompt(quota, digits) { export function renderQuotaWithPrompt(quota, digits) {
let displayInCurrency = localStorage.getItem('display_in_currency'); const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
displayInCurrency = displayInCurrency === 'true'; if (quotaDisplayType !== 'TOKENS') {
if (displayInCurrency) {
return i18next.t('等价金额:') + renderQuota(quota, digits); return i18next.t('等价金额:') + renderQuota(quota, digits);
} }
return ''; return '';

View File

@@ -646,9 +646,25 @@ export const calculateModelPrice = ({
const numCompletion = const numCompletion =
parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor; parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
let symbol = '$';
if (currency === 'CNY') {
symbol = '¥';
} else if (currency === 'CUSTOM') {
try {
const statusStr = localStorage.getItem('status');
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || '¤';
} else {
symbol = '¤';
}
} catch (e) {
symbol = '¤';
}
}
return { return {
inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`, inputPrice: `${symbol}${numInput.toFixed(precision)}`,
completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`, completionPrice: `${symbol}${numCompletion.toFixed(precision)}`,
unitLabel, unitLabel,
isPerToken: true, isPerToken: true,
usedGroup, usedGroup,

View File

@@ -25,9 +25,13 @@ import {
showInfo, showInfo,
showSuccess, showSuccess,
loadChannelModels, loadChannelModels,
copy copy,
} from '../../helpers'; } from '../../helpers';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants'; import {
CHANNEL_OPTIONS,
ITEMS_PER_PAGE,
MODEL_TABLE_PAGE_SIZE,
} from '../../constants';
import { useIsMobile } from '../common/useIsMobile'; import { useIsMobile } from '../common/useIsMobile';
import { useTableCompactMode } from '../common/useTableCompactMode'; import { useTableCompactMode } from '../common/useTableCompactMode';
import { Modal } from '@douyinfe/semi-ui'; import { Modal } from '@douyinfe/semi-ui';
@@ -64,7 +68,7 @@ export const useChannelsData = () => {
// Status filter // Status filter
const [statusFilter, setStatusFilter] = useState( const [statusFilter, setStatusFilter] = useState(
localStorage.getItem('channel-status-filter') || 'all' localStorage.getItem('channel-status-filter') || 'all',
); );
// Type tabs states // Type tabs states
@@ -80,7 +84,7 @@ export const useChannelsData = () => {
const [selectedModelKeys, setSelectedModelKeys] = useState([]); const [selectedModelKeys, setSelectedModelKeys] = useState([]);
const [isBatchTesting, setIsBatchTesting] = useState(false); const [isBatchTesting, setIsBatchTesting] = useState(false);
const [modelTablePage, setModelTablePage] = useState(1); const [modelTablePage, setModelTablePage] = useState(1);
// 使用 ref 来避免闭包问题,类似旧版实现 // 使用 ref 来避免闭包问题,类似旧版实现
const shouldStopBatchTestingRef = useRef(false); const shouldStopBatchTestingRef = useRef(false);
@@ -116,9 +120,12 @@ export const useChannelsData = () => {
// Initialize from localStorage // Initialize from localStorage
useEffect(() => { useEffect(() => {
const localIdSort = localStorage.getItem('id-sort') === 'true'; const localIdSort = localStorage.getItem('id-sort') === 'true';
const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; const localPageSize =
const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true'; parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true'; const localEnableTagMode =
localStorage.getItem('enable-tag-mode') === 'true';
const localEnableBatchDelete =
localStorage.getItem('enable-batch-delete') === 'true';
setIdSort(localIdSort); setIdSort(localIdSort);
setPageSize(localPageSize); setPageSize(localPageSize);
@@ -176,7 +183,10 @@ export const useChannelsData = () => {
// Save column preferences // Save column preferences
useEffect(() => { useEffect(() => {
if (Object.keys(visibleColumns).length > 0) { if (Object.keys(visibleColumns).length > 0) {
localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns)); localStorage.setItem(
'channels-table-columns',
JSON.stringify(visibleColumns),
);
} }
}, [visibleColumns]); }, [visibleColumns]);
@@ -290,14 +300,21 @@ export const useChannelsData = () => {
const { searchKeyword, searchGroup, searchModel } = getFormValues(); const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') { if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
setLoading(true); setLoading(true);
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort); await searchChannels(
enableTagMode,
typeKey,
statusF,
page,
pageSize,
idSort,
);
setLoading(false); setLoading(false);
return; return;
} }
const reqId = ++requestCounter.current; const reqId = ++requestCounter.current;
setLoading(true); setLoading(true);
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get( const res = await API.get(
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`, `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
@@ -311,7 +328,10 @@ export const useChannelsData = () => {
if (success) { if (success) {
const { items, total, type_counts } = data; const { items, total, type_counts } = data;
if (type_counts) { if (type_counts) {
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0); const sumAll = Object.values(type_counts).reduce(
(acc, v) => acc + v,
0,
);
setTypeCounts({ ...type_counts, all: sumAll }); setTypeCounts({ ...type_counts, all: sumAll });
} }
setChannelFormat(items, enableTagMode); setChannelFormat(items, enableTagMode);
@@ -335,11 +355,18 @@ export const useChannelsData = () => {
setSearching(true); setSearching(true);
try { try {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') { if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF); await loadChannels(
page,
pageSz,
sortFlag,
enableTagMode,
typeKey,
statusF,
);
return; return;
} }
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get( const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`, `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
@@ -347,7 +374,10 @@ export const useChannelsData = () => {
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
const { items = [], total = 0, type_counts = {} } = data; const { items = [], total = 0, type_counts = {} } = data;
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0); const sumAll = Object.values(type_counts).reduce(
(acc, v) => acc + v,
0,
);
setTypeCounts({ ...type_counts, all: sumAll }); setTypeCounts({ ...type_counts, all: sumAll });
setChannelFormat(items, enableTagMode); setChannelFormat(items, enableTagMode);
setChannelCount(total); setChannelCount(total);
@@ -366,7 +396,14 @@ export const useChannelsData = () => {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') { if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(page, pageSize, idSort, enableTagMode); await loadChannels(page, pageSize, idSort, enableTagMode);
} else { } else {
await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort); await searchChannels(
enableTagMode,
activeTypeKey,
statusFilter,
page,
pageSize,
idSort,
);
} }
}; };
@@ -452,9 +489,16 @@ export const useChannelsData = () => {
const { searchKeyword, searchGroup, searchModel } = getFormValues(); const { searchKeyword, searchGroup, searchModel } = getFormValues();
setActivePage(page); setActivePage(page);
if (searchKeyword === '' && searchGroup === '' && searchModel === '') { if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { }); loadChannels(page, pageSize, idSort, enableTagMode).then(() => {});
} else { } else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort); searchChannels(
enableTagMode,
activeTypeKey,
statusFilter,
page,
pageSize,
idSort,
);
} }
}; };
@@ -470,7 +514,14 @@ export const useChannelsData = () => {
showError(reason); showError(reason);
}); });
} else { } else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort); searchChannels(
enableTagMode,
activeTypeKey,
statusFilter,
1,
size,
idSort,
);
} }
}; };
@@ -501,7 +552,10 @@ export const useChannelsData = () => {
showError(res?.data?.message || t('渠道复制失败')); showError(res?.data?.message || t('渠道复制失败'));
} }
} catch (error) { } catch (error) {
showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error)); showError(
t('渠道复制失败: ') +
(error?.response?.data?.message || error?.message || error),
);
} }
}; };
@@ -540,7 +594,11 @@ export const useChannelsData = () => {
data.priority = parseInt(data.priority); data.priority = parseInt(data.priority);
break; break;
case 'weight': case 'weight':
if (data.weight === undefined || data.weight < 0 || data.weight === '') { if (
data.weight === undefined ||
data.weight < 0 ||
data.weight === ''
) {
showInfo('权重必须是非负整数!'); showInfo('权重必须是非负整数!');
return; return;
} }
@@ -683,7 +741,11 @@ export const useChannelsData = () => {
const res = await API.post(`/api/channel/fix`); const res = await API.post(`/api/channel/fix`);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails)); showSuccess(
t('已修复 ${success} 个通道,失败 ${fails} 个通道。')
.replace('${success}', data.success)
.replace('${fails}', data.fails),
);
await refresh(); await refresh();
} else { } else {
showError(message); showError(message);
@@ -700,10 +762,12 @@ export const useChannelsData = () => {
} }
// 添加到正在测试的模型集合 // 添加到正在测试的模型集合
setTestingModels(prev => new Set([...prev, model])); setTestingModels((prev) => new Set([...prev, model]));
try { try {
const res = await API.get(`/api/channel/test/${record.id}?model=${model}`); const res = await API.get(
`/api/channel/test/${record.id}?model=${model}`,
);
// 检查是否在请求期间被停止 // 检查是否在请求期间被停止
if (shouldStopBatchTestingRef.current && isBatchTesting) { if (shouldStopBatchTestingRef.current && isBatchTesting) {
@@ -713,14 +777,14 @@ export const useChannelsData = () => {
const { success, message, time } = res.data; const { success, message, time } = res.data;
// 更新测试结果 // 更新测试结果
setModelTestResults(prev => ({ setModelTestResults((prev) => ({
...prev, ...prev,
[testKey]: { [testKey]: {
success, success,
message, message,
time: time || 0, time: time || 0,
timestamp: Date.now() timestamp: Date.now(),
} },
})); }));
if (success) { if (success) {
@@ -738,7 +802,9 @@ export const useChannelsData = () => {
); );
} else { } else {
showInfo( showInfo(
t('通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。') t(
'通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。',
)
.replace('${name}', record.name) .replace('${name}', record.name)
.replace('${model}', model) .replace('${model}', model)
.replace('${time.toFixed(2)}', time.toFixed(2)), .replace('${time.toFixed(2)}', time.toFixed(2)),
@@ -750,19 +816,19 @@ export const useChannelsData = () => {
} catch (error) { } catch (error) {
// 处理网络错误 // 处理网络错误
const testKey = `${record.id}-${model}`; const testKey = `${record.id}-${model}`;
setModelTestResults(prev => ({ setModelTestResults((prev) => ({
...prev, ...prev,
[testKey]: { [testKey]: {
success: false, success: false,
message: error.message || t('网络错误'), message: error.message || t('网络错误'),
time: 0, time: 0,
timestamp: Date.now() timestamp: Date.now(),
} },
})); }));
showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`); showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
} finally { } finally {
// 从正在测试的模型集合中移除 // 从正在测试的模型集合中移除
setTestingModels(prev => { setTestingModels((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
newSet.delete(model); newSet.delete(model);
return newSet; return newSet;
@@ -777,9 +843,11 @@ export const useChannelsData = () => {
return; return;
} }
const models = currentTestChannel.models.split(',').filter(model => const models = currentTestChannel.models
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) .split(',')
); .filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
);
if (models.length === 0) { if (models.length === 0) {
showError(t('没有找到匹配的模型')); showError(t('没有找到匹配的模型'));
@@ -790,9 +858,9 @@ export const useChannelsData = () => {
shouldStopBatchTestingRef.current = false; // 重置停止标志 shouldStopBatchTestingRef.current = false; // 重置停止标志
// 清空该渠道之前的测试结果 // 清空该渠道之前的测试结果
setModelTestResults(prev => { setModelTestResults((prev) => {
const newResults = { ...prev }; const newResults = { ...prev };
models.forEach(model => { models.forEach((model) => {
const testKey = `${currentTestChannel.id}-${model}`; const testKey = `${currentTestChannel.id}-${model}`;
delete newResults[testKey]; delete newResults[testKey];
}); });
@@ -800,7 +868,12 @@ export const useChannelsData = () => {
}); });
try { try {
showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length)); showInfo(
t('开始批量测试 ${count} 个模型,已清空上次结果...').replace(
'${count}',
models.length,
),
);
// 提高并发数量以加快测试速度,参考旧版的并发限制 // 提高并发数量以加快测试速度,参考旧版的并发限制
const concurrencyLimit = 5; const concurrencyLimit = 5;
@@ -814,13 +887,16 @@ export const useChannelsData = () => {
} }
const batch = models.slice(i, i + concurrencyLimit); const batch = models.slice(i, i + concurrencyLimit);
showInfo(t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)') showInfo(
.replace('${current}', i + 1) t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
.replace('${end}', Math.min(i + concurrencyLimit, models.length)) .replace('${current}', i + 1)
.replace('${total}', models.length) .replace('${end}', Math.min(i + concurrencyLimit, models.length))
.replace('${total}', models.length),
); );
const batchPromises = batch.map(model => testChannel(currentTestChannel, model)); const batchPromises = batch.map((model) =>
testChannel(currentTestChannel, model),
);
const batchResults = await Promise.allSettled(batchPromises); const batchResults = await Promise.allSettled(batchPromises);
results.push(...batchResults); results.push(...batchResults);
@@ -832,20 +908,20 @@ export const useChannelsData = () => {
// 短暂延迟避免过于频繁的请求 // 短暂延迟避免过于频繁的请求
if (i + concurrencyLimit < models.length) { if (i + concurrencyLimit < models.length) {
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
} }
} }
if (!shouldStopBatchTestingRef.current) { if (!shouldStopBatchTestingRef.current) {
// 等待一小段时间确保所有结果都已更新 // 等待一小段时间确保所有结果都已更新
await new Promise(resolve => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
// 使用当前状态重新计算结果统计 // 使用当前状态重新计算结果统计
setModelTestResults(currentResults => { setModelTestResults((currentResults) => {
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
models.forEach(model => { models.forEach((model) => {
const testKey = `${currentTestChannel.id}-${model}`; const testKey = `${currentTestChannel.id}-${model}`;
const result = currentResults[testKey]; const result = currentResults[testKey];
if (result && result.success) { if (result && result.success) {
@@ -857,10 +933,11 @@ export const useChannelsData = () => {
// 显示完成消息 // 显示完成消息
setTimeout(() => { setTimeout(() => {
showSuccess(t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}') showSuccess(
.replace('${success}', successCount) t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
.replace('${fail}', failCount) .replace('${success}', successCount)
.replace('${total}', models.length) .replace('${fail}', failCount)
.replace('${total}', models.length),
); );
}, 100); }, 100);
@@ -1045,4 +1122,4 @@ export const useChannelsData = () => {
setCompactMode, setCompactMode,
setActivePage, setActivePage,
}; };
}; };

View File

@@ -128,7 +128,7 @@ export const useSidebar = () => {
// 刷新用户配置的方法(供外部调用) // 刷新用户配置的方法(供外部调用)
const refreshUserConfig = async () => { const refreshUserConfig = async () => {
if (Object.keys(adminConfig).length > 0) { if (Object.keys(adminConfig).length > 0) {
await loadUserConfig(); await loadUserConfig();
} }
@@ -155,7 +155,10 @@ export const useSidebar = () => {
sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
return () => { return () => {
sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); sidebarEventTarget.removeEventListener(
SIDEBAR_REFRESH_EVENT,
handleRefresh,
);
}; };
}, [adminConfig]); }, [adminConfig]);

View File

@@ -64,6 +64,29 @@ export const useModelPricingData = () => {
() => statusState?.status?.usd_exchange_rate ?? priceRate, () => statusState?.status?.usd_exchange_rate ?? priceRate,
[statusState, priceRate], [statusState, priceRate],
); );
const customExchangeRate = useMemo(
() => statusState?.status?.custom_currency_exchange_rate ?? 1,
[statusState],
);
const customCurrencySymbol = useMemo(
() => statusState?.status?.custom_currency_symbol ?? '¤',
[statusState],
);
// 默认货币与站点展示类型同步USD/CNYTOKENS 时仍允许切换视图内货币
const siteDisplayType = useMemo(
() => statusState?.status?.quota_display_type || 'USD',
[statusState],
);
useEffect(() => {
if (
siteDisplayType === 'USD' ||
siteDisplayType === 'CNY' ||
siteDisplayType === 'CUSTOM'
) {
setCurrency(siteDisplayType);
}
}, [siteDisplayType]);
const filteredModels = useMemo(() => { const filteredModels = useMemo(() => {
let result = models; let result = models;
@@ -156,6 +179,8 @@ export const useModelPricingData = () => {
if (currency === 'CNY') { if (currency === 'CNY') {
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`; return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
} else if (currency === 'CUSTOM') {
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
} }
return `$${priceInUSD.toFixed(3)}`; return `$${priceInUSD.toFixed(3)}`;
}; };

View File

@@ -1773,7 +1773,10 @@
"自定义模型名称": "Custom model name", "自定义模型名称": "Custom model name",
"启用全部密钥": "Enable all keys", "启用全部密钥": "Enable all keys",
"充值价格显示": "Recharge price", "充值价格显示": "Recharge price",
"美元汇率(非充值汇率,仅用于定价页面换算)": "USD exchange rate (not recharge rate, only used for pricing page conversion)", "自定义货币": "Custom currency",
"自定义货币符号": "Custom currency symbol",
"例如 €, £, Rp, ₩, ₹...": "For example, €, £, Rp, ₩, ₹...",
"站点额度展示类型及汇率": "Site quota display type and exchange rate",
"美元汇率": "USD exchange rate", "美元汇率": "USD exchange rate",
"隐藏操作项": "Hide actions", "隐藏操作项": "Hide actions",
"显示操作项": "Show actions", "显示操作项": "Show actions",

View File

@@ -1773,7 +1773,10 @@
"自定义模型名称": "Nom de modèle personnalisé", "自定义模型名称": "Nom de modèle personnalisé",
"启用全部密钥": "Activer toutes les clés", "启用全部密钥": "Activer toutes les clés",
"充值价格显示": "Prix de recharge", "充值价格显示": "Prix de recharge",
"美元汇率(非充值汇率,仅用于定价页面换算)": "Taux de change USD (pas de taux de recharge, uniquement utilisé pour la conversion de la page de tarification)", "站点额度展示类型及汇率": "Type d'affichage du quota du site et taux de change",
"自定义货币": "Devise personnalisée",
"自定义货币符号": "Symbole de devise personnalisé",
"例如 €, £, Rp, ₩, ₹...": "Par exemple, €, £, Rp, ₩, ₹...",
"美元汇率": "Taux de change USD", "美元汇率": "Taux de change USD",
"隐藏操作项": "Masquer les actions", "隐藏操作项": "Masquer les actions",
"显示操作项": "Afficher les actions", "显示操作项": "Afficher les actions",
@@ -2137,4 +2140,4 @@
"common": { "common": {
"changeLanguage": "Changer de langue" "changeLanguage": "Changer de langue"
} }
} }

View File

@@ -17,8 +17,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef, useMemo } from 'react';
import { Banner, Button, Col, Form, Row, Spin, Modal } from '@douyinfe/semi-ui'; import {
Banner,
Button,
Col,
Form,
Row,
Spin,
Modal,
Select,
InputGroup,
Input,
} from '@douyinfe/semi-ui';
import { import {
compareObjects, compareObjects,
API, API,
@@ -35,10 +46,12 @@ export default function GeneralSettings(props) {
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
TopUpLink: '', TopUpLink: '',
'general_setting.docs_link': '', 'general_setting.docs_link': '',
'general_setting.quota_display_type': 'USD',
'general_setting.custom_currency_symbol': '¤',
'general_setting.custom_currency_exchange_rate': '',
QuotaPerUnit: '', QuotaPerUnit: '',
RetryTimes: '', RetryTimes: '',
USDExchangeRate: '', USDExchangeRate: '',
DisplayInCurrencyEnabled: false,
DisplayTokenStatEnabled: false, DisplayTokenStatEnabled: false,
DefaultCollapseSidebar: false, DefaultCollapseSidebar: false,
DemoSiteEnabled: false, DemoSiteEnabled: false,
@@ -88,6 +101,30 @@ export default function GeneralSettings(props) {
}); });
} }
// 计算展示在输入框中的“1 USD = X <currency>”中的 X
const combinedRate = useMemo(() => {
const type = inputs['general_setting.quota_display_type'];
if (type === 'USD') return '1';
if (type === 'CNY') return String(inputs['USDExchangeRate'] || '');
if (type === 'TOKENS') return String(inputs['QuotaPerUnit'] || '');
if (type === 'CUSTOM')
return String(
inputs['general_setting.custom_currency_exchange_rate'] || '',
);
return '';
}, [inputs]);
const onCombinedRateChange = (val) => {
const type = inputs['general_setting.quota_display_type'];
if (type === 'CNY') {
handleFieldChange('USDExchangeRate')(val);
} else if (type === 'TOKENS') {
handleFieldChange('QuotaPerUnit')(val);
} else if (type === 'CUSTOM') {
handleFieldChange('general_setting.custom_currency_exchange_rate')(val);
}
};
useEffect(() => { useEffect(() => {
const currentInputs = {}; const currentInputs = {};
for (let key in props.options) { for (let key in props.options) {
@@ -95,6 +132,28 @@ export default function GeneralSettings(props) {
currentInputs[key] = props.options[key]; currentInputs[key] = props.options[key];
} }
} }
// 若旧字段存在且新字段缺失,则做一次兜底映射
if (
currentInputs['general_setting.quota_display_type'] === undefined &&
props.options?.DisplayInCurrencyEnabled !== undefined
) {
currentInputs['general_setting.quota_display_type'] = props.options
.DisplayInCurrencyEnabled
? 'USD'
: 'TOKENS';
}
// 回填自定义货币相关字段(如果后端已存在)
if (props.options['general_setting.custom_currency_symbol'] !== undefined) {
currentInputs['general_setting.custom_currency_symbol'] =
props.options['general_setting.custom_currency_symbol'];
}
if (
props.options['general_setting.custom_currency_exchange_rate'] !==
undefined
) {
currentInputs['general_setting.custom_currency_exchange_rate'] =
props.options['general_setting.custom_currency_exchange_rate'];
}
setInputs(currentInputs); setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs)); setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs); refForm.current.setValues(currentInputs);
@@ -130,29 +189,7 @@ export default function GeneralSettings(props) {
showClear showClear
/> />
</Col> </Col>
{inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && ( {/* 单位美元额度已合入汇率组合控件TOKENS 模式下编辑),不再单独展示 */}
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field={'QuotaPerUnit'}
label={t('单位美元额度')}
initValue={''}
placeholder={t('一单位货币能兑换的额度')}
onChange={handleFieldChange('QuotaPerUnit')}
showClear
onClick={() => setShowQuotaWarning(true)}
/>
</Col>
)}
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field={'USDExchangeRate'}
label={t('美元汇率(非充值汇率,仅用于定价页面换算)')}
initValue={''}
placeholder={t('美元汇率')}
onChange={handleFieldChange('USDExchangeRate')}
showClear
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}> <Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input <Form.Input
field={'RetryTimes'} field={'RetryTimes'}
@@ -163,18 +200,51 @@ export default function GeneralSettings(props) {
showClear showClear
/> />
</Col> </Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}> <Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch <Form.Slot label={t('站点额度展示类型及汇率')}>
field={'DisplayInCurrencyEnabled'} <InputGroup style={{ width: '100%' }}>
label={t('以货币形式显示额度')} <Input
size='default' prefix={'1 USD = '}
checkedText='' style={{ width: '50%' }}
uncheckedText='' value={combinedRate}
onChange={handleFieldChange('DisplayInCurrencyEnabled')} onChange={onCombinedRateChange}
disabled={
inputs['general_setting.quota_display_type'] === 'USD'
}
/>
<Select
style={{ width: '50%' }}
value={inputs['general_setting.quota_display_type']}
onChange={handleFieldChange(
'general_setting.quota_display_type',
)}
>
<Select.Option value='USD'>USD ($)</Select.Option>
<Select.Option value='CNY'>CNY (¥)</Select.Option>
<Select.Option value='TOKENS'>Tokens</Select.Option>
<Select.Option value='CUSTOM'>
{t('自定义货币')}
</Select.Option>
</Select>
</InputGroup>
</Form.Slot>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field={'general_setting.custom_currency_symbol'}
label={t('自定义货币符号')}
placeholder={t('例如 €, £, Rp, ₩, ₹...')}
onChange={handleFieldChange(
'general_setting.custom_currency_symbol',
)}
showClear
disabled={
inputs['general_setting.quota_display_type'] !== 'CUSTOM'
}
/> />
</Col> </Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}> <Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch <Form.Switch
field={'DisplayTokenStatEnabled'} field={'DisplayTokenStatEnabled'}
@@ -195,8 +265,6 @@ export default function GeneralSettings(props) {
onChange={handleFieldChange('DefaultCollapseSidebar')} onChange={handleFieldChange('DefaultCollapseSidebar')}
/> />
</Col> </Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}> <Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch <Form.Switch
field={'DemoSiteEnabled'} field={'DemoSiteEnabled'}

View File

@@ -128,7 +128,8 @@ export default function SettingsMonitoring(props) {
onChange={(value) => onChange={(value) =>
setInputs({ setInputs({
...inputs, ...inputs,
'monitor_setting.auto_test_channel_minutes': parseInt(value), 'monitor_setting.auto_test_channel_minutes':
parseInt(value),
}) })
} }
/> />

View File

@@ -118,14 +118,20 @@ export default function SettingsPaymentGateway(props) {
} }
} }
if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') { if (
originInputs['AmountOptions'] !== inputs.AmountOptions &&
inputs.AmountOptions.trim() !== ''
) {
if (!verifyJSON(inputs.AmountOptions)) { if (!verifyJSON(inputs.AmountOptions)) {
showError(t('自定义充值数量选项不是合法的 JSON 数组')); showError(t('自定义充值数量选项不是合法的 JSON 数组'));
return; return;
} }
} }
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') { if (
originInputs['AmountDiscount'] !== inputs.AmountDiscount &&
inputs.AmountDiscount.trim() !== ''
) {
if (!verifyJSON(inputs.AmountDiscount)) { if (!verifyJSON(inputs.AmountDiscount)) {
showError(t('充值金额折扣配置不是合法的 JSON 对象')); showError(t('充值金额折扣配置不是合法的 JSON 对象'));
return; return;
@@ -163,10 +169,16 @@ export default function SettingsPaymentGateway(props) {
options.push({ key: 'PayMethods', value: inputs.PayMethods }); options.push({ key: 'PayMethods', value: inputs.PayMethods });
} }
if (originInputs['AmountOptions'] !== inputs.AmountOptions) { if (originInputs['AmountOptions'] !== inputs.AmountOptions) {
options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions }); options.push({
key: 'payment_setting.amount_options',
value: inputs.AmountOptions,
});
} }
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) { if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) {
options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount }); options.push({
key: 'payment_setting.amount_discount',
value: inputs.AmountDiscount,
});
} }
// 发送请求 // 发送请求
@@ -273,7 +285,7 @@ export default function SettingsPaymentGateway(props) {
placeholder={t('为一个 JSON 文本')} placeholder={t('为一个 JSON 文本')}
autosize autosize
/> />
<Row <Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
@@ -282,13 +294,17 @@ export default function SettingsPaymentGateway(props) {
<Form.TextArea <Form.TextArea
field='AmountOptions' field='AmountOptions'
label={t('自定义充值数量选项')} label={t('自定义充值数量选项')}
placeholder={t('为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]')} placeholder={t(
'为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]',
)}
autosize autosize
extraText={t('设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]')} extraText={t(
'设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]',
)}
/> />
</Col> </Col>
</Row> </Row>
<Row <Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
@@ -297,13 +313,17 @@ export default function SettingsPaymentGateway(props) {
<Form.TextArea <Form.TextArea
field='AmountDiscount' field='AmountDiscount'
label={t('充值金额折扣配置')} label={t('充值金额折扣配置')}
placeholder={t('为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')} placeholder={t(
'为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
)}
autosize autosize
extraText={t('设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')} extraText={t(
'设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
)}
/> />
</Col> </Col>
</Row> </Row>
<Button onClick={submitPayAddress}>{t('更新支付设置')}</Button> <Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
</Form.Section> </Form.Section>
</Form> </Form>

View File

@@ -226,8 +226,12 @@ export default function ModelRatioSettings(props) {
<Col xs={24} sm={16}> <Col xs={24} sm={16}>
<Form.TextArea <Form.TextArea
label={t('图片输入倍率(仅部分模型支持该计费)')} label={t('图片输入倍率(仅部分模型支持该计费)')}
extraText={t('图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费')} extraText={t(
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}')} '图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费',
)}
placeholder={t(
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}',
)}
field={'ImageRatio'} field={'ImageRatio'}
autosize={{ minRows: 6, maxRows: 12 }} autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur' trigger='blur'
@@ -238,9 +242,7 @@ export default function ModelRatioSettings(props) {
message: '不是合法的 JSON 字符串', message: '不是合法的 JSON 字符串',
}, },
]} ]}
onChange={(value) => onChange={(value) => setInputs({ ...inputs, ImageRatio: value })}
setInputs({ ...inputs, ImageRatio: value })
}
/> />
</Col> </Col>
</Row> </Row>
@@ -249,7 +251,9 @@ export default function ModelRatioSettings(props) {
<Form.TextArea <Form.TextArea
label={t('音频倍率(仅部分模型支持该计费)')} label={t('音频倍率(仅部分模型支持该计费)')}
extraText={t('音频输入相关的倍率设置,键为模型名称,值为倍率')} extraText={t('音频输入相关的倍率设置,键为模型名称,值为倍率')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}')} placeholder={t(
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}',
)}
field={'AudioRatio'} field={'AudioRatio'}
autosize={{ minRows: 6, maxRows: 12 }} autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur' trigger='blur'
@@ -260,9 +264,7 @@ export default function ModelRatioSettings(props) {
message: '不是合法的 JSON 字符串', message: '不是合法的 JSON 字符串',
}, },
]} ]}
onChange={(value) => onChange={(value) => setInputs({ ...inputs, AudioRatio: value })}
setInputs({ ...inputs, AudioRatio: value })
}
/> />
</Col> </Col>
</Row> </Row>
@@ -270,8 +272,12 @@ export default function ModelRatioSettings(props) {
<Col xs={24} sm={16}> <Col xs={24} sm={16}>
<Form.TextArea <Form.TextArea
label={t('音频补全倍率(仅部分模型支持该计费)')} label={t('音频补全倍率(仅部分模型支持该计费)')}
extraText={t('音频输出补全相关的倍率设置,键为模型名称,值为倍率')} extraText={t(
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}')} '音频输出补全相关的倍率设置,键为模型名称,值为倍率',
)}
placeholder={t(
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}',
)}
field={'AudioCompletionRatio'} field={'AudioCompletionRatio'}
autosize={{ minRows: 6, maxRows: 12 }} autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur' trigger='blur'