Переглянути джерело

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

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

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

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

Notes
- No database migrations required.
- Legacy clients remain functional via compatibility fields.
t0ng7u 2 місяців тому
батько
коміт
39a868faea
46 змінених файлів з 1268 додано та 601 видалено
  1. 1 0
      common/constants.go
  2. 1 1
      common/database.go
  3. 20 4
      controller/billing.go
  4. 16 12
      controller/misc.go
  5. 1 1
      controller/setup.go
  6. 5 4
      controller/topup.go
  7. 2 2
      controller/topup_stripe.go
  8. 44 6
      logger/logger.go
  9. 9 1
      model/option.go
  10. 22 8
      relay/channel/ollama/adaptor.go
  11. 22 23
      relay/channel/ollama/dto.go
  12. 144 50
      relay/channel/ollama/relay-ollama.go
  13. 252 184
      relay/channel/ollama/stream.go
  14. 1 1
      relay/channel/vertex/adaptor.go
  15. 69 3
      setting/operation_setting/general_setting.go
  16. 1 1
      web/index.html
  17. 1 1
      web/jsconfig.json
  18. 3 1
      web/src/components/common/modals/TwoFactorAuthModal.jsx
  19. 1 1
      web/src/components/settings/OperationSetting.jsx
  20. 51 23
      web/src/components/settings/SystemSetting.jsx
  21. 6 3
      web/src/components/settings/personal/cards/AccountManagement.jsx
  22. 37 43
      web/src/components/table/channels/modals/EditChannelModal.jsx
  23. 2 2
      web/src/components/table/mj-logs/MjLogsFilters.jsx
  24. 1 0
      web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx
  25. 1 0
      web/src/components/table/model-pricing/layout/header/SearchActions.jsx
  26. 3 2
      web/src/components/table/task-logs/TaskLogsColumnDefs.jsx
  27. 2 2
      web/src/components/table/task-logs/TaskLogsFilters.jsx
  28. 48 30
      web/src/components/table/task-logs/modals/ContentModal.jsx
  29. 2 2
      web/src/components/table/usage-logs/UsageLogsFilters.jsx
  30. 57 25
      web/src/components/topup/RechargeCard.jsx
  31. 20 11
      web/src/components/topup/index.jsx
  32. 4 3
      web/src/components/topup/modals/PaymentConfirmModal.jsx
  33. 5 5
      web/src/constants/console.constants.js
  34. 0 2
      web/src/helpers/api.js
  35. 2 0
      web/src/helpers/data.js
  36. 72 26
      web/src/helpers/render.jsx
  37. 18 2
      web/src/helpers/utils.jsx
  38. 128 51
      web/src/hooks/channels/useChannelsData.jsx
  39. 5 2
      web/src/hooks/common/useSidebar.js
  40. 25 0
      web/src/hooks/model-pricing/useModelPricingData.jsx
  41. 4 1
      web/src/i18n/locales/en.json
  42. 5 2
      web/src/i18n/locales/fr.json
  43. 105 37
      web/src/pages/Setting/Operation/SettingsGeneral.jsx
  44. 2 1
      web/src/pages/Setting/Operation/SettingsMonitoring.jsx
  45. 31 11
      web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx
  46. 17 11
      web/src/pages/Setting/Ratio/ModelRatioSettings.jsx

+ 1 - 0
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

+ 1 - 1
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"
+var SQLitePath = "one-api.db?_busy_timeout=30000"

+ 20 - 4
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",

+ 16 - 12
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,

+ 1 - 1
controller/setup.go

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

+ 5 - 4
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()

+ 2 - 2
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)

+ 44 - 6
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)
 	}
 }
 

+ 9 - 1
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":

+ 22 - 8
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)

+ 22 - 23
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"`
 }
-

+ 144 - 50
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.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.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.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
 }
-

+ 252 - 184
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
+}

+ 1 - 1
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

+ 69 - 3
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 <currency> 的 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
+	}
+}

+ 1 - 1
web/index.html

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

+ 1 - 1
web/jsconfig.json

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

+ 3 - 1
web/src/components/common/modals/TwoFactorAuthModal.jsx

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

+ 1 - 1
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,

+ 51 - 23
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防护(推荐开启以保护服务器安全)')}
                       </Form.Checkbox>
                     </Col>
                   </Row>
-                  
+
                   <Row
                     gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
                     style={{ marginTop: 16 }}
@@ -713,14 +717,19 @@ const SystemSetting = () => {
                         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等内网地址)',
+                        )}
                       </Form.Checkbox>
                     </Col>
                   </Row>
-                  
+
                   <Row
                     gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
                     style={{ marginTop: 16 }}
