From 39a868faeaa122965540c68c2418a3f155b1bfda Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 29 Sep 2025 23:23:31 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=B1=20feat(settings):=20introduce=20si?= =?UTF-8?q?te-wide=20quota=20display=20type=20(USD/CNY/TOKENS/CUSTOM)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- common/constants.go | 1 + common/database.go | 2 +- controller/billing.go | 24 +- controller/misc.go | 28 +- controller/setup.go | 2 +- controller/topup.go | 9 +- controller/topup_stripe.go | 4 +- logger/logger.go | 50 +- model/option.go | 10 +- relay/channel/ollama/adaptor.go | 30 +- relay/channel/ollama/dto.go | 45 +- relay/channel/ollama/relay-ollama.go | 194 ++++++-- relay/channel/ollama/stream.go | 436 ++++++++++-------- relay/channel/vertex/adaptor.go | 2 +- setting/operation_setting/general_setting.go | 72 ++- web/index.html | 2 +- web/jsconfig.json | 2 +- .../common/modals/TwoFactorAuthModal.jsx | 4 +- .../components/settings/OperationSetting.jsx | 2 +- web/src/components/settings/SystemSetting.jsx | 74 ++- .../personal/cards/AccountManagement.jsx | 9 +- .../channels/modals/EditChannelModal.jsx | 80 ++-- .../table/mj-logs/MjLogsFilters.jsx | 4 +- .../filter/PricingDisplaySettings.jsx | 1 + .../layout/header/SearchActions.jsx | 1 + .../table/task-logs/TaskLogsColumnDefs.jsx | 5 +- .../table/task-logs/TaskLogsFilters.jsx | 4 +- .../table/task-logs/modals/ContentModal.jsx | 78 ++-- .../table/usage-logs/UsageLogsFilters.jsx | 4 +- web/src/components/topup/RechargeCard.jsx | 82 +++- web/src/components/topup/index.jsx | 31 +- .../topup/modals/PaymentConfirmModal.jsx | 7 +- web/src/constants/console.constants.js | 10 +- web/src/helpers/api.js | 2 - web/src/helpers/data.js | 2 + web/src/helpers/render.jsx | 98 ++-- web/src/helpers/utils.jsx | 20 +- web/src/hooks/channels/useChannelsData.jsx | 179 +++++-- web/src/hooks/common/useSidebar.js | 7 +- .../model-pricing/useModelPricingData.jsx | 25 + web/src/i18n/locales/en.json | 5 +- web/src/i18n/locales/fr.json | 7 +- .../Setting/Operation/SettingsGeneral.jsx | 142 ++++-- .../Setting/Operation/SettingsMonitoring.jsx | 3 +- .../Payment/SettingsPaymentGateway.jsx | 42 +- .../Setting/Ratio/ModelRatioSettings.jsx | 28 +- 46 files changed, 1268 insertions(+), 601 deletions(-) diff --git a/common/constants.go b/common/constants.go index e6d59d101..2ef2b7df2 100644 --- a/common/constants.go +++ b/common/constants.go @@ -19,6 +19,7 @@ var TopUpLink = "" // var ChatLink = "" // var ChatLink2 = "" var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens +// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制 var DisplayInCurrencyEnabled = true var DisplayTokenStatEnabled = true var DrawingEnabled = true diff --git a/common/database.go b/common/database.go index 38a54d5e6..71dbd94d5 100644 --- a/common/database.go +++ b/common/database.go @@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries var UsingMySQL = false var UsingClickHouse = false -var SQLitePath = "one-api.db?_busy_timeout=30000" \ No newline at end of file +var SQLitePath = "one-api.db?_busy_timeout=30000" diff --git a/controller/billing.go b/controller/billing.go index 1fb83633e..db3b62b1e 100644 --- a/controller/billing.go +++ b/controller/billing.go @@ -5,6 +5,7 @@ import ( "one-api/common" "one-api/dto" "one-api/model" + "one-api/setting/operation_setting" ) func GetSubscription(c *gin.Context) { @@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) { } quota := remainQuota + usedQuota amount := float64(quota) - if common.DisplayInCurrencyEnabled { - amount /= common.QuotaPerUnit + // OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值: + // 我们将其解释为以“站点展示类型”为准: + // - 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 { amount = 100000000 @@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) { return } amount := float64(quota) - if common.DisplayInCurrencyEnabled { - amount /= common.QuotaPerUnit + switch operation_setting.GetQuotaDisplayType() { + case operation_setting.QuotaDisplayTypeCNY: + amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate + case operation_setting.QuotaDisplayTypeTokens: + // tokens 保持原值 + default: + amount = amount / common.QuotaPerUnit } usage := OpenAIUsageResponse{ Object: "list", diff --git a/controller/misc.go b/controller/misc.go index 875142ffb..8d78e3358 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -64,18 +64,22 @@ func GetStatus(c *gin.Context) { "top_up_link": common.TopUpLink, "docs_link": operation_setting.GetGeneralSetting().DocsLink, "quota_per_unit": common.QuotaPerUnit, - "display_in_currency": common.DisplayInCurrencyEnabled, - "enable_batch_update": common.BatchUpdateEnabled, - "enable_drawing": common.DrawingEnabled, - "enable_task": common.TaskEnabled, - "enable_data_export": common.DataExportEnabled, - "data_export_default_time": common.DataExportDefaultTime, - "default_collapse_sidebar": common.DefaultCollapseSidebar, - "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, + // 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type + "display_in_currency": operation_setting.IsCurrencyDisplay(), + "quota_display_type": operation_setting.GetQuotaDisplayType(), + "custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol, + "custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate, + "enable_batch_update": common.BatchUpdateEnabled, + "enable_drawing": common.DrawingEnabled, + "enable_task": common.TaskEnabled, + "enable_data_export": common.DataExportEnabled, + "data_export_default_time": common.DataExportDefaultTime, + "default_collapse_sidebar": common.DefaultCollapseSidebar, + "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, "price": operation_setting.Price, diff --git a/controller/setup.go b/controller/setup.go index 3ae255e94..8b7fa3e7a 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -178,4 +178,4 @@ func boolToString(b bool) string { return "true" } return "false" -} \ No newline at end of file +} diff --git a/controller/topup.go b/controller/topup.go index 243e67940..7e2cadf1d 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -86,8 +86,9 @@ func GetEpayClient() *epay.Client { func getPayMoney(amount int64, group string) float64 { 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) dAmount = dAmount.Div(dQuotaPerUnit) } @@ -115,7 +116,7 @@ func getPayMoney(amount int64, group string) float64 { func getMinTopup() int64 { minTopup := operation_setting.MinTopUp - if !common.DisplayInCurrencyEnabled { + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { dMinTopup := decimal.NewFromInt(int64(minTopup)) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart()) @@ -176,7 +177,7 @@ func RequestEpay(c *gin.Context) { return } amount := req.Amount - if !common.DisplayInCurrencyEnabled { + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { dAmount := decimal.NewFromInt(int64(amount)) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) amount = dAmount.Div(dQuotaPerUnit).IntPart() diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 9a568d857..628a3fea5 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -258,7 +258,7 @@ func GetChargedAmount(count float64, user model.User) float64 { func getStripePayMoney(amount float64, group string) float64 { originalAmount := amount - if !common.DisplayInCurrencyEnabled { + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { amount = amount / common.QuotaPerUnit } // 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 { minTopup := setting.StripeMinTopUp - if !common.DisplayInCurrencyEnabled { + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { minTopup = minTopup * int(common.QuotaPerUnit) } return int64(minTopup) diff --git a/logger/logger.go b/logger/logger.go index d59e51cb8..68f564370 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -7,6 +7,7 @@ import ( "io" "log" "one-api/common" + "one-api/setting/operation_setting" "os" "path/filepath" "sync" @@ -92,18 +93,55 @@ func logHelper(ctx context.Context, level string, msg string) { } func LogQuota(quota int) string { - if common.DisplayInCurrencyEnabled { - return fmt.Sprintf("$%.6f 额度", float64(quota)/common.QuotaPerUnit) - } else { + // 新逻辑:根据额度展示类型输出 + q := float64(quota) + 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) + default: // USD + return fmt.Sprintf("$%.6f 额度", q/common.QuotaPerUnit) } } func FormatQuota(quota int) string { - if common.DisplayInCurrencyEnabled { - return fmt.Sprintf("$%.6f", float64(quota)/common.QuotaPerUnit) - } else { + q := float64(quota) + 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) + default: + return fmt.Sprintf("$%.6f", q/common.QuotaPerUnit) } } diff --git a/model/option.go b/model/option.go index 9ace8fece..77525ea25 100644 --- a/model/option.go +++ b/model/option.go @@ -240,7 +240,15 @@ func updateOptionMap(key string, value string) (err error) { case "LogConsumeEnabled": common.LogConsumeEnabled = boolValue 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": common.DisplayTokenStatEnabled = boolValue case "DrawingEnabled": diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go index bafe73b92..d88e04d6f 100644 --- a/relay/channel/ollama/adaptor.go +++ b/relay/channel/ollama/adaptor.go @@ -18,7 +18,9 @@ import ( 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) { openaiAdaptor := openai.Adaptor{} @@ -33,17 +35,25 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn 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) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil } - if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil } - return info.ChannelBaseUrl + "/api/chat", nil + if info.RelayMode == relayconstant.RelayModeEmbeddings { + return info.ChannelBaseUrl + "/api/embed", 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 { @@ -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) { - if request == nil { return nil, errors.New("request is nil") } + if request == nil { + return nil, errors.New("request is nil") + } // decide generate or chat if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return openAIToGenerate(c, request) @@ -69,7 +81,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela 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) { return channel.DoApiRequest(a, c, info, requestBody) diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go index 45e49ab43..2434a4cbc 100644 --- a/relay/channel/ollama/dto.go +++ b/relay/channel/ollama/dto.go @@ -5,12 +5,12 @@ import ( ) type OllamaChatMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - Images []string `json:"images,omitempty"` - ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"` - ToolName string `json:"tool_name,omitempty"` - Thinking json.RawMessage `json:"thinking,omitempty"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + Images []string `json:"images,omitempty"` + ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Thinking json.RawMessage `json:"thinking,omitempty"` } type OllamaToolFunction struct { @@ -20,7 +20,7 @@ type OllamaToolFunction struct { } type OllamaTool struct { - Type string `json:"type"` + Type string `json:"type"` Function OllamaToolFunction `json:"function"` } @@ -43,28 +43,27 @@ type OllamaChatRequest struct { } type OllamaGenerateRequest struct { - Model string `json:"model"` - Prompt string `json:"prompt,omitempty"` - Suffix string `json:"suffix,omitempty"` - Images []string `json:"images,omitempty"` - Format interface{} `json:"format,omitempty"` - Stream bool `json:"stream,omitempty"` - Options map[string]any `json:"options,omitempty"` - KeepAlive interface{} `json:"keep_alive,omitempty"` + Model string `json:"model"` + Prompt string `json:"prompt,omitempty"` + Suffix string `json:"suffix,omitempty"` + Images []string `json:"images,omitempty"` + Format interface{} `json:"format,omitempty"` + Stream bool `json:"stream,omitempty"` + Options map[string]any `json:"options,omitempty"` + KeepAlive interface{} `json:"keep_alive,omitempty"` Think json.RawMessage `json:"think,omitempty"` } type OllamaEmbeddingRequest struct { - Model string `json:"model"` - Input interface{} `json:"input"` - Options map[string]any `json:"options,omitempty"` + Model string `json:"model"` + Input interface{} `json:"input"` + Options map[string]any `json:"options,omitempty"` Dimensions int `json:"dimensions,omitempty"` } type OllamaEmbeddingResponse struct { - Error string `json:"error,omitempty"` - Model string `json:"model"` - Embeddings [][]float64 `json:"embeddings"` - PromptEvalCount int `json:"prompt_eval_count,omitempty"` + Error string `json:"error,omitempty"` + Model string `json:"model"` + Embeddings [][]float64 `json:"embeddings"` + PromptEvalCount int `json:"prompt_eval_count,omitempty"` } - diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go index 3b67f9525..f94a654c7 100644 --- a/relay/channel/ollama/relay-ollama.go +++ b/relay/channel/ollama/relay-ollama.go @@ -35,13 +35,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam } // options mapping - if r.Temperature != nil { chatReq.Options["temperature"] = r.Temperature } - if r.TopP != 0 { chatReq.Options["top_p"] = r.TopP } - 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.Temperature != nil { + chatReq.Options["temperature"] = r.Temperature + } + if r.TopP != 0 { + chatReq.Options["top_p"] = r.TopP + } + 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 { switch v := r.Stop.(type) { @@ -50,21 +64,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam case []string: chatReq.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 { chatReq.Options["stop"] = arr } + arr := make([]string, 0, len(v)) + for _, i := range v { + if s, ok := i.(string); ok { + arr = append(arr, s) + } + } + if len(arr) > 0 { + chatReq.Options["stop"] = arr + } } } if len(r.Tools) > 0 { - tools := make([]OllamaTool,0,len(r.Tools)) + tools := make([]OllamaTool, 0, len(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}}) } chatReq.Tools = tools } - chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages)) + chatReq.Messages = make([]OllamaChatMessage, 0, len(r.Messages)) for _, m := range r.Messages { var textBuilder strings.Builder var images []string @@ -79,14 +99,20 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam var base64Data string if strings.HasPrefix(img.Url, "http") { 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 } 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 { base64Data = img.Url } - if base64Data != "" { images = append(images, base64Data) } + if base64Data != "" { + images = append(images, base64Data) + } } } else if part.Type == dto.ContentTypeText { 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()} - if len(images)>0 { cm.Images = images } - if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name } + if len(images) > 0 { + cm.Images = images + } + if m.Role == "tool" && m.Name != nil { + cm.ToolName = *m.Name + } if m.ToolCalls != nil && len(m.ToolCalls) > 0 { parsed := m.ParseToolCalls() if len(parsed) > 0 { - calls := make([]OllamaToolCall,0,len(parsed)) + calls := make([]OllamaToolCall, 0, len(parsed)) for _, tc := range parsed { var args interface{} - if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) } - if args==nil { args = map[string]any{} } + if tc.Function.Arguments != "" { + _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) + } + if args == nil { + args = map[string]any{} + } oc := OllamaToolCall{} oc.Function.Name = tc.Function.Name oc.Function.Arguments = args @@ -132,28 +166,67 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener gen.Prompt = v case []any: 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() default: gen.Prompt = fmt.Sprintf("%v", r.Prompt) } } - if r.Suffix != nil { if s,ok:=r.Suffix.(string); ok { 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.Suffix != nil { + if s, ok := r.Suffix.(string); ok { + 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 { switch v := r.Stop.(type) { - case string: gen.Options["stop"] = []string{v} - 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 } + case string: + gen.Options["stop"] = []string{v} + 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 @@ -161,30 +234,51 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest { opts := map[string]any{} - if r.Temperature != nil { opts["temperature"] = r.Temperature } - if r.TopP != 0 { opts["top_p"] = r.TopP } - 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 } + if r.Temperature != nil { + opts["temperature"] = r.Temperature + } + if r.TopP != 0 { + opts["top_p"] = r.TopP + } + 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() - if len(input)==1 { 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} + if len(input) == 1 { + 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) { var oResp OllamaEmbeddingResponse 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) - if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), 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}) } - usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens:0, TotalTokens: oResp.PromptEvalCount} - embResp := &dto.OpenAIEmbeddingResponse{Object:"list", Data:data, Model: info.UpstreamModelName, Usage:*usage} + if err = common.Unmarshal(body, &oResp); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if oResp.Error != "" { + return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), 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}) + } + 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) service.IOCopyBytesGracefully(c, resp, out) return usage, nil } - diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go index 964f11d90..9a98f7b69 100644 --- a/relay/channel/ollama/stream.go +++ b/relay/channel/ollama/stream.go @@ -1,210 +1,278 @@ package ollama import ( - "bufio" - "encoding/json" - "fmt" - "io" - "net/http" - "one-api/common" - "one-api/dto" - "one-api/logger" - relaycommon "one-api/relay/common" - "one-api/relay/helper" - "one-api/service" - "one-api/types" - "strings" - "time" + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/logger" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + "time" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" ) type ollamaChatStreamChunk struct { - Model string `json:"model"` - CreatedAt string `json:"created_at"` - // chat - Message *struct { - Role string `json:"role"` - Content string `json:"content"` - Thinking json.RawMessage `json:"thinking"` - ToolCalls []struct { - Function struct { - Name string `json:"name"` - Arguments interface{} `json:"arguments"` - } `json:"function"` - } `json:"tool_calls"` - } `json:"message"` - // generate - Response string `json:"response"` - Done bool `json:"done"` - DoneReason string `json:"done_reason"` - TotalDuration int64 `json:"total_duration"` - LoadDuration int64 `json:"load_duration"` - PromptEvalCount int `json:"prompt_eval_count"` - EvalCount int `json:"eval_count"` - PromptEvalDuration int64 `json:"prompt_eval_duration"` - EvalDuration int64 `json:"eval_duration"` + Model string `json:"model"` + CreatedAt string `json:"created_at"` + // chat + Message *struct { + Role string `json:"role"` + Content string `json:"content"` + Thinking json.RawMessage `json:"thinking"` + ToolCalls []struct { + Function struct { + Name string `json:"name"` + Arguments interface{} `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + // generate + Response string `json:"response"` + Done bool `json:"done"` + DoneReason string `json:"done_reason"` + TotalDuration int64 `json:"total_duration"` + LoadDuration int64 `json:"load_duration"` + PromptEvalCount int `json:"prompt_eval_count"` + EvalCount int `json:"eval_count"` + PromptEvalDuration int64 `json:"prompt_eval_duration"` + EvalDuration int64 `json:"eval_duration"` } func toUnix(ts string) int64 { - if ts == "" { return time.Now().Unix() } - // try time.RFC3339 or with nanoseconds - 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() + if ts == "" { + return time.Now().Unix() + } + // try time.RFC3339 or with nanoseconds + 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) { - if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) } - defer service.CloseResponseBodyGracefully(resp) + if resp == nil || resp.Body == nil { + return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) + } + defer service.CloseResponseBodyGracefully(resp) - helper.SetEventStreamHeaders(c) - scanner := bufio.NewScanner(resp.Body) - usage := &dto.Usage{} - var model = info.UpstreamModelName - var responseId = common.GetUUID() - var created = time.Now().Unix() - var toolCallIndex int - start := helper.GenerateStartEmptyResponse(responseId, created, model, nil) - if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) } + helper.SetEventStreamHeaders(c) + scanner := bufio.NewScanner(resp.Body) + usage := &dto.Usage{} + var model = info.UpstreamModelName + var responseId = common.GetUUID() + var created = time.Now().Unix() + var toolCallIndex int + start := helper.GenerateStartEmptyResponse(responseId, created, model, nil) + if data, err := common.Marshal(start); err == nil { + _ = helper.StringData(c, string(data)) + } - for scanner.Scan() { - line := scanner.Text() - line = strings.TrimSpace(line) - if line == "" { continue } - var chunk ollamaChatStreamChunk - if err := json.Unmarshal([]byte(line), &chunk); err != nil { - logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line) - return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) - } - if chunk.Model != "" { model = chunk.Model } - created = toUnix(chunk.CreatedAt) + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + if line == "" { + continue + } + var chunk ollamaChatStreamChunk + if err := json.Unmarshal([]byte(line), &chunk); err != nil { + logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line) + return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if chunk.Model != "" { + model = chunk.Model + } + created = toUnix(chunk.CreatedAt) - if !chunk.Done { - // delta content - var content string - if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response } - delta := dto.ChatCompletionsStreamResponse{ - Id: responseId, - Object: "chat.completion.chunk", - Created: created, - Model: model, - Choices: []dto.ChatCompletionsStreamResponseChoice{ { - Index: 0, - Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" }, - } }, - } - if content != "" { delta.Choices[0].Delta.SetContentString(content) } - if chunk.Message != nil && len(chunk.Message.Thinking) > 0 { - raw := strings.TrimSpace(string(chunk.Message.Thinking)) - if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) } - } - // tool calls - if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 { - delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls)) - for _, tc := range chunk.Message.ToolCalls { - // arguments -> string - argBytes, _ := json.Marshal(tc.Function.Arguments) - 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) - toolCallIndex++ - delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr) - } - } - if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) } - continue - } - // done frame - // finalize once and break loop - usage.PromptTokens = chunk.PromptEvalCount - usage.CompletionTokens = chunk.EvalCount - usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens - finishReason := chunk.DoneReason - if finishReason == "" { finishReason = "stop" } - // emit stop delta - 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 + if !chunk.Done { + // delta content + var content string + if chunk.Message != nil { + content = chunk.Message.Content + } else { + content = chunk.Response + } + delta := dto.ChatCompletionsStreamResponse{ + Id: responseId, + Object: "chat.completion.chunk", + Created: created, + Model: model, + Choices: []dto.ChatCompletionsStreamResponseChoice{{ + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}, + }}, + } + if content != "" { + delta.Choices[0].Delta.SetContentString(content) + } + if chunk.Message != nil && len(chunk.Message.Thinking) > 0 { + raw := strings.TrimSpace(string(chunk.Message.Thinking)) + if raw != "" && raw != "null" { + delta.Choices[0].Delta.SetReasoningContent(raw) + } + } + // tool calls + if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 { + delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 0, len(chunk.Message.ToolCalls)) + for _, tc := range chunk.Message.ToolCalls { + // arguments -> string + argBytes, _ := json.Marshal(tc.Function.Arguments) + 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) + toolCallIndex++ + delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr) + } + } + if data, err := common.Marshal(delta); err == nil { + _ = helper.StringData(c, string(data)) + } + continue + } + // done frame + // finalize once and break loop + usage.PromptTokens = chunk.PromptEvalCount + usage.CompletionTokens = chunk.EvalCount + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + finishReason := chunk.DoneReason + if finishReason == "" { + finishReason = "stop" + } + // emit stop delta + 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 func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { - body, err := io.ReadAll(resp.Body) - if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } - service.CloseResponseBodyGracefully(resp) - raw := string(body) - if common.DebugEnabled { println("ollama non-stream raw resp:", raw) } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + raw := string(body) + if common.DebugEnabled { + println("ollama non-stream raw resp:", raw) + } - lines := strings.Split(raw, "\n") - var ( - aggContent strings.Builder - reasoningBuilder strings.Builder - lastChunk ollamaChatStreamChunk - parsedAny bool - ) - for _, ln := range lines { - ln = strings.TrimSpace(ln) - if ln == "" { continue } - var ck ollamaChatStreamChunk - if err := json.Unmarshal([]byte(ln), &ck); err != nil { - if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - continue - } - parsedAny = true - lastChunk = ck - 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) } - } + lines := strings.Split(raw, "\n") + var ( + aggContent strings.Builder + reasoningBuilder strings.Builder + lastChunk ollamaChatStreamChunk + parsedAny bool + ) + for _, ln := range lines { + ln = strings.TrimSpace(ln) + if ln == "" { + continue + } + var ck ollamaChatStreamChunk + if err := json.Unmarshal([]byte(ln), &ck); err != nil { + if len(lines) == 1 { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + continue + } + parsedAny = true + lastChunk = ck + 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 { - var single ollamaChatStreamChunk - if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - lastChunk = single - if single.Message != nil { - 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) } - } + if !parsedAny { + var single ollamaChatStreamChunk + if err := json.Unmarshal(body, &single); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + lastChunk = single + if single.Message != nil { + 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 - if model == "" { model = info.UpstreamModelName } - created := toUnix(lastChunk.CreatedAt) - usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount} - content := aggContent.String() - finishReason := lastChunk.DoneReason - if finishReason == "" { finishReason = "stop" } + model := lastChunk.Model + if model == "" { + model = info.UpstreamModelName + } + created := toUnix(lastChunk.CreatedAt) + usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount} + content := aggContent.String() + finishReason := lastChunk.DoneReason + if finishReason == "" { + finishReason = "stop" + } - msg := dto.Message{Role: "assistant", Content: contentPtr(content)} - if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc } - full := dto.OpenAITextResponse{ - Id: common.GetUUID(), - Model: model, - Object: "chat.completion", - Created: created, - Choices: []dto.OpenAITextResponseChoice{ { - Index: 0, - Message: msg, - FinishReason: finishReason, - } }, - Usage: *usage, - } - out, _ := common.Marshal(full) - service.IOCopyBytesGracefully(c, resp, out) - return usage, nil + msg := dto.Message{Role: "assistant", Content: contentPtr(content)} + if rc := reasoningBuilder.String(); rc != "" { + msg.ReasoningContent = rc + } + full := dto.OpenAITextResponse{ + Id: common.GetUUID(), + Model: model, + Object: "chat.completion", + Created: created, + Choices: []dto.OpenAITextResponseChoice{{ + Index: 0, + Message: msg, + FinishReason: finishReason, + }}, + Usage: *usage, + } + 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 +} diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index a424cb1a4..834ed45db 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -187,7 +187,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel } req.Set("Authorization", "Bearer "+accessToken) } - if a.AccountCredentials.ProjectID != "" { + if a.AccountCredentials.ProjectID != "" { req.Set("x-goog-user-project", a.AccountCredentials.ProjectID) } return nil diff --git a/setting/operation_setting/general_setting.go b/setting/operation_setting/general_setting.go index ae0c436ec..c47ff64fa 100644 --- a/setting/operation_setting/general_setting.go +++ b/setting/operation_setting/general_setting.go @@ -2,17 +2,34 @@ package operation_setting import "one-api/setting/config" +// 额度展示类型 +const ( + QuotaDisplayTypeUSD = "USD" + QuotaDisplayTypeCNY = "CNY" + QuotaDisplayTypeTokens = "TOKENS" + QuotaDisplayTypeCustom = "CUSTOM" +) + type GeneralSetting struct { DocsLink string `json:"docs_link"` PingIntervalEnabled bool `json:"ping_interval_enabled"` 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{ - DocsLink: "https://docs.newapi.pro", - PingIntervalEnabled: false, - PingIntervalSeconds: 60, + DocsLink: "https://docs.newapi.pro", + PingIntervalEnabled: false, + PingIntervalSeconds: 60, + QuotaDisplayType: QuotaDisplayTypeUSD, + CustomCurrencySymbol: "¤", + CustomCurrencyExchangeRate: 1.0, } func init() { @@ -23,3 +40,52 @@ func init() { func GetGeneralSetting() *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 的 X(TOKENS 不适用) +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 + } +} diff --git a/web/index.html b/web/index.html index df6b0e398..a9df87f5d 100644 --- a/web/index.html +++ b/web/index.html @@ -10,7 +10,7 @@ content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用" /> New API - + diff --git a/web/jsconfig.json b/web/jsconfig.json index ced4d0543..170a7cb4c 100644 --- a/web/jsconfig.json +++ b/web/jsconfig.json @@ -6,4 +6,4 @@ } }, "include": ["src/**/*"] -} \ No newline at end of file +} diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx index 2a9a8b25b..082e63d79 100644 --- a/web/src/components/common/modals/TwoFactorAuthModal.jsx +++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx @@ -135,7 +135,9 @@ const TwoFactorAuthModal = ({ autoFocus /> - {t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')} + {t( + '支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。', + )} diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx index 0d6e44107..51cdb1de6 100644 --- a/web/src/components/settings/OperationSetting.jsx +++ b/web/src/components/settings/OperationSetting.jsx @@ -42,7 +42,7 @@ const OperationSetting = () => { QuotaPerUnit: 0, USDExchangeRate: 0, RetryTimes: 0, - DisplayInCurrencyEnabled: false, + 'general_setting.quota_display_type': 'USD', DisplayTokenStatEnabled: false, DefaultCollapseSidebar: false, DemoSiteEnabled: false, diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index f9a2c019d..6a567d227 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -45,7 +45,6 @@ import { useTranslation } from 'react-i18next'; const SystemSetting = () => { const { t } = useTranslation(); let [inputs, setInputs] = useState({ - PasswordLoginEnabled: '', PasswordRegisterEnabled: '', EmailVerificationEnabled: '', @@ -188,7 +187,9 @@ const SystemSetting = () => { setInputs(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']); } if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') { @@ -695,14 +696,17 @@ const SystemSetting = () => { noLabel extraText={t('SSRF防护开关详细说明')} onChange={(e) => - handleCheckboxChange('fetch_setting.enable_ssrf_protection', e) + handleCheckboxChange( + 'fetch_setting.enable_ssrf_protection', + e, + ) } > {t('启用SSRF防护(推荐开启以保护服务器安全)')} - + { noLabel extraText={t('私有IP访问详细说明')} 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等内网地址)', + )} - + { noLabel extraText={t('域名IP过滤详细说明')} onChange={(e) => - handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e) + handleCheckboxChange( + 'fetch_setting.apply_ip_filter_for_domain', + e, + ) } style={{ marginBottom: 8 }} > @@ -740,17 +752,23 @@ const SystemSetting = () => { {t(domainFilterMode ? '域名白名单' : '域名黑名单')} - - {t('支持通配符格式,如:example.com, *.api.example.com')} + + {t( + '支持通配符格式,如:example.com, *.api.example.com', + )} { - const selected = val && val.target ? val.target.value : val; + const selected = + val && val.target ? val.target.value : val; const isWhitelist = selected === 'whitelist'; setDomainFilterMode(isWhitelist); - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, 'fetch_setting.domain_filter_mode': isWhitelist, })); @@ -765,9 +783,9 @@ const SystemSetting = () => { onChange={(value) => { setDomainList(value); // 触发Form的onChange事件 - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, - 'fetch_setting.domain_list': value + 'fetch_setting.domain_list': value, })); }} placeholder={t('输入域名后回车,如:example.com')} @@ -784,17 +802,21 @@ const SystemSetting = () => { {t(ipFilterMode ? 'IP白名单' : 'IP黑名单')} - + {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')} { - const selected = val && val.target ? val.target.value : val; + const selected = + val && val.target ? val.target.value : val; const isWhitelist = selected === 'whitelist'; setIpFilterMode(isWhitelist); - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, 'fetch_setting.ip_filter_mode': isWhitelist, })); @@ -809,9 +831,9 @@ const SystemSetting = () => { onChange={(value) => { setIpList(value); // 触发Form的onChange事件 - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, - 'fetch_setting.ip_list': value + 'fetch_setting.ip_list': value, })); }} placeholder={t('输入IP地址后回车,如:8.8.8.8')} @@ -826,7 +848,10 @@ const SystemSetting = () => { > {t('允许的端口')} - + {t('支持单个端口和端口范围,如:80, 443, 8000-8999')} { onChange={(value) => { setAllowedPorts(value); // 触发Form的onChange事件 - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, - 'fetch_setting.allowed_ports': value + 'fetch_setting.allowed_ports': value, })); }} placeholder={t('输入端口后回车,如:80 或 8000-8999')} style={{ width: '100%' }} /> - + {t('端口配置详细说明')} diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index 017e7c1e6..01f2fcdd3 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -85,7 +85,8 @@ const AccountManagement = ({ ); }; const isBound = (accountId) => Boolean(accountId); - const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false); + const [showTelegramBindModal, setShowTelegramBindModal] = + React.useState(false); return ( @@ -226,7 +227,8 @@ const AccountManagement = ({ onGitHubOAuthClicked(status.github_client_id) } disabled={ - isBound(userState.user?.github_id) || !status.github_oauth + isBound(userState.user?.github_id) || + !status.github_oauth } > {status.github_oauth ? t('绑定') : t('未启用')} @@ -384,7 +386,8 @@ const AccountManagement = ({ onLinuxDOOAuthClicked(status.linuxdo_client_id) } disabled={ - isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth + isBound(userState.user?.linux_do_id) || + !status.linuxdo_oauth } > {status.linuxdo_oauth ? t('绑定') : t('未启用')} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 6f5f665b4..841373889 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -87,23 +87,7 @@ const REGION_EXAMPLE = { // 支持并且已适配通过接口获取模型列表的渠道类型 const MODEL_FETCHABLE_TYPES = new Set([ - 1, - 4, - 14, - 34, - 17, - 26, - 24, - 47, - 25, - 20, - 23, - 31, - 35, - 40, - 42, - 48, - 43, + 1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43, ]); function type2secretPrompt(type) { @@ -348,7 +332,10 @@ const EditChannelModal = (props) => { break; case 45: 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; default: localModels = getChannelModels(value); @@ -442,7 +429,8 @@ const EditChannelModal = (props) => { // 读取 Vertex 密钥格式 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) { console.error('解析其他设置失败:', error); data.azure_responses_version = ''; @@ -868,7 +856,10 @@ const EditChannelModal = (props) => { showInfo(t('请至少选择一个模型!')); 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地址!')); return; } @@ -912,7 +903,8 @@ const EditChannelModal = (props) => { } } // 设置企业账户标识,无论是true还是false都要传到后端 - settings.openrouter_enterprise = localInputs.is_enterprise_account === true; + settings.openrouter_enterprise = + localInputs.is_enterprise_account === true; localInputs.settings = JSON.stringify(settings); } @@ -1318,7 +1310,9 @@ const EditChannelModal = (props) => { setIsEnterpriseAccount(value); handleInputChange('is_enterprise_account', value); }} - extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')} + extraText={t( + '企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选', + )} initValue={inputs.is_enterprise_account} /> )} @@ -1944,27 +1938,27 @@ const EditChannelModal = (props) => { )} {inputs.type === 45 && ( -
- - handleInputChange('base_url', value) - } - optionList={[ - { - value: 'https://ark.cn-beijing.volces.com', - label: 'https://ark.cn-beijing.volces.com' - }, - { - value: 'https://ark.ap-southeast.bytepluses.com', - label: 'https://ark.ap-southeast.bytepluses.com' - } - ]} - defaultValue='https://ark.cn-beijing.volces.com' - /> -
+
+ + handleInputChange('base_url', value) + } + optionList={[ + { + value: 'https://ark.cn-beijing.volces.com', + label: 'https://ark.cn-beijing.volces.com', + }, + { + value: 'https://ark.ap-southeast.bytepluses.com', + label: 'https://ark.ap-southeast.bytepluses.com', + }, + ]} + defaultValue='https://ark.cn-beijing.volces.com' + /> +
)}
)} diff --git a/web/src/components/table/mj-logs/MjLogsFilters.jsx b/web/src/components/table/mj-logs/MjLogsFilters.jsx index 6db96e791..7c61454e0 100644 --- a/web/src/components/table/mj-logs/MjLogsFilters.jsx +++ b/web/src/components/table/mj-logs/MjLogsFilters.jsx @@ -56,10 +56,10 @@ const MjLogsFilters = ({ showClear pure size='small' - presets={DATE_RANGE_PRESETS.map(preset => ({ + presets={DATE_RANGE_PRESETS.map((preset) => ({ text: t(preset.text), start: preset.start(), - end: preset.end() + end: preset.end(), }))} /> diff --git a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx index 4423ce39c..71dbd2000 100644 --- a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx +++ b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx @@ -56,6 +56,7 @@ const PricingDisplaySettings = ({ const currencyItems = [ { value: 'USD', label: 'USD ($)' }, { value: 'CNY', label: 'CNY (¥)' }, + { value: 'CUSTOM', label: t('自定义货币') }, ]; const handleChange = (value) => { diff --git a/web/src/components/table/model-pricing/layout/header/SearchActions.jsx b/web/src/components/table/model-pricing/layout/header/SearchActions.jsx index 646ffe023..c961b8dc1 100644 --- a/web/src/components/table/model-pricing/layout/header/SearchActions.jsx +++ b/web/src/components/table/model-pricing/layout/header/SearchActions.jsx @@ -107,6 +107,7 @@ const SearchActions = memo( optionList={[ { value: 'USD', label: 'USD' }, { value: 'CNY', label: 'CNY' }, + { value: 'CUSTOM', label: t('自定义货币') }, ]} /> )} diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx index b63c7dd4f..1f097b2b7 100644 --- a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx @@ -36,8 +36,9 @@ import { } from 'lucide-react'; import { TASK_ACTION_FIRST_TAIL_GENERATE, - TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE, - TASK_ACTION_TEXT_GENERATE + TASK_ACTION_GENERATE, + TASK_ACTION_REFERENCE_GENERATE, + TASK_ACTION_TEXT_GENERATE, } from '../../../constants/common.constant'; import { CHANNEL_OPTIONS } from '../../../constants/channel.constants'; diff --git a/web/src/components/table/task-logs/TaskLogsFilters.jsx b/web/src/components/table/task-logs/TaskLogsFilters.jsx index e27cea867..3bfae77a4 100644 --- a/web/src/components/table/task-logs/TaskLogsFilters.jsx +++ b/web/src/components/table/task-logs/TaskLogsFilters.jsx @@ -56,10 +56,10 @@ const TaskLogsFilters = ({ showClear pure size='small' - presets={DATE_RANGE_PRESETS.map(preset => ({ + presets={DATE_RANGE_PRESETS.map((preset) => ({ text: t(preset.text), start: preset.start(), - end: preset.end() + end: preset.end(), }))} /> diff --git a/web/src/components/table/task-logs/modals/ContentModal.jsx b/web/src/components/table/task-logs/modals/ContentModal.jsx index 3bfba37b1..1c7d96641 100644 --- a/web/src/components/table/task-logs/modals/ContentModal.jsx +++ b/web/src/components/table/task-logs/modals/ContentModal.jsx @@ -60,38 +60,54 @@ const ContentModal = ({ if (videoError) { return (
- + 视频无法在当前浏览器中播放,这可能是由于: - + • 视频服务商的跨域限制 - + • 需要特定的请求头或认证 - + • 防盗链保护机制 - +
- -
- -
- + {modalContent} @@ -104,22 +120,24 @@ const ContentModal = ({ return (
{isLoading && ( -
- +
+
)} -
diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 03ea2b31e..0a299ffa2 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -30,7 +30,8 @@ import { Space, Row, Col, - Spin, Tooltip + Spin, + Tooltip, } from '@douyinfe/semi-ui'; import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'; import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react'; @@ -266,7 +267,8 @@ const RechargeCard = ({ {payMethods && payMethods.length > 0 ? ( {payMethods.map((payMethod) => { - const minTopupVal = Number(payMethod.min_topup) || 0; + const minTopupVal = + Number(payMethod.min_topup) || 0; const isStripe = payMethod.type === 'stripe'; const disabled = (!enableOnlineTopUp && !isStripe) || @@ -280,7 +282,9 @@ const RechargeCard = ({ type='tertiary' onClick={() => preTopUp(payMethod.type)} disabled={disabled} - loading={paymentLoading && payWay === payMethod.type} + loading={ + paymentLoading && payWay === payMethod.type + } icon={ payMethod.type === 'alipay' ? ( @@ -291,7 +295,10 @@ const RechargeCard = ({ ) : ( ) } @@ -301,12 +308,22 @@ const RechargeCard = ({ ); - return disabled && minTopupVal > Number(topUpCount || 0) ? ( - + return disabled && + minTopupVal > Number(topUpCount || 0) ? ( + {buttonEl} ) : ( - {buttonEl} + + {buttonEl} + ); })} @@ -324,23 +341,27 @@ const RechargeCard = ({
{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 discountedPrice = originalPrice * discount; const hasDiscount = discount < 1.0; const actualPay = discountedPrice; const save = originalPrice - discountedPrice; - + return ( { @@ -352,24 +373,35 @@ const RechargeCard = ({ }} >
- + {formatLargeNumber(preset.value)} {hasDiscount && ( - - {t('折').includes('off') ? - ((1 - parseFloat(discount)) * 100).toFixed(1) : - (discount * 10).toFixed(1)}{t('折')} - + + {t('折').includes('off') + ? ( + (1 - parseFloat(discount)) * + 100 + ).toFixed(1) + : (discount * 10).toFixed(1)} + {t('折')} + )} -
+
{t('实付')} {actualPay.toFixed(2)}, - {hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`} + {hasDiscount + ? `${t('节省')} ${save.toFixed(2)}` + : `${t('节省')} 0.00`}
diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 929a47e39..558c67050 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -80,11 +80,11 @@ const TopUp = () => { // 预设充值额度选项 const [presetAmounts, setPresetAmounts] = useState([]); const [selectedPreset, setSelectedPreset] = useState(null); - + // 充值配置信息 const [topupInfo, setTopupInfo] = useState({ amount_options: [], - discount: {} + discount: {}, }); const topUp = async () => { @@ -262,9 +262,9 @@ const TopUp = () => { if (success) { setTopupInfo({ amount_options: data.amount_options || [], - discount: data.discount || {} + discount: data.discount || {}, }); - + // 处理支付方式 let payMethods = data.pay_methods || []; try { @@ -280,10 +280,15 @@ const TopUp = () => { payMethods = payMethods.map((method) => { // 规范化最小充值数 const normalizedMinTopup = Number(method.min_topup); - method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0; + method.min_topup = Number.isFinite(normalizedMinTopup) + ? normalizedMinTopup + : 0; // 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); if (Number.isFinite(stripeMin)) { method.min_topup = stripeMin; @@ -313,7 +318,11 @@ const TopUp = () => { setPayMethods(payMethods); const enableStripeTopUp = data.enable_stripe_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); setEnableStripeTopUp(enableStripeTopUp); setMinTopUp(minTopUpValue); @@ -330,12 +339,12 @@ const TopUp = () => { console.log('解析支付方式失败:', e); setPayMethods([]); } - + // 如果有自定义充值数量选项,使用它们替换默认的预设选项 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, - discount: data.discount[amount] || 1.0 + discount: data.discount[amount] || 1.0, })); setPresetAmounts(customPresets); } @@ -483,7 +492,7 @@ const TopUp = () => { const selectPresetAmount = (preset) => { setTopUpCount(preset.value); setSelectedPreset(preset.value); - + // 计算实际支付金额,考虑折扣 const discount = preset.discount || topupInfo.discount[preset.value] || 1.0; const discountedAmount = preset.value * priceRatio * discount; diff --git a/web/src/components/topup/modals/PaymentConfirmModal.jsx b/web/src/components/topup/modals/PaymentConfirmModal.jsx index 1bffbfed1..8bd5455c7 100644 --- a/web/src/components/topup/modals/PaymentConfirmModal.jsx +++ b/web/src/components/topup/modals/PaymentConfirmModal.jsx @@ -40,9 +40,10 @@ const PaymentConfirmModal = ({ amountNumber, discountRate, }) => { - const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; - const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0; - const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0; + const hasDiscount = + discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; + const originalAmount = hasDiscount ? amountNumber / discountRate : 0; + const discountAmount = hasDiscount ? originalAmount - amountNumber : 0; return ( dayjs().startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '近 7 天', start: () => dayjs().subtract(6, 'day').startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '本周', start: () => dayjs().startOf('week').toDate(), - end: () => dayjs().endOf('week').toDate() + end: () => dayjs().endOf('week').toDate(), }, { text: '近 30 天', start: () => dayjs().subtract(29, 'day').startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '本月', start: () => dayjs().startOf('month').toDate(), - end: () => dayjs().endOf('month').toDate() + end: () => dayjs().endOf('month').toDate(), }, ]; diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index bc389b2e8..1ccfffaf2 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -131,13 +131,11 @@ export const buildApiPayload = ( seed: 'seed', }; - Object.entries(parameterMappings).forEach(([key, param]) => { const enabled = parameterEnabled[key]; const value = inputs[param]; const hasValue = value !== undefined && value !== null; - if (enabled && hasValue) { payload[param] = value; } diff --git a/web/src/helpers/data.js b/web/src/helpers/data.js index b894a953c..e45aac3e9 100644 --- a/web/src/helpers/data.js +++ b/web/src/helpers/data.js @@ -23,7 +23,9 @@ export function setStatusData(data) { localStorage.setItem('logo', data.logo); localStorage.setItem('footer_html', data.footer_html); localStorage.setItem('quota_per_unit', data.quota_per_unit); + // 兼容:保留旧字段,同时写入新的额度展示类型 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_task', data.enable_task); localStorage.setItem('enable_data_export', data.enable_data_export); diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index c19e2849d..19e10ce31 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -830,12 +830,25 @@ export function renderQuotaNumberWithDigit(num, digits = 2) { if (typeof num !== 'number' || isNaN(num)) { return 0; } - let displayInCurrency = localStorage.getItem('display_in_currency'); + const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; num = num.toFixed(digits); - if (displayInCurrency) { + if (quotaDisplayType === 'CNY') { + return '¥' + num; + } else if (quotaDisplayType === 'USD') { 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) { @@ -887,33 +900,67 @@ export function getQuotaWithUnit(quota, digits = 6) { } export function renderQuotaWithAmount(amount) { - let displayInCurrency = localStorage.getItem('display_in_currency'); - displayInCurrency = displayInCurrency === 'true'; - if (displayInCurrency) { - return '$' + amount; - } else { + const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; + if (quotaDisplayType === 'TOKENS') { 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) { 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); - displayInCurrency = displayInCurrency === 'true'; - if (displayInCurrency) { - 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; + if (quotaDisplayType === 'TOKENS') { + return renderNumber(quota); } - 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) { @@ -1072,7 +1119,7 @@ export function renderModelPrice( (completionTokens / 1000000) * completionRatioPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + - (imageGenerationCallPrice * groupRatio); + imageGenerationCallPrice * groupRatio; return ( <> @@ -1510,9 +1557,8 @@ export function renderAudioModelPrice( } export function renderQuotaWithPrompt(quota, digits) { - let displayInCurrency = localStorage.getItem('display_in_currency'); - displayInCurrency = displayInCurrency === 'true'; - if (displayInCurrency) { + const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; + if (quotaDisplayType !== 'TOKENS') { return i18next.t('等价金额:') + renderQuota(quota, digits); } return ''; diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index bcd13230e..0cbdebd15 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -646,9 +646,25 @@ export const calculateModelPrice = ({ const numCompletion = 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 { - inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`, - completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`, + inputPrice: `${symbol}${numInput.toFixed(precision)}`, + completionPrice: `${symbol}${numCompletion.toFixed(precision)}`, unitLabel, isPerToken: true, usedGroup, diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index 7d09d4dfd..2d1f4c1e3 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -25,9 +25,13 @@ import { showInfo, showSuccess, loadChannelModels, - copy + copy, } 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 { useTableCompactMode } from '../common/useTableCompactMode'; import { Modal } from '@douyinfe/semi-ui'; @@ -64,7 +68,7 @@ export const useChannelsData = () => { // Status filter const [statusFilter, setStatusFilter] = useState( - localStorage.getItem('channel-status-filter') || 'all' + localStorage.getItem('channel-status-filter') || 'all', ); // Type tabs states @@ -80,7 +84,7 @@ export const useChannelsData = () => { const [selectedModelKeys, setSelectedModelKeys] = useState([]); const [isBatchTesting, setIsBatchTesting] = useState(false); const [modelTablePage, setModelTablePage] = useState(1); - + // 使用 ref 来避免闭包问题,类似旧版实现 const shouldStopBatchTestingRef = useRef(false); @@ -116,9 +120,12 @@ export const useChannelsData = () => { // Initialize from localStorage useEffect(() => { const localIdSort = localStorage.getItem('id-sort') === 'true'; - const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; - const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true'; - const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true'; + const localPageSize = + parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; + const localEnableTagMode = + localStorage.getItem('enable-tag-mode') === 'true'; + const localEnableBatchDelete = + localStorage.getItem('enable-batch-delete') === 'true'; setIdSort(localIdSort); setPageSize(localPageSize); @@ -176,7 +183,10 @@ export const useChannelsData = () => { // Save column preferences useEffect(() => { if (Object.keys(visibleColumns).length > 0) { - localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns)); + localStorage.setItem( + 'channels-table-columns', + JSON.stringify(visibleColumns), + ); } }, [visibleColumns]); @@ -290,14 +300,21 @@ export const useChannelsData = () => { const { searchKeyword, searchGroup, searchModel } = getFormValues(); if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') { setLoading(true); - await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort); + await searchChannels( + enableTagMode, + typeKey, + statusF, + page, + pageSize, + idSort, + ); setLoading(false); return; } const reqId = ++requestCounter.current; setLoading(true); - const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; + const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : ''; const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; const res = await API.get( `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`, @@ -311,7 +328,10 @@ export const useChannelsData = () => { if (success) { const { items, total, type_counts } = data; 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 }); } setChannelFormat(items, enableTagMode); @@ -335,11 +355,18 @@ export const useChannelsData = () => { setSearching(true); try { if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF); + await loadChannels( + page, + pageSz, + sortFlag, + enableTagMode, + typeKey, + statusF, + ); return; } - const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; + const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : ''; const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; 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}`, @@ -347,7 +374,10 @@ export const useChannelsData = () => { const { success, message, data } = res.data; if (success) { 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 }); setChannelFormat(items, enableTagMode); setChannelCount(total); @@ -366,7 +396,14 @@ export const useChannelsData = () => { if (searchKeyword === '' && searchGroup === '' && searchModel === '') { await loadChannels(page, pageSize, idSort, enableTagMode); } 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(); setActivePage(page); if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - loadChannels(page, pageSize, idSort, enableTagMode).then(() => { }); + loadChannels(page, pageSize, idSort, enableTagMode).then(() => {}); } else { - searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort); + searchChannels( + enableTagMode, + activeTypeKey, + statusFilter, + page, + pageSize, + idSort, + ); } }; @@ -470,7 +514,14 @@ export const useChannelsData = () => { showError(reason); }); } 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('渠道复制失败')); } } 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); break; case 'weight': - if (data.weight === undefined || data.weight < 0 || data.weight === '') { + if ( + data.weight === undefined || + data.weight < 0 || + data.weight === '' + ) { showInfo('权重必须是非负整数!'); return; } @@ -683,7 +741,11 @@ export const useChannelsData = () => { const res = await API.post(`/api/channel/fix`); const { success, message, data } = res.data; 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(); } else { showError(message); @@ -700,10 +762,12 @@ export const useChannelsData = () => { } // 添加到正在测试的模型集合 - setTestingModels(prev => new Set([...prev, model])); + setTestingModels((prev) => new Set([...prev, model])); 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) { @@ -713,14 +777,14 @@ export const useChannelsData = () => { const { success, message, time } = res.data; // 更新测试结果 - setModelTestResults(prev => ({ + setModelTestResults((prev) => ({ ...prev, [testKey]: { success, message, time: time || 0, - timestamp: Date.now() - } + timestamp: Date.now(), + }, })); if (success) { @@ -738,7 +802,9 @@ export const useChannelsData = () => { ); } else { showInfo( - t('通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。') + t( + '通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。', + ) .replace('${name}', record.name) .replace('${model}', model) .replace('${time.toFixed(2)}', time.toFixed(2)), @@ -750,19 +816,19 @@ export const useChannelsData = () => { } catch (error) { // 处理网络错误 const testKey = `${record.id}-${model}`; - setModelTestResults(prev => ({ + setModelTestResults((prev) => ({ ...prev, [testKey]: { success: false, message: error.message || t('网络错误'), time: 0, - timestamp: Date.now() - } + timestamp: Date.now(), + }, })); showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`); } finally { // 从正在测试的模型集合中移除 - setTestingModels(prev => { + setTestingModels((prev) => { const newSet = new Set(prev); newSet.delete(model); return newSet; @@ -777,9 +843,11 @@ export const useChannelsData = () => { return; } - const models = currentTestChannel.models.split(',').filter(model => - model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) - ); + const models = currentTestChannel.models + .split(',') + .filter((model) => + model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), + ); if (models.length === 0) { showError(t('没有找到匹配的模型')); @@ -790,9 +858,9 @@ export const useChannelsData = () => { shouldStopBatchTestingRef.current = false; // 重置停止标志 // 清空该渠道之前的测试结果 - setModelTestResults(prev => { + setModelTestResults((prev) => { const newResults = { ...prev }; - models.forEach(model => { + models.forEach((model) => { const testKey = `${currentTestChannel.id}-${model}`; delete newResults[testKey]; }); @@ -800,7 +868,12 @@ export const useChannelsData = () => { }); try { - showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length)); + showInfo( + t('开始批量测试 ${count} 个模型,已清空上次结果...').replace( + '${count}', + models.length, + ), + ); // 提高并发数量以加快测试速度,参考旧版的并发限制 const concurrencyLimit = 5; @@ -814,13 +887,16 @@ export const useChannelsData = () => { } const batch = models.slice(i, i + concurrencyLimit); - showInfo(t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)') - .replace('${current}', i + 1) - .replace('${end}', Math.min(i + concurrencyLimit, models.length)) - .replace('${total}', models.length) + showInfo( + t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)') + .replace('${current}', i + 1) + .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); results.push(...batchResults); @@ -832,20 +908,20 @@ export const useChannelsData = () => { // 短暂延迟避免过于频繁的请求 if (i + concurrencyLimit < models.length) { - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); } } 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 failCount = 0; - models.forEach(model => { + models.forEach((model) => { const testKey = `${currentTestChannel.id}-${model}`; const result = currentResults[testKey]; if (result && result.success) { @@ -857,10 +933,11 @@ export const useChannelsData = () => { // 显示完成消息 setTimeout(() => { - showSuccess(t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}') - .replace('${success}', successCount) - .replace('${fail}', failCount) - .replace('${total}', models.length) + showSuccess( + t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}') + .replace('${success}', successCount) + .replace('${fail}', failCount) + .replace('${total}', models.length), ); }, 100); @@ -1045,4 +1122,4 @@ export const useChannelsData = () => { setCompactMode, setActivePage, }; -}; \ No newline at end of file +}; diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 13d76fd86..0b8eb3d8f 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -128,7 +128,7 @@ export const useSidebar = () => { // 刷新用户配置的方法(供外部调用) const refreshUserConfig = async () => { - if (Object.keys(adminConfig).length > 0) { + if (Object.keys(adminConfig).length > 0) { await loadUserConfig(); } @@ -155,7 +155,10 @@ export const useSidebar = () => { sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); return () => { - sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); + sidebarEventTarget.removeEventListener( + SIDEBAR_REFRESH_EVENT, + handleRefresh, + ); }; }, [adminConfig]); diff --git a/web/src/hooks/model-pricing/useModelPricingData.jsx b/web/src/hooks/model-pricing/useModelPricingData.jsx index 799cdc136..0a407236d 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.jsx +++ b/web/src/hooks/model-pricing/useModelPricingData.jsx @@ -64,6 +64,29 @@ export const useModelPricingData = () => { () => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate], ); + const customExchangeRate = useMemo( + () => statusState?.status?.custom_currency_exchange_rate ?? 1, + [statusState], + ); + const customCurrencySymbol = useMemo( + () => statusState?.status?.custom_currency_symbol ?? '¤', + [statusState], + ); + + // 默认货币与站点展示类型同步(USD/CNY),TOKENS 时仍允许切换视图内货币 + const siteDisplayType = useMemo( + () => statusState?.status?.quota_display_type || 'USD', + [statusState], + ); + useEffect(() => { + if ( + siteDisplayType === 'USD' || + siteDisplayType === 'CNY' || + siteDisplayType === 'CUSTOM' + ) { + setCurrency(siteDisplayType); + } + }, [siteDisplayType]); const filteredModels = useMemo(() => { let result = models; @@ -156,6 +179,8 @@ export const useModelPricingData = () => { if (currency === 'CNY') { return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`; + } else if (currency === 'CUSTOM') { + return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`; } return `$${priceInUSD.toFixed(3)}`; }; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8a9d32f21..6f26d670b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1773,7 +1773,10 @@ "自定义模型名称": "Custom model name", "启用全部密钥": "Enable all keys", "充值价格显示": "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", "隐藏操作项": "Hide actions", "显示操作项": "Show actions", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 35a635bdd..6d601ef2b 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1773,7 +1773,10 @@ "自定义模型名称": "Nom de modèle personnalisé", "启用全部密钥": "Activer toutes les clés", "充值价格显示": "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", "隐藏操作项": "Masquer les actions", "显示操作项": "Afficher les actions", @@ -2137,4 +2140,4 @@ "common": { "changeLanguage": "Changer de langue" } -} \ No newline at end of file +} diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.jsx b/web/src/pages/Setting/Operation/SettingsGeneral.jsx index 5af750ec3..fbfa0ed99 100644 --- a/web/src/pages/Setting/Operation/SettingsGeneral.jsx +++ b/web/src/pages/Setting/Operation/SettingsGeneral.jsx @@ -17,8 +17,19 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useState, useRef } from 'react'; -import { Banner, Button, Col, Form, Row, Spin, Modal } from '@douyinfe/semi-ui'; +import React, { useEffect, useState, useRef, useMemo } from 'react'; +import { + Banner, + Button, + Col, + Form, + Row, + Spin, + Modal, + Select, + InputGroup, + Input, +} from '@douyinfe/semi-ui'; import { compareObjects, API, @@ -35,10 +46,12 @@ export default function GeneralSettings(props) { const [inputs, setInputs] = useState({ TopUpLink: '', 'general_setting.docs_link': '', + 'general_setting.quota_display_type': 'USD', + 'general_setting.custom_currency_symbol': '¤', + 'general_setting.custom_currency_exchange_rate': '', QuotaPerUnit: '', RetryTimes: '', USDExchangeRate: '', - DisplayInCurrencyEnabled: false, DisplayTokenStatEnabled: false, DefaultCollapseSidebar: false, DemoSiteEnabled: false, @@ -88,6 +101,30 @@ export default function GeneralSettings(props) { }); } + // 计算展示在输入框中的“1 USD = X ”中的 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(() => { const currentInputs = {}; for (let key in props.options) { @@ -95,6 +132,28 @@ export default function GeneralSettings(props) { 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); setInputsRow(structuredClone(currentInputs)); refForm.current.setValues(currentInputs); @@ -130,29 +189,7 @@ export default function GeneralSettings(props) { showClear /> - {inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && ( - - setShowQuotaWarning(true)} - /> - - )} - - - + {/* 单位美元额度已合入汇率组合控件(TOKENS 模式下编辑),不再单独展示 */} - - - + + + + + + + + + + - - setInputs({ ...inputs, - 'monitor_setting.auto_test_channel_minutes': parseInt(value), + 'monitor_setting.auto_test_channel_minutes': + parseInt(value), }) } /> diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx index d681b6a27..a4f1029a1 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx @@ -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)) { showError(t('自定义充值数量选项不是合法的 JSON 数组')); return; } } - if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') { + if ( + originInputs['AmountDiscount'] !== inputs.AmountDiscount && + inputs.AmountDiscount.trim() !== '' + ) { if (!verifyJSON(inputs.AmountDiscount)) { showError(t('充值金额折扣配置不是合法的 JSON 对象')); return; @@ -163,10 +169,16 @@ export default function SettingsPaymentGateway(props) { options.push({ key: 'PayMethods', value: inputs.PayMethods }); } 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) { - 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 文本')} autosize /> - + - + - + diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx index ed982edcf..b298cc787 100644 --- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx +++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx @@ -226,8 +226,12 @@ export default function ModelRatioSettings(props) { - setInputs({ ...inputs, ImageRatio: value }) - } + onChange={(value) => setInputs({ ...inputs, ImageRatio: value })} /> @@ -249,7 +251,9 @@ export default function ModelRatioSettings(props) { - setInputs({ ...inputs, AudioRatio: value }) - } + onChange={(value) => setInputs({ ...inputs, AudioRatio: value })} /> @@ -270,8 +272,12 @@ export default function ModelRatioSettings(props) {