mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 05:02:17 +00:00
💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM)
Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe
configuration `general_setting.quota_display_type`, and wire it through the
backend and frontend.
Backend
- Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected
registration via `config.GlobalConfig.Register("general_setting", ...)`.
Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`.
- Expose `quota_display_type` in `/api/status` and keep legacy
`display_in_currency` for backward compatibility.
- Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When
CNY is selected, convert using `operation_setting.USDExchangeRate`.
- Controllers:
- `billing`: compute subscription/usage amounts based on the selected type
(USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens).
- `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as
token-count for TOKENS; adjust min topup and pay money accordingly.
- `misc`: include `quota_display_type` in status payload.
- Compatibility: in `model/option.UpdateOption`, map updates to
`DisplayInCurrencyEnabled` → `general_setting.quota_display_type`
(true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`.
Frontend
- Settings: replace the “display in currency” switch with a Select
(`general_setting.quota_display_type`) offering USD / CNY / Tokens.
Provide fallback mapping from legacy `DisplayInCurrencyEnabled`.
- Persist `quota_display_type` to localStorage (keep `display_in_currency`
for legacy components).
- Rendering helpers: base all quota/price rendering on `quota_display_type`;
use `usd_exchange_rate` for CNY symbol/values.
- Pricing page: default view currency follows site display type (USD/CNY),
while TOKENS mode still allows per-view currency toggling when needed.
Notes
- No database migrations required.
- Legacy clients remain functional via compatibility fields.
This commit is contained in:
@@ -19,6 +19,7 @@ var TopUpLink = ""
|
|||||||
// var ChatLink = ""
|
// var ChatLink = ""
|
||||||
// var ChatLink2 = ""
|
// var ChatLink2 = ""
|
||||||
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
||||||
|
// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制
|
||||||
var DisplayInCurrencyEnabled = true
|
var DisplayInCurrencyEnabled = true
|
||||||
var DisplayTokenStatEnabled = true
|
var DisplayTokenStatEnabled = true
|
||||||
var DrawingEnabled = true
|
var DrawingEnabled = true
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
|
|||||||
var UsingMySQL = false
|
var UsingMySQL = false
|
||||||
var UsingClickHouse = false
|
var UsingClickHouse = false
|
||||||
|
|
||||||
var SQLitePath = "one-api.db?_busy_timeout=30000"
|
var SQLitePath = "one-api.db?_busy_timeout=30000"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"one-api/common"
|
"one-api/common"
|
||||||
"one-api/dto"
|
"one-api/dto"
|
||||||
"one-api/model"
|
"one-api/model"
|
||||||
|
"one-api/setting/operation_setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetSubscription(c *gin.Context) {
|
func GetSubscription(c *gin.Context) {
|
||||||
@@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
quota := remainQuota + usedQuota
|
quota := remainQuota + usedQuota
|
||||||
amount := float64(quota)
|
amount := float64(quota)
|
||||||
if common.DisplayInCurrencyEnabled {
|
// OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值:
|
||||||
amount /= common.QuotaPerUnit
|
// 我们将其解释为以“站点展示类型”为准:
|
||||||
|
// - USD: 直接除以 QuotaPerUnit
|
||||||
|
// - CNY: 先转 USD 再乘汇率
|
||||||
|
// - TOKENS: 直接使用 tokens 数量
|
||||||
|
switch operation_setting.GetQuotaDisplayType() {
|
||||||
|
case operation_setting.QuotaDisplayTypeCNY:
|
||||||
|
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||||
|
case operation_setting.QuotaDisplayTypeTokens:
|
||||||
|
// amount 保持 tokens 数值
|
||||||
|
default:
|
||||||
|
amount = amount / common.QuotaPerUnit
|
||||||
}
|
}
|
||||||
if token != nil && token.UnlimitedQuota {
|
if token != nil && token.UnlimitedQuota {
|
||||||
amount = 100000000
|
amount = 100000000
|
||||||
@@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
amount := float64(quota)
|
amount := float64(quota)
|
||||||
if common.DisplayInCurrencyEnabled {
|
switch operation_setting.GetQuotaDisplayType() {
|
||||||
amount /= common.QuotaPerUnit
|
case operation_setting.QuotaDisplayTypeCNY:
|
||||||
|
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||||
|
case operation_setting.QuotaDisplayTypeTokens:
|
||||||
|
// tokens 保持原值
|
||||||
|
default:
|
||||||
|
amount = amount / common.QuotaPerUnit
|
||||||
}
|
}
|
||||||
usage := OpenAIUsageResponse{
|
usage := OpenAIUsageResponse{
|
||||||
Object: "list",
|
Object: "list",
|
||||||
|
|||||||
@@ -64,18 +64,22 @@ func GetStatus(c *gin.Context) {
|
|||||||
"top_up_link": common.TopUpLink,
|
"top_up_link": common.TopUpLink,
|
||||||
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
||||||
"quota_per_unit": common.QuotaPerUnit,
|
"quota_per_unit": common.QuotaPerUnit,
|
||||||
"display_in_currency": common.DisplayInCurrencyEnabled,
|
// 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
|
||||||
"enable_batch_update": common.BatchUpdateEnabled,
|
"display_in_currency": operation_setting.IsCurrencyDisplay(),
|
||||||
"enable_drawing": common.DrawingEnabled,
|
"quota_display_type": operation_setting.GetQuotaDisplayType(),
|
||||||
"enable_task": common.TaskEnabled,
|
"custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
|
||||||
"enable_data_export": common.DataExportEnabled,
|
"custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
|
||||||
"data_export_default_time": common.DataExportDefaultTime,
|
"enable_batch_update": common.BatchUpdateEnabled,
|
||||||
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
"enable_drawing": common.DrawingEnabled,
|
||||||
"mj_notify_enabled": setting.MjNotifyEnabled,
|
"enable_task": common.TaskEnabled,
|
||||||
"chats": setting.Chats,
|
"enable_data_export": common.DataExportEnabled,
|
||||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
"data_export_default_time": common.DataExportDefaultTime,
|
||||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
||||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
"mj_notify_enabled": setting.MjNotifyEnabled,
|
||||||
|
"chats": setting.Chats,
|
||||||
|
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||||
|
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||||
|
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||||
|
|
||||||
"usd_exchange_rate": operation_setting.USDExchangeRate,
|
"usd_exchange_rate": operation_setting.USDExchangeRate,
|
||||||
"price": operation_setting.Price,
|
"price": operation_setting.Price,
|
||||||
|
|||||||
@@ -178,4 +178,4 @@ func boolToString(b bool) string {
|
|||||||
return "true"
|
return "true"
|
||||||
}
|
}
|
||||||
return "false"
|
return "false"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ func GetEpayClient() *epay.Client {
|
|||||||
|
|
||||||
func getPayMoney(amount int64, group string) float64 {
|
func getPayMoney(amount int64, group string) float64 {
|
||||||
dAmount := decimal.NewFromInt(amount)
|
dAmount := decimal.NewFromInt(amount)
|
||||||
|
// 充值金额以“展示类型”为准:
|
||||||
if !common.DisplayInCurrencyEnabled {
|
// - USD/CNY: 前端传 amount 为金额单位;TOKENS: 前端传 tokens,需要换成 USD 金额
|
||||||
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||||
dAmount = dAmount.Div(dQuotaPerUnit)
|
dAmount = dAmount.Div(dQuotaPerUnit)
|
||||||
}
|
}
|
||||||
@@ -115,7 +116,7 @@ func getPayMoney(amount int64, group string) float64 {
|
|||||||
|
|
||||||
func getMinTopup() int64 {
|
func getMinTopup() int64 {
|
||||||
minTopup := operation_setting.MinTopUp
|
minTopup := operation_setting.MinTopUp
|
||||||
if !common.DisplayInCurrencyEnabled {
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
dMinTopup := decimal.NewFromInt(int64(minTopup))
|
dMinTopup := decimal.NewFromInt(int64(minTopup))
|
||||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||||
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
|
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
|
||||||
@@ -176,7 +177,7 @@ func RequestEpay(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
amount := req.Amount
|
amount := req.Amount
|
||||||
if !common.DisplayInCurrencyEnabled {
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
dAmount := decimal.NewFromInt(int64(amount))
|
dAmount := decimal.NewFromInt(int64(amount))
|
||||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||||
amount = dAmount.Div(dQuotaPerUnit).IntPart()
|
amount = dAmount.Div(dQuotaPerUnit).IntPart()
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ func GetChargedAmount(count float64, user model.User) float64 {
|
|||||||
|
|
||||||
func getStripePayMoney(amount float64, group string) float64 {
|
func getStripePayMoney(amount float64, group string) float64 {
|
||||||
originalAmount := amount
|
originalAmount := amount
|
||||||
if !common.DisplayInCurrencyEnabled {
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
amount = amount / common.QuotaPerUnit
|
amount = amount / common.QuotaPerUnit
|
||||||
}
|
}
|
||||||
// Using float64 for monetary calculations is acceptable here due to the small amounts involved
|
// Using float64 for monetary calculations is acceptable here due to the small amounts involved
|
||||||
@@ -279,7 +279,7 @@ func getStripePayMoney(amount float64, group string) float64 {
|
|||||||
|
|
||||||
func getStripeMinTopup() int64 {
|
func getStripeMinTopup() int64 {
|
||||||
minTopup := setting.StripeMinTopUp
|
minTopup := setting.StripeMinTopUp
|
||||||
if !common.DisplayInCurrencyEnabled {
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||||
minTopup = minTopup * int(common.QuotaPerUnit)
|
minTopup = minTopup * int(common.QuotaPerUnit)
|
||||||
}
|
}
|
||||||
return int64(minTopup)
|
return int64(minTopup)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"one-api/common"
|
"one-api/common"
|
||||||
|
"one-api/setting/operation_setting"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -92,18 +93,55 @@ func logHelper(ctx context.Context, level string, msg string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LogQuota(quota int) string {
|
func LogQuota(quota int) string {
|
||||||
if common.DisplayInCurrencyEnabled {
|
// 新逻辑:根据额度展示类型输出
|
||||||
return fmt.Sprintf("$%.6f 额度", float64(quota)/common.QuotaPerUnit)
|
q := float64(quota)
|
||||||
} else {
|
switch operation_setting.GetQuotaDisplayType() {
|
||||||
|
case operation_setting.QuotaDisplayTypeCNY:
|
||||||
|
usd := q / common.QuotaPerUnit
|
||||||
|
cny := usd * operation_setting.USDExchangeRate
|
||||||
|
return fmt.Sprintf("¥%.6f 额度", cny)
|
||||||
|
case operation_setting.QuotaDisplayTypeCustom:
|
||||||
|
usd := q / common.QuotaPerUnit
|
||||||
|
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
|
||||||
|
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
|
||||||
|
if symbol == "" {
|
||||||
|
symbol = "¤"
|
||||||
|
}
|
||||||
|
if rate <= 0 {
|
||||||
|
rate = 1
|
||||||
|
}
|
||||||
|
v := usd * rate
|
||||||
|
return fmt.Sprintf("%s%.6f 额度", symbol, v)
|
||||||
|
case operation_setting.QuotaDisplayTypeTokens:
|
||||||
return fmt.Sprintf("%d 点额度", quota)
|
return fmt.Sprintf("%d 点额度", quota)
|
||||||
|
default: // USD
|
||||||
|
return fmt.Sprintf("$%.6f 额度", q/common.QuotaPerUnit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatQuota(quota int) string {
|
func FormatQuota(quota int) string {
|
||||||
if common.DisplayInCurrencyEnabled {
|
q := float64(quota)
|
||||||
return fmt.Sprintf("$%.6f", float64(quota)/common.QuotaPerUnit)
|
switch operation_setting.GetQuotaDisplayType() {
|
||||||
} else {
|
case operation_setting.QuotaDisplayTypeCNY:
|
||||||
|
usd := q / common.QuotaPerUnit
|
||||||
|
cny := usd * operation_setting.USDExchangeRate
|
||||||
|
return fmt.Sprintf("¥%.6f", cny)
|
||||||
|
case operation_setting.QuotaDisplayTypeCustom:
|
||||||
|
usd := q / common.QuotaPerUnit
|
||||||
|
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
|
||||||
|
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
|
||||||
|
if symbol == "" {
|
||||||
|
symbol = "¤"
|
||||||
|
}
|
||||||
|
if rate <= 0 {
|
||||||
|
rate = 1
|
||||||
|
}
|
||||||
|
v := usd * rate
|
||||||
|
return fmt.Sprintf("%s%.6f", symbol, v)
|
||||||
|
case operation_setting.QuotaDisplayTypeTokens:
|
||||||
return fmt.Sprintf("%d", quota)
|
return fmt.Sprintf("%d", quota)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("$%.6f", q/common.QuotaPerUnit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -240,7 +240,15 @@ func updateOptionMap(key string, value string) (err error) {
|
|||||||
case "LogConsumeEnabled":
|
case "LogConsumeEnabled":
|
||||||
common.LogConsumeEnabled = boolValue
|
common.LogConsumeEnabled = boolValue
|
||||||
case "DisplayInCurrencyEnabled":
|
case "DisplayInCurrencyEnabled":
|
||||||
common.DisplayInCurrencyEnabled = boolValue
|
// 兼容旧字段:同步到新配置 general_setting.quota_display_type(运行时生效)
|
||||||
|
// true -> USD, false -> TOKENS
|
||||||
|
newVal := "USD"
|
||||||
|
if !boolValue {
|
||||||
|
newVal = "TOKENS"
|
||||||
|
}
|
||||||
|
if cfg := config.GlobalConfig.Get("general_setting"); cfg != nil {
|
||||||
|
_ = config.UpdateConfigFromMap(cfg, map[string]string{"quota_display_type": newVal})
|
||||||
|
}
|
||||||
case "DisplayTokenStatEnabled":
|
case "DisplayTokenStatEnabled":
|
||||||
common.DisplayTokenStatEnabled = boolValue
|
common.DisplayTokenStatEnabled = boolValue
|
||||||
case "DrawingEnabled":
|
case "DrawingEnabled":
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import (
|
|||||||
type Adaptor struct {
|
type Adaptor struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { return nil, errors.New("not implemented") }
|
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
||||||
openaiAdaptor := openai.Adaptor{}
|
openaiAdaptor := openai.Adaptor{}
|
||||||
@@ -33,17 +35,25 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
|
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") }
|
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { return nil, errors.New("not implemented") }
|
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||||
if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil }
|
if info.RelayMode == relayconstant.RelayModeEmbeddings {
|
||||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil }
|
return info.ChannelBaseUrl + "/api/embed", nil
|
||||||
return info.ChannelBaseUrl + "/api/chat", nil
|
}
|
||||||
|
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
||||||
|
return info.ChannelBaseUrl + "/api/generate", nil
|
||||||
|
}
|
||||||
|
return info.ChannelBaseUrl + "/api/chat", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||||
@@ -53,7 +63,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||||
if request == nil { return nil, errors.New("request is nil") }
|
if request == nil {
|
||||||
|
return nil, errors.New("request is nil")
|
||||||
|
}
|
||||||
// decide generate or chat
|
// decide generate or chat
|
||||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
||||||
return openAIToGenerate(c, request)
|
return openAIToGenerate(c, request)
|
||||||
@@ -69,7 +81,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
|||||||
return requestOpenAI2Embeddings(request), nil
|
return requestOpenAI2Embeddings(request), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { return nil, errors.New("not implemented") }
|
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||||
return channel.DoApiRequest(a, c, info, requestBody)
|
return channel.DoApiRequest(a, c, info, requestBody)
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type OllamaChatMessage struct {
|
type OllamaChatMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Images []string `json:"images,omitempty"`
|
Images []string `json:"images,omitempty"`
|
||||||
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
|
||||||
ToolName string `json:"tool_name,omitempty"`
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OllamaToolFunction struct {
|
type OllamaToolFunction struct {
|
||||||
@@ -20,7 +20,7 @@ type OllamaToolFunction struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OllamaTool struct {
|
type OllamaTool struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Function OllamaToolFunction `json:"function"`
|
Function OllamaToolFunction `json:"function"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,28 +43,27 @@ type OllamaChatRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OllamaGenerateRequest struct {
|
type OllamaGenerateRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Prompt string `json:"prompt,omitempty"`
|
Prompt string `json:"prompt,omitempty"`
|
||||||
Suffix string `json:"suffix,omitempty"`
|
Suffix string `json:"suffix,omitempty"`
|
||||||
Images []string `json:"images,omitempty"`
|
Images []string `json:"images,omitempty"`
|
||||||
Format interface{} `json:"format,omitempty"`
|
Format interface{} `json:"format,omitempty"`
|
||||||
Stream bool `json:"stream,omitempty"`
|
Stream bool `json:"stream,omitempty"`
|
||||||
Options map[string]any `json:"options,omitempty"`
|
Options map[string]any `json:"options,omitempty"`
|
||||||
KeepAlive interface{} `json:"keep_alive,omitempty"`
|
KeepAlive interface{} `json:"keep_alive,omitempty"`
|
||||||
Think json.RawMessage `json:"think,omitempty"`
|
Think json.RawMessage `json:"think,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OllamaEmbeddingRequest struct {
|
type OllamaEmbeddingRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Input interface{} `json:"input"`
|
Input interface{} `json:"input"`
|
||||||
Options map[string]any `json:"options,omitempty"`
|
Options map[string]any `json:"options,omitempty"`
|
||||||
Dimensions int `json:"dimensions,omitempty"`
|
Dimensions int `json:"dimensions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OllamaEmbeddingResponse struct {
|
type OllamaEmbeddingResponse struct {
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Embeddings [][]float64 `json:"embeddings"`
|
Embeddings [][]float64 `json:"embeddings"`
|
||||||
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,13 +35,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
|||||||
}
|
}
|
||||||
|
|
||||||
// options mapping
|
// options mapping
|
||||||
if r.Temperature != nil { chatReq.Options["temperature"] = r.Temperature }
|
if r.Temperature != nil {
|
||||||
if r.TopP != 0 { chatReq.Options["top_p"] = r.TopP }
|
chatReq.Options["temperature"] = r.Temperature
|
||||||
if r.TopK != 0 { chatReq.Options["top_k"] = r.TopK }
|
}
|
||||||
if r.FrequencyPenalty != 0 { chatReq.Options["frequency_penalty"] = r.FrequencyPenalty }
|
if r.TopP != 0 {
|
||||||
if r.PresencePenalty != 0 { chatReq.Options["presence_penalty"] = r.PresencePenalty }
|
chatReq.Options["top_p"] = r.TopP
|
||||||
if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) }
|
}
|
||||||
if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) }
|
if r.TopK != 0 {
|
||||||
|
chatReq.Options["top_k"] = r.TopK
|
||||||
|
}
|
||||||
|
if r.FrequencyPenalty != 0 {
|
||||||
|
chatReq.Options["frequency_penalty"] = r.FrequencyPenalty
|
||||||
|
}
|
||||||
|
if r.PresencePenalty != 0 {
|
||||||
|
chatReq.Options["presence_penalty"] = r.PresencePenalty
|
||||||
|
}
|
||||||
|
if r.Seed != 0 {
|
||||||
|
chatReq.Options["seed"] = int(r.Seed)
|
||||||
|
}
|
||||||
|
if mt := r.GetMaxTokens(); mt != 0 {
|
||||||
|
chatReq.Options["num_predict"] = int(mt)
|
||||||
|
}
|
||||||
|
|
||||||
if r.Stop != nil {
|
if r.Stop != nil {
|
||||||
switch v := r.Stop.(type) {
|
switch v := r.Stop.(type) {
|
||||||
@@ -50,21 +64,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
|||||||
case []string:
|
case []string:
|
||||||
chatReq.Options["stop"] = v
|
chatReq.Options["stop"] = v
|
||||||
case []any:
|
case []any:
|
||||||
arr := make([]string,0,len(v))
|
arr := make([]string, 0, len(v))
|
||||||
for _, i := range v { if s,ok:=i.(string); ok { arr = append(arr,s) } }
|
for _, i := range v {
|
||||||
if len(arr)>0 { chatReq.Options["stop"] = arr }
|
if s, ok := i.(string); ok {
|
||||||
|
arr = append(arr, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(arr) > 0 {
|
||||||
|
chatReq.Options["stop"] = arr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(r.Tools) > 0 {
|
if len(r.Tools) > 0 {
|
||||||
tools := make([]OllamaTool,0,len(r.Tools))
|
tools := make([]OllamaTool, 0, len(r.Tools))
|
||||||
for _, t := range r.Tools {
|
for _, t := range r.Tools {
|
||||||
tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}})
|
tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}})
|
||||||
}
|
}
|
||||||
chatReq.Tools = tools
|
chatReq.Tools = tools
|
||||||
}
|
}
|
||||||
|
|
||||||
chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages))
|
chatReq.Messages = make([]OllamaChatMessage, 0, len(r.Messages))
|
||||||
for _, m := range r.Messages {
|
for _, m := range r.Messages {
|
||||||
var textBuilder strings.Builder
|
var textBuilder strings.Builder
|
||||||
var images []string
|
var images []string
|
||||||
@@ -79,14 +99,20 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
|||||||
var base64Data string
|
var base64Data string
|
||||||
if strings.HasPrefix(img.Url, "http") {
|
if strings.HasPrefix(img.Url, "http") {
|
||||||
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
|
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
|
||||||
if err != nil { return nil, err }
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
base64Data = fileData.Base64Data
|
base64Data = fileData.Base64Data
|
||||||
} else if strings.HasPrefix(img.Url, "data:") {
|
} else if strings.HasPrefix(img.Url, "data:") {
|
||||||
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) { base64Data = img.Url[idx+1:] }
|
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) {
|
||||||
|
base64Data = img.Url[idx+1:]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
base64Data = img.Url
|
base64Data = img.Url
|
||||||
}
|
}
|
||||||
if base64Data != "" { images = append(images, base64Data) }
|
if base64Data != "" {
|
||||||
|
images = append(images, base64Data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if part.Type == dto.ContentTypeText {
|
} else if part.Type == dto.ContentTypeText {
|
||||||
textBuilder.WriteString(part.Text)
|
textBuilder.WriteString(part.Text)
|
||||||
@@ -94,16 +120,24 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
|
cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
|
||||||
if len(images)>0 { cm.Images = images }
|
if len(images) > 0 {
|
||||||
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
|
cm.Images = images
|
||||||
|
}
|
||||||
|
if m.Role == "tool" && m.Name != nil {
|
||||||
|
cm.ToolName = *m.Name
|
||||||
|
}
|
||||||
if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
|
if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
|
||||||
parsed := m.ParseToolCalls()
|
parsed := m.ParseToolCalls()
|
||||||
if len(parsed) > 0 {
|
if len(parsed) > 0 {
|
||||||
calls := make([]OllamaToolCall,0,len(parsed))
|
calls := make([]OllamaToolCall, 0, len(parsed))
|
||||||
for _, tc := range parsed {
|
for _, tc := range parsed {
|
||||||
var args interface{}
|
var args interface{}
|
||||||
if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
|
if tc.Function.Arguments != "" {
|
||||||
if args==nil { args = map[string]any{} }
|
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||||
|
}
|
||||||
|
if args == nil {
|
||||||
|
args = map[string]any{}
|
||||||
|
}
|
||||||
oc := OllamaToolCall{}
|
oc := OllamaToolCall{}
|
||||||
oc.Function.Name = tc.Function.Name
|
oc.Function.Name = tc.Function.Name
|
||||||
oc.Function.Arguments = args
|
oc.Function.Arguments = args
|
||||||
@@ -132,28 +166,67 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
|
|||||||
gen.Prompt = v
|
gen.Prompt = v
|
||||||
case []any:
|
case []any:
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, it := range v { if s,ok:=it.(string); ok { sb.WriteString(s) } }
|
for _, it := range v {
|
||||||
|
if s, ok := it.(string); ok {
|
||||||
|
sb.WriteString(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
gen.Prompt = sb.String()
|
gen.Prompt = sb.String()
|
||||||
default:
|
default:
|
||||||
gen.Prompt = fmt.Sprintf("%v", r.Prompt)
|
gen.Prompt = fmt.Sprintf("%v", r.Prompt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.Suffix != nil { if s,ok:=r.Suffix.(string); ok { gen.Suffix = s } }
|
if r.Suffix != nil {
|
||||||
if r.ResponseFormat != nil {
|
if s, ok := r.Suffix.(string); ok {
|
||||||
if r.ResponseFormat.Type == "json" { gen.Format = "json" } else if r.ResponseFormat.Type == "json_schema" { var schema any; _ = json.Unmarshal(r.ResponseFormat.JsonSchema,&schema); gen.Format=schema }
|
gen.Suffix = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.ResponseFormat != nil {
|
||||||
|
if r.ResponseFormat.Type == "json" {
|
||||||
|
gen.Format = "json"
|
||||||
|
} else if r.ResponseFormat.Type == "json_schema" {
|
||||||
|
var schema any
|
||||||
|
_ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema)
|
||||||
|
gen.Format = schema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Temperature != nil {
|
||||||
|
gen.Options["temperature"] = r.Temperature
|
||||||
|
}
|
||||||
|
if r.TopP != 0 {
|
||||||
|
gen.Options["top_p"] = r.TopP
|
||||||
|
}
|
||||||
|
if r.TopK != 0 {
|
||||||
|
gen.Options["top_k"] = r.TopK
|
||||||
|
}
|
||||||
|
if r.FrequencyPenalty != 0 {
|
||||||
|
gen.Options["frequency_penalty"] = r.FrequencyPenalty
|
||||||
|
}
|
||||||
|
if r.PresencePenalty != 0 {
|
||||||
|
gen.Options["presence_penalty"] = r.PresencePenalty
|
||||||
|
}
|
||||||
|
if r.Seed != 0 {
|
||||||
|
gen.Options["seed"] = int(r.Seed)
|
||||||
|
}
|
||||||
|
if mt := r.GetMaxTokens(); mt != 0 {
|
||||||
|
gen.Options["num_predict"] = int(mt)
|
||||||
}
|
}
|
||||||
if r.Temperature != nil { gen.Options["temperature"] = r.Temperature }
|
|
||||||
if r.TopP != 0 { gen.Options["top_p"] = r.TopP }
|
|
||||||
if r.TopK != 0 { gen.Options["top_k"] = r.TopK }
|
|
||||||
if r.FrequencyPenalty != 0 { gen.Options["frequency_penalty"] = r.FrequencyPenalty }
|
|
||||||
if r.PresencePenalty != 0 { gen.Options["presence_penalty"] = r.PresencePenalty }
|
|
||||||
if r.Seed != 0 { gen.Options["seed"] = int(r.Seed) }
|
|
||||||
if mt := r.GetMaxTokens(); mt != 0 { gen.Options["num_predict"] = int(mt) }
|
|
||||||
if r.Stop != nil {
|
if r.Stop != nil {
|
||||||
switch v := r.Stop.(type) {
|
switch v := r.Stop.(type) {
|
||||||
case string: gen.Options["stop"] = []string{v}
|
case string:
|
||||||
case []string: gen.Options["stop"] = v
|
gen.Options["stop"] = []string{v}
|
||||||
case []any: arr:=make([]string,0,len(v)); for _,i:= range v { if s,ok:=i.(string); ok { arr=append(arr,s) } }; if len(arr)>0 { gen.Options["stop"]=arr }
|
case []string:
|
||||||
|
gen.Options["stop"] = v
|
||||||
|
case []any:
|
||||||
|
arr := make([]string, 0, len(v))
|
||||||
|
for _, i := range v {
|
||||||
|
if s, ok := i.(string); ok {
|
||||||
|
arr = append(arr, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(arr) > 0 {
|
||||||
|
gen.Options["stop"] = arr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return gen, nil
|
return gen, nil
|
||||||
@@ -161,30 +234,51 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
|
|||||||
|
|
||||||
func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest {
|
func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest {
|
||||||
opts := map[string]any{}
|
opts := map[string]any{}
|
||||||
if r.Temperature != nil { opts["temperature"] = r.Temperature }
|
if r.Temperature != nil {
|
||||||
if r.TopP != 0 { opts["top_p"] = r.TopP }
|
opts["temperature"] = r.Temperature
|
||||||
if r.FrequencyPenalty != 0 { opts["frequency_penalty"] = r.FrequencyPenalty }
|
}
|
||||||
if r.PresencePenalty != 0 { opts["presence_penalty"] = r.PresencePenalty }
|
if r.TopP != 0 {
|
||||||
if r.Seed != 0 { opts["seed"] = int(r.Seed) }
|
opts["top_p"] = r.TopP
|
||||||
if r.Dimensions != 0 { opts["dimensions"] = r.Dimensions }
|
}
|
||||||
|
if r.FrequencyPenalty != 0 {
|
||||||
|
opts["frequency_penalty"] = r.FrequencyPenalty
|
||||||
|
}
|
||||||
|
if r.PresencePenalty != 0 {
|
||||||
|
opts["presence_penalty"] = r.PresencePenalty
|
||||||
|
}
|
||||||
|
if r.Seed != 0 {
|
||||||
|
opts["seed"] = int(r.Seed)
|
||||||
|
}
|
||||||
|
if r.Dimensions != 0 {
|
||||||
|
opts["dimensions"] = r.Dimensions
|
||||||
|
}
|
||||||
input := r.ParseInput()
|
input := r.ParseInput()
|
||||||
if len(input)==1 { return &OllamaEmbeddingRequest{Model:r.Model, Input: input[0], Options: opts, Dimensions:r.Dimensions} }
|
if len(input) == 1 {
|
||||||
return &OllamaEmbeddingRequest{Model:r.Model, Input: input, Options: opts, Dimensions:r.Dimensions}
|
return &OllamaEmbeddingRequest{Model: r.Model, Input: input[0], Options: opts, Dimensions: r.Dimensions}
|
||||||
|
}
|
||||||
|
return &OllamaEmbeddingRequest{Model: r.Model, Input: input, Options: opts, Dimensions: r.Dimensions}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||||
var oResp OllamaEmbeddingResponse
|
var oResp OllamaEmbeddingResponse
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
if err != nil {
|
||||||
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
service.CloseResponseBodyGracefully(resp)
|
service.CloseResponseBodyGracefully(resp)
|
||||||
if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
if err = common.Unmarshal(body, &oResp); err != nil {
|
||||||
if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
data := make([]dto.OpenAIEmbeddingResponseItem,0,len(oResp.Embeddings))
|
}
|
||||||
for i, emb := range oResp.Embeddings { data = append(data, dto.OpenAIEmbeddingResponseItem{Index:i,Object:"embedding",Embedding:emb}) }
|
if oResp.Error != "" {
|
||||||
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens:0, TotalTokens: oResp.PromptEvalCount}
|
return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
embResp := &dto.OpenAIEmbeddingResponse{Object:"list", Data:data, Model: info.UpstreamModelName, Usage:*usage}
|
}
|
||||||
|
data := make([]dto.OpenAIEmbeddingResponseItem, 0, len(oResp.Embeddings))
|
||||||
|
for i, emb := range oResp.Embeddings {
|
||||||
|
data = append(data, dto.OpenAIEmbeddingResponseItem{Index: i, Object: "embedding", Embedding: emb})
|
||||||
|
}
|
||||||
|
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens: 0, TotalTokens: oResp.PromptEvalCount}
|
||||||
|
embResp := &dto.OpenAIEmbeddingResponse{Object: "list", Data: data, Model: info.UpstreamModelName, Usage: *usage}
|
||||||
out, _ := common.Marshal(embResp)
|
out, _ := common.Marshal(embResp)
|
||||||
service.IOCopyBytesGracefully(c, resp, out)
|
service.IOCopyBytesGracefully(c, resp, out)
|
||||||
return usage, nil
|
return usage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,210 +1,278 @@
|
|||||||
package ollama
|
package ollama
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
"one-api/common"
|
||||||
"one-api/dto"
|
"one-api/dto"
|
||||||
"one-api/logger"
|
"one-api/logger"
|
||||||
relaycommon "one-api/relay/common"
|
relaycommon "one-api/relay/common"
|
||||||
"one-api/relay/helper"
|
"one-api/relay/helper"
|
||||||
"one-api/service"
|
"one-api/service"
|
||||||
"one-api/types"
|
"one-api/types"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ollamaChatStreamChunk struct {
|
type ollamaChatStreamChunk struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
// chat
|
// chat
|
||||||
Message *struct {
|
Message *struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Thinking json.RawMessage `json:"thinking"`
|
Thinking json.RawMessage `json:"thinking"`
|
||||||
ToolCalls []struct {
|
ToolCalls []struct {
|
||||||
Function struct {
|
Function struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Arguments interface{} `json:"arguments"`
|
Arguments interface{} `json:"arguments"`
|
||||||
} `json:"function"`
|
} `json:"function"`
|
||||||
} `json:"tool_calls"`
|
} `json:"tool_calls"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
// generate
|
// generate
|
||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
Done bool `json:"done"`
|
Done bool `json:"done"`
|
||||||
DoneReason string `json:"done_reason"`
|
DoneReason string `json:"done_reason"`
|
||||||
TotalDuration int64 `json:"total_duration"`
|
TotalDuration int64 `json:"total_duration"`
|
||||||
LoadDuration int64 `json:"load_duration"`
|
LoadDuration int64 `json:"load_duration"`
|
||||||
PromptEvalCount int `json:"prompt_eval_count"`
|
PromptEvalCount int `json:"prompt_eval_count"`
|
||||||
EvalCount int `json:"eval_count"`
|
EvalCount int `json:"eval_count"`
|
||||||
PromptEvalDuration int64 `json:"prompt_eval_duration"`
|
PromptEvalDuration int64 `json:"prompt_eval_duration"`
|
||||||
EvalDuration int64 `json:"eval_duration"`
|
EvalDuration int64 `json:"eval_duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toUnix(ts string) int64 {
|
func toUnix(ts string) int64 {
|
||||||
if ts == "" { return time.Now().Unix() }
|
if ts == "" {
|
||||||
// try time.RFC3339 or with nanoseconds
|
return time.Now().Unix()
|
||||||
t, err := time.Parse(time.RFC3339Nano, ts)
|
}
|
||||||
if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
|
// try time.RFC3339 or with nanoseconds
|
||||||
return t.Unix()
|
t, err := time.Parse(time.RFC3339Nano, ts)
|
||||||
|
if err != nil {
|
||||||
|
t2, err2 := time.Parse(time.RFC3339, ts)
|
||||||
|
if err2 == nil {
|
||||||
|
return t2.Unix()
|
||||||
|
}
|
||||||
|
return time.Now().Unix()
|
||||||
|
}
|
||||||
|
return t.Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||||
if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
|
if resp == nil || resp.Body == nil {
|
||||||
defer service.CloseResponseBodyGracefully(resp)
|
return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
defer service.CloseResponseBodyGracefully(resp)
|
||||||
|
|
||||||
helper.SetEventStreamHeaders(c)
|
helper.SetEventStreamHeaders(c)
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
usage := &dto.Usage{}
|
usage := &dto.Usage{}
|
||||||
var model = info.UpstreamModelName
|
var model = info.UpstreamModelName
|
||||||
var responseId = common.GetUUID()
|
var responseId = common.GetUUID()
|
||||||
var created = time.Now().Unix()
|
var created = time.Now().Unix()
|
||||||
var toolCallIndex int
|
var toolCallIndex int
|
||||||
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
|
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
|
||||||
if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
|
if data, err := common.Marshal(start); err == nil {
|
||||||
|
_ = helper.StringData(c, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" { continue }
|
if line == "" {
|
||||||
var chunk ollamaChatStreamChunk
|
continue
|
||||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
}
|
||||||
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
|
var chunk ollamaChatStreamChunk
|
||||||
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||||
}
|
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
|
||||||
if chunk.Model != "" { model = chunk.Model }
|
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
created = toUnix(chunk.CreatedAt)
|
}
|
||||||
|
if chunk.Model != "" {
|
||||||
|
model = chunk.Model
|
||||||
|
}
|
||||||
|
created = toUnix(chunk.CreatedAt)
|
||||||
|
|
||||||
if !chunk.Done {
|
if !chunk.Done {
|
||||||
// delta content
|
// delta content
|
||||||
var content string
|
var content string
|
||||||
if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
|
if chunk.Message != nil {
|
||||||
delta := dto.ChatCompletionsStreamResponse{
|
content = chunk.Message.Content
|
||||||
Id: responseId,
|
} else {
|
||||||
Object: "chat.completion.chunk",
|
content = chunk.Response
|
||||||
Created: created,
|
}
|
||||||
Model: model,
|
delta := dto.ChatCompletionsStreamResponse{
|
||||||
Choices: []dto.ChatCompletionsStreamResponseChoice{ {
|
Id: responseId,
|
||||||
Index: 0,
|
Object: "chat.completion.chunk",
|
||||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" },
|
Created: created,
|
||||||
} },
|
Model: model,
|
||||||
}
|
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||||
if content != "" { delta.Choices[0].Delta.SetContentString(content) }
|
Index: 0,
|
||||||
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"},
|
||||||
raw := strings.TrimSpace(string(chunk.Message.Thinking))
|
}},
|
||||||
if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
|
}
|
||||||
}
|
if content != "" {
|
||||||
// tool calls
|
delta.Choices[0].Delta.SetContentString(content)
|
||||||
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
|
}
|
||||||
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
|
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
|
||||||
for _, tc := range chunk.Message.ToolCalls {
|
raw := strings.TrimSpace(string(chunk.Message.Thinking))
|
||||||
// arguments -> string
|
if raw != "" && raw != "null" {
|
||||||
argBytes, _ := json.Marshal(tc.Function.Arguments)
|
delta.Choices[0].Delta.SetReasoningContent(raw)
|
||||||
toolId := fmt.Sprintf("call_%d", toolCallIndex)
|
}
|
||||||
tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
|
}
|
||||||
tr.SetIndex(toolCallIndex)
|
// tool calls
|
||||||
toolCallIndex++
|
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
|
||||||
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
|
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 0, len(chunk.Message.ToolCalls))
|
||||||
}
|
for _, tc := range chunk.Message.ToolCalls {
|
||||||
}
|
// arguments -> string
|
||||||
if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
|
argBytes, _ := json.Marshal(tc.Function.Arguments)
|
||||||
continue
|
toolId := fmt.Sprintf("call_%d", toolCallIndex)
|
||||||
}
|
tr := dto.ToolCallResponse{ID: toolId, Type: "function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
|
||||||
// done frame
|
tr.SetIndex(toolCallIndex)
|
||||||
// finalize once and break loop
|
toolCallIndex++
|
||||||
usage.PromptTokens = chunk.PromptEvalCount
|
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
|
||||||
usage.CompletionTokens = chunk.EvalCount
|
}
|
||||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
}
|
||||||
finishReason := chunk.DoneReason
|
if data, err := common.Marshal(delta); err == nil {
|
||||||
if finishReason == "" { finishReason = "stop" }
|
_ = helper.StringData(c, string(data))
|
||||||
// emit stop delta
|
}
|
||||||
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
|
continue
|
||||||
if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
|
}
|
||||||
}
|
// done frame
|
||||||
// emit usage frame
|
// finalize once and break loop
|
||||||
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
|
usage.PromptTokens = chunk.PromptEvalCount
|
||||||
if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
|
usage.CompletionTokens = chunk.EvalCount
|
||||||
}
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
// send [DONE]
|
finishReason := chunk.DoneReason
|
||||||
helper.Done(c)
|
if finishReason == "" {
|
||||||
break
|
finishReason = "stop"
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
|
// emit stop delta
|
||||||
return usage, nil
|
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
|
||||||
|
if data, err := common.Marshal(stop); err == nil {
|
||||||
|
_ = helper.StringData(c, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// emit usage frame
|
||||||
|
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
|
||||||
|
if data, err := common.Marshal(final); err == nil {
|
||||||
|
_ = helper.StringData(c, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// send [DONE]
|
||||||
|
helper.Done(c)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil && err != io.EOF {
|
||||||
|
logger.LogError(c, "ollama stream scan error: "+err.Error())
|
||||||
|
}
|
||||||
|
return usage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// non-stream handler for chat/generate
|
// non-stream handler for chat/generate
|
||||||
func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
|
if err != nil {
|
||||||
service.CloseResponseBodyGracefully(resp)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||||
raw := string(body)
|
}
|
||||||
if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
|
service.CloseResponseBodyGracefully(resp)
|
||||||
|
raw := string(body)
|
||||||
|
if common.DebugEnabled {
|
||||||
|
println("ollama non-stream raw resp:", raw)
|
||||||
|
}
|
||||||
|
|
||||||
lines := strings.Split(raw, "\n")
|
lines := strings.Split(raw, "\n")
|
||||||
var (
|
var (
|
||||||
aggContent strings.Builder
|
aggContent strings.Builder
|
||||||
reasoningBuilder strings.Builder
|
reasoningBuilder strings.Builder
|
||||||
lastChunk ollamaChatStreamChunk
|
lastChunk ollamaChatStreamChunk
|
||||||
parsedAny bool
|
parsedAny bool
|
||||||
)
|
)
|
||||||
for _, ln := range lines {
|
for _, ln := range lines {
|
||||||
ln = strings.TrimSpace(ln)
|
ln = strings.TrimSpace(ln)
|
||||||
if ln == "" { continue }
|
if ln == "" {
|
||||||
var ck ollamaChatStreamChunk
|
continue
|
||||||
if err := json.Unmarshal([]byte(ln), &ck); err != nil {
|
}
|
||||||
if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
var ck ollamaChatStreamChunk
|
||||||
continue
|
if err := json.Unmarshal([]byte(ln), &ck); err != nil {
|
||||||
}
|
if len(lines) == 1 {
|
||||||
parsedAny = true
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
lastChunk = ck
|
}
|
||||||
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
|
continue
|
||||||
raw := strings.TrimSpace(string(ck.Message.Thinking))
|
}
|
||||||
if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
|
parsedAny = true
|
||||||
}
|
lastChunk = ck
|
||||||
if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
|
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
|
||||||
}
|
raw := strings.TrimSpace(string(ck.Message.Thinking))
|
||||||
|
if raw != "" && raw != "null" {
|
||||||
|
reasoningBuilder.WriteString(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ck.Message != nil && ck.Message.Content != "" {
|
||||||
|
aggContent.WriteString(ck.Message.Content)
|
||||||
|
} else if ck.Response != "" {
|
||||||
|
aggContent.WriteString(ck.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !parsedAny {
|
if !parsedAny {
|
||||||
var single ollamaChatStreamChunk
|
var single ollamaChatStreamChunk
|
||||||
if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
if err := json.Unmarshal(body, &single); err != nil {
|
||||||
lastChunk = single
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
if single.Message != nil {
|
}
|
||||||
if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
|
lastChunk = single
|
||||||
aggContent.WriteString(single.Message.Content)
|
if single.Message != nil {
|
||||||
} else { aggContent.WriteString(single.Response) }
|
if len(single.Message.Thinking) > 0 {
|
||||||
}
|
raw := strings.TrimSpace(string(single.Message.Thinking))
|
||||||
|
if raw != "" && raw != "null" {
|
||||||
|
reasoningBuilder.WriteString(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aggContent.WriteString(single.Message.Content)
|
||||||
|
} else {
|
||||||
|
aggContent.WriteString(single.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
model := lastChunk.Model
|
model := lastChunk.Model
|
||||||
if model == "" { model = info.UpstreamModelName }
|
if model == "" {
|
||||||
created := toUnix(lastChunk.CreatedAt)
|
model = info.UpstreamModelName
|
||||||
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
|
}
|
||||||
content := aggContent.String()
|
created := toUnix(lastChunk.CreatedAt)
|
||||||
finishReason := lastChunk.DoneReason
|
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
|
||||||
if finishReason == "" { finishReason = "stop" }
|
content := aggContent.String()
|
||||||
|
finishReason := lastChunk.DoneReason
|
||||||
|
if finishReason == "" {
|
||||||
|
finishReason = "stop"
|
||||||
|
}
|
||||||
|
|
||||||
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
||||||
if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
|
if rc := reasoningBuilder.String(); rc != "" {
|
||||||
full := dto.OpenAITextResponse{
|
msg.ReasoningContent = rc
|
||||||
Id: common.GetUUID(),
|
}
|
||||||
Model: model,
|
full := dto.OpenAITextResponse{
|
||||||
Object: "chat.completion",
|
Id: common.GetUUID(),
|
||||||
Created: created,
|
Model: model,
|
||||||
Choices: []dto.OpenAITextResponseChoice{ {
|
Object: "chat.completion",
|
||||||
Index: 0,
|
Created: created,
|
||||||
Message: msg,
|
Choices: []dto.OpenAITextResponseChoice{{
|
||||||
FinishReason: finishReason,
|
Index: 0,
|
||||||
} },
|
Message: msg,
|
||||||
Usage: *usage,
|
FinishReason: finishReason,
|
||||||
}
|
}},
|
||||||
out, _ := common.Marshal(full)
|
Usage: *usage,
|
||||||
service.IOCopyBytesGracefully(c, resp, out)
|
}
|
||||||
return usage, nil
|
out, _ := common.Marshal(full)
|
||||||
|
service.IOCopyBytesGracefully(c, resp, out)
|
||||||
|
return usage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func contentPtr(s string) *string { if s=="" { return nil }; return &s }
|
func contentPtr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
|||||||
}
|
}
|
||||||
req.Set("Authorization", "Bearer "+accessToken)
|
req.Set("Authorization", "Bearer "+accessToken)
|
||||||
}
|
}
|
||||||
if a.AccountCredentials.ProjectID != "" {
|
if a.AccountCredentials.ProjectID != "" {
|
||||||
req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
|
req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -2,17 +2,34 @@ package operation_setting
|
|||||||
|
|
||||||
import "one-api/setting/config"
|
import "one-api/setting/config"
|
||||||
|
|
||||||
|
// 额度展示类型
|
||||||
|
const (
|
||||||
|
QuotaDisplayTypeUSD = "USD"
|
||||||
|
QuotaDisplayTypeCNY = "CNY"
|
||||||
|
QuotaDisplayTypeTokens = "TOKENS"
|
||||||
|
QuotaDisplayTypeCustom = "CUSTOM"
|
||||||
|
)
|
||||||
|
|
||||||
type GeneralSetting struct {
|
type GeneralSetting struct {
|
||||||
DocsLink string `json:"docs_link"`
|
DocsLink string `json:"docs_link"`
|
||||||
PingIntervalEnabled bool `json:"ping_interval_enabled"`
|
PingIntervalEnabled bool `json:"ping_interval_enabled"`
|
||||||
PingIntervalSeconds int `json:"ping_interval_seconds"`
|
PingIntervalSeconds int `json:"ping_interval_seconds"`
|
||||||
|
// 当前站点额度展示类型:USD / CNY / TOKENS
|
||||||
|
QuotaDisplayType string `json:"quota_display_type"`
|
||||||
|
// 自定义货币符号,用于 CUSTOM 展示类型
|
||||||
|
CustomCurrencySymbol string `json:"custom_currency_symbol"`
|
||||||
|
// 自定义货币与美元汇率(1 USD = X Custom)
|
||||||
|
CustomCurrencyExchangeRate float64 `json:"custom_currency_exchange_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
var generalSetting = GeneralSetting{
|
var generalSetting = GeneralSetting{
|
||||||
DocsLink: "https://docs.newapi.pro",
|
DocsLink: "https://docs.newapi.pro",
|
||||||
PingIntervalEnabled: false,
|
PingIntervalEnabled: false,
|
||||||
PingIntervalSeconds: 60,
|
PingIntervalSeconds: 60,
|
||||||
|
QuotaDisplayType: QuotaDisplayTypeUSD,
|
||||||
|
CustomCurrencySymbol: "¤",
|
||||||
|
CustomCurrencyExchangeRate: 1.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -23,3 +40,52 @@ func init() {
|
|||||||
func GetGeneralSetting() *GeneralSetting {
|
func GetGeneralSetting() *GeneralSetting {
|
||||||
return &generalSetting
|
return &generalSetting
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsCurrencyDisplay 是否以货币形式展示(美元或人民币)
|
||||||
|
func IsCurrencyDisplay() bool {
|
||||||
|
return generalSetting.QuotaDisplayType != QuotaDisplayTypeTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCNYDisplay 是否以人民币展示
|
||||||
|
func IsCNYDisplay() bool {
|
||||||
|
return generalSetting.QuotaDisplayType == QuotaDisplayTypeCNY
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuotaDisplayType 返回额度展示类型
|
||||||
|
func GetQuotaDisplayType() string {
|
||||||
|
return generalSetting.QuotaDisplayType
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrencySymbol 返回当前展示类型对应符号
|
||||||
|
func GetCurrencySymbol() string {
|
||||||
|
switch generalSetting.QuotaDisplayType {
|
||||||
|
case QuotaDisplayTypeUSD:
|
||||||
|
return "$"
|
||||||
|
case QuotaDisplayTypeCNY:
|
||||||
|
return "¥"
|
||||||
|
case QuotaDisplayTypeCustom:
|
||||||
|
if generalSetting.CustomCurrencySymbol != "" {
|
||||||
|
return generalSetting.CustomCurrencySymbol
|
||||||
|
}
|
||||||
|
return "¤"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsdToCurrencyRate 返回 1 USD = X <currency> 的 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||||
/>
|
/>
|
||||||
<title>New API</title>
|
<title>New API</title>
|
||||||
<analytics></analytics>
|
<analytics></analytics>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -6,4 +6,4 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ const TwoFactorAuthModal = ({
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||||
{t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')}
|
{t(
|
||||||
|
'支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。',
|
||||||
|
)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const OperationSetting = () => {
|
|||||||
QuotaPerUnit: 0,
|
QuotaPerUnit: 0,
|
||||||
USDExchangeRate: 0,
|
USDExchangeRate: 0,
|
||||||
RetryTimes: 0,
|
RetryTimes: 0,
|
||||||
DisplayInCurrencyEnabled: false,
|
'general_setting.quota_display_type': 'USD',
|
||||||
DisplayTokenStatEnabled: false,
|
DisplayTokenStatEnabled: false,
|
||||||
DefaultCollapseSidebar: false,
|
DefaultCollapseSidebar: false,
|
||||||
DemoSiteEnabled: false,
|
DemoSiteEnabled: false,
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import { useTranslation } from 'react-i18next';
|
|||||||
const SystemSetting = () => {
|
const SystemSetting = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
let [inputs, setInputs] = useState({
|
let [inputs, setInputs] = useState({
|
||||||
|
|
||||||
PasswordLoginEnabled: '',
|
PasswordLoginEnabled: '',
|
||||||
PasswordRegisterEnabled: '',
|
PasswordRegisterEnabled: '',
|
||||||
EmailVerificationEnabled: '',
|
EmailVerificationEnabled: '',
|
||||||
@@ -188,7 +187,9 @@ const SystemSetting = () => {
|
|||||||
setInputs(newInputs);
|
setInputs(newInputs);
|
||||||
setOriginInputs(newInputs);
|
setOriginInputs(newInputs);
|
||||||
// 同步模式布尔到本地状态
|
// 同步模式布尔到本地状态
|
||||||
if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') {
|
if (
|
||||||
|
typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined'
|
||||||
|
) {
|
||||||
setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
|
setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
|
||||||
}
|
}
|
||||||
if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
|
if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
|
||||||
@@ -695,14 +696,17 @@ const SystemSetting = () => {
|
|||||||
noLabel
|
noLabel
|
||||||
extraText={t('SSRF防护开关详细说明')}
|
extraText={t('SSRF防护开关详细说明')}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleCheckboxChange('fetch_setting.enable_ssrf_protection', e)
|
handleCheckboxChange(
|
||||||
|
'fetch_setting.enable_ssrf_protection',
|
||||||
|
e,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t('启用SSRF防护(推荐开启以保护服务器安全)')}
|
{t('启用SSRF防护(推荐开启以保护服务器安全)')}
|
||||||
</Form.Checkbox>
|
</Form.Checkbox>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row
|
<Row
|
||||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
@@ -713,14 +717,19 @@ const SystemSetting = () => {
|
|||||||
noLabel
|
noLabel
|
||||||
extraText={t('私有IP访问详细说明')}
|
extraText={t('私有IP访问详细说明')}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleCheckboxChange('fetch_setting.allow_private_ip', e)
|
handleCheckboxChange(
|
||||||
|
'fetch_setting.allow_private_ip',
|
||||||
|
e,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')}
|
{t(
|
||||||
|
'允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)',
|
||||||
|
)}
|
||||||
</Form.Checkbox>
|
</Form.Checkbox>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row
|
<Row
|
||||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
@@ -731,7 +740,10 @@ const SystemSetting = () => {
|
|||||||
noLabel
|
noLabel
|
||||||
extraText={t('域名IP过滤详细说明')}
|
extraText={t('域名IP过滤详细说明')}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
|
handleCheckboxChange(
|
||||||
|
'fetch_setting.apply_ip_filter_for_domain',
|
||||||
|
e,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
style={{ marginBottom: 8 }}
|
style={{ marginBottom: 8 }}
|
||||||
>
|
>
|
||||||
@@ -740,17 +752,23 @@ const SystemSetting = () => {
|
|||||||
<Text strong>
|
<Text strong>
|
||||||
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
|
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
<Text
|
||||||
{t('支持通配符格式,如:example.com, *.api.example.com')}
|
type='secondary'
|
||||||
|
style={{ display: 'block', marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
'支持通配符格式,如:example.com, *.api.example.com',
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
type='button'
|
type='button'
|
||||||
value={domainFilterMode ? 'whitelist' : 'blacklist'}
|
value={domainFilterMode ? 'whitelist' : 'blacklist'}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
const selected = val && val.target ? val.target.value : val;
|
const selected =
|
||||||
|
val && val.target ? val.target.value : val;
|
||||||
const isWhitelist = selected === 'whitelist';
|
const isWhitelist = selected === 'whitelist';
|
||||||
setDomainFilterMode(isWhitelist);
|
setDomainFilterMode(isWhitelist);
|
||||||
setInputs(prev => ({
|
setInputs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
'fetch_setting.domain_filter_mode': isWhitelist,
|
'fetch_setting.domain_filter_mode': isWhitelist,
|
||||||
}));
|
}));
|
||||||
@@ -765,9 +783,9 @@ const SystemSetting = () => {
|
|||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setDomainList(value);
|
setDomainList(value);
|
||||||
// 触发Form的onChange事件
|
// 触发Form的onChange事件
|
||||||
setInputs(prev => ({
|
setInputs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
'fetch_setting.domain_list': value
|
'fetch_setting.domain_list': value,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
placeholder={t('输入域名后回车,如:example.com')}
|
placeholder={t('输入域名后回车,如:example.com')}
|
||||||
@@ -784,17 +802,21 @@ const SystemSetting = () => {
|
|||||||
<Text strong>
|
<Text strong>
|
||||||
{t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
|
{t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
<Text
|
||||||
|
type='secondary'
|
||||||
|
style={{ display: 'block', marginBottom: 8 }}
|
||||||
|
>
|
||||||
{t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')}
|
{t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')}
|
||||||
</Text>
|
</Text>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
type='button'
|
type='button'
|
||||||
value={ipFilterMode ? 'whitelist' : 'blacklist'}
|
value={ipFilterMode ? 'whitelist' : 'blacklist'}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
const selected = val && val.target ? val.target.value : val;
|
const selected =
|
||||||
|
val && val.target ? val.target.value : val;
|
||||||
const isWhitelist = selected === 'whitelist';
|
const isWhitelist = selected === 'whitelist';
|
||||||
setIpFilterMode(isWhitelist);
|
setIpFilterMode(isWhitelist);
|
||||||
setInputs(prev => ({
|
setInputs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
'fetch_setting.ip_filter_mode': isWhitelist,
|
'fetch_setting.ip_filter_mode': isWhitelist,
|
||||||
}));
|
}));
|
||||||
@@ -809,9 +831,9 @@ const SystemSetting = () => {
|
|||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setIpList(value);
|
setIpList(value);
|
||||||
// 触发Form的onChange事件
|
// 触发Form的onChange事件
|
||||||
setInputs(prev => ({
|
setInputs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
'fetch_setting.ip_list': value
|
'fetch_setting.ip_list': value,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
placeholder={t('输入IP地址后回车,如:8.8.8.8')}
|
placeholder={t('输入IP地址后回车,如:8.8.8.8')}
|
||||||
@@ -826,7 +848,10 @@ const SystemSetting = () => {
|
|||||||
>
|
>
|
||||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||||
<Text strong>{t('允许的端口')}</Text>
|
<Text strong>{t('允许的端口')}</Text>
|
||||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
<Text
|
||||||
|
type='secondary'
|
||||||
|
style={{ display: 'block', marginBottom: 8 }}
|
||||||
|
>
|
||||||
{t('支持单个端口和端口范围,如:80, 443, 8000-8999')}
|
{t('支持单个端口和端口范围,如:80, 443, 8000-8999')}
|
||||||
</Text>
|
</Text>
|
||||||
<TagInput
|
<TagInput
|
||||||
@@ -834,15 +859,18 @@ const SystemSetting = () => {
|
|||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setAllowedPorts(value);
|
setAllowedPorts(value);
|
||||||
// 触发Form的onChange事件
|
// 触发Form的onChange事件
|
||||||
setInputs(prev => ({
|
setInputs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
'fetch_setting.allowed_ports': value
|
'fetch_setting.allowed_ports': value,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
placeholder={t('输入端口后回车,如:80 或 8000-8999')}
|
placeholder={t('输入端口后回车,如:80 或 8000-8999')}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
<Text
|
||||||
|
type='secondary'
|
||||||
|
style={{ display: 'block', marginBottom: 8 }}
|
||||||
|
>
|
||||||
{t('端口配置详细说明')}
|
{t('端口配置详细说明')}
|
||||||
</Text>
|
</Text>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ const AccountManagement = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
const isBound = (accountId) => Boolean(accountId);
|
const isBound = (accountId) => Boolean(accountId);
|
||||||
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
|
const [showTelegramBindModal, setShowTelegramBindModal] =
|
||||||
|
React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='!rounded-2xl'>
|
<Card className='!rounded-2xl'>
|
||||||
@@ -226,7 +227,8 @@ const AccountManagement = ({
|
|||||||
onGitHubOAuthClicked(status.github_client_id)
|
onGitHubOAuthClicked(status.github_client_id)
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
isBound(userState.user?.github_id) || !status.github_oauth
|
isBound(userState.user?.github_id) ||
|
||||||
|
!status.github_oauth
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status.github_oauth ? t('绑定') : t('未启用')}
|
{status.github_oauth ? t('绑定') : t('未启用')}
|
||||||
@@ -384,7 +386,8 @@ const AccountManagement = ({
|
|||||||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
|
isBound(userState.user?.linux_do_id) ||
|
||||||
|
!status.linuxdo_oauth
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
||||||
|
|||||||
@@ -87,23 +87,7 @@ const REGION_EXAMPLE = {
|
|||||||
|
|
||||||
// 支持并且已适配通过接口获取模型列表的渠道类型
|
// 支持并且已适配通过接口获取模型列表的渠道类型
|
||||||
const MODEL_FETCHABLE_TYPES = new Set([
|
const MODEL_FETCHABLE_TYPES = new Set([
|
||||||
1,
|
1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43,
|
||||||
4,
|
|
||||||
14,
|
|
||||||
34,
|
|
||||||
17,
|
|
||||||
26,
|
|
||||||
24,
|
|
||||||
47,
|
|
||||||
25,
|
|
||||||
20,
|
|
||||||
23,
|
|
||||||
31,
|
|
||||||
35,
|
|
||||||
40,
|
|
||||||
42,
|
|
||||||
48,
|
|
||||||
43,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function type2secretPrompt(type) {
|
function type2secretPrompt(type) {
|
||||||
@@ -348,7 +332,10 @@ const EditChannelModal = (props) => {
|
|||||||
break;
|
break;
|
||||||
case 45:
|
case 45:
|
||||||
localModels = getChannelModels(value);
|
localModels = getChannelModels(value);
|
||||||
setInputs((prevInputs) => ({ ...prevInputs, base_url: 'https://ark.cn-beijing.volces.com' }));
|
setInputs((prevInputs) => ({
|
||||||
|
...prevInputs,
|
||||||
|
base_url: 'https://ark.cn-beijing.volces.com',
|
||||||
|
}));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
localModels = getChannelModels(value);
|
localModels = getChannelModels(value);
|
||||||
@@ -442,7 +429,8 @@ const EditChannelModal = (props) => {
|
|||||||
// 读取 Vertex 密钥格式
|
// 读取 Vertex 密钥格式
|
||||||
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
|
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
|
||||||
// 读取企业账户设置
|
// 读取企业账户设置
|
||||||
data.is_enterprise_account = parsedSettings.openrouter_enterprise === true;
|
data.is_enterprise_account =
|
||||||
|
parsedSettings.openrouter_enterprise === true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('解析其他设置失败:', error);
|
console.error('解析其他设置失败:', error);
|
||||||
data.azure_responses_version = '';
|
data.azure_responses_version = '';
|
||||||
@@ -868,7 +856,10 @@ const EditChannelModal = (props) => {
|
|||||||
showInfo(t('请至少选择一个模型!'));
|
showInfo(t('请至少选择一个模型!'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (localInputs.type === 45 && (!localInputs.base_url || localInputs.base_url.trim() === '')) {
|
if (
|
||||||
|
localInputs.type === 45 &&
|
||||||
|
(!localInputs.base_url || localInputs.base_url.trim() === '')
|
||||||
|
) {
|
||||||
showInfo(t('请输入API地址!'));
|
showInfo(t('请输入API地址!'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -912,7 +903,8 @@ const EditChannelModal = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 设置企业账户标识,无论是true还是false都要传到后端
|
// 设置企业账户标识,无论是true还是false都要传到后端
|
||||||
settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
|
settings.openrouter_enterprise =
|
||||||
|
localInputs.is_enterprise_account === true;
|
||||||
localInputs.settings = JSON.stringify(settings);
|
localInputs.settings = JSON.stringify(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1318,7 +1310,9 @@ const EditChannelModal = (props) => {
|
|||||||
setIsEnterpriseAccount(value);
|
setIsEnterpriseAccount(value);
|
||||||
handleInputChange('is_enterprise_account', value);
|
handleInputChange('is_enterprise_account', value);
|
||||||
}}
|
}}
|
||||||
extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')}
|
extraText={t(
|
||||||
|
'企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选',
|
||||||
|
)}
|
||||||
initValue={inputs.is_enterprise_account}
|
initValue={inputs.is_enterprise_account}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1944,27 +1938,27 @@ const EditChannelModal = (props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{inputs.type === 45 && (
|
{inputs.type === 45 && (
|
||||||
<div>
|
<div>
|
||||||
<Form.Select
|
<Form.Select
|
||||||
field='base_url'
|
field='base_url'
|
||||||
label={t('API地址')}
|
label={t('API地址')}
|
||||||
placeholder={t('请选择API地址')}
|
placeholder={t('请选择API地址')}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
handleInputChange('base_url', value)
|
handleInputChange('base_url', value)
|
||||||
}
|
}
|
||||||
optionList={[
|
optionList={[
|
||||||
{
|
{
|
||||||
value: 'https://ark.cn-beijing.volces.com',
|
value: 'https://ark.cn-beijing.volces.com',
|
||||||
label: 'https://ark.cn-beijing.volces.com'
|
label: 'https://ark.cn-beijing.volces.com',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'https://ark.ap-southeast.bytepluses.com',
|
value: 'https://ark.ap-southeast.bytepluses.com',
|
||||||
label: 'https://ark.ap-southeast.bytepluses.com'
|
label: 'https://ark.ap-southeast.bytepluses.com',
|
||||||
}
|
},
|
||||||
]}
|
]}
|
||||||
defaultValue='https://ark.cn-beijing.volces.com'
|
defaultValue='https://ark.cn-beijing.volces.com'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ const MjLogsFilters = ({
|
|||||||
showClear
|
showClear
|
||||||
pure
|
pure
|
||||||
size='small'
|
size='small'
|
||||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
presets={DATE_RANGE_PRESETS.map((preset) => ({
|
||||||
text: t(preset.text),
|
text: t(preset.text),
|
||||||
start: preset.start(),
|
start: preset.start(),
|
||||||
end: preset.end()
|
end: preset.end(),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const PricingDisplaySettings = ({
|
|||||||
const currencyItems = [
|
const currencyItems = [
|
||||||
{ value: 'USD', label: 'USD ($)' },
|
{ value: 'USD', label: 'USD ($)' },
|
||||||
{ value: 'CNY', label: 'CNY (¥)' },
|
{ value: 'CNY', label: 'CNY (¥)' },
|
||||||
|
{ value: 'CUSTOM', label: t('自定义货币') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleChange = (value) => {
|
const handleChange = (value) => {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const SearchActions = memo(
|
|||||||
optionList={[
|
optionList={[
|
||||||
{ value: 'USD', label: 'USD' },
|
{ value: 'USD', label: 'USD' },
|
||||||
{ value: 'CNY', label: 'CNY' },
|
{ value: 'CNY', label: 'CNY' },
|
||||||
|
{ value: 'CUSTOM', label: t('自定义货币') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
TASK_ACTION_FIRST_TAIL_GENERATE,
|
TASK_ACTION_FIRST_TAIL_GENERATE,
|
||||||
TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE,
|
TASK_ACTION_GENERATE,
|
||||||
TASK_ACTION_TEXT_GENERATE
|
TASK_ACTION_REFERENCE_GENERATE,
|
||||||
|
TASK_ACTION_TEXT_GENERATE,
|
||||||
} from '../../../constants/common.constant';
|
} from '../../../constants/common.constant';
|
||||||
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
|
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
|
||||||
|
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ const TaskLogsFilters = ({
|
|||||||
showClear
|
showClear
|
||||||
pure
|
pure
|
||||||
size='small'
|
size='small'
|
||||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
presets={DATE_RANGE_PRESETS.map((preset) => ({
|
||||||
text: t(preset.text),
|
text: t(preset.text),
|
||||||
start: preset.start(),
|
start: preset.start(),
|
||||||
end: preset.end()
|
end: preset.end(),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,38 +60,54 @@ const ContentModal = ({
|
|||||||
if (videoError) {
|
if (videoError) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}>
|
<Text
|
||||||
|
type='tertiary'
|
||||||
|
style={{ display: 'block', marginBottom: '16px' }}
|
||||||
|
>
|
||||||
视频无法在当前浏览器中播放,这可能是由于:
|
视频无法在当前浏览器中播放,这可能是由于:
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
<Text
|
||||||
|
type='tertiary'
|
||||||
|
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
|
||||||
|
>
|
||||||
• 视频服务商的跨域限制
|
• 视频服务商的跨域限制
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
<Text
|
||||||
|
type='tertiary'
|
||||||
|
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
|
||||||
|
>
|
||||||
• 需要特定的请求头或认证
|
• 需要特定的请求头或认证
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}>
|
<Text
|
||||||
|
type='tertiary'
|
||||||
|
style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}
|
||||||
|
>
|
||||||
• 防盗链保护机制
|
• 防盗链保护机制
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div style={{ marginTop: '20px' }}>
|
<div style={{ marginTop: '20px' }}>
|
||||||
<Button
|
<Button
|
||||||
icon={<IconExternalOpen />}
|
icon={<IconExternalOpen />}
|
||||||
onClick={handleOpenInNewTab}
|
onClick={handleOpenInNewTab}
|
||||||
style={{ marginRight: '8px' }}
|
style={{ marginRight: '8px' }}
|
||||||
>
|
>
|
||||||
在新标签页中打开
|
在新标签页中打开
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button icon={<IconCopy />} onClick={handleCopyUrl}>
|
||||||
icon={<IconCopy />}
|
|
||||||
onClick={handleCopyUrl}
|
|
||||||
>
|
|
||||||
复制链接
|
复制链接
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
|
<div
|
||||||
<Text
|
style={{
|
||||||
type="tertiary"
|
marginTop: '16px',
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
type='tertiary'
|
||||||
style={{ fontSize: '10px', wordBreak: 'break-all' }}
|
style={{ fontSize: '10px', wordBreak: 'break-all' }}
|
||||||
>
|
>
|
||||||
{modalContent}
|
{modalContent}
|
||||||
@@ -104,22 +120,24 @@ const ContentModal = ({
|
|||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div style={{
|
<div
|
||||||
position: 'absolute',
|
style={{
|
||||||
top: '50%',
|
position: 'absolute',
|
||||||
left: '50%',
|
top: '50%',
|
||||||
transform: 'translate(-50%, -50%)',
|
left: '50%',
|
||||||
zIndex: 10
|
transform: 'translate(-50%, -50%)',
|
||||||
}}>
|
zIndex: 10,
|
||||||
<Spin size="large" />
|
}}
|
||||||
|
>
|
||||||
|
<Spin size='large' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<video
|
<video
|
||||||
src={modalContent}
|
src={modalContent}
|
||||||
controls
|
controls
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
autoPlay
|
autoPlay
|
||||||
crossOrigin="anonymous"
|
crossOrigin='anonymous'
|
||||||
onError={handleVideoError}
|
onError={handleVideoError}
|
||||||
onLoadedData={handleVideoLoaded}
|
onLoadedData={handleVideoLoaded}
|
||||||
onLoadStart={() => setIsLoading(true)}
|
onLoadStart={() => setIsLoading(true)}
|
||||||
@@ -134,10 +152,10 @@ const ContentModal = ({
|
|||||||
onOk={() => setIsModalOpen(false)}
|
onOk={() => setIsModalOpen(false)}
|
||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
closable={null}
|
closable={null}
|
||||||
bodyStyle={{
|
bodyStyle={{
|
||||||
height: isVideo ? '450px' : '400px',
|
height: isVideo ? '450px' : '400px',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
padding: isVideo && videoError ? '0' : '24px'
|
padding: isVideo && videoError ? '0' : '24px',
|
||||||
}}
|
}}
|
||||||
width={800}
|
width={800}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ const LogsFilters = ({
|
|||||||
showClear
|
showClear
|
||||||
pure
|
pure
|
||||||
size='small'
|
size='small'
|
||||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
presets={DATE_RANGE_PRESETS.map((preset) => ({
|
||||||
text: t(preset.text),
|
text: t(preset.text),
|
||||||
start: preset.start(),
|
start: preset.start(),
|
||||||
end: preset.end()
|
end: preset.end(),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Spin, Tooltip
|
Spin,
|
||||||
|
Tooltip,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
|
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
|
||||||
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
|
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
|
||||||
@@ -266,7 +267,8 @@ const RechargeCard = ({
|
|||||||
{payMethods && payMethods.length > 0 ? (
|
{payMethods && payMethods.length > 0 ? (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{payMethods.map((payMethod) => {
|
{payMethods.map((payMethod) => {
|
||||||
const minTopupVal = Number(payMethod.min_topup) || 0;
|
const minTopupVal =
|
||||||
|
Number(payMethod.min_topup) || 0;
|
||||||
const isStripe = payMethod.type === 'stripe';
|
const isStripe = payMethod.type === 'stripe';
|
||||||
const disabled =
|
const disabled =
|
||||||
(!enableOnlineTopUp && !isStripe) ||
|
(!enableOnlineTopUp && !isStripe) ||
|
||||||
@@ -280,7 +282,9 @@ const RechargeCard = ({
|
|||||||
type='tertiary'
|
type='tertiary'
|
||||||
onClick={() => preTopUp(payMethod.type)}
|
onClick={() => preTopUp(payMethod.type)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
loading={paymentLoading && payWay === payMethod.type}
|
loading={
|
||||||
|
paymentLoading && payWay === payMethod.type
|
||||||
|
}
|
||||||
icon={
|
icon={
|
||||||
payMethod.type === 'alipay' ? (
|
payMethod.type === 'alipay' ? (
|
||||||
<SiAlipay size={18} color='#1677FF' />
|
<SiAlipay size={18} color='#1677FF' />
|
||||||
@@ -291,7 +295,10 @@ const RechargeCard = ({
|
|||||||
) : (
|
) : (
|
||||||
<CreditCard
|
<CreditCard
|
||||||
size={18}
|
size={18}
|
||||||
color={payMethod.color || 'var(--semi-color-text-2)'}
|
color={
|
||||||
|
payMethod.color ||
|
||||||
|
'var(--semi-color-text-2)'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -301,12 +308,22 @@ const RechargeCard = ({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return disabled && minTopupVal > Number(topUpCount || 0) ? (
|
return disabled &&
|
||||||
<Tooltip content={t('此支付方式最低充值金额为') + ' ' + minTopupVal} key={payMethod.type}>
|
minTopupVal > Number(topUpCount || 0) ? (
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
t('此支付方式最低充值金额为') +
|
||||||
|
' ' +
|
||||||
|
minTopupVal
|
||||||
|
}
|
||||||
|
key={payMethod.type}
|
||||||
|
>
|
||||||
{buttonEl}
|
{buttonEl}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<React.Fragment key={payMethod.type}>{buttonEl}</React.Fragment>
|
<React.Fragment key={payMethod.type}>
|
||||||
|
{buttonEl}
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -324,23 +341,27 @@ const RechargeCard = ({
|
|||||||
<Form.Slot label={t('选择充值额度')}>
|
<Form.Slot label={t('选择充值额度')}>
|
||||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
|
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
|
||||||
{presetAmounts.map((preset, index) => {
|
{presetAmounts.map((preset, index) => {
|
||||||
const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0;
|
const discount =
|
||||||
|
preset.discount ||
|
||||||
|
topupInfo?.discount?.[preset.value] ||
|
||||||
|
1.0;
|
||||||
const originalPrice = preset.value * priceRatio;
|
const originalPrice = preset.value * priceRatio;
|
||||||
const discountedPrice = originalPrice * discount;
|
const discountedPrice = originalPrice * discount;
|
||||||
const hasDiscount = discount < 1.0;
|
const hasDiscount = discount < 1.0;
|
||||||
const actualPay = discountedPrice;
|
const actualPay = discountedPrice;
|
||||||
const save = originalPrice - discountedPrice;
|
const save = originalPrice - discountedPrice;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={index}
|
key={index}
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: selectedPreset === preset.value
|
border:
|
||||||
? '2px solid var(--semi-color-primary)'
|
selectedPreset === preset.value
|
||||||
: '1px solid var(--semi-color-border)',
|
? '2px solid var(--semi-color-primary)'
|
||||||
|
: '1px solid var(--semi-color-border)',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
width: '100%'
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
bodyStyle={{ padding: '12px' }}
|
bodyStyle={{ padding: '12px' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -352,24 +373,35 @@ const RechargeCard = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<Typography.Title heading={6} style={{ margin: '0 0 8px 0' }}>
|
<Typography.Title
|
||||||
|
heading={6}
|
||||||
|
style={{ margin: '0 0 8px 0' }}
|
||||||
|
>
|
||||||
<Coins size={18} />
|
<Coins size={18} />
|
||||||
{formatLargeNumber(preset.value)}
|
{formatLargeNumber(preset.value)}
|
||||||
{hasDiscount && (
|
{hasDiscount && (
|
||||||
<Tag style={{ marginLeft: 4 }} color="green">
|
<Tag style={{ marginLeft: 4 }} color='green'>
|
||||||
{t('折').includes('off') ?
|
{t('折').includes('off')
|
||||||
((1 - parseFloat(discount)) * 100).toFixed(1) :
|
? (
|
||||||
(discount * 10).toFixed(1)}{t('折')}
|
(1 - parseFloat(discount)) *
|
||||||
</Tag>
|
100
|
||||||
|
).toFixed(1)
|
||||||
|
: (discount * 10).toFixed(1)}
|
||||||
|
{t('折')}
|
||||||
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<div style={{
|
<div
|
||||||
color: 'var(--semi-color-text-2)',
|
style={{
|
||||||
fontSize: '12px',
|
color: 'var(--semi-color-text-2)',
|
||||||
margin: '4px 0'
|
fontSize: '12px',
|
||||||
}}>
|
margin: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('实付')} {actualPay.toFixed(2)},
|
{t('实付')} {actualPay.toFixed(2)},
|
||||||
{hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`}
|
{hasDiscount
|
||||||
|
? `${t('节省')} ${save.toFixed(2)}`
|
||||||
|
: `${t('节省')} 0.00`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -80,11 +80,11 @@ const TopUp = () => {
|
|||||||
// 预设充值额度选项
|
// 预设充值额度选项
|
||||||
const [presetAmounts, setPresetAmounts] = useState([]);
|
const [presetAmounts, setPresetAmounts] = useState([]);
|
||||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||||
|
|
||||||
// 充值配置信息
|
// 充值配置信息
|
||||||
const [topupInfo, setTopupInfo] = useState({
|
const [topupInfo, setTopupInfo] = useState({
|
||||||
amount_options: [],
|
amount_options: [],
|
||||||
discount: {}
|
discount: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const topUp = async () => {
|
const topUp = async () => {
|
||||||
@@ -262,9 +262,9 @@ const TopUp = () => {
|
|||||||
if (success) {
|
if (success) {
|
||||||
setTopupInfo({
|
setTopupInfo({
|
||||||
amount_options: data.amount_options || [],
|
amount_options: data.amount_options || [],
|
||||||
discount: data.discount || {}
|
discount: data.discount || {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理支付方式
|
// 处理支付方式
|
||||||
let payMethods = data.pay_methods || [];
|
let payMethods = data.pay_methods || [];
|
||||||
try {
|
try {
|
||||||
@@ -280,10 +280,15 @@ const TopUp = () => {
|
|||||||
payMethods = payMethods.map((method) => {
|
payMethods = payMethods.map((method) => {
|
||||||
// 规范化最小充值数
|
// 规范化最小充值数
|
||||||
const normalizedMinTopup = Number(method.min_topup);
|
const normalizedMinTopup = Number(method.min_topup);
|
||||||
method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0;
|
method.min_topup = Number.isFinite(normalizedMinTopup)
|
||||||
|
? normalizedMinTopup
|
||||||
|
: 0;
|
||||||
|
|
||||||
// Stripe 的最小充值从后端字段回填
|
// Stripe 的最小充值从后端字段回填
|
||||||
if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) {
|
if (
|
||||||
|
method.type === 'stripe' &&
|
||||||
|
(!method.min_topup || method.min_topup <= 0)
|
||||||
|
) {
|
||||||
const stripeMin = Number(data.stripe_min_topup);
|
const stripeMin = Number(data.stripe_min_topup);
|
||||||
if (Number.isFinite(stripeMin)) {
|
if (Number.isFinite(stripeMin)) {
|
||||||
method.min_topup = stripeMin;
|
method.min_topup = stripeMin;
|
||||||
@@ -313,7 +318,11 @@ const TopUp = () => {
|
|||||||
setPayMethods(payMethods);
|
setPayMethods(payMethods);
|
||||||
const enableStripeTopUp = data.enable_stripe_topup || false;
|
const enableStripeTopUp = data.enable_stripe_topup || false;
|
||||||
const enableOnlineTopUp = data.enable_online_topup || false;
|
const enableOnlineTopUp = data.enable_online_topup || false;
|
||||||
const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1;
|
const minTopUpValue = enableOnlineTopUp
|
||||||
|
? data.min_topup
|
||||||
|
: enableStripeTopUp
|
||||||
|
? data.stripe_min_topup
|
||||||
|
: 1;
|
||||||
setEnableOnlineTopUp(enableOnlineTopUp);
|
setEnableOnlineTopUp(enableOnlineTopUp);
|
||||||
setEnableStripeTopUp(enableStripeTopUp);
|
setEnableStripeTopUp(enableStripeTopUp);
|
||||||
setMinTopUp(minTopUpValue);
|
setMinTopUp(minTopUpValue);
|
||||||
@@ -330,12 +339,12 @@ const TopUp = () => {
|
|||||||
console.log('解析支付方式失败:', e);
|
console.log('解析支付方式失败:', e);
|
||||||
setPayMethods([]);
|
setPayMethods([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有自定义充值数量选项,使用它们替换默认的预设选项
|
// 如果有自定义充值数量选项,使用它们替换默认的预设选项
|
||||||
if (data.amount_options && data.amount_options.length > 0) {
|
if (data.amount_options && data.amount_options.length > 0) {
|
||||||
const customPresets = data.amount_options.map(amount => ({
|
const customPresets = data.amount_options.map((amount) => ({
|
||||||
value: amount,
|
value: amount,
|
||||||
discount: data.discount[amount] || 1.0
|
discount: data.discount[amount] || 1.0,
|
||||||
}));
|
}));
|
||||||
setPresetAmounts(customPresets);
|
setPresetAmounts(customPresets);
|
||||||
}
|
}
|
||||||
@@ -483,7 +492,7 @@ const TopUp = () => {
|
|||||||
const selectPresetAmount = (preset) => {
|
const selectPresetAmount = (preset) => {
|
||||||
setTopUpCount(preset.value);
|
setTopUpCount(preset.value);
|
||||||
setSelectedPreset(preset.value);
|
setSelectedPreset(preset.value);
|
||||||
|
|
||||||
// 计算实际支付金额,考虑折扣
|
// 计算实际支付金额,考虑折扣
|
||||||
const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
|
const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
|
||||||
const discountedAmount = preset.value * priceRatio * discount;
|
const discountedAmount = preset.value * priceRatio * discount;
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ const PaymentConfirmModal = ({
|
|||||||
amountNumber,
|
amountNumber,
|
||||||
discountRate,
|
discountRate,
|
||||||
}) => {
|
}) => {
|
||||||
const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
|
const hasDiscount =
|
||||||
const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0;
|
discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
|
||||||
const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0;
|
const originalAmount = hasDiscount ? amountNumber / discountRate : 0;
|
||||||
|
const discountAmount = hasDiscount ? originalAmount - amountNumber : 0;
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -24,26 +24,26 @@ export const DATE_RANGE_PRESETS = [
|
|||||||
{
|
{
|
||||||
text: '今天',
|
text: '今天',
|
||||||
start: () => dayjs().startOf('day').toDate(),
|
start: () => dayjs().startOf('day').toDate(),
|
||||||
end: () => dayjs().endOf('day').toDate()
|
end: () => dayjs().endOf('day').toDate(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: '近 7 天',
|
text: '近 7 天',
|
||||||
start: () => dayjs().subtract(6, 'day').startOf('day').toDate(),
|
start: () => dayjs().subtract(6, 'day').startOf('day').toDate(),
|
||||||
end: () => dayjs().endOf('day').toDate()
|
end: () => dayjs().endOf('day').toDate(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: '本周',
|
text: '本周',
|
||||||
start: () => dayjs().startOf('week').toDate(),
|
start: () => dayjs().startOf('week').toDate(),
|
||||||
end: () => dayjs().endOf('week').toDate()
|
end: () => dayjs().endOf('week').toDate(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: '近 30 天',
|
text: '近 30 天',
|
||||||
start: () => dayjs().subtract(29, 'day').startOf('day').toDate(),
|
start: () => dayjs().subtract(29, 'day').startOf('day').toDate(),
|
||||||
end: () => dayjs().endOf('day').toDate()
|
end: () => dayjs().endOf('day').toDate(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: '本月',
|
text: '本月',
|
||||||
start: () => dayjs().startOf('month').toDate(),
|
start: () => dayjs().startOf('month').toDate(),
|
||||||
end: () => dayjs().endOf('month').toDate()
|
end: () => dayjs().endOf('month').toDate(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -131,13 +131,11 @@ export const buildApiPayload = (
|
|||||||
seed: 'seed',
|
seed: 'seed',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
Object.entries(parameterMappings).forEach(([key, param]) => {
|
Object.entries(parameterMappings).forEach(([key, param]) => {
|
||||||
const enabled = parameterEnabled[key];
|
const enabled = parameterEnabled[key];
|
||||||
const value = inputs[param];
|
const value = inputs[param];
|
||||||
const hasValue = value !== undefined && value !== null;
|
const hasValue = value !== undefined && value !== null;
|
||||||
|
|
||||||
|
|
||||||
if (enabled && hasValue) {
|
if (enabled && hasValue) {
|
||||||
payload[param] = value;
|
payload[param] = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ export function setStatusData(data) {
|
|||||||
localStorage.setItem('logo', data.logo);
|
localStorage.setItem('logo', data.logo);
|
||||||
localStorage.setItem('footer_html', data.footer_html);
|
localStorage.setItem('footer_html', data.footer_html);
|
||||||
localStorage.setItem('quota_per_unit', data.quota_per_unit);
|
localStorage.setItem('quota_per_unit', data.quota_per_unit);
|
||||||
|
// 兼容:保留旧字段,同时写入新的额度展示类型
|
||||||
localStorage.setItem('display_in_currency', data.display_in_currency);
|
localStorage.setItem('display_in_currency', data.display_in_currency);
|
||||||
|
localStorage.setItem('quota_display_type', data.quota_display_type || 'USD');
|
||||||
localStorage.setItem('enable_drawing', data.enable_drawing);
|
localStorage.setItem('enable_drawing', data.enable_drawing);
|
||||||
localStorage.setItem('enable_task', data.enable_task);
|
localStorage.setItem('enable_task', data.enable_task);
|
||||||
localStorage.setItem('enable_data_export', data.enable_data_export);
|
localStorage.setItem('enable_data_export', data.enable_data_export);
|
||||||
|
|||||||
@@ -830,12 +830,25 @@ export function renderQuotaNumberWithDigit(num, digits = 2) {
|
|||||||
if (typeof num !== 'number' || isNaN(num)) {
|
if (typeof num !== 'number' || isNaN(num)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||||
num = num.toFixed(digits);
|
num = num.toFixed(digits);
|
||||||
if (displayInCurrency) {
|
if (quotaDisplayType === 'CNY') {
|
||||||
|
return '¥' + num;
|
||||||
|
} else if (quotaDisplayType === 'USD') {
|
||||||
return '$' + num;
|
return '$' + num;
|
||||||
|
} else if (quotaDisplayType === 'CUSTOM') {
|
||||||
|
const statusStr = localStorage.getItem('status');
|
||||||
|
let symbol = '¤';
|
||||||
|
try {
|
||||||
|
if (statusStr) {
|
||||||
|
const s = JSON.parse(statusStr);
|
||||||
|
symbol = s?.custom_currency_symbol || symbol;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return symbol + num;
|
||||||
|
} else {
|
||||||
|
return num;
|
||||||
}
|
}
|
||||||
return num;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderNumberWithPoint(num) {
|
export function renderNumberWithPoint(num) {
|
||||||
@@ -887,33 +900,67 @@ export function getQuotaWithUnit(quota, digits = 6) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderQuotaWithAmount(amount) {
|
export function renderQuotaWithAmount(amount) {
|
||||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||||
displayInCurrency = displayInCurrency === 'true';
|
if (quotaDisplayType === 'TOKENS') {
|
||||||
if (displayInCurrency) {
|
|
||||||
return '$' + amount;
|
|
||||||
} else {
|
|
||||||
return renderNumber(renderUnitWithQuota(amount));
|
return renderNumber(renderUnitWithQuota(amount));
|
||||||
}
|
}
|
||||||
|
if (quotaDisplayType === 'CNY') {
|
||||||
|
return '¥' + amount;
|
||||||
|
} else if (quotaDisplayType === 'CUSTOM') {
|
||||||
|
const statusStr = localStorage.getItem('status');
|
||||||
|
let symbol = '¤';
|
||||||
|
try {
|
||||||
|
if (statusStr) {
|
||||||
|
const s = JSON.parse(statusStr);
|
||||||
|
symbol = s?.custom_currency_symbol || symbol;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return symbol + amount;
|
||||||
|
}
|
||||||
|
return '$' + amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderQuota(quota, digits = 2) {
|
export function renderQuota(quota, digits = 2) {
|
||||||
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
||||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||||
quotaPerUnit = parseFloat(quotaPerUnit);
|
quotaPerUnit = parseFloat(quotaPerUnit);
|
||||||
displayInCurrency = displayInCurrency === 'true';
|
if (quotaDisplayType === 'TOKENS') {
|
||||||
if (displayInCurrency) {
|
return renderNumber(quota);
|
||||||
const result = quota / quotaPerUnit;
|
|
||||||
const fixedResult = result.toFixed(digits);
|
|
||||||
|
|
||||||
// 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值
|
|
||||||
if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
|
|
||||||
const minValue = Math.pow(10, -digits);
|
|
||||||
return '$' + minValue.toFixed(digits);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '$' + fixedResult;
|
|
||||||
}
|
}
|
||||||
return renderNumber(quota);
|
const resultUSD = quota / quotaPerUnit;
|
||||||
|
let symbol = '$';
|
||||||
|
let value = resultUSD;
|
||||||
|
if (quotaDisplayType === 'CNY') {
|
||||||
|
const statusStr = localStorage.getItem('status');
|
||||||
|
let usdRate = 1;
|
||||||
|
try {
|
||||||
|
if (statusStr) {
|
||||||
|
const s = JSON.parse(statusStr);
|
||||||
|
usdRate = s?.usd_exchange_rate || 1;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
value = resultUSD * usdRate;
|
||||||
|
symbol = '¥';
|
||||||
|
} else if (quotaDisplayType === 'CUSTOM') {
|
||||||
|
const statusStr = localStorage.getItem('status');
|
||||||
|
let symbolCustom = '¤';
|
||||||
|
let rate = 1;
|
||||||
|
try {
|
||||||
|
if (statusStr) {
|
||||||
|
const s = JSON.parse(statusStr);
|
||||||
|
symbolCustom = s?.custom_currency_symbol || symbolCustom;
|
||||||
|
rate = s?.custom_currency_exchange_rate || rate;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
value = resultUSD * rate;
|
||||||
|
symbol = symbolCustom;
|
||||||
|
}
|
||||||
|
const fixedResult = value.toFixed(digits);
|
||||||
|
if (parseFloat(fixedResult) === 0 && quota > 0 && value > 0) {
|
||||||
|
const minValue = Math.pow(10, -digits);
|
||||||
|
return symbol + minValue.toFixed(digits);
|
||||||
|
}
|
||||||
|
return symbol + fixedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidGroupRatio(ratio) {
|
function isValidGroupRatio(ratio) {
|
||||||
@@ -1072,7 +1119,7 @@ export function renderModelPrice(
|
|||||||
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
|
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
|
||||||
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
|
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
|
||||||
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
|
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
|
||||||
(imageGenerationCallPrice * groupRatio);
|
imageGenerationCallPrice * groupRatio;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1510,9 +1557,8 @@ export function renderAudioModelPrice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderQuotaWithPrompt(quota, digits) {
|
export function renderQuotaWithPrompt(quota, digits) {
|
||||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||||
displayInCurrency = displayInCurrency === 'true';
|
if (quotaDisplayType !== 'TOKENS') {
|
||||||
if (displayInCurrency) {
|
|
||||||
return i18next.t('等价金额:') + renderQuota(quota, digits);
|
return i18next.t('等价金额:') + renderQuota(quota, digits);
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -646,9 +646,25 @@ export const calculateModelPrice = ({
|
|||||||
const numCompletion =
|
const numCompletion =
|
||||||
parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
|
parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||||
|
|
||||||
|
let symbol = '$';
|
||||||
|
if (currency === 'CNY') {
|
||||||
|
symbol = '¥';
|
||||||
|
} else if (currency === 'CUSTOM') {
|
||||||
|
try {
|
||||||
|
const statusStr = localStorage.getItem('status');
|
||||||
|
if (statusStr) {
|
||||||
|
const s = JSON.parse(statusStr);
|
||||||
|
symbol = s?.custom_currency_symbol || '¤';
|
||||||
|
} else {
|
||||||
|
symbol = '¤';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
symbol = '¤';
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`,
|
inputPrice: `${symbol}${numInput.toFixed(precision)}`,
|
||||||
completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`,
|
completionPrice: `${symbol}${numCompletion.toFixed(precision)}`,
|
||||||
unitLabel,
|
unitLabel,
|
||||||
isPerToken: true,
|
isPerToken: true,
|
||||||
usedGroup,
|
usedGroup,
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ import {
|
|||||||
showInfo,
|
showInfo,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
loadChannelModels,
|
loadChannelModels,
|
||||||
copy
|
copy,
|
||||||
} from '../../helpers';
|
} from '../../helpers';
|
||||||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants';
|
import {
|
||||||
|
CHANNEL_OPTIONS,
|
||||||
|
ITEMS_PER_PAGE,
|
||||||
|
MODEL_TABLE_PAGE_SIZE,
|
||||||
|
} from '../../constants';
|
||||||
import { useIsMobile } from '../common/useIsMobile';
|
import { useIsMobile } from '../common/useIsMobile';
|
||||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||||
import { Modal } from '@douyinfe/semi-ui';
|
import { Modal } from '@douyinfe/semi-ui';
|
||||||
@@ -64,7 +68,7 @@ export const useChannelsData = () => {
|
|||||||
|
|
||||||
// Status filter
|
// Status filter
|
||||||
const [statusFilter, setStatusFilter] = useState(
|
const [statusFilter, setStatusFilter] = useState(
|
||||||
localStorage.getItem('channel-status-filter') || 'all'
|
localStorage.getItem('channel-status-filter') || 'all',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Type tabs states
|
// Type tabs states
|
||||||
@@ -80,7 +84,7 @@ export const useChannelsData = () => {
|
|||||||
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
|
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
|
||||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||||
const [modelTablePage, setModelTablePage] = useState(1);
|
const [modelTablePage, setModelTablePage] = useState(1);
|
||||||
|
|
||||||
// 使用 ref 来避免闭包问题,类似旧版实现
|
// 使用 ref 来避免闭包问题,类似旧版实现
|
||||||
const shouldStopBatchTestingRef = useRef(false);
|
const shouldStopBatchTestingRef = useRef(false);
|
||||||
|
|
||||||
@@ -116,9 +120,12 @@ export const useChannelsData = () => {
|
|||||||
// Initialize from localStorage
|
// Initialize from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const localIdSort = localStorage.getItem('id-sort') === 'true';
|
const localIdSort = localStorage.getItem('id-sort') === 'true';
|
||||||
const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
const localPageSize =
|
||||||
const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
|
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||||
const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
|
const localEnableTagMode =
|
||||||
|
localStorage.getItem('enable-tag-mode') === 'true';
|
||||||
|
const localEnableBatchDelete =
|
||||||
|
localStorage.getItem('enable-batch-delete') === 'true';
|
||||||
|
|
||||||
setIdSort(localIdSort);
|
setIdSort(localIdSort);
|
||||||
setPageSize(localPageSize);
|
setPageSize(localPageSize);
|
||||||
@@ -176,7 +183,10 @@ export const useChannelsData = () => {
|
|||||||
// Save column preferences
|
// Save column preferences
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.keys(visibleColumns).length > 0) {
|
if (Object.keys(visibleColumns).length > 0) {
|
||||||
localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns));
|
localStorage.setItem(
|
||||||
|
'channels-table-columns',
|
||||||
|
JSON.stringify(visibleColumns),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [visibleColumns]);
|
}, [visibleColumns]);
|
||||||
|
|
||||||
@@ -290,14 +300,21 @@ export const useChannelsData = () => {
|
|||||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||||
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
|
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
|
await searchChannels(
|
||||||
|
enableTagMode,
|
||||||
|
typeKey,
|
||||||
|
statusF,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
idSort,
|
||||||
|
);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reqId = ++requestCounter.current;
|
const reqId = ++requestCounter.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
|
||||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||||
const res = await API.get(
|
const res = await API.get(
|
||||||
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
|
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
|
||||||
@@ -311,7 +328,10 @@ export const useChannelsData = () => {
|
|||||||
if (success) {
|
if (success) {
|
||||||
const { items, total, type_counts } = data;
|
const { items, total, type_counts } = data;
|
||||||
if (type_counts) {
|
if (type_counts) {
|
||||||
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
const sumAll = Object.values(type_counts).reduce(
|
||||||
|
(acc, v) => acc + v,
|
||||||
|
0,
|
||||||
|
);
|
||||||
setTypeCounts({ ...type_counts, all: sumAll });
|
setTypeCounts({ ...type_counts, all: sumAll });
|
||||||
}
|
}
|
||||||
setChannelFormat(items, enableTagMode);
|
setChannelFormat(items, enableTagMode);
|
||||||
@@ -335,11 +355,18 @@ export const useChannelsData = () => {
|
|||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||||
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
|
await loadChannels(
|
||||||
|
page,
|
||||||
|
pageSz,
|
||||||
|
sortFlag,
|
||||||
|
enableTagMode,
|
||||||
|
typeKey,
|
||||||
|
statusF,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
|
||||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||||
const res = await API.get(
|
const res = await API.get(
|
||||||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
|
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
|
||||||
@@ -347,7 +374,10 @@ export const useChannelsData = () => {
|
|||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
const { items = [], total = 0, type_counts = {} } = data;
|
const { items = [], total = 0, type_counts = {} } = data;
|
||||||
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
const sumAll = Object.values(type_counts).reduce(
|
||||||
|
(acc, v) => acc + v,
|
||||||
|
0,
|
||||||
|
);
|
||||||
setTypeCounts({ ...type_counts, all: sumAll });
|
setTypeCounts({ ...type_counts, all: sumAll });
|
||||||
setChannelFormat(items, enableTagMode);
|
setChannelFormat(items, enableTagMode);
|
||||||
setChannelCount(total);
|
setChannelCount(total);
|
||||||
@@ -366,7 +396,14 @@ export const useChannelsData = () => {
|
|||||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||||
await loadChannels(page, pageSize, idSort, enableTagMode);
|
await loadChannels(page, pageSize, idSort, enableTagMode);
|
||||||
} else {
|
} else {
|
||||||
await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
await searchChannels(
|
||||||
|
enableTagMode,
|
||||||
|
activeTypeKey,
|
||||||
|
statusFilter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
idSort,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -452,9 +489,16 @@ export const useChannelsData = () => {
|
|||||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
|
loadChannels(page, pageSize, idSort, enableTagMode).then(() => {});
|
||||||
} else {
|
} else {
|
||||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
searchChannels(
|
||||||
|
enableTagMode,
|
||||||
|
activeTypeKey,
|
||||||
|
statusFilter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
idSort,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -470,7 +514,14 @@ export const useChannelsData = () => {
|
|||||||
showError(reason);
|
showError(reason);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
|
searchChannels(
|
||||||
|
enableTagMode,
|
||||||
|
activeTypeKey,
|
||||||
|
statusFilter,
|
||||||
|
1,
|
||||||
|
size,
|
||||||
|
idSort,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -501,7 +552,10 @@ export const useChannelsData = () => {
|
|||||||
showError(res?.data?.message || t('渠道复制失败'));
|
showError(res?.data?.message || t('渠道复制失败'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
|
showError(
|
||||||
|
t('渠道复制失败: ') +
|
||||||
|
(error?.response?.data?.message || error?.message || error),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -540,7 +594,11 @@ export const useChannelsData = () => {
|
|||||||
data.priority = parseInt(data.priority);
|
data.priority = parseInt(data.priority);
|
||||||
break;
|
break;
|
||||||
case 'weight':
|
case 'weight':
|
||||||
if (data.weight === undefined || data.weight < 0 || data.weight === '') {
|
if (
|
||||||
|
data.weight === undefined ||
|
||||||
|
data.weight < 0 ||
|
||||||
|
data.weight === ''
|
||||||
|
) {
|
||||||
showInfo('权重必须是非负整数!');
|
showInfo('权重必须是非负整数!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -683,7 +741,11 @@ export const useChannelsData = () => {
|
|||||||
const res = await API.post(`/api/channel/fix`);
|
const res = await API.post(`/api/channel/fix`);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
|
showSuccess(
|
||||||
|
t('已修复 ${success} 个通道,失败 ${fails} 个通道。')
|
||||||
|
.replace('${success}', data.success)
|
||||||
|
.replace('${fails}', data.fails),
|
||||||
|
);
|
||||||
await refresh();
|
await refresh();
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
@@ -700,10 +762,12 @@ export const useChannelsData = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加到正在测试的模型集合
|
// 添加到正在测试的模型集合
|
||||||
setTestingModels(prev => new Set([...prev, model]));
|
setTestingModels((prev) => new Set([...prev, model]));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
|
const res = await API.get(
|
||||||
|
`/api/channel/test/${record.id}?model=${model}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 检查是否在请求期间被停止
|
// 检查是否在请求期间被停止
|
||||||
if (shouldStopBatchTestingRef.current && isBatchTesting) {
|
if (shouldStopBatchTestingRef.current && isBatchTesting) {
|
||||||
@@ -713,14 +777,14 @@ export const useChannelsData = () => {
|
|||||||
const { success, message, time } = res.data;
|
const { success, message, time } = res.data;
|
||||||
|
|
||||||
// 更新测试结果
|
// 更新测试结果
|
||||||
setModelTestResults(prev => ({
|
setModelTestResults((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[testKey]: {
|
[testKey]: {
|
||||||
success,
|
success,
|
||||||
message,
|
message,
|
||||||
time: time || 0,
|
time: time || 0,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now(),
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -738,7 +802,9 @@ export const useChannelsData = () => {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
showInfo(
|
showInfo(
|
||||||
t('通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。')
|
t(
|
||||||
|
'通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。',
|
||||||
|
)
|
||||||
.replace('${name}', record.name)
|
.replace('${name}', record.name)
|
||||||
.replace('${model}', model)
|
.replace('${model}', model)
|
||||||
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
||||||
@@ -750,19 +816,19 @@ export const useChannelsData = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 处理网络错误
|
// 处理网络错误
|
||||||
const testKey = `${record.id}-${model}`;
|
const testKey = `${record.id}-${model}`;
|
||||||
setModelTestResults(prev => ({
|
setModelTestResults((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[testKey]: {
|
[testKey]: {
|
||||||
success: false,
|
success: false,
|
||||||
message: error.message || t('网络错误'),
|
message: error.message || t('网络错误'),
|
||||||
time: 0,
|
time: 0,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now(),
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
|
showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
|
||||||
} finally {
|
} finally {
|
||||||
// 从正在测试的模型集合中移除
|
// 从正在测试的模型集合中移除
|
||||||
setTestingModels(prev => {
|
setTestingModels((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(model);
|
newSet.delete(model);
|
||||||
return newSet;
|
return newSet;
|
||||||
@@ -777,9 +843,11 @@ export const useChannelsData = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = currentTestChannel.models.split(',').filter(model =>
|
const models = currentTestChannel.models
|
||||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
.split(',')
|
||||||
);
|
.filter((model) =>
|
||||||
|
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
showError(t('没有找到匹配的模型'));
|
showError(t('没有找到匹配的模型'));
|
||||||
@@ -790,9 +858,9 @@ export const useChannelsData = () => {
|
|||||||
shouldStopBatchTestingRef.current = false; // 重置停止标志
|
shouldStopBatchTestingRef.current = false; // 重置停止标志
|
||||||
|
|
||||||
// 清空该渠道之前的测试结果
|
// 清空该渠道之前的测试结果
|
||||||
setModelTestResults(prev => {
|
setModelTestResults((prev) => {
|
||||||
const newResults = { ...prev };
|
const newResults = { ...prev };
|
||||||
models.forEach(model => {
|
models.forEach((model) => {
|
||||||
const testKey = `${currentTestChannel.id}-${model}`;
|
const testKey = `${currentTestChannel.id}-${model}`;
|
||||||
delete newResults[testKey];
|
delete newResults[testKey];
|
||||||
});
|
});
|
||||||
@@ -800,7 +868,12 @@ export const useChannelsData = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length));
|
showInfo(
|
||||||
|
t('开始批量测试 ${count} 个模型,已清空上次结果...').replace(
|
||||||
|
'${count}',
|
||||||
|
models.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// 提高并发数量以加快测试速度,参考旧版的并发限制
|
// 提高并发数量以加快测试速度,参考旧版的并发限制
|
||||||
const concurrencyLimit = 5;
|
const concurrencyLimit = 5;
|
||||||
@@ -814,13 +887,16 @@ export const useChannelsData = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const batch = models.slice(i, i + concurrencyLimit);
|
const batch = models.slice(i, i + concurrencyLimit);
|
||||||
showInfo(t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
|
showInfo(
|
||||||
.replace('${current}', i + 1)
|
t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
|
||||||
.replace('${end}', Math.min(i + concurrencyLimit, models.length))
|
.replace('${current}', i + 1)
|
||||||
.replace('${total}', models.length)
|
.replace('${end}', Math.min(i + concurrencyLimit, models.length))
|
||||||
|
.replace('${total}', models.length),
|
||||||
);
|
);
|
||||||
|
|
||||||
const batchPromises = batch.map(model => testChannel(currentTestChannel, model));
|
const batchPromises = batch.map((model) =>
|
||||||
|
testChannel(currentTestChannel, model),
|
||||||
|
);
|
||||||
const batchResults = await Promise.allSettled(batchPromises);
|
const batchResults = await Promise.allSettled(batchPromises);
|
||||||
results.push(...batchResults);
|
results.push(...batchResults);
|
||||||
|
|
||||||
@@ -832,20 +908,20 @@ export const useChannelsData = () => {
|
|||||||
|
|
||||||
// 短暂延迟避免过于频繁的请求
|
// 短暂延迟避免过于频繁的请求
|
||||||
if (i + concurrencyLimit < models.length) {
|
if (i + concurrencyLimit < models.length) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldStopBatchTestingRef.current) {
|
if (!shouldStopBatchTestingRef.current) {
|
||||||
// 等待一小段时间确保所有结果都已更新
|
// 等待一小段时间确保所有结果都已更新
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
// 使用当前状态重新计算结果统计
|
// 使用当前状态重新计算结果统计
|
||||||
setModelTestResults(currentResults => {
|
setModelTestResults((currentResults) => {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
|
||||||
models.forEach(model => {
|
models.forEach((model) => {
|
||||||
const testKey = `${currentTestChannel.id}-${model}`;
|
const testKey = `${currentTestChannel.id}-${model}`;
|
||||||
const result = currentResults[testKey];
|
const result = currentResults[testKey];
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
@@ -857,10 +933,11 @@ export const useChannelsData = () => {
|
|||||||
|
|
||||||
// 显示完成消息
|
// 显示完成消息
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showSuccess(t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
|
showSuccess(
|
||||||
.replace('${success}', successCount)
|
t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
|
||||||
.replace('${fail}', failCount)
|
.replace('${success}', successCount)
|
||||||
.replace('${total}', models.length)
|
.replace('${fail}', failCount)
|
||||||
|
.replace('${total}', models.length),
|
||||||
);
|
);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
@@ -1045,4 +1122,4 @@ export const useChannelsData = () => {
|
|||||||
setCompactMode,
|
setCompactMode,
|
||||||
setActivePage,
|
setActivePage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const useSidebar = () => {
|
|||||||
|
|
||||||
// 刷新用户配置的方法(供外部调用)
|
// 刷新用户配置的方法(供外部调用)
|
||||||
const refreshUserConfig = async () => {
|
const refreshUserConfig = async () => {
|
||||||
if (Object.keys(adminConfig).length > 0) {
|
if (Object.keys(adminConfig).length > 0) {
|
||||||
await loadUserConfig();
|
await loadUserConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +155,10 @@ export const useSidebar = () => {
|
|||||||
sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
sidebarEventTarget.removeEventListener(
|
||||||
|
SIDEBAR_REFRESH_EVENT,
|
||||||
|
handleRefresh,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, [adminConfig]);
|
}, [adminConfig]);
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,29 @@ export const useModelPricingData = () => {
|
|||||||
() => statusState?.status?.usd_exchange_rate ?? priceRate,
|
() => statusState?.status?.usd_exchange_rate ?? priceRate,
|
||||||
[statusState, priceRate],
|
[statusState, priceRate],
|
||||||
);
|
);
|
||||||
|
const customExchangeRate = useMemo(
|
||||||
|
() => statusState?.status?.custom_currency_exchange_rate ?? 1,
|
||||||
|
[statusState],
|
||||||
|
);
|
||||||
|
const customCurrencySymbol = useMemo(
|
||||||
|
() => statusState?.status?.custom_currency_symbol ?? '¤',
|
||||||
|
[statusState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 默认货币与站点展示类型同步(USD/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(() => {
|
const filteredModels = useMemo(() => {
|
||||||
let result = models;
|
let result = models;
|
||||||
@@ -156,6 +179,8 @@ export const useModelPricingData = () => {
|
|||||||
|
|
||||||
if (currency === 'CNY') {
|
if (currency === 'CNY') {
|
||||||
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
|
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
|
||||||
|
} else if (currency === 'CUSTOM') {
|
||||||
|
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
|
||||||
}
|
}
|
||||||
return `$${priceInUSD.toFixed(3)}`;
|
return `$${priceInUSD.toFixed(3)}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1773,7 +1773,10 @@
|
|||||||
"自定义模型名称": "Custom model name",
|
"自定义模型名称": "Custom model name",
|
||||||
"启用全部密钥": "Enable all keys",
|
"启用全部密钥": "Enable all keys",
|
||||||
"充值价格显示": "Recharge price",
|
"充值价格显示": "Recharge price",
|
||||||
"美元汇率(非充值汇率,仅用于定价页面换算)": "USD exchange rate (not recharge rate, only used for pricing page conversion)",
|
"自定义货币": "Custom currency",
|
||||||
|
"自定义货币符号": "Custom currency symbol",
|
||||||
|
"例如 €, £, Rp, ₩, ₹...": "For example, €, £, Rp, ₩, ₹...",
|
||||||
|
"站点额度展示类型及汇率": "Site quota display type and exchange rate",
|
||||||
"美元汇率": "USD exchange rate",
|
"美元汇率": "USD exchange rate",
|
||||||
"隐藏操作项": "Hide actions",
|
"隐藏操作项": "Hide actions",
|
||||||
"显示操作项": "Show actions",
|
"显示操作项": "Show actions",
|
||||||
|
|||||||
@@ -1773,7 +1773,10 @@
|
|||||||
"自定义模型名称": "Nom de modèle personnalisé",
|
"自定义模型名称": "Nom de modèle personnalisé",
|
||||||
"启用全部密钥": "Activer toutes les clés",
|
"启用全部密钥": "Activer toutes les clés",
|
||||||
"充值价格显示": "Prix de recharge",
|
"充值价格显示": "Prix de recharge",
|
||||||
"美元汇率(非充值汇率,仅用于定价页面换算)": "Taux de change USD (pas de taux de recharge, uniquement utilisé pour la conversion de la page de tarification)",
|
"站点额度展示类型及汇率": "Type d'affichage du quota du site et taux de change",
|
||||||
|
"自定义货币": "Devise personnalisée",
|
||||||
|
"自定义货币符号": "Symbole de devise personnalisé",
|
||||||
|
"例如 €, £, Rp, ₩, ₹...": "Par exemple, €, £, Rp, ₩, ₹...",
|
||||||
"美元汇率": "Taux de change USD",
|
"美元汇率": "Taux de change USD",
|
||||||
"隐藏操作项": "Masquer les actions",
|
"隐藏操作项": "Masquer les actions",
|
||||||
"显示操作项": "Afficher les actions",
|
"显示操作项": "Afficher les actions",
|
||||||
@@ -2137,4 +2140,4 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"changeLanguage": "Changer de langue"
|
"changeLanguage": "Changer de langue"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||||
import { Banner, Button, Col, Form, Row, Spin, Modal } from '@douyinfe/semi-ui';
|
import {
|
||||||
|
Banner,
|
||||||
|
Button,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
Row,
|
||||||
|
Spin,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
InputGroup,
|
||||||
|
Input,
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
compareObjects,
|
compareObjects,
|
||||||
API,
|
API,
|
||||||
@@ -35,10 +46,12 @@ export default function GeneralSettings(props) {
|
|||||||
const [inputs, setInputs] = useState({
|
const [inputs, setInputs] = useState({
|
||||||
TopUpLink: '',
|
TopUpLink: '',
|
||||||
'general_setting.docs_link': '',
|
'general_setting.docs_link': '',
|
||||||
|
'general_setting.quota_display_type': 'USD',
|
||||||
|
'general_setting.custom_currency_symbol': '¤',
|
||||||
|
'general_setting.custom_currency_exchange_rate': '',
|
||||||
QuotaPerUnit: '',
|
QuotaPerUnit: '',
|
||||||
RetryTimes: '',
|
RetryTimes: '',
|
||||||
USDExchangeRate: '',
|
USDExchangeRate: '',
|
||||||
DisplayInCurrencyEnabled: false,
|
|
||||||
DisplayTokenStatEnabled: false,
|
DisplayTokenStatEnabled: false,
|
||||||
DefaultCollapseSidebar: false,
|
DefaultCollapseSidebar: false,
|
||||||
DemoSiteEnabled: false,
|
DemoSiteEnabled: false,
|
||||||
@@ -88,6 +101,30 @@ export default function GeneralSettings(props) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算展示在输入框中的“1 USD = X <currency>”中的 X
|
||||||
|
const combinedRate = useMemo(() => {
|
||||||
|
const type = inputs['general_setting.quota_display_type'];
|
||||||
|
if (type === 'USD') return '1';
|
||||||
|
if (type === 'CNY') return String(inputs['USDExchangeRate'] || '');
|
||||||
|
if (type === 'TOKENS') return String(inputs['QuotaPerUnit'] || '');
|
||||||
|
if (type === 'CUSTOM')
|
||||||
|
return String(
|
||||||
|
inputs['general_setting.custom_currency_exchange_rate'] || '',
|
||||||
|
);
|
||||||
|
return '';
|
||||||
|
}, [inputs]);
|
||||||
|
|
||||||
|
const onCombinedRateChange = (val) => {
|
||||||
|
const type = inputs['general_setting.quota_display_type'];
|
||||||
|
if (type === 'CNY') {
|
||||||
|
handleFieldChange('USDExchangeRate')(val);
|
||||||
|
} else if (type === 'TOKENS') {
|
||||||
|
handleFieldChange('QuotaPerUnit')(val);
|
||||||
|
} else if (type === 'CUSTOM') {
|
||||||
|
handleFieldChange('general_setting.custom_currency_exchange_rate')(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentInputs = {};
|
const currentInputs = {};
|
||||||
for (let key in props.options) {
|
for (let key in props.options) {
|
||||||
@@ -95,6 +132,28 @@ export default function GeneralSettings(props) {
|
|||||||
currentInputs[key] = props.options[key];
|
currentInputs[key] = props.options[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 若旧字段存在且新字段缺失,则做一次兜底映射
|
||||||
|
if (
|
||||||
|
currentInputs['general_setting.quota_display_type'] === undefined &&
|
||||||
|
props.options?.DisplayInCurrencyEnabled !== undefined
|
||||||
|
) {
|
||||||
|
currentInputs['general_setting.quota_display_type'] = props.options
|
||||||
|
.DisplayInCurrencyEnabled
|
||||||
|
? 'USD'
|
||||||
|
: 'TOKENS';
|
||||||
|
}
|
||||||
|
// 回填自定义货币相关字段(如果后端已存在)
|
||||||
|
if (props.options['general_setting.custom_currency_symbol'] !== undefined) {
|
||||||
|
currentInputs['general_setting.custom_currency_symbol'] =
|
||||||
|
props.options['general_setting.custom_currency_symbol'];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.options['general_setting.custom_currency_exchange_rate'] !==
|
||||||
|
undefined
|
||||||
|
) {
|
||||||
|
currentInputs['general_setting.custom_currency_exchange_rate'] =
|
||||||
|
props.options['general_setting.custom_currency_exchange_rate'];
|
||||||
|
}
|
||||||
setInputs(currentInputs);
|
setInputs(currentInputs);
|
||||||
setInputsRow(structuredClone(currentInputs));
|
setInputsRow(structuredClone(currentInputs));
|
||||||
refForm.current.setValues(currentInputs);
|
refForm.current.setValues(currentInputs);
|
||||||
@@ -130,29 +189,7 @@ export default function GeneralSettings(props) {
|
|||||||
showClear
|
showClear
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
{inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && (
|
{/* 单位美元额度已合入汇率组合控件(TOKENS 模式下编辑),不再单独展示 */}
|
||||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
|
||||||
<Form.Input
|
|
||||||
field={'QuotaPerUnit'}
|
|
||||||
label={t('单位美元额度')}
|
|
||||||
initValue={''}
|
|
||||||
placeholder={t('一单位货币能兑换的额度')}
|
|
||||||
onChange={handleFieldChange('QuotaPerUnit')}
|
|
||||||
showClear
|
|
||||||
onClick={() => setShowQuotaWarning(true)}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
|
||||||
<Form.Input
|
|
||||||
field={'USDExchangeRate'}
|
|
||||||
label={t('美元汇率(非充值汇率,仅用于定价页面换算)')}
|
|
||||||
initValue={''}
|
|
||||||
placeholder={t('美元汇率')}
|
|
||||||
onChange={handleFieldChange('USDExchangeRate')}
|
|
||||||
showClear
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
field={'RetryTimes'}
|
field={'RetryTimes'}
|
||||||
@@ -163,18 +200,51 @@ export default function GeneralSettings(props) {
|
|||||||
showClear
|
showClear
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||||
<Form.Switch
|
<Form.Slot label={t('站点额度展示类型及汇率')}>
|
||||||
field={'DisplayInCurrencyEnabled'}
|
<InputGroup style={{ width: '100%' }}>
|
||||||
label={t('以货币形式显示额度')}
|
<Input
|
||||||
size='default'
|
prefix={'1 USD = '}
|
||||||
checkedText='|'
|
style={{ width: '50%' }}
|
||||||
uncheckedText='〇'
|
value={combinedRate}
|
||||||
onChange={handleFieldChange('DisplayInCurrencyEnabled')}
|
onChange={onCombinedRateChange}
|
||||||
|
disabled={
|
||||||
|
inputs['general_setting.quota_display_type'] === 'USD'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
style={{ width: '50%' }}
|
||||||
|
value={inputs['general_setting.quota_display_type']}
|
||||||
|
onChange={handleFieldChange(
|
||||||
|
'general_setting.quota_display_type',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select.Option value='USD'>USD ($)</Select.Option>
|
||||||
|
<Select.Option value='CNY'>CNY (¥)</Select.Option>
|
||||||
|
<Select.Option value='TOKENS'>Tokens</Select.Option>
|
||||||
|
<Select.Option value='CUSTOM'>
|
||||||
|
{t('自定义货币')}
|
||||||
|
</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</InputGroup>
|
||||||
|
</Form.Slot>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||||
|
<Form.Input
|
||||||
|
field={'general_setting.custom_currency_symbol'}
|
||||||
|
label={t('自定义货币符号')}
|
||||||
|
placeholder={t('例如 €, £, Rp, ₩, ₹...')}
|
||||||
|
onChange={handleFieldChange(
|
||||||
|
'general_setting.custom_currency_symbol',
|
||||||
|
)}
|
||||||
|
showClear
|
||||||
|
disabled={
|
||||||
|
inputs['general_setting.quota_display_type'] !== 'CUSTOM'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||||
<Form.Switch
|
<Form.Switch
|
||||||
field={'DisplayTokenStatEnabled'}
|
field={'DisplayTokenStatEnabled'}
|
||||||
@@ -195,8 +265,6 @@ export default function GeneralSettings(props) {
|
|||||||
onChange={handleFieldChange('DefaultCollapseSidebar')}
|
onChange={handleFieldChange('DefaultCollapseSidebar')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||||
<Form.Switch
|
<Form.Switch
|
||||||
field={'DemoSiteEnabled'}
|
field={'DemoSiteEnabled'}
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ export default function SettingsMonitoring(props) {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setInputs({
|
setInputs({
|
||||||
...inputs,
|
...inputs,
|
||||||
'monitor_setting.auto_test_channel_minutes': parseInt(value),
|
'monitor_setting.auto_test_channel_minutes':
|
||||||
|
parseInt(value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -118,14 +118,20 @@ export default function SettingsPaymentGateway(props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') {
|
if (
|
||||||
|
originInputs['AmountOptions'] !== inputs.AmountOptions &&
|
||||||
|
inputs.AmountOptions.trim() !== ''
|
||||||
|
) {
|
||||||
if (!verifyJSON(inputs.AmountOptions)) {
|
if (!verifyJSON(inputs.AmountOptions)) {
|
||||||
showError(t('自定义充值数量选项不是合法的 JSON 数组'));
|
showError(t('自定义充值数量选项不是合法的 JSON 数组'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') {
|
if (
|
||||||
|
originInputs['AmountDiscount'] !== inputs.AmountDiscount &&
|
||||||
|
inputs.AmountDiscount.trim() !== ''
|
||||||
|
) {
|
||||||
if (!verifyJSON(inputs.AmountDiscount)) {
|
if (!verifyJSON(inputs.AmountDiscount)) {
|
||||||
showError(t('充值金额折扣配置不是合法的 JSON 对象'));
|
showError(t('充值金额折扣配置不是合法的 JSON 对象'));
|
||||||
return;
|
return;
|
||||||
@@ -163,10 +169,16 @@ export default function SettingsPaymentGateway(props) {
|
|||||||
options.push({ key: 'PayMethods', value: inputs.PayMethods });
|
options.push({ key: 'PayMethods', value: inputs.PayMethods });
|
||||||
}
|
}
|
||||||
if (originInputs['AmountOptions'] !== inputs.AmountOptions) {
|
if (originInputs['AmountOptions'] !== inputs.AmountOptions) {
|
||||||
options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions });
|
options.push({
|
||||||
|
key: 'payment_setting.amount_options',
|
||||||
|
value: inputs.AmountOptions,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) {
|
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) {
|
||||||
options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount });
|
options.push({
|
||||||
|
key: 'payment_setting.amount_discount',
|
||||||
|
value: inputs.AmountDiscount,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 发送请求
|
||||||
@@ -273,7 +285,7 @@ export default function SettingsPaymentGateway(props) {
|
|||||||
placeholder={t('为一个 JSON 文本')}
|
placeholder={t('为一个 JSON 文本')}
|
||||||
autosize
|
autosize
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Row
|
<Row
|
||||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
@@ -282,13 +294,17 @@ export default function SettingsPaymentGateway(props) {
|
|||||||
<Form.TextArea
|
<Form.TextArea
|
||||||
field='AmountOptions'
|
field='AmountOptions'
|
||||||
label={t('自定义充值数量选项')}
|
label={t('自定义充值数量选项')}
|
||||||
placeholder={t('为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]')}
|
placeholder={t(
|
||||||
|
'为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]',
|
||||||
|
)}
|
||||||
autosize
|
autosize
|
||||||
extraText={t('设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]')}
|
extraText={t(
|
||||||
|
'设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row
|
<Row
|
||||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
@@ -297,13 +313,17 @@ export default function SettingsPaymentGateway(props) {
|
|||||||
<Form.TextArea
|
<Form.TextArea
|
||||||
field='AmountDiscount'
|
field='AmountDiscount'
|
||||||
label={t('充值金额折扣配置')}
|
label={t('充值金额折扣配置')}
|
||||||
placeholder={t('为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
|
placeholder={t(
|
||||||
|
'为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
|
||||||
|
)}
|
||||||
autosize
|
autosize
|
||||||
extraText={t('设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
|
extraText={t(
|
||||||
|
'设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
|
<Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
|
||||||
</Form.Section>
|
</Form.Section>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -226,8 +226,12 @@ export default function ModelRatioSettings(props) {
|
|||||||
<Col xs={24} sm={16}>
|
<Col xs={24} sm={16}>
|
||||||
<Form.TextArea
|
<Form.TextArea
|
||||||
label={t('图片输入倍率(仅部分模型支持该计费)')}
|
label={t('图片输入倍率(仅部分模型支持该计费)')}
|
||||||
extraText={t('图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费')}
|
extraText={t(
|
||||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}')}
|
'图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费',
|
||||||
|
)}
|
||||||
|
placeholder={t(
|
||||||
|
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}',
|
||||||
|
)}
|
||||||
field={'ImageRatio'}
|
field={'ImageRatio'}
|
||||||
autosize={{ minRows: 6, maxRows: 12 }}
|
autosize={{ minRows: 6, maxRows: 12 }}
|
||||||
trigger='blur'
|
trigger='blur'
|
||||||
@@ -238,9 +242,7 @@ export default function ModelRatioSettings(props) {
|
|||||||
message: '不是合法的 JSON 字符串',
|
message: '不是合法的 JSON 字符串',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onChange={(value) =>
|
onChange={(value) => setInputs({ ...inputs, ImageRatio: value })}
|
||||||
setInputs({ ...inputs, ImageRatio: value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -249,7 +251,9 @@ export default function ModelRatioSettings(props) {
|
|||||||
<Form.TextArea
|
<Form.TextArea
|
||||||
label={t('音频倍率(仅部分模型支持该计费)')}
|
label={t('音频倍率(仅部分模型支持该计费)')}
|
||||||
extraText={t('音频输入相关的倍率设置,键为模型名称,值为倍率')}
|
extraText={t('音频输入相关的倍率设置,键为模型名称,值为倍率')}
|
||||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}')}
|
placeholder={t(
|
||||||
|
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}',
|
||||||
|
)}
|
||||||
field={'AudioRatio'}
|
field={'AudioRatio'}
|
||||||
autosize={{ minRows: 6, maxRows: 12 }}
|
autosize={{ minRows: 6, maxRows: 12 }}
|
||||||
trigger='blur'
|
trigger='blur'
|
||||||
@@ -260,9 +264,7 @@ export default function ModelRatioSettings(props) {
|
|||||||
message: '不是合法的 JSON 字符串',
|
message: '不是合法的 JSON 字符串',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onChange={(value) =>
|
onChange={(value) => setInputs({ ...inputs, AudioRatio: value })}
|
||||||
setInputs({ ...inputs, AudioRatio: value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -270,8 +272,12 @@ export default function ModelRatioSettings(props) {
|
|||||||
<Col xs={24} sm={16}>
|
<Col xs={24} sm={16}>
|
||||||
<Form.TextArea
|
<Form.TextArea
|
||||||
label={t('音频补全倍率(仅部分模型支持该计费)')}
|
label={t('音频补全倍率(仅部分模型支持该计费)')}
|
||||||
extraText={t('音频输出补全相关的倍率设置,键为模型名称,值为倍率')}
|
extraText={t(
|
||||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}')}
|
'音频输出补全相关的倍率设置,键为模型名称,值为倍率',
|
||||||
|
)}
|
||||||
|
placeholder={t(
|
||||||
|
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}',
|
||||||
|
)}
|
||||||
field={'AudioCompletionRatio'}
|
field={'AudioCompletionRatio'}
|
||||||
autosize={{ minRows: 6, maxRows: 12 }}
|
autosize={{ minRows: 6, maxRows: 12 }}
|
||||||
trigger='blur'
|
trigger='blur'
|
||||||
|
|||||||
Reference in New Issue
Block a user