@@ -731,7 +740,10 @@ const SystemSetting = () => {
                         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 = () => {
                       <Text strong>
                         {t(domainFilterMode ? '域名白名单' : '域名黑名单')}
                       </Text>
-                      <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
-                        {t('支持通配符格式,如:example.com, *.api.example.com')}
+                      <Text
+                        type='secondary'
+                        style={{ display: 'block', marginBottom: 8 }}
+                      >
+                        {t(
+                          '支持通配符格式,如:example.com, *.api.example.com',
+                        )}
                       </Text>
                       <Radio.Group
                         type='button'
                         value={domainFilterMode ? 'whitelist' : 'blacklist'}
                         onChange={(val) => {
-                          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 = () => {
                       <Text strong>
                         {t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
                       </Text>
-                      <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
+                      <Text
+                        type='secondary'
+                        style={{ display: 'block', marginBottom: 8 }}
+                      >
                         {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')}
                       </Text>
                       <Radio.Group
                         type='button'
                         value={ipFilterMode ? 'whitelist' : 'blacklist'}
                         onChange={(val) => {
-                          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 = () => {
                   >
                     <Col xs={24} sm={24} md={24} lg={24} xl={24}>
                       <Text strong>{t('允许的端口')}</Text>
-                      <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
+                      <Text
+                        type='secondary'
+                        style={{ display: 'block', marginBottom: 8 }}
+                      >
                         {t('支持单个端口和端口范围,如:80, 443, 8000-8999')}
                       </Text>
                       <TagInput
@@ -834,15 +859,18 @@ const SystemSetting = () => {
                         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%' }}
                       />
-                      <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
+                      <Text
+                        type='secondary'
+                        style={{ display: 'block', marginBottom: 8 }}
+                      >
                         {t('端口配置详细说明')}
                       </Text>
                     </Col>

+ 6 - 3
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 (
     <Card className='!rounded-2xl'>
@@ -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('未启用')}

+ 37 - 43
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 && (
-                        <div>
-                          <Form.Select
-                              field='base_url'
-                              label={t('API地址')}
-                              placeholder={t('请选择API地址')}
-                              onChange={(value) =>
-                                  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'
-                          />
-                        </div>
+                      <div>
+                        <Form.Select
+                          field='base_url'
+                          label={t('API地址')}
+                          placeholder={t('请选择API地址')}
+                          onChange={(value) =>
+                            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'
+                        />
+                      </div>
                     )}
                   </Card>
                 )}

+ 2 - 2
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(),
               }))}
             />
           </div>

+ 1 - 0
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) => {

+ 1 - 0
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('自定义货币') },
                 ]}
               />
             )}

+ 3 - 2
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';
 

+ 2 - 2
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(),
               }))}
             />
           </div>

+ 48 - 30
web/src/components/table/task-logs/modals/ContentModal.jsx

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

+ 2 - 2
web/src/components/table/usage-logs/UsageLogsFilters.jsx

@@ -57,10 +57,10 @@ const LogsFilters = ({
               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(),
               }))}
             />
           </div>

+ 57 - 25
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 ? (
                           <Space wrap>
                             {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' ? (
                                       <SiAlipay size={18} color='#1677FF' />
@@ -291,7 +295,10 @@ const RechargeCard = ({
                                     ) : (
                                       <CreditCard
                                         size={18}
-                                        color={payMethod.color || 'var(--semi-color-text-2)'}
+                                        color={
+                                          payMethod.color ||
+                                          'var(--semi-color-text-2)'
+                                        }
                                       />
                                     )
                                   }
@@ -301,12 +308,22 @@ const RechargeCard = ({
                                 </Button>
                               );
 
-                              return disabled && minTopupVal > Number(topUpCount || 0) ? (
-                                <Tooltip content={t('此支付方式最低充值金额为') + ' ' + minTopupVal} key={payMethod.type}>
+                              return disabled &&
+                                minTopupVal > Number(topUpCount || 0) ? (
+                                <Tooltip
+                                  content={
+                                    t('此支付方式最低充值金额为') +
+                                    ' ' +
+                                    minTopupVal
+                                  }
+                                  key={payMethod.type}
+                                >
                                   {buttonEl}
                                 </Tooltip>
                               ) : (
-                                <React.Fragment key={payMethod.type}>{buttonEl}</React.Fragment>
+                                <React.Fragment key={payMethod.type}>
+                                  {buttonEl}
+                                </React.Fragment>
                               );
                             })}
                           </Space>
@@ -324,23 +341,27 @@ const RechargeCard = ({
                   <Form.Slot label={t('选择充值额度')}>
                     <div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
                       {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 (
                           <Card
                             key={index}
                             style={{
                               cursor: 'pointer',
-                              border: selectedPreset === preset.value 
-                                ? '2px solid var(--semi-color-primary)' 
-                                : '1px solid var(--semi-color-border)',
+                              border:
+                                selectedPreset === preset.value
+                                  ? '2px solid var(--semi-color-primary)'
+                                  : '1px solid var(--semi-color-border)',
                               height: '100%',
-                              width: '100%'
+                              width: '100%',
                             }}
                             bodyStyle={{ padding: '12px' }}
                             onClick={() => {
@@ -352,24 +373,35 @@ const RechargeCard = ({
                             }}
                           >
                             <div style={{ textAlign: 'center' }}>
-                              <Typography.Title heading={6} style={{ margin: '0 0 8px 0' }}>
+                              <Typography.Title
+                                heading={6}
+                                style={{ margin: '0 0 8px 0' }}
+                              >
                                 <Coins size={18} />
                                 {formatLargeNumber(preset.value)}
                                 {hasDiscount && (
-                                   <Tag style={{ marginLeft: 4 }} color="green">
-                                   {t('折').includes('off') ?
-                                     ((1 - parseFloat(discount)) * 100).toFixed(1) :
-                                     (discount * 10).toFixed(1)}{t('折')}
-                                 </Tag>
+                                  <Tag style={{ marginLeft: 4 }} color='green'>
+                                    {t('折').includes('off')
+                                      ? (
+                                          (1 - parseFloat(discount)) *
+                                          100
+                                        ).toFixed(1)
+                                      : (discount * 10).toFixed(1)}
+                                    {t('折')}
+                                  </Tag>
                                 )}
                               </Typography.Title>
-                              <div style={{ 
-                                color: 'var(--semi-color-text-2)', 
-                                fontSize: '12px', 
-                                margin: '4px 0' 
-                              }}>
+                              <div
+                                style={{
+                                  color: 'var(--semi-color-text-2)',
+                                  fontSize: '12px',
+                                  margin: '4px 0',
+                                }}
+                              >
                                 {t('实付')} {actualPay.toFixed(2)},
-                                {hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`}
+                                {hasDiscount
+                                  ? `${t('节省')} ${save.toFixed(2)}`
+                                  : `${t('节省')} 0.00`}
                               </div>
                             </div>
                           </Card>

+ 20 - 11
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;

+ 4 - 3
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 (
     <Modal
       title={

+ 5 - 5
web/src/constants/console.constants.js

@@ -24,26 +24,26 @@ export const DATE_RANGE_PRESETS = [
   {
     text: '今天',
     start: () => 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(),
   },
 ];

+ 0 - 2
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;
     }

+ 2 - 0
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);

+ 72 - 26
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);
+  }
+  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 renderNumber(quota);
+  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 '';

+ 18 - 2
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,

+ 128 - 51
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,
   };
-}; 
+};

+ 5 - 2
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]);
 

+ 25 - 0
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)}`;
   };

+ 4 - 1
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",

+ 5 - 2
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"
   }
-}
+}

+ 105 - 37
web/src/pages/Setting/Operation/SettingsGeneral.jsx

@@ -17,8 +17,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact [email protected]
 */
 
-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 <currency>”中的 X
+  const combinedRate = useMemo(() => {
+    const type = inputs['general_setting.quota_display_type'];
+    if (type === 'USD') return '1';
+    if (type === 'CNY') return String(inputs['USDExchangeRate'] || '');
+    if (type === 'TOKENS') return String(inputs['QuotaPerUnit'] || '');
+    if (type === 'CUSTOM')
+      return String(
+        inputs['general_setting.custom_currency_exchange_rate'] || '',
+      );
+    return '';
+  }, [inputs]);
+
+  const onCombinedRateChange = (val) => {
+    const type = inputs['general_setting.quota_display_type'];
+    if (type === 'CNY') {
+      handleFieldChange('USDExchangeRate')(val);
+    } else if (type === 'TOKENS') {
+      handleFieldChange('QuotaPerUnit')(val);
+    } else if (type === 'CUSTOM') {
+      handleFieldChange('general_setting.custom_currency_exchange_rate')(val);
+    }
+  };
+
   useEffect(() => {
     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
                 />
               </Col>
-              {inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && (
-                <Col xs={24} sm={12} md={8} lg={8} xl={8}>
-                  <Form.Input
-                    field={'QuotaPerUnit'}
-                    label={t('单位美元额度')}
-                    initValue={''}
-                    placeholder={t('一单位货币能兑换的额度')}
-                    onChange={handleFieldChange('QuotaPerUnit')}
-                    showClear
-                    onClick={() => setShowQuotaWarning(true)}
-                  />
-                </Col>
-              )}
-              <Col xs={24} sm={12} md={8} lg={8} xl={8}>
-                <Form.Input
-                  field={'USDExchangeRate'}
-                  label={t('美元汇率(非充值汇率,仅用于定价页面换算)')}
-                  initValue={''}
-                  placeholder={t('美元汇率')}
-                  onChange={handleFieldChange('USDExchangeRate')}
-                  showClear
-                />
-              </Col>
+              {/* 单位美元额度已合入汇率组合控件(TOKENS 模式下编辑),不再单独展示 */}
               <Col xs={24} sm={12} md={8} lg={8} xl={8}>
                 <Form.Input
                   field={'RetryTimes'}
@@ -163,18 +200,51 @@ export default function GeneralSettings(props) {
                   showClear
                 />
               </Col>
-            </Row>
-            <Row gutter={16}>
               <Col xs={24} sm={12} md={8} lg={8} xl={8}>
-                <Form.Switch
-                  field={'DisplayInCurrencyEnabled'}
-                  label={t('以货币形式显示额度')}
-                  size='default'
-                  checkedText='|'
-                  uncheckedText='〇'
-                  onChange={handleFieldChange('DisplayInCurrencyEnabled')}
+                <Form.Slot label={t('站点额度展示类型及汇率')}>
+                  <InputGroup style={{ width: '100%' }}>
+                    <Input
+                      prefix={'1 USD = '}
+                      style={{ width: '50%' }}
+                      value={combinedRate}
+                      onChange={onCombinedRateChange}
+                      disabled={
+                        inputs['general_setting.quota_display_type'] === 'USD'
+                      }
+                    />
+                    <Select
+                      style={{ width: '50%' }}
+                      value={inputs['general_setting.quota_display_type']}
+                      onChange={handleFieldChange(
+                        'general_setting.quota_display_type',
+                      )}
+                    >
+                      <Select.Option value='USD'>USD ($)</Select.Option>
+                      <Select.Option value='CNY'>CNY (¥)</Select.Option>
+                      <Select.Option value='TOKENS'>Tokens</Select.Option>
+                      <Select.Option value='CUSTOM'>
+                        {t('自定义货币')}
+                      </Select.Option>
+                    </Select>
+                  </InputGroup>
+                </Form.Slot>
+              </Col>
+              <Col xs={24} sm={12} md={8} lg={8} xl={8}>
+                <Form.Input
+                  field={'general_setting.custom_currency_symbol'}
+                  label={t('自定义货币符号')}
+                  placeholder={t('例如 €, £, Rp, ₩, ₹...')}
+                  onChange={handleFieldChange(
+                    'general_setting.custom_currency_symbol',
+                  )}
+                  showClear
+                  disabled={
+                    inputs['general_setting.quota_display_type'] !== 'CUSTOM'
+                  }
                 />
               </Col>
+            </Row>
+            <Row gutter={16}>
               <Col xs={24} sm={12} md={8} lg={8} xl={8}>
                 <Form.Switch
                   field={'DisplayTokenStatEnabled'}
@@ -195,8 +265,6 @@ export default function GeneralSettings(props) {
                   onChange={handleFieldChange('DefaultCollapseSidebar')}
                 />
               </Col>
-            </Row>
-            <Row gutter={16}>
               <Col xs={24} sm={12} md={8} lg={8} xl={8}>
                 <Form.Switch
                   field={'DemoSiteEnabled'}

+ 2 - 1
web/src/pages/Setting/Operation/SettingsMonitoring.jsx

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

+ 31 - 11
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
           />
-          
+
           <Row
             gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
             style={{ marginTop: 16 }}
@@ -282,13 +294,17 @@ export default function SettingsPaymentGateway(props) {
               <Form.TextArea
                 field='AmountOptions'
                 label={t('自定义充值数量选项')}
-                placeholder={t('为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]')}
+                placeholder={t(
+                  '为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]',
+                )}
                 autosize
-                extraText={t('设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]')}
+                extraText={t(
+                  '设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]',
+                )}
               />
             </Col>
           </Row>
-          
+
           <Row
             gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
             style={{ marginTop: 16 }}
@@ -297,13 +313,17 @@ export default function SettingsPaymentGateway(props) {
               <Form.TextArea
                 field='AmountDiscount'
                 label={t('充值金额折扣配置')}
-                placeholder={t('为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
+                placeholder={t(
+                  '为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
+                )}
                 autosize
-                extraText={t('设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
+                extraText={t(
+                  '设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
+                )}
               />
             </Col>
           </Row>
-          
+
           <Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
         </Form.Section>
       </Form>

+ 17 - 11
web/src/pages/Setting/Ratio/ModelRatioSettings.jsx

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