feat(notify): turn each kind of notification on or off (#7868)

SMTP settings now only decide whether mail can be sent. Verification
codes, password reset, email binding and account security mails depend
on SMTP alone, and no notification switch affects them.

Admins get a site-wide low balance reminder switch, on by default, next
to the SMTP settings. The default threshold is entered as an amount.

Users choose which notifications they receive. Low balance reminders are
on for everyone by default. Admins can also turn channel notifications
and upstream model update notices on or off. The root user receives
channel notifications until it turns them off. Other admins receive them
after they turn them on.

A low balance reminder is sent once, when a charge takes the balance
below the threshold. A top-up above the threshold arms it again.

Channel notifications reach every admin who has them on, in that admin's
language. Email binding codes and account security mails follow the
account's language.

Saving notification settings no longer clears the language, the sidebar
layout or the billing preference.

Existing settings keep their behavior, and old clients that do not send
the new fields keep the stored switches. Webhook, Bark and Gotify
payloads are unchanged.
This commit is contained in:
Calcium-Ion
2026-10-17 22:17:59 +08:00
committed by GitHub
parent 5c31c1fa9e
commit 6461f084ff
47 changed files with 1388 additions and 463 deletions
+1
View File
@@ -133,6 +133,7 @@ var QuotaForInvitee = 0
var ChannelDisableThreshold = 5.0
var AutomaticDisableChannelEnabled = false
var AutomaticEnableChannelEnabled = false
var QuotaRemindEnabled = true
var QuotaRemindThreshold = 1000
// PreConsumedQuota is retained for old option clients; token reservations now
+2 -3
View File
@@ -531,8 +531,6 @@
"failed to marshal setting: channel_id=%d, error=%v": "序列化渠道设置失败: channel_id=%d, error=%v",
"failed to migrate options primary key: %s": "为 options 表补主键失败: %s",
"failed to migrate retired frontend options: %s": "迁移旧前端的配置项失败: %s",
"failed to notify root user: %s": "通知 root 用户失败: %s",
"failed to notify user %d for upstream model update: %s": "向用户 %d 发送上游模型更新通知失败: %s",
"failed to open log file": "打开日志文件失败",
"failed to parse %s: %s, using default value: %d": "环境变量 %s 解析失败: %s,已使用默认值: %d",
"failed to parse %s: %s, using default value: %t": "环境变量 %s 解析失败: %s,已使用默认值: %t",
@@ -543,10 +541,10 @@
"failed to populate newly created user session cache: %s": "新建登录会话后写入缓存失败: %s",
"failed to publish auth tombstone after hard deleting user %d: %v": "永久删除用户 %d 后发布登录失效通知失败: %v",
"failed to publish websocket close event: %v": "向其他节点广播 WebSocket 关闭事件失败: %v",
"failed to query administrators to notify: %s": "查询需要通知的管理员失败: %s",
"failed to query channels: %s": "查询渠道失败: %s",
"failed to query log stat: %s": "查询日志统计失败: %s",
"failed to query rpm/tpm stat: %s": "查询 rpm/tpm 统计失败: %s",
"failed to query upstream update notification users: %s": "查询接收上游更新通知的用户失败: %s",
"failed to read TTS response body: %v": "读取 TTS 响应体失败: %v",
"failed to record log: %s": "记录日志失败: %s",
"failed to record task billing log: %s": "记录任务计费日志失败: %s",
@@ -564,6 +562,7 @@
"failed to search topups: %s": "搜索充值记录失败: %s",
"failed to search user logs: %s": "搜索用户日志失败: %s",
"failed to send email to %s: %v": "向 %s 发送邮件失败: %v",
"failed to send notice %s to user %d: %s": "向用户 %[2]d 发送通知 %[1]s 失败: %[3]s",
"failed to send password reset email to %s: %s": "向 %s 发送密码重置邮件失败: %s",
"failed to send quota notify to user %d: %s": "向用户 %d 发送额度通知失败: %s",
"failed to send request: %s": "发送请求失败: %s",
+6 -4
View File
@@ -1090,8 +1090,9 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse
// stops promptly. mode selects the channel set: an empty mode falls back to the
// configured monitor ChannelTestMode (scheduled behavior), while a manual
// trigger passes ChannelTestModeScheduledAll to test every channel. When notify
// is set the root user is notified on completion. Cross-instance execution is
// guarded by the system task per-type lock, so no process-local guard is needed.
// is set, the administrators who receive channel notices are notified on
// completion. Cross-instance execution is guarded by the system task per-type
// lock, so no process-local guard is needed.
func runChannelTestTask(ctx context.Context, mode string, notify bool, report func(processed, total int)) (channelTestSummary, error) {
testUserID, err := resolveChannelTestUserID(nil)
if err != nil {
@@ -1109,8 +1110,9 @@ func runChannelTestTask(ctx context.Context, mode string, notify bool, report fu
concurrency := operation_setting.GetMonitorSetting().ChannelTestConcurrency
summary := performChannelTests(ctx, selected, testUserID, allowDisable, concurrency, report)
if notify && (ctx == nil || ctx.Err() == nil) {
lang := service.RootUserLanguage()
service.NotifyRootUser(dto.NotifyTypeChannelTest, i18n.Translate(lang, i18n.MsgChannelTestCompletedSubject), i18n.Translate(lang, i18n.MsgChannelTestCompletedContent))
service.NotifyChannelWatchers(dto.NotifyTypeChannelTest, func(lang string) (string, string) {
return i18n.Translate(lang, i18n.MsgChannelTestCompletedSubject), i18n.Translate(lang, i18n.MsgChannelTestCompletedContent)
})
}
return summary, nil
}
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
@@ -547,7 +548,7 @@ func UnbindCustomOAuth(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
notificationFailed = service.NotifyAccountSecurityChange(user.Email, "Login account unlinked") != nil
notificationFailed = service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginUnlinked) != nil
common.ApiSuccessT(c, "Unbound successfully", gin.H{"notification_warning": notificationFailed})
}
+6 -4
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
@@ -46,7 +47,7 @@ func EmailBindStart(c *gin.Context) {
if authorization == nil {
return
}
data, err := service.StartEmailBinding(identity, authorization, email)
data, err := service.StartEmailBinding(identity, authorization, email, service.AccountMailLanguage(c, identity.UserID))
if err != nil {
writeSecurityOperationError(c, err)
return
@@ -70,7 +71,7 @@ func EmailBindResend(c *gin.Context) {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
data, err := service.ResendAccountEmailBinding(identity, request.FlowToken)
data, err := service.ResendAccountEmailBinding(identity, request.FlowToken, service.AccountMailLanguage(c, identity.UserID))
if err != nil {
writeSecurityOperationError(c, err)
return
@@ -100,8 +101,9 @@ func EmailBind(c *gin.Context) {
return
}
succeeded = true
notificationFailed = service.NotifyAccountSecurityChange(state.CurrentEmail, "Email address changed") != nil
if err := service.NotifyAccountSecurityChange(state.Email, "Email address confirmed"); err != nil {
lang := service.AccountMailLanguage(c, identity.UserID)
notificationFailed = service.NotifyAccountSecurityChange(lang, state.CurrentEmail, i18n.MsgSecurityEventEmailChanged) != nil
if err := service.NotifyAccountSecurityChange(lang, state.Email, i18n.MsgSecurityEventEmailConfirmed); err != nil {
notificationFailed = true
}
if err := model.PublishUserAuthCache(identity.UserID); err != nil {
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
@@ -159,7 +160,7 @@ func completeVerifiedLoginResponse(c *gin.Context, bundle *service.AuthBundle, m
c.Set("login_verification_method", method)
if migration != nil {
// The legacy GitHub binding was rewritten together with this session.
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: GitHub") != nil
notificationFailed := service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": "GitHub"}) != nil
recordLegacyGitHubBindingAudit(c, user, true, map[string]any{
"legacy_id": migration.LegacyID, "provider_user_id": migration.GitHubID,
"verification_method": method, "notification_failed": notificationFailed,
+2
View File
@@ -125,6 +125,8 @@ func GetStatus(c *gin.Context) {
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
"log_export_enabled": operation_setting.GetLogSetting().UserExportEnabled,
"log_max_range_days": operation_setting.GetLogSetting().MaxRangeDays,
"quota_remind_enabled": common.QuotaRemindEnabled,
"quota_remind_threshold": common.QuotaRemindThreshold,
}
// 根据启用状态注入可选内容
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
@@ -387,7 +388,7 @@ func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provide
writeSecurityOperationError(c, err)
return true, true
}
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: "+provider.GetName()) != nil
notificationFailed := service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": provider.GetName()}) != nil
common.ApiSuccessT(c, "Binding successful", gin.H{"action": "bind", "notification_warning": notificationFailed})
return true, notificationFailed
}
@@ -464,7 +465,7 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
}
if written {
user.GitHubId = oauthUser.ProviderUserID
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: "+provider.GetName()) != nil
notificationFailed := service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": provider.GetName()}) != nil
recordLegacyGitHubBindingAudit(c, user, true, map[string]any{
"legacy_id": legacyID, "provider_user_id": oauthUser.ProviderUserID,
"verified_email_matched": true, "notification_failed": notificationFailed,
+55 -2
View File
@@ -13,6 +13,7 @@ import (
"io"
"net"
"net/http"
"net/http/httptest"
"net/textproto"
"regexp"
"strings"
@@ -21,8 +22,10 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
@@ -651,7 +654,7 @@ func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
flow := startSecurityEmailBinding(t, identity, "[email protected]", service.VerificationMethodPassword)
_, err := service.ResendAccountEmailBinding(identity, flow.FlowToken)
_, err := service.ResendAccountEmailBinding(identity, flow.FlowToken, "")
assert.ErrorIs(t, err, model.ErrEmailBindingResendWait)
_, err = service.FinishEmailBinding(identity, flow.FlowToken, "invalid", "")
assert.ErrorIs(t, err, model.ErrEmailBindingCodeInvalid)
@@ -662,7 +665,7 @@ func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) {
payload, err := common.Marshal(state)
require.NoError(t, err)
require.NoError(t, model.DB.Model(stored).Update("payload", string(payload)).Error)
replacement, err := service.ResendAccountEmailBinding(identity, flow.FlowToken)
replacement, err := service.ResendAccountEmailBinding(identity, flow.FlowToken, "")
require.NoError(t, err)
assert.Equal(t, flow.ExpiresAt, replacement.ExpiresAt)
_, state, err = model.GetEmailBinding(identity, flow.FlowToken)
@@ -682,6 +685,56 @@ func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) {
assert.ErrorIs(t, err, model.ErrEmailBindingLocked)
}
// Email binding codes and account security notices are written in the account
// owner's saved language, else in the language the request states, and quote
// provider names HTML-escaped.
func TestSecurityAccountMailsFollowTheOwnersLanguage(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
mails := func(receiver string) string {
mailbox.mutex.Lock()
defer mailbox.mutex.Unlock()
return strings.Join(mailbox.mail[receiver], "\n")
}
saveLanguage := func(language string) {
setting, err := common.Marshal(dto.UserSetting{Language: language})
require.NoError(t, err)
require.NoError(t, model.DB.Model(user).Update("setting", string(setting)).Error)
}
saveLanguage("zhCN")
require.NoError(t, model.DB.Model(user).Update("email", "[email protected]").Error)
flow := startSecurityEmailBinding(t, identity, "[email protected]", service.VerificationMethodPassword)
request, err := common.Marshal(map[string]string{"flow_token": flow.FlowToken, "new_code": mailbox.code(t, flow.Email), "old_code": mailbox.code(t, "[email protected]")})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/oauth/email/bind", string(request), "", identity, EmailBind)
require.Contains(t, response.Body.String(), `"success":true`)
assert.Contains(t, mails("[email protected]"), "请确认将此邮箱地址绑定到您的账户。")
assert.Contains(t, mails("[email protected]"), "您的账户安全设置有变动:邮箱地址已确认。")
assert.Contains(t, mails("[email protected]"), "您的账户正在申请更换邮箱地址,请确认替换当前邮箱。")
assert.Contains(t, mails("[email protected]"), "您的账户安全设置有变动:邮箱地址已更换。")
for _, tc := range []struct {
name, saved, acceptLanguage, receiver, want string
}{
{"request language when none is saved", "", "zh-TW", "[email protected]", "您的帳戶安全設定有變動:已綁定登入帳號 <b>Corp</b>。"},
{"saved language over the request", "en", "zh-CN", "[email protected]", "Your account security settings have changed: Login account linked: <b>Corp</b>."},
{"default language when neither states one", "", "", "[email protected]", "Your account security settings have changed: Login account linked: <b>Corp</b>."},
} {
t.Run(tc.name, func(t *testing.T) {
saveLanguage(tc.saved)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/oauth/github", nil)
if tc.acceptLanguage != "" {
c.Request.Header.Set("Accept-Language", tc.acceptLanguage)
}
lang := service.AccountMailLanguage(c, user.Id)
require.NoError(t, service.NotifyAccountSecurityChange(lang, tc.receiver, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": "<b>Corp</b>"}))
assert.Contains(t, mails(tc.receiver), tc.want)
})
}
}
func TestSecurityAccountEmailConcurrentClaimsHaveOneOwner(t *testing.T) {
user, firstIdentity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
+33 -33
View File
@@ -896,7 +896,7 @@ func UpdateSelf(c *gin.Context) {
return
}
succeeded = true
notificationFailed = service.NotifyAccountSecurityChange(current.Email, "Password updated") != nil
notificationFailed = service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, current.Id), current.Email, i18n.MsgSecurityEventPasswordUpdated) != nil
if err := model.PublishUserAuthCache(cleanUser.Id); err != nil {
writeSecurityOperationError(c, err)
return
@@ -1318,6 +1318,7 @@ func TopUp(c *gin.Context) {
type UpdateUserSettingRequest struct {
QuotaWarningType string `json:"notify_type"`
QuotaWarningThreshold float64 `json:"quota_warning_threshold"`
QuotaWarningEnabled *bool `json:"quota_warning_enabled,omitempty"`
WebhookUrl string `json:"webhook_url,omitempty"`
WebhookSecret string `json:"webhook_secret,omitempty"`
NotificationEmail string `json:"notification_email,omitempty"`
@@ -1326,6 +1327,7 @@ type UpdateUserSettingRequest struct {
GotifyToken string `json:"gotify_token,omitempty"`
GotifyPriority int `json:"gotify_priority,omitempty"`
UpstreamModelUpdateNotifyEnabled *bool `json:"upstream_model_update_notify_enabled,omitempty"`
ChannelNotifyEnabled *bool `json:"channel_notify_enabled,omitempty"`
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
RecordIpLog bool `json:"record_ip_log"`
}
@@ -1343,9 +1345,9 @@ func UpdateUserSetting(c *gin.Context) {
return
}
// 验证预警阈值
if req.QuotaWarningThreshold <= 0 {
common.ApiErrorT(c, "Warning threshold must be greater than 0")
// 验证预警阈值,0 表示使用系统默认阈值
if req.QuotaWarningThreshold < 0 {
common.ApiErrorT(c, "Reminder threshold cannot be negative")
return
}
@@ -1417,41 +1419,39 @@ func UpdateUserSetting(c *gin.Context) {
common.ApiError(c, err)
return
}
existingSettings := user.GetSetting()
upstreamModelUpdateNotifyEnabled := existingSettings.UpstreamModelUpdateNotifyEnabled
if user.Role >= common.RoleAdminUser && req.UpstreamModelUpdateNotifyEnabled != nil {
upstreamModelUpdateNotifyEnabled = *req.UpstreamModelUpdateNotifyEnabled
// 从已保存的设置出发,语言、侧边栏、扣费策略等不由此表单管理的字段保持不变;
// 请求未携带的开关也保持原值
settings := user.GetSetting()
settings.NotifyType = req.QuotaWarningType
settings.QuotaWarningThreshold = req.QuotaWarningThreshold
settings.AcceptUnsetRatioModel = req.AcceptUnsetModelRatioModel
settings.RecordIpLog = req.RecordIpLog
if req.QuotaWarningEnabled != nil {
settings.QuotaWarningEnabled = req.QuotaWarningEnabled
}
// 构建设置
settings := dto.UserSetting{
NotifyType: req.QuotaWarningType,
QuotaWarningThreshold: req.QuotaWarningThreshold,
UpstreamModelUpdateNotifyEnabled: upstreamModelUpdateNotifyEnabled,
AcceptUnsetRatioModel: req.AcceptUnsetModelRatioModel,
RecordIpLog: req.RecordIpLog,
}
// 如果是webhook类型,添加webhook相关设置
if req.QuotaWarningType == dto.NotifyTypeWebhook {
settings.WebhookUrl = req.WebhookUrl
if req.WebhookSecret != "" {
settings.WebhookSecret = req.WebhookSecret
if user.Role >= common.RoleAdminUser {
if req.UpstreamModelUpdateNotifyEnabled != nil {
settings.UpstreamModelUpdateNotifyEnabled = *req.UpstreamModelUpdateNotifyEnabled
}
if req.ChannelNotifyEnabled != nil {
settings.ChannelNotifyEnabled = req.ChannelNotifyEnabled
}
}
// 如果提供了通知邮箱,添加到设置中
if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" {
// 只保留所选通知方式的配置,其他方式的配置清空
settings.WebhookUrl, settings.WebhookSecret = "", ""
settings.NotificationEmail = ""
settings.BarkUrl = ""
settings.GotifyUrl, settings.GotifyToken, settings.GotifyPriority = "", "", 0
switch req.QuotaWarningType {
case dto.NotifyTypeWebhook:
settings.WebhookUrl = req.WebhookUrl
settings.WebhookSecret = req.WebhookSecret
case dto.NotifyTypeEmail:
settings.NotificationEmail = req.NotificationEmail
}
// 如果是Bark类型,添加Bark URL到设置中
if req.QuotaWarningType == dto.NotifyTypeBark {
case dto.NotifyTypeBark:
settings.BarkUrl = req.BarkUrl
}
// 如果是Gotify类型,添加Gotify配置到设置中
if req.QuotaWarningType == dto.NotifyTypeGotify {
case dto.NotifyTypeGotify:
settings.GotifyUrl = req.GotifyUrl
settings.GotifyToken = req.GotifyToken
// Gotify优先级范围0-10,超出范围则使用默认值5
+83
View File
@@ -17,6 +17,7 @@ import (
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
"github.com/alicebob/miniredis/v2"
@@ -641,3 +642,85 @@ func TestManageUserQuotaCacheUsesCommittedIntegerDifference(t *testing.T) {
})
}
}
// Saving notification settings starts from what the user stored: fields the
// form does not manage stay, and switches an older client leaves out keep
// their stored values. Only administrators change the channel and upstream
// switches, and a threshold of 0 means the site default.
func TestUpdateUserSettingKeepsWhatTheRequestLeavesOut(t *testing.T) {
on, off := true, false
stored := dto.UserSetting{
NotifyType: dto.NotifyTypeBark,
BarkUrl: "https://api.day.app/key/{{title}}/{{content}}",
QuotaWarningThreshold: 500000,
QuotaWarningEnabled: &off,
ChannelNotifyEnabled: &on,
UpstreamModelUpdateNotifyEnabled: true,
Language: "zhCN",
SidebarModules: `{"chat":{"enabled":true}}`,
BillingPreference: "wallet_only",
}
kept := func(setting dto.UserSetting) dto.UserSetting {
setting.Language, setting.SidebarModules, setting.BillingPreference = stored.Language, stored.SidebarModules, stored.BillingPreference
return setting
}
switchesChanged := `{"notify_type":"email","quota_warning_threshold":0,"quota_warning_enabled":true,"channel_notify_enabled":false,"upstream_model_update_notify_enabled":false}`
for _, tc := range []struct {
name string
role int
body string
wantSuccess bool
want dto.UserSetting
}{
{
name: "older client without the new switches",
role: common.RoleAdminUser,
body: `{"notify_type":"webhook","quota_warning_threshold":2000,"webhook_url":"https://example.com/hook","webhook_secret":"hook-secret","accept_unset_model_ratio_model":true,"record_ip_log":true}`,
wantSuccess: true,
want: kept(dto.UserSetting{
NotifyType: dto.NotifyTypeWebhook, WebhookUrl: "https://example.com/hook", WebhookSecret: "hook-secret",
QuotaWarningThreshold: 2000, QuotaWarningEnabled: &off, ChannelNotifyEnabled: &on, UpstreamModelUpdateNotifyEnabled: true,
AcceptUnsetRatioModel: true, RecordIpLog: true,
}),
},
{
name: "administrator changes the switches and uses the site default threshold",
role: common.RoleAdminUser,
body: switchesChanged,
wantSuccess: true,
want: kept(dto.UserSetting{NotifyType: dto.NotifyTypeEmail, QuotaWarningEnabled: &on, ChannelNotifyEnabled: &off}),
},
{
name: "user cannot change the administrator switches",
role: common.RoleCommonUser,
body: switchesChanged,
wantSuccess: true,
want: kept(dto.UserSetting{NotifyType: dto.NotifyTypeEmail, QuotaWarningEnabled: &on, ChannelNotifyEnabled: &on, UpstreamModelUpdateNotifyEnabled: true}),
},
{
name: "negative threshold",
role: common.RoleCommonUser,
body: `{"notify_type":"email","quota_warning_threshold":-1}`,
want: stored,
},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupManageUserTestDB(t)
setting, err := common.Marshal(stored)
require.NoError(t, err)
user := model.User{Username: "setting-owner", Role: tc.role, Status: common.UserStatusEnabled, AffCode: "setting-owner-aff", Setting: string(setting)}
require.NoError(t, db.Create(&user).Error)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPut, "/api/user/setting", strings.NewReader(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("id", user.Id)
UpdateUserSetting(c)
assert.Contains(t, recorder.Body.String(), fmt.Sprintf(`"success":%t`, tc.wantSuccess))
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, tc.want, user.GetSetting())
})
}
}
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
@@ -157,7 +158,7 @@ func WeChatBind(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
notificationFailed = service.NotifyAccountSecurityChange(user.Email, "WeChat account linked") != nil
notificationFailed = service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventWeChatLinked) != nil
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
+21 -1
View File
@@ -63,6 +63,25 @@ const (
MsgEmailPasswordResetLink = "email.password_reset.link"
MsgEmailPasswordResetLinkFallback = "email.password_reset.link_fallback"
MsgEmailPasswordResetValidity = "email.password_reset.validity"
MsgEmailBindingSubject = "email.binding.subject"
MsgEmailBindingNewIntro = "email.binding.new_intro"
MsgEmailBindingOldIntro = "email.binding.old_intro"
MsgEmailBindingCode = "email.binding.code"
MsgEmailBindingNewValidity = "email.binding.new_validity"
MsgEmailBindingOldValidity = "email.binding.old_validity"
MsgEmailSecurityNoticeSubject = "email.security_notice.subject"
MsgEmailSecurityNoticeChanged = "email.security_notice.changed"
MsgEmailSecurityNoticeAdvice = "email.security_notice.advice"
MsgSecurityEventPasswordUpdated = "email.security_event.password_updated"
MsgSecurityEventEmailChangeRequested = "email.security_event.email_change_requested"
MsgSecurityEventEmailChanged = "email.security_event.email_changed"
MsgSecurityEventEmailConfirmed = "email.security_event.email_confirmed"
MsgSecurityEventLoginLinked = "email.security_event.login_linked"
MsgSecurityEventLoginUnlinked = "email.security_event.login_unlinked"
MsgSecurityEventWeChatLinked = "email.security_event.wechat_linked"
)
// Low-quota notices, in the recipient's saved language
@@ -74,7 +93,8 @@ const (
MsgNotifyQuotaLowEmail = "notify.quota_low_email"
)
// Channel notices sent to the root user and model update watchers
// Channel notices sent to administrators who receive them and to model update
// watchers
const (
MsgChannelNotifyDisabledSubject = "channel.notify_disabled_subject"
MsgChannelNotifyDisabledContent = "channel.notify_disabled_content"
+17 -1
View File
@@ -49,6 +49,22 @@ email.password_reset.greeting: "Hello, you are resetting your password for {{.Sy
email.password_reset.link: "Click <a href='{{.Link}}'>here</a> to reset your password."
email.password_reset.link_fallback: "If the link does not open, copy the link below into your browser:<br> {{.Link}}"
email.password_reset.validity: "The reset link is valid for {{.Minutes}} minutes. If you did not request it, please ignore this email."
email.binding.subject: "{{.SystemName}} — Confirm your email address"
email.binding.new_intro: "Confirm linking this email address to your account."
email.binding.old_intro: "A change to your account email address was requested. Confirm replacing your current address."
email.binding.code: "Verification code: <strong>{{.Code}}</strong>"
email.binding.new_validity: "This code expires in {{.Minutes}} minutes. If you did not request this change, do not share this code."
email.binding.old_validity: "This code expires in {{.Minutes}} minutes. If you did not request this change, do not share this code and contact your administrator."
email.security_notice.subject: "{{.SystemName}} — Account security notification"
email.security_notice.changed: "Your account security settings have changed: {{.Event}}."
email.security_notice.advice: "If you did not make this change, open your account security settings, revoke other login sessions, and contact your administrator."
email.security_event.password_updated: "Password updated"
email.security_event.email_change_requested: "A change of your email address was requested"
email.security_event.email_changed: "Email address changed"
email.security_event.email_confirmed: "Email address confirmed"
email.security_event.login_linked: "Login account linked: {{.Provider}}"
email.security_event.login_unlinked: "Login account unlinked"
email.security_event.wechat_linked: "WeChat account linked"
# Low-quota notices
notify.quota_low_title: "Your quota is running low"
@@ -57,7 +73,7 @@ notify.quota_low_bark: "{{.Prompt}}, remaining quota: {{.Quota}}. Please top up
notify.quota_low_gotify: "{{.Prompt}}. Your remaining quota is {{.Quota}}. Please top up soon."
notify.quota_low_email: "{{.Prompt}}. Your remaining quota is {{.Quota}}. Top up soon to keep using the service.<br/>Top-up link: <a href='{{.Link}}'>{{.Link}}</a>"
# Channel notices sent to the root user and model update watchers
# Channel notices sent to administrators who receive them and to model update watchers
channel.notify_disabled_subject: "Channel \"{{.Name}}\" (#{{.Id}}) has been disabled"
channel.notify_disabled_content: "Channel \"{{.Name}}\" (#{{.Id}}) has been disabled. Reason: {{.Reason}}"
channel.notify_enabled_subject: "Channel \"{{.Name}}\" (#{{.Id}}) has been enabled"
+17 -1
View File
@@ -50,6 +50,22 @@ email.password_reset.greeting: "您好,你正在进行{{.SystemName}}密码重
email.password_reset.link: "点击 <a href='{{.Link}}'>此处</a> 进行密码重置。"
email.password_reset.link_fallback: "如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> {{.Link}}"
email.password_reset.validity: "重置链接 {{.Minutes}} 分钟内有效,如果不是本人操作,请忽略。"
email.binding.subject: "{{.SystemName}}邮箱绑定确认"
email.binding.new_intro: "请确认将此邮箱地址绑定到您的账户。"
email.binding.old_intro: "您的账户正在申请更换邮箱地址,请确认替换当前邮箱。"
email.binding.code: "验证码:<strong>{{.Code}}</strong>"
email.binding.new_validity: "验证码 {{.Minutes}} 分钟内有效。如果不是您本人操作,请勿把验证码告诉任何人。"
email.binding.old_validity: "验证码 {{.Minutes}} 分钟内有效。如果不是您本人操作,请勿把验证码告诉任何人,并联系管理员。"
email.security_notice.subject: "{{.SystemName}}账户安全提醒"
email.security_notice.changed: "您的账户安全设置有变动:{{.Event}}。"
email.security_notice.advice: "如果不是您本人操作,请打开账户安全设置,退出其他会话,并联系管理员。"
email.security_event.password_updated: "密码已修改"
email.security_event.email_change_requested: "已申请更换邮箱地址"
email.security_event.email_changed: "邮箱地址已更换"
email.security_event.email_confirmed: "邮箱地址已确认"
email.security_event.login_linked: "已绑定登录账号 {{.Provider}}"
email.security_event.login_unlinked: "已解绑登录账号"
email.security_event.wechat_linked: "已绑定微信账号"
# Low-quota notices
notify.quota_low_title: "您的额度即将用尽"
@@ -58,7 +74,7 @@ notify.quota_low_bark: "{{.Prompt}},剩余额度:{{.Quota}},请及时充
notify.quota_low_gotify: "{{.Prompt}},当前剩余额度为 {{.Quota}},请及时充值。"
notify.quota_low_email: "{{.Prompt}},当前剩余额度为 {{.Quota}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{.Link}}'>{{.Link}}</a>"
# Channel notices sent to the root user and model update watchers
# Channel notices sent to administrators who receive them and to model update watchers
channel.notify_disabled_subject: "通道「{{.Name}}」(#{{.Id}})已被禁用"
channel.notify_disabled_content: "通道「{{.Name}}」(#{{.Id}})已被禁用,原因:{{.Reason}}"
channel.notify_enabled_subject: "通道「{{.Name}}」(#{{.Id}})已被启用"
+17 -1
View File
@@ -50,6 +50,22 @@ email.password_reset.greeting: "您好,您正在進行{{.SystemName}}密碼重
email.password_reset.link: "點擊 <a href='{{.Link}}'>此處</a> 進行密碼重設。"
email.password_reset.link_fallback: "如果連結無法點擊,請嘗試點擊下面的連結或將其複製到瀏覽器中開啟:<br> {{.Link}}"
email.password_reset.validity: "重設連結 {{.Minutes}} 分鐘內有效,如果不是本人操作,請忽略。"
email.binding.subject: "{{.SystemName}}電子郵件綁定確認"
email.binding.new_intro: "請確認將此電子郵件地址綁定到您的帳戶。"
email.binding.old_intro: "您的帳戶正在申請更換電子郵件地址,請確認替換目前的電子郵件。"
email.binding.code: "驗證碼:<strong>{{.Code}}</strong>"
email.binding.new_validity: "驗證碼 {{.Minutes}} 分鐘內有效。如果不是您本人操作,請勿將驗證碼告訴任何人。"
email.binding.old_validity: "驗證碼 {{.Minutes}} 分鐘內有效。如果不是您本人操作,請勿將驗證碼告訴任何人,並聯絡管理員。"
email.security_notice.subject: "{{.SystemName}}帳戶安全提醒"
email.security_notice.changed: "您的帳戶安全設定有變動:{{.Event}}。"
email.security_notice.advice: "如果不是您本人操作,請開啟帳戶安全設定,登出其他工作階段,並聯絡管理員。"
email.security_event.password_updated: "密碼已修改"
email.security_event.email_change_requested: "已申請更換電子郵件地址"
email.security_event.email_changed: "電子郵件地址已更換"
email.security_event.email_confirmed: "電子郵件地址已確認"
email.security_event.login_linked: "已綁定登入帳號 {{.Provider}}"
email.security_event.login_unlinked: "已解綁登入帳號"
email.security_event.wechat_linked: "已綁定微信帳號"
# Low-quota notices
notify.quota_low_title: "您的額度即將用盡"
@@ -58,7 +74,7 @@ notify.quota_low_bark: "{{.Prompt}},剩餘額度:{{.Quota}},請及時儲
notify.quota_low_gotify: "{{.Prompt}},目前剩餘額度為 {{.Quota}},請及時儲值。"
notify.quota_low_email: "{{.Prompt}},目前剩餘額度為 {{.Quota}},為了不影響您的使用,請及時儲值。<br/>儲值連結:<a href='{{.Link}}'>{{.Link}}</a>"
# Channel notices sent to the root user and model update watchers
# Channel notices sent to administrators who receive them and to model update watchers
channel.notify_disabled_subject: "管道「{{.Name}}」(#{{.Id}})已被停用"
channel.notify_disabled_content: "管道「{{.Name}}」(#{{.Id}})已被停用,原因:{{.Reason}}"
channel.notify_enabled_subject: "管道「{{.Name}}」(#{{.Id}})已被啟用"
+3
View File
@@ -143,6 +143,7 @@ func InitOptionMap() {
common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser)
common.OptionMap["QuotaForInviter"] = strconv.Itoa(common.QuotaForInviter)
common.OptionMap["QuotaForInvitee"] = strconv.Itoa(common.QuotaForInvitee)
common.OptionMap["QuotaRemindEnabled"] = strconv.FormatBool(common.QuotaRemindEnabled)
common.OptionMap["QuotaRemindThreshold"] = strconv.Itoa(common.QuotaRemindThreshold)
common.OptionMap["PreConsumedQuota"] = strconv.Itoa(common.PreConsumedQuota)
common.OptionMap["ModelRequestRateLimitCount"] = strconv.Itoa(setting.ModelRequestRateLimitCount)
@@ -404,6 +405,8 @@ func updateOptionMap(key string, value string) (err error) {
common.AutomaticDisableChannelEnabled = boolValue
case "AutomaticEnableChannelEnabled":
common.AutomaticEnableChannelEnabled = boolValue
case "QuotaRemindEnabled":
common.QuotaRemindEnabled = boolValue
case "LogConsumeEnabled":
common.LogConsumeEnabled = boolValue
case "DisplayInCurrencyEnabled":
-5
View File
@@ -1425,11 +1425,6 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) {
// return email
//}
func GetRootUser() (user *User) {
DB.Where("role = ?", common.RoleRootUser).First(&user)
return user
}
func UpdateUserLastLoginAt(id int) {
if err := DB.Model(&User{}).Where("id = ?", id).Update("last_login_at", common.GetTimestamp()).Error; err != nil {
common.SysLog(common.LogText("failed to update user last_login_at: %s", err.Error()))
+2
View File
@@ -3,6 +3,7 @@ package dto
type UserSetting struct {
NotifyType string `json:"notify_type,omitempty"` // QuotaWarningType 额度预警类型
QuotaWarningThreshold float64 `json:"quota_warning_threshold,omitempty"` // QuotaWarningThreshold 额度预警阈值
QuotaWarningEnabled *bool `json:"quota_warning_enabled,omitempty"` // 是否接收余额不足提醒,未设置时接收
WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
@@ -11,6 +12,7 @@ type UserSetting struct {
GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌
GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级
UpstreamModelUpdateNotifyEnabled bool `json:"upstream_model_update_notify_enabled,omitempty"` // 是否接收上游模型更新定时检测通知(仅管理员)
ChannelNotifyEnabled *bool `json:"channel_notify_enabled,omitempty"` // 是否接收渠道通知(仅管理员),未设置时 root 接收、其他管理员不接收
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
+19 -3
View File
@@ -5,9 +5,11 @@ import (
"html"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
)
func UnbindAccountOAuth(identity AuthIdentity, providerID int) error {
@@ -29,13 +31,27 @@ func UnbindAccountOAuth(identity AuthIdentity, providerID int) error {
return model.UnbindUserOAuthForSession(identity, providerID, enabled)
}
// AccountMailLanguage is the language of mail about a user's account: the
// language the user saved, else the one the request stated.
func AccountMailLanguage(c *gin.Context, userId int) string {
if lang := model.GetUserLanguage(userId); lang != "" {
return lang
}
return i18n.StatedLang(c)
}
// NotifyAccountSecurityChange never includes credentials or tokens. The caller
// records delivery failure independently from the already-committed change.
func NotifyAccountSecurityChange(email, event string) error {
// eventKey names the change; the notice is written in lang (see
// AccountMailLanguage).
func NotifyAccountSecurityChange(lang, email, eventKey string, eventArgs ...map[string]any) error {
if email == "" {
return nil
}
subject := common.SystemName + " — Account security notification"
content := fmt.Sprintf("<p>Your account security settings have changed: %s.</p><p>If you did not make this change, open your account security settings, revoke other login sessions, and contact your administrator.</p>", html.EscapeString(event))
event := html.EscapeString(i18n.Translate(lang, eventKey, eventArgs...))
subject := i18n.Translate(lang, i18n.MsgEmailSecurityNoticeSubject, map[string]any{"SystemName": common.SystemName})
content := fmt.Sprintf("<p>%s</p><p>%s</p>",
i18n.Translate(lang, i18n.MsgEmailSecurityNoticeChanged, map[string]any{"Event": event}),
i18n.Translate(lang, i18n.MsgEmailSecurityNoticeAdvice))
return common.SendEmail(subject, email, content)
}
+6 -18
View File
@@ -25,16 +25,6 @@ func shouldCloseActiveWebSocketsAfterDisable(channelId int) bool {
return channel.Status != common.ChannelStatusEnabled
}
// RootUserLanguage returns the saved language of the root user, who receives
// channel notices; it is empty when the root user saved none.
func RootUserLanguage() string {
root := model.GetRootUser()
if root == nil {
return ""
}
return root.GetSetting().Language
}
// disable & notify
func DisableChannel(channelError types.ChannelError, reason string) {
common.SysLog(common.LogText("channel %q (#%d) failed, disabling it, reason: %s", channelError.ChannelName, channelError.ChannelId, common.LocalLogPreview(reason)))
@@ -50,22 +40,20 @@ func DisableChannel(channelError types.ChannelError, reason string) {
if shouldCloseActiveWebSocketsAfterDisable(channelError.ChannelId) {
CloseActiveWebSocketsForChannel(channelError.ChannelId, ChannelDisabledCloseReason)
}
lang := RootUserLanguage()
params := map[string]any{"Name": channelError.ChannelName, "Id": channelError.ChannelId, "Reason": reason}
subject := i18n.Translate(lang, i18n.MsgChannelNotifyDisabledSubject, params)
content := i18n.Translate(lang, i18n.MsgChannelNotifyDisabledContent, params)
NotifyRootUser(formatNotifyType(channelError.ChannelId, common.ChannelStatusAutoDisabled), subject, content)
NotifyChannelWatchers(formatNotifyType(channelError.ChannelId, common.ChannelStatusAutoDisabled), func(lang string) (string, string) {
return i18n.Translate(lang, i18n.MsgChannelNotifyDisabledSubject, params), i18n.Translate(lang, i18n.MsgChannelNotifyDisabledContent, params)
})
}
}
func EnableChannel(channelId int, usingKey string, channelName string) {
success := model.UpdateChannelStatus(channelId, usingKey, common.ChannelStatusEnabled, "")
if success {
lang := RootUserLanguage()
params := map[string]any{"Name": channelName, "Id": channelId}
subject := i18n.Translate(lang, i18n.MsgChannelNotifyEnabledSubject, params)
content := i18n.Translate(lang, i18n.MsgChannelNotifyEnabledContent, params)
NotifyRootUser(formatNotifyType(channelId, common.ChannelStatusEnabled), subject, content)
NotifyChannelWatchers(formatNotifyType(channelId, common.ChannelStatusEnabled), func(lang string) (string, string) {
return i18n.Translate(lang, i18n.MsgChannelNotifyEnabledSubject, params), i18n.Translate(lang, i18n.MsgChannelNotifyEnabledContent, params)
})
}
}
+17 -9
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
)
@@ -53,7 +54,7 @@ func ValidateAccountEmail(email string) (string, error) {
return email, nil
}
func StartEmailBinding(identity AuthIdentity, authorization *model.AuthFlowAuthorization, email string) (*EmailBindingData, error) {
func StartEmailBinding(identity AuthIdentity, authorization *model.AuthFlowAuthorization, email string, lang string) (*EmailBindingData, error) {
email, err := ValidateAccountEmail(email)
if err != nil {
return nil, err
@@ -80,19 +81,19 @@ func StartEmailBinding(identity AuthIdentity, authorization *model.AuthFlowAutho
if err != nil {
return nil, err
}
if err := sendEmailBindingCodes(state, codes); err != nil {
if err := sendEmailBindingCodes(lang, state, codes); err != nil {
// A partially delivered pair must not leave a usable change request.
_, _ = model.ConsumeAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID})
return nil, ErrEmailBindingDelivery
}
data := emailBindingData(token, flow, &state)
if state.CurrentEmail != "" && !requireOld {
data.NotificationWarning = NotifyAccountSecurityChange(state.CurrentEmail, "A change of your email address was requested") != nil
data.NotificationWarning = NotifyAccountSecurityChange(lang, state.CurrentEmail, i18n.MsgSecurityEventEmailChangeRequested) != nil
}
return data, nil
}
func ResendAccountEmailBinding(identity AuthIdentity, token string) (*EmailBindingData, error) {
func ResendAccountEmailBinding(identity AuthIdentity, token string, lang string) (*EmailBindingData, error) {
_, state, err := model.GetEmailBinding(identity, token)
if err != nil {
return nil, err
@@ -118,7 +119,7 @@ func ResendAccountEmailBinding(identity AuthIdentity, token string) (*EmailBindi
if err != nil {
return nil, err
}
if err := sendEmailBindingCodes(*state, codes); err != nil {
if err := sendEmailBindingCodes(lang, *state, codes); err != nil {
_, _ = model.ConsumeAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID})
return nil, ErrEmailBindingDelivery
}
@@ -177,14 +178,21 @@ func generateEmailBindingCodes(requireOld bool) (emailBindingCodes, error) {
return codes, nil
}
func sendEmailBindingCodes(state model.EmailBindingState, codes emailBindingCodes) error {
subject := common.SystemName + " — Confirm your email address"
content := fmt.Sprintf("<p>Confirm linking this email address to your account.</p><p>Verification code: <strong>%s</strong></p><p>This code expires in 10 minutes. If you did not request this change, do not share this code.</p>", html.EscapeString(codes.New))
func sendEmailBindingCodes(lang string, state model.EmailBindingState, codes emailBindingCodes) error {
subject := i18n.Translate(lang, i18n.MsgEmailBindingSubject, map[string]any{"SystemName": common.SystemName})
minutes := map[string]any{"Minutes": int(model.EmailBindingTTL / time.Minute)}
content := fmt.Sprintf("<p>%s</p><p>%s</p><p>%s</p>",
i18n.Translate(lang, i18n.MsgEmailBindingNewIntro),
i18n.Translate(lang, i18n.MsgEmailBindingCode, map[string]any{"Code": html.EscapeString(codes.New)}),
i18n.Translate(lang, i18n.MsgEmailBindingNewValidity, minutes))
if err := common.SendEmail(subject, state.Email, content); err != nil {
return err
}
if codes.Old != "" {
content = fmt.Sprintf("<p>A change to your account email address was requested. Confirm replacing your current address.</p><p>Verification code: <strong>%s</strong></p><p>This code expires in 10 minutes. If you did not request this change, do not share this code and contact your administrator.</p>", html.EscapeString(codes.Old))
content = fmt.Sprintf("<p>%s</p><p>%s</p><p>%s</p>",
i18n.Translate(lang, i18n.MsgEmailBindingOldIntro),
i18n.Translate(lang, i18n.MsgEmailBindingCode, map[string]any{"Code": html.EscapeString(codes.Old)}),
i18n.Translate(lang, i18n.MsgEmailBindingOldValidity, minutes))
return common.SendEmail(subject, state.CurrentEmail, content)
}
return nil
+33 -24
View File
@@ -486,25 +486,18 @@ func postConsumeQuotaWithResult(relayInfo *relaycommon.RelayInfo, quota int, pre
func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int) {
gopool.Go(func() {
userSetting := relayInfo.UserSetting
threshold := common.QuotaRemindThreshold
if userSetting.QuotaWarningThreshold != 0 {
threshold = int(userSetting.QuotaWarningThreshold)
// UserQuota is the balance before this request; quota plus
// preConsumedQuota is what the request finally cost.
balanceAfter := relayInfo.UserQuota - (quota + preConsumedQuota)
if !quotaReminderDue(userSetting, int64(relayInfo.UserQuota), int64(balanceAfter)) {
return
}
//noMoreQuota := userCache.Quota-(quota+preConsumedQuota) <= 0
quotaTooLow := false
consumeQuota := quota + preConsumedQuota
if relayInfo.UserQuota-consumeQuota < threshold {
quotaTooLow = true
}
if quotaTooLow {
lang := userSetting.Language
prompt := i18n.Translate(lang, i18n.MsgNotifyQuotaLowTitle)
content, values := quotaNotifyContent(lang, userSetting.NotifyType, prompt, logger.FormatQuota(relayInfo.UserQuota))
err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values))
if err != nil {
common.SysError(common.LogText("failed to send quota notify to user %d: %s", relayInfo.UserId, err.Error()))
}
lang := userSetting.Language
prompt := i18n.Translate(lang, i18n.MsgNotifyQuotaLowTitle)
content, values := quotaNotifyContent(lang, userSetting.NotifyType, prompt, logger.FormatQuota(balanceAfter))
err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values))
if err != nil {
common.SysError(common.LogText("failed to send quota notify to user %d: %s", relayInfo.UserId, err.Error()))
}
})
}
@@ -519,14 +512,10 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
}
userSetting := relayInfo.UserSetting
threshold := common.QuotaRemindThreshold
if userSetting.QuotaWarningThreshold != 0 {
threshold = int(userSetting.QuotaWarningThreshold)
}
usedBefore := relayInfo.SubscriptionAmountUsedAfterPreConsume - relayInfo.SubscriptionPreConsumed
usedAfter := relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta
remaining := relayInfo.SubscriptionAmountTotal - usedAfter
if remaining >= int64(threshold) {
if !quotaReminderDue(userSetting, relayInfo.SubscriptionAmountTotal-usedBefore, remaining) {
return
}
@@ -539,6 +528,26 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
})
}
// quotaReminderDue reports whether a charge that moved a balance (wallet or
// subscription) from before to after earns a low-balance reminder. The site
// and the user must both have reminders on, and the charge must cross the
// user's threshold, or the site default when the user set none. A balance
// already below the threshold earns nothing more until a top-up lifts it back
// above, so a user gets one reminder per drop.
func quotaReminderDue(userSetting dto.UserSetting, before int64, after int64) bool {
if !common.QuotaRemindEnabled {
return false
}
if userSetting.QuotaWarningEnabled != nil && !*userSetting.QuotaWarningEnabled {
return false
}
threshold := float64(common.QuotaRemindThreshold)
if userSetting.QuotaWarningThreshold != 0 {
threshold = userSetting.QuotaWarningThreshold
}
return float64(before) >= threshold && float64(after) < threshold
}
// quotaNotifyContent renders a low-quota notice in the user's saved language.
// Bark and Gotify get plain text; email and webhook get HTML with the top-up
// link. values stay in the notify payload for webhook receivers.
+27 -12
View File
@@ -13,41 +13,56 @@ import (
"github.com/QuantumNous/new-api/setting/system_setting"
)
func NotifyRootUser(t string, subject string, content string) {
user := model.GetRootUser().ToBaseUser()
err := NotifyUser(user.Id, user.Email, user.GetSetting(), dto.NewNotify(t, subject, content, nil))
if err != nil {
common.SysLog(common.LogText("failed to notify root user: %s", err.Error()))
}
// NotifyChannelWatchers sends a channel notice (a channel disabled or enabled
// automatically, or a test of all channels finished) to every administrator
// who receives channel notices. Until they choose, the root user receives them
// and other administrators do not. render builds the subject and content in
// the recipient's saved language; an empty language means DEFAULT_LANGUAGE.
func NotifyChannelWatchers(notifyType string, render func(lang string) (subject string, content string)) {
notifyAdmins(notifyType, func(role int, setting dto.UserSetting) bool {
if setting.ChannelNotifyEnabled != nil {
return *setting.ChannelNotifyEnabled
}
return role == common.RoleRootUser
}, render)
}
// NotifyUpstreamModelUpdateWatchers sends the upstream model update notice to
// every administrator who enabled it. render builds the subject and content in
// the recipient's saved language; an empty language means DEFAULT_LANGUAGE.
func NotifyUpstreamModelUpdateWatchers(render func(lang string) (subject string, content string)) {
sentCount := notifyAdmins(dto.NotifyTypeChannelUpdate, func(_ int, setting dto.UserSetting) bool {
return setting.UpstreamModelUpdateNotifyEnabled
}, render)
common.SysLog(common.LogText("upstream model update notifications sent: %d", sentCount))
}
// notifyAdmins sends a notice to each enabled administrator for whom receives
// returns true, and returns how many notices it sent.
func notifyAdmins(notifyType string, receives func(role int, setting dto.UserSetting) bool, render func(lang string) (subject string, content string)) int {
var users []model.User
if err := model.DB.
Select("id", "email", "role", "status", "setting").
Where("status = ? AND role >= ?", common.UserStatusEnabled, common.RoleAdminUser).
Find(&users).Error; err != nil {
common.SysLog(common.LogText("failed to query upstream update notification users: %s", err.Error()))
return
common.SysLog(common.LogText("failed to query administrators to notify: %s", err.Error()))
return 0
}
sentCount := 0
for _, user := range users {
userSetting := user.GetSetting()
if !userSetting.UpstreamModelUpdateNotifyEnabled {
if !receives(user.Role, userSetting) {
continue
}
subject, content := render(userSetting.Language)
if err := NotifyUser(user.Id, user.Email, userSetting, dto.NewNotify(dto.NotifyTypeChannelUpdate, subject, content, nil)); err != nil {
common.SysLog(common.LogText("failed to notify user %d for upstream model update: %s", user.Id, err.Error()))
if err := NotifyUser(user.Id, user.Email, userSetting, dto.NewNotify(notifyType, subject, content, nil)); err != nil {
common.SysLog(common.LogText("failed to send notice %s to user %d: %s", notifyType, user.Id, err.Error()))
continue
}
sentCount++
}
common.SysLog(common.LogText("upstream model update notifications sent: %d", sentCount))
return sentCount
}
func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data dto.Notify) error {
+191
View File
@@ -0,0 +1,191 @@
package service
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// A low-balance reminder goes out once, on the charge that takes a wallet or
// subscription balance below the threshold. A balance already below it earns
// nothing more until a top-up lifts it back above the line. The site switch
// and the user's switch both turn reminders off; a user who never chose gets
// them.
func TestQuotaReminderFiresOncePerDrop(t *testing.T) {
previousEnabled, previousThreshold := common.QuotaRemindEnabled, common.QuotaRemindThreshold
t.Cleanup(func() {
common.QuotaRemindEnabled, common.QuotaRemindThreshold = previousEnabled, previousThreshold
})
common.QuotaRemindThreshold = 1000
on, off := true, false
// changes move the balance from start: negative ones are charges, which
// may remind; positive ones are top-ups. want holds one entry per charge.
for _, tc := range []struct {
name string
siteEnabled bool
setting dto.UserSetting
start int64
changes []int64
want []bool
}{
{
name: "never chosen: one reminder per drop, again after a top-up",
siteEnabled: true,
start: 1500,
changes: []int64{-400, -200, -300, +1500, -1200},
want: []bool{false, true, false, true},
},
{
name: "switched on with its own threshold",
siteEnabled: true,
setting: dto.UserSetting{QuotaWarningEnabled: &on, QuotaWarningThreshold: 5000},
start: 6000,
changes: []int64{-500, -600, -2000},
want: []bool{false, true, false},
},
{
name: "a charge that lands exactly on the threshold does not remind",
siteEnabled: true,
start: 1200,
changes: []int64{-200, -1},
want: []bool{false, true},
},
{
name: "user switched off",
siteEnabled: true,
setting: dto.UserSetting{QuotaWarningEnabled: &off},
start: 1500,
changes: []int64{-600},
want: []bool{false},
},
{
name: "site switched off",
setting: dto.UserSetting{QuotaWarningEnabled: &on},
start: 1500,
changes: []int64{-600},
want: []bool{false},
},
} {
t.Run(tc.name, func(t *testing.T) {
common.QuotaRemindEnabled = tc.siteEnabled
got := make([]bool, 0, len(tc.want))
balance := tc.start
for _, change := range tc.changes {
if change < 0 {
got = append(got, quotaReminderDue(tc.setting, balance, balance+change))
}
balance += change
}
assert.Equal(t, tc.want, got)
})
}
}
// Channel notices go to every enabled administrator whose channel switch is
// on, in that administrator's saved language. Until an administrator chooses,
// root receives them and other administrators do not; other users never do.
func TestChannelNoticesFollowEachAdministratorsSwitch(t *testing.T) {
previousDB := model.DB
previousCache, previousRedis := common.MemoryCacheEnabled, common.RedisEnabled
previousNotifyLimit := constant.NotifyLimitCount
previousClient, previousWorker := httpClient, system_setting.WorkerUrl
fetch := system_setting.GetFetchSetting()
previousFetch := *fetch
t.Cleanup(func() {
model.DB = previousDB
common.MemoryCacheEnabled, common.RedisEnabled = previousCache, previousRedis
constant.NotifyLimitCount = previousNotifyLimit
httpClient, system_setting.WorkerUrl = previousClient, previousWorker
*fetch = previousFetch
})
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := database.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.User{}))
model.DB = database
common.MemoryCacheEnabled, common.RedisEnabled = false, false
constant.NotifyLimitCount = 100
var mutex sync.Mutex
received := map[string][]string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload WebhookPayload
if err := common.DecodeJson(r.Body, &payload); err == nil {
mutex.Lock()
recipient := strings.TrimPrefix(r.URL.Path, "/")
received[recipient] = append(received[recipient], payload.Type+" "+payload.Title)
mutex.Unlock()
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
httpClient, system_setting.WorkerUrl = server.Client(), ""
fetch.EnableSSRFProtection = false
on, off := true, false
for _, user := range []struct {
name string
role int
status int
receives *bool
language string
}{
{"root-never-chose", common.RoleRootUser, common.UserStatusEnabled, nil, ""},
{"root-turned-off", common.RoleRootUser, common.UserStatusEnabled, &off, ""},
{"admin-never-chose", common.RoleAdminUser, common.UserStatusEnabled, nil, ""},
{"admin-turned-on", common.RoleAdminUser, common.UserStatusEnabled, &on, "zhCN"},
{"disabled-admin-turned-on", common.RoleAdminUser, common.UserStatusDisabled, &on, ""},
{"user-with-switch-stored-on", common.RoleCommonUser, common.UserStatusEnabled, &on, ""},
} {
setting, err := common.Marshal(dto.UserSetting{
NotifyType: dto.NotifyTypeWebhook,
WebhookUrl: server.URL + "/" + user.name,
ChannelNotifyEnabled: user.receives,
Language: user.language,
})
require.NoError(t, err)
require.NoError(t, database.Create(&model.User{Username: user.name, Role: user.role, Status: user.status, Setting: string(setting), AffCode: user.name}).Error)
}
channel := &model.Channel{Name: "notice-review", Key: "fixture-key", Type: 1, Status: common.ChannelStatusEnabled, Group: "default", Models: "test-model"}
require.NoError(t, channel.Insert())
DisableChannel(types.ChannelError{ChannelId: channel.Id, ChannelName: channel.Name, AutoBan: true}, "upstream failed")
EnableChannel(channel.Id, "", channel.Name)
NotifyChannelWatchers(dto.NotifyTypeChannelTest, func(lang string) (string, string) {
return i18n.Translate(lang, i18n.MsgChannelTestCompletedSubject), i18n.Translate(lang, i18n.MsgChannelTestCompletedContent)
})
disabledType := formatNotifyType(channel.Id, common.ChannelStatusAutoDisabled)
enabledType := formatNotifyType(channel.Id, common.ChannelStatusEnabled)
assert.Equal(t, map[string][]string{
"root-never-chose": {
fmt.Sprintf(`%s Channel "notice-review" (#%d) has been disabled`, disabledType, channel.Id),
fmt.Sprintf(`%s Channel "notice-review" (#%d) has been enabled`, enabledType, channel.Id),
"channel_test Channel test completed",
},
"admin-turned-on": {
fmt.Sprintf("%s 通道「notice-review」(#%d)已被禁用", disabledType, channel.Id),
fmt.Sprintf("%s 通道「notice-review」(#%d)已被启用", enabledType, channel.Id),
"channel_test 通道测试完成",
},
}, received)
}
+3
View File
@@ -184,6 +184,9 @@ export interface SystemStatus {
custom_oauth_providers?: CustomOAuthProviderInfo[]
log_export_enabled?: boolean
log_max_range_days?: number
quota_remind_enabled?: boolean
/** Site default low balance reminder threshold, in quota units */
quota_remind_threshold?: number
[key: string]: unknown
}
@@ -20,6 +20,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { ROLE } from '@/lib/roles'
import { updateUserSettings } from '../api'
import { NotificationTab } from '../components/tabs/notification-tab'
@@ -57,6 +58,11 @@ const settings = {
afterEach(() => vi.restoreAllMocks())
/** The body a PUT sends over the wire: undefined fields are left out. */
function sentBody(put: { mock: { calls: unknown[][] } }): unknown {
return JSON.parse(JSON.stringify(put.mock.calls[0][1]))
}
describe('user settings saves across profile and security', () => {
it('disabling IP recording sends the latest complete notification settings', async () => {
const get = vi.spyOn(api, 'get').mockResolvedValue({
@@ -76,17 +82,18 @@ describe('user settings saves across profile and security', () => {
})
})
it('saving IP recording for a user with no settings supplies the existing defaults', async () => {
it('saving IP recording for a user with no settings supplies the defaults and leaves unchosen switches unset', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: profile },
data: { success: true, data: { ...profile, role: ROLE.SUPER_ADMIN } },
})
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
await updateUserSettings({ record_ip_log: true })
expect(put).toHaveBeenCalledWith('/api/user/setting', {
expect(put.mock.calls[0][0]).toBe('/api/user/setting')
expect(sentBody(put)).toEqual({
notify_type: 'email',
quota_warning_threshold: 500000,
quota_warning_threshold: 0,
notification_email: '',
webhook_url: '',
webhook_secret: '',
@@ -174,14 +181,16 @@ describe('user settings saves across profile and security', () => {
<NotificationTab
profile={{ ...profile, setting: JSON.stringify(settings) }}
onUpdate={onUpdate}
quotaRemindEnabled
quotaRemindThreshold={1000}
/>
)
expect(
screen.queryByRole('switch', { name: 'Record IP Address' })
).not.toBeInTheDocument()
fireEvent.change(
screen.getByRole('spinbutton', { name: 'Quota Warning Threshold' }),
{ target: { value: '2700' } }
screen.getByRole('spinbutton', { name: 'Reminder threshold (USD)' }),
{ target: { value: '0.0054' } }
)
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }))
await waitFor(() => expect(onUpdate).toHaveBeenCalled())
@@ -192,3 +201,145 @@ describe('user settings saves across profile and security', () => {
})
})
})
describe('notifications a user receives', () => {
function renderNotificationTab(
role: number,
setting: Record<string, unknown>,
quotaRemindEnabled = true
) {
const onUpdate = vi.fn()
render(
<NotificationTab
profile={{ ...profile, role, setting: JSON.stringify(setting) }}
onUpdate={onUpdate}
quotaRemindEnabled={quotaRemindEnabled}
quotaRemindThreshold={1000}
/>
)
return onUpdate
}
function mockSave(role: number, setting: Record<string, unknown>) {
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: { ...profile, role, setting: JSON.stringify(setting) },
},
})
return vi.spyOn(api, 'put').mockResolvedValue({ data: { success: true } })
}
async function save(onUpdate: () => void) {
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }))
await waitFor(() => expect(onUpdate).toHaveBeenCalled())
}
it.each([
{ name: 'user', role: ROLE.USER, channel: undefined },
{ name: 'admin', role: ROLE.ADMIN, channel: false },
{ name: 'root', role: ROLE.SUPER_ADMIN, channel: true },
])(
'a $name who never chose sees the reminder on and the channel switch as $channel',
(row) => {
renderNotificationTab(row.role, { notify_type: 'email' })
expect(
screen.getByRole('switch', { name: 'Low balance reminder' })
).toBeChecked()
const channel = screen.queryByRole('switch', {
name: 'Channel notifications',
})
const upstream = screen.queryByRole('switch', {
name: 'Upstream Model Updates',
})
if (row.channel === undefined) {
expect(channel).not.toBeInTheDocument()
expect(upstream).not.toBeInTheDocument()
return
}
expect(channel?.getAttribute('aria-checked')).toBe(String(row.channel))
expect(upstream).not.toBeChecked()
}
)
it('a user on a site with reminders off sees one line instead of notification settings', () => {
renderNotificationTab(ROLE.USER, { notify_type: 'email' }, false)
expect(
screen.getByText('No notifications are available to you right now')
).toBeInTheDocument()
expect(screen.queryByText('Notification Method')).not.toBeInTheDocument()
expect(
screen.queryByRole('switch', { name: 'Low balance reminder' })
).not.toBeInTheDocument()
})
it('an admin on a site with reminders off keeps only the administrator notifications', () => {
renderNotificationTab(ROLE.ADMIN, { notify_type: 'email' }, false)
expect(
screen.queryByRole('switch', { name: 'Low balance reminder' })
).not.toBeInTheDocument()
expect(
screen.getByRole('switch', { name: 'Channel notifications' })
).toBeInTheDocument()
expect(screen.getByText('Notification Method')).toBeInTheDocument()
})
it('an empty threshold shows the site default and saves as the site default', async () => {
const put = mockSave(ROLE.USER, { notify_type: 'email' })
const onUpdate = renderNotificationTab(ROLE.USER, { notify_type: 'email' })
const threshold = screen.getByRole('spinbutton', {
name: 'Reminder threshold (USD)',
})
expect(threshold).toHaveValue(null)
expect(threshold).toHaveAttribute('placeholder', '0.002')
expect(
screen.getByText('Leave empty to use the site default ($0.002)')
).toBeInTheDocument()
await save(onUpdate)
expect(sentBody(put)).toMatchObject({ quota_warning_threshold: 0 })
})
it('an untouched threshold is saved as stored, in quota units', async () => {
const setting = { notify_type: 'email', quota_warning_threshold: 1234 }
const put = mockSave(ROLE.USER, setting)
const onUpdate = renderNotificationTab(ROLE.USER, setting)
expect(
screen.getByRole('spinbutton', { name: 'Reminder threshold (USD)' })
).toHaveValue(0.002468)
await save(onUpdate)
expect(sentBody(put)).toMatchObject({ quota_warning_threshold: 1234 })
})
it('turning the reminder off hides its threshold and saves the switch', async () => {
const put = mockSave(ROLE.USER, { notify_type: 'email' })
const onUpdate = renderNotificationTab(ROLE.USER, { notify_type: 'email' })
fireEvent.click(
screen.getByRole('switch', { name: 'Low balance reminder' })
)
expect(
screen.queryByRole('spinbutton', { name: 'Reminder threshold (USD)' })
).not.toBeInTheDocument()
await save(onUpdate)
expect(sentBody(put)).toMatchObject({ quota_warning_enabled: false })
})
it('switches root never touched stay unset when it saves', async () => {
const put = mockSave(ROLE.SUPER_ADMIN, { notify_type: 'email' })
const onUpdate = renderNotificationTab(ROLE.SUPER_ADMIN, {
notify_type: 'email',
})
await save(onUpdate)
expect(sentBody(put)).not.toHaveProperty('channel_notify_enabled')
expect(sentBody(put)).not.toHaveProperty('quota_warning_enabled')
})
it('an admin turning channel notifications on saves the switch', async () => {
const put = mockSave(ROLE.ADMIN, { notify_type: 'email' })
const onUpdate = renderNotificationTab(ROLE.ADMIN, { notify_type: 'email' })
fireEvent.click(
screen.getByRole('switch', { name: 'Channel notifications' })
)
await save(onUpdate)
expect(sentBody(put)).toMatchObject({ channel_notify_enabled: true })
})
})
@@ -34,12 +34,16 @@ interface ProfileSettingsCardProps {
profile: UserProfile | null
loading: boolean
onProfileUpdate: () => void
quotaRemindEnabled: boolean
quotaRemindThreshold: number
}
export function ProfileSettingsCard({
profile,
loading,
onProfileUpdate,
quotaRemindEnabled,
quotaRemindThreshold,
}: ProfileSettingsCardProps) {
const { t } = useTranslation()
@@ -67,7 +71,12 @@ export function ProfileSettingsCard({
iconTone='info'
disableHoverEffect
>
<NotificationTab profile={profile} onUpdate={onProfileUpdate} />
<NotificationTab
profile={profile}
onUpdate={onProfileUpdate}
quotaRemindEnabled={quotaRemindEnabled}
quotaRemindThreshold={quotaRemindThreshold}
/>
</TitledCard>
)
}
@@ -27,6 +27,17 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import {
SettingsControlChildren,
SettingsControlGroup,
SettingsSwitchField,
} from '@/features/system-settings/components/settings-form-layout'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import {
formatQuota,
parseQuotaFromDollars,
quotaUnitsToDollars,
} from '@/lib/format'
import { handleServerError } from '@/lib/handle-server-error'
import { ROLE } from '@/lib/roles'
@@ -49,13 +60,24 @@ const NOTIFICATION_ICONS: Record<NotifyType, typeof Mail> = {
interface NotificationTabProps {
profile: UserProfile | null
onUpdate: () => void
/** Whether the site sends low balance reminders at all */
quotaRemindEnabled: boolean
/** The site default reminder threshold, in quota units */
quotaRemindThreshold: number
}
export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
export function NotificationTab({
profile,
onUpdate,
quotaRemindEnabled,
quotaRemindThreshold,
}: NotificationTabProps) {
const { t } = useTranslation()
const isAdmin = (profile?.role ?? 0) >= ROLE.ADMIN
const role = profile?.role ?? 0
const isAdmin = role >= ROLE.ADMIN
const [loading, setLoading] = useState(false)
const [settings, setSettings] = useState(() => normalizeUserSettings())
const [thresholdInput, setThresholdInput] = useState('')
// Update form field helper
const updateField = useCallback(
@@ -70,7 +92,14 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
useEffect(() => {
if (profile?.setting) {
setSettings(normalizeUserSettings(profile.setting))
const next = normalizeUserSettings(profile.setting)
setSettings(next)
// 0 means the site default and shows as an empty input.
setThresholdInput(
next.quota_warning_threshold
? String(quotaUnitsToDollars(next.quota_warning_threshold))
: ''
)
}
}, [profile])
@@ -94,188 +123,284 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
}
const notifyType = settings.notify_type
// Switches the user never chose: reminders stay on, and root alone gets
// channel notifications.
const quotaWarningEnabled = settings.quota_warning_enabled ?? true
const channelNotifyEnabled =
settings.channel_notify_enabled ?? role >= ROLE.SUPER_ADMIN
const tokensOnly = getCurrencyDisplay().meta.kind === 'tokens'
return (
<div className='space-y-4 sm:space-y-6'>
{/* Notification Type */}
<div className='space-y-2.5'>
<Label>{t('Notification Method')}</Label>
<ToggleGroup
value={[notifyType]}
onValueChange={(value) => {
const nextValue = value.find((item) => item !== notifyType)
if (nextValue) updateField('notify_type', nextValue as NotifyType)
}}
aria-label={t('Notification Method')}
variant='outline'
size='lg'
spacing={2}
className='grid w-full grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-3'
>
{NOTIFICATION_METHODS.map((method) => {
const Icon = NOTIFICATION_ICONS[method.value]
return (
<ToggleGroupItem
key={method.value}
value={method.value}
className='h-auto min-h-14 w-full flex-col gap-1.5 px-3 py-3 sm:min-h-16'
>
<Icon className='h-4 w-4 sm:h-5 sm:w-5' />
<span className='max-w-full truncate text-xs font-medium sm:text-sm'>
{t(method.label)}
</span>
</ToggleGroupItem>
)
})}
</ToggleGroup>
</div>
{/* Warning Threshold */}
<div className='space-y-1.5'>
<Label htmlFor='threshold'>{t('Quota Warning Threshold')}</Label>
<Input
id='threshold'
type='number'
className='h-9'
value={settings.quota_warning_threshold}
onChange={(e) =>
updateField('quota_warning_threshold', Number(e.target.value))
}
placeholder={t('Enter threshold')}
/>
<p className='text-muted-foreground text-xs'>
{t('Get notified when balance falls below this value')}
{!quotaRemindEnabled && !isAdmin && (
<p className='text-muted-foreground text-sm'>
{t('No notifications are available to you right now')}
</p>
</div>
{/* Email Settings */}
{notifyType === 'email' && (
<div className='space-y-1.5'>
<Label htmlFor='notifyEmail'>{t('Notification Email')}</Label>
<Input
id='notifyEmail'
type='email'
className='h-9'
value={settings.notification_email}
onChange={(e) => updateField('notification_email', e.target.value)}
placeholder={t('Leave empty to use account email')}
/>
</div>
)}
{/* Webhook Settings */}
{notifyType === 'webhook' && (
{(quotaRemindEnabled || isAdmin) && (
<>
<div className='space-y-1.5'>
<Label htmlFor='webhookUrl'>{t('Webhook URL')}</Label>
<Input
id='webhookUrl'
type='url'
className='h-9'
value={settings.webhook_url}
onChange={(e) => updateField('webhook_url', e.target.value)}
placeholder={t('https://example.com/webhook')}
/>
{/* Notification Type */}
<div className='space-y-2.5'>
<Label>{t('Notification Method')}</Label>
<ToggleGroup
value={[notifyType]}
onValueChange={(value) => {
const nextValue = value.find((item) => item !== notifyType)
if (nextValue) {
updateField('notify_type', nextValue as NotifyType)
}
}}
aria-label={t('Notification Method')}
variant='outline'
size='lg'
spacing={2}
className='grid w-full grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-3'
>
{NOTIFICATION_METHODS.map((method) => {
const Icon = NOTIFICATION_ICONS[method.value]
return (
<ToggleGroupItem
key={method.value}
value={method.value}
className='h-auto min-h-14 w-full flex-col gap-1.5 px-3 py-3 sm:min-h-16'
>
<Icon className='h-4 w-4 sm:h-5 sm:w-5' />
<span className='max-w-full truncate text-xs font-medium sm:text-sm'>
{t(method.label)}
</span>
</ToggleGroupItem>
)
})}
</ToggleGroup>
</div>
<div className='space-y-1.5'>
<Label htmlFor='webhookSecret'>{t('Webhook Secret')}</Label>
<PasswordInput
id='webhookSecret'
value={settings.webhook_secret}
onChange={(e) => updateField('webhook_secret', e.target.value)}
placeholder={t('Enter secret key')}
/>
</div>
</>
)}
{/* Bark Settings */}
{notifyType === 'bark' && (
<div className='space-y-1.5'>
<Label htmlFor='barkUrl'>{t('Bark Push URL')}</Label>
<Input
id='barkUrl'
type='url'
className='h-9'
value={settings.bark_url}
onChange={(e) => updateField('bark_url', e.target.value)}
placeholder={t('https://api.day.app/yourkey/{{title}}/{{content}}')}
/>
<p className='text-muted-foreground text-xs'>
{t('Template variables:')} {'{{title}}'}, {'{{content}}'}
</p>
</div>
)}
{/* Email Settings */}
{notifyType === 'email' && (
<div className='space-y-1.5'>
<Label htmlFor='notifyEmail'>{t('Notification Email')}</Label>
<Input
id='notifyEmail'
type='email'
className='h-9'
value={settings.notification_email}
onChange={(e) =>
updateField('notification_email', e.target.value)
}
placeholder={t('Leave empty to use account email')}
/>
</div>
)}
{/* Gotify Settings */}
{notifyType === 'gotify' && (
<>
<div className='space-y-1.5'>
<Label htmlFor='gotifyUrl'>{t('Gotify Server URL')}</Label>
<Input
id='gotifyUrl'
type='url'
className='h-9'
value={settings.gotify_url}
onChange={(e) => updateField('gotify_url', e.target.value)}
placeholder={t('https://gotify.example.com')}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter the full URL of your Gotify server')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='gotifyToken'>{t('Gotify Application Token')}</Label>
<PasswordInput
id='gotifyToken'
value={settings.gotify_token}
onChange={(e) => updateField('gotify_token', e.target.value)}
placeholder={t('Enter application token')}
/>
<p className='text-muted-foreground text-xs'>
{t('Token obtained from your Gotify application')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='gotifyPriority'>{t('Message Priority')}</Label>
<Input
id='gotifyPriority'
type='number'
className='h-9'
min='0'
max='10'
value={settings.gotify_priority}
onChange={(e) =>
updateField('gotify_priority', Number(e.target.value))
}
placeholder='5'
/>
<p className='text-muted-foreground text-xs'>
{t(
'Priority level from 0 (lowest) to 10 (highest), default is 5'
{/* Webhook Settings */}
{notifyType === 'webhook' && (
<>
<div className='space-y-1.5'>
<Label htmlFor='webhookUrl'>{t('Webhook URL')}</Label>
<Input
id='webhookUrl'
type='url'
className='h-9'
value={settings.webhook_url}
onChange={(e) => updateField('webhook_url', e.target.value)}
placeholder={t('https://example.com/webhook')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='webhookSecret'>{t('Webhook Secret')}</Label>
<PasswordInput
id='webhookSecret'
value={settings.webhook_secret}
onChange={(e) =>
updateField('webhook_secret', e.target.value)
}
placeholder={t('Enter secret key')}
/>
</div>
</>
)}
{/* Bark Settings */}
{notifyType === 'bark' && (
<div className='space-y-1.5'>
<Label htmlFor='barkUrl'>{t('Bark Push URL')}</Label>
<Input
id='barkUrl'
type='url'
className='h-9'
value={settings.bark_url}
onChange={(e) => updateField('bark_url', e.target.value)}
placeholder={t(
'https://api.day.app/yourkey/{{title}}/{{content}}'
)}
/>
<p className='text-muted-foreground text-xs'>
{t('Template variables:')} {'{{title}}'}, {'{{content}}'}
</p>
</div>
)}
{/* Gotify Settings */}
{notifyType === 'gotify' && (
<>
<div className='space-y-1.5'>
<Label htmlFor='gotifyUrl'>{t('Gotify Server URL')}</Label>
<Input
id='gotifyUrl'
type='url'
className='h-9'
value={settings.gotify_url}
onChange={(e) => updateField('gotify_url', e.target.value)}
placeholder={t('https://gotify.example.com')}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter the full URL of your Gotify server')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='gotifyToken'>
{t('Gotify Application Token')}
</Label>
<PasswordInput
id='gotifyToken'
value={settings.gotify_token}
onChange={(e) => updateField('gotify_token', e.target.value)}
placeholder={t('Enter application token')}
/>
<p className='text-muted-foreground text-xs'>
{t('Token obtained from your Gotify application')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='gotifyPriority'>{t('Message Priority')}</Label>
<Input
id='gotifyPriority'
type='number'
className='h-9'
min='0'
max='10'
value={settings.gotify_priority}
onChange={(e) =>
updateField('gotify_priority', Number(e.target.value))
}
placeholder='5'
/>
<p className='text-muted-foreground text-xs'>
{t(
'Priority level from 0 (lowest) to 10 (highest), default is 5'
)}
</p>
</div>
<div className='bg-muted/50 rounded-lg border p-3 sm:p-4'>
<h5 className='mb-1.5 text-sm font-medium sm:mb-2'>
{t('Setup Instructions')}
</h5>
<ol className='text-muted-foreground space-y-1 text-xs'>
<li>{t('1. Create an application in your Gotify server')}</li>
<li>{t('2. Copy the application token')}</li>
<li>
{t('3. Enter your Gotify server URL and token above')}
</li>
</ol>
<p className='text-muted-foreground mt-3 text-xs'>
{t('Learn more:')}{' '}
<a
href='https://gotify.net/'
target='_blank'
rel='noopener noreferrer'
className='text-primary underline underline-offset-4'
>
{t('Gotify Documentation')}
</a>
</p>
</div>
</>
)}
{/* Notifications to receive */}
<div className='space-y-2.5'>
<h4 className='text-sm font-medium'>
{t('Notifications to receive')}
</h4>
<SettingsControlGroup>
{quotaRemindEnabled && (
<>
<SettingsSwitchField
controlId='quotaWarningEnabled'
label={t('Low balance reminder')}
description={t(
'Remind me once when my balance drops below the threshold'
)}
checked={quotaWarningEnabled}
onCheckedChange={(checked) =>
updateField('quota_warning_enabled', checked)
}
/>
{quotaWarningEnabled && (
<SettingsControlChildren className='space-y-1.5 pb-1'>
<Label htmlFor='threshold'>
{t('Reminder threshold')} ({getCurrencyLabel()})
</Label>
<Input
id='threshold'
type='number'
className='h-9'
min={0}
step={tokensOnly ? 1 : 0.000001}
value={thresholdInput}
onChange={(e) => {
const value = e.target.value
setThresholdInput(value)
updateField(
'quota_warning_threshold',
value.trim() === ''
? 0
: parseQuotaFromDollars(Number(value))
)
}}
placeholder={String(
quotaUnitsToDollars(quotaRemindThreshold)
)}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to use the site default ({{amount}})', {
amount: formatQuota(quotaRemindThreshold),
})}
</p>
</SettingsControlChildren>
)}
</>
)}
</p>
</div>
<div className='bg-muted/50 rounded-lg border p-3 sm:p-4'>
<h5 className='mb-1.5 text-sm font-medium sm:mb-2'>
{t('Setup Instructions')}
</h5>
<ol className='text-muted-foreground space-y-1 text-xs'>
<li>{t('1. Create an application in your Gotify server')}</li>
<li>{t('2. Copy the application token')}</li>
<li>{t('3. Enter your Gotify server URL and token above')}</li>
</ol>
<p className='text-muted-foreground mt-3 text-xs'>
{t('Learn more:')}{' '}
<a
href='https://gotify.net/'
target='_blank'
rel='noopener noreferrer'
className='text-primary underline underline-offset-4'
>
{t('Gotify Documentation')}
</a>
</p>
{isAdmin && (
<>
<SettingsSwitchField
controlId='channelNotify'
label={t('Channel notifications')}
description={t(
'When a channel is disabled or re-enabled automatically, or a test of all channels finishes'
)}
checked={channelNotifyEnabled}
onCheckedChange={(checked) =>
updateField('channel_notify_enabled', checked)
}
/>
<SettingsSwitchField
controlId='upstreamModelUpdateNotify'
label={t('Upstream Model Updates')}
description={t(
'Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.'
)}
checked={settings.upstream_model_update_notify_enabled}
onCheckedChange={(checked) =>
updateField(
'upstream_model_update_notify_enabled',
checked
)
}
/>
</>
)}
</SettingsControlGroup>
</div>
</>
)}
@@ -292,30 +417,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
</p>
</div>
{/* Receive Upstream Model Update Notifications (admin only) */}
{isAdmin && (
<div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
<div className='space-y-0.5'>
<Label htmlFor='upstreamModelUpdateNotify'>
{t('Receive Upstream Model Update Notifications')}
</Label>
<p className='text-muted-foreground line-clamp-3 text-xs sm:line-clamp-none sm:text-sm'>
{t(
'Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.'
)}
</p>
</div>
<Switch
id='upstreamModelUpdateNotify'
className='shrink-0'
checked={settings.upstream_model_update_notify_enabled}
onCheckedChange={(checked) =>
updateField('upstream_model_update_notify_enabled', checked)
}
/>
</div>
)}
{/* Accept Unset Model Price */}
<div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
<div className='space-y-0.5'>
-5
View File
@@ -20,11 +20,6 @@ For commercial licensing, please contact [email protected]
// Profile Constants
// ============================================================================
/**
* Default quota warning threshold (500,000 = $1)
*/
export const DEFAULT_QUOTA_WARNING_THRESHOLD = 500000
/**
* Notification methods
*/
+5
View File
@@ -42,6 +42,9 @@ export function Profile() {
)
const turnstileSiteKey = status?.turnstile_site_key || ''
const canConfigureSidebar = permissions?.sidebar_settings !== false
// Older cached status payloads lack these fields; the site default is on.
const quotaRemindEnabled = status?.quota_remind_enabled !== false
const quotaRemindThreshold = status?.quota_remind_threshold ?? 0
return (
<Main>
@@ -58,6 +61,8 @@ export function Profile() {
profile={profile}
loading={loading}
onProfileUpdate={refreshProfile}
quotaRemindEnabled={quotaRemindEnabled}
quotaRemindThreshold={quotaRemindThreshold}
/>
<LanguagePreferencesCard
profile={profile}
+18 -7
View File
@@ -16,24 +16,34 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
DEFAULT_QUOTA_WARNING_THRESHOLD,
NOTIFICATION_METHODS,
} from '../constants'
import { NOTIFICATION_METHODS } from '../constants'
import type { NotifyType, UpdateUserSettingsRequest } from '../types'
import { parseUserSettings } from './format'
/** Switches whose absence means "never chosen", which the server resolves */
type DefaultedSwitch = 'quota_warning_enabled' | 'channel_notify_enabled'
type NormalizedUserSettings = Required<
Omit<UpdateUserSettingsRequest, DefaultedSwitch>
> &
Pick<UpdateUserSettingsRequest, DefaultedSwitch> & { notify_type: NotifyType }
/**
* Settings sent back on every save. A switch the user never chose stays
* undefined, so the save leaves it unset and its default keeps applying. A
* threshold of 0 means the site default.
*/
export function normalizeUserSettings(
setting?: string
): Required<UpdateUserSettingsRequest> & { notify_type: NotifyType } {
): NormalizedUserSettings {
const parsed = parseUserSettings(setting)
const notifyType =
NOTIFICATION_METHODS.find((method) => method.value === parsed.notify_type)
?.value ?? 'email'
return {
notify_type: notifyType,
quota_warning_threshold:
parsed.quota_warning_threshold ?? DEFAULT_QUOTA_WARNING_THRESHOLD,
quota_warning_threshold: parsed.quota_warning_threshold ?? 0,
quota_warning_enabled: parsed.quota_warning_enabled,
notification_email: parsed.notification_email ?? '',
webhook_url: parsed.webhook_url ?? '',
webhook_secret: parsed.webhook_secret ?? '',
@@ -46,5 +56,6 @@ export function normalizeUserSettings(
record_ip_log: parsed.record_ip_log || false,
upstream_model_update_notify_enabled:
parsed.upstream_model_update_notify_enabled || false,
channel_notify_enabled: parsed.channel_notify_enabled,
}
}
+7 -1
View File
@@ -98,8 +98,10 @@ export type NotifyType = 'email' | 'webhook' | 'bark' | 'gotify'
export interface UserSettings {
/** Notification type */
notify_type?: NotifyType
/** Quota warning threshold */
/** Quota warning threshold in quota units; 0 or absent uses the site default */
quota_warning_threshold?: number
/** Receive low balance reminders; absent means on */
quota_warning_enabled?: boolean
/** Webhook URL */
webhook_url?: string
/** Webhook secret */
@@ -120,6 +122,8 @@ export interface UserSettings {
record_ip_log?: boolean
/** Receive upstream model update notifications (admin only) */
upstream_model_update_notify_enabled?: boolean
/** Receive channel notifications (admin only); absent means on for root only */
channel_notify_enabled?: boolean
/** Preferred interface/API response language */
language?: string
}
@@ -152,6 +156,7 @@ export interface EmailBindingFlow extends AccountSecurityResult {
export interface UpdateUserSettingsRequest {
notify_type?: string
quota_warning_threshold?: number
quota_warning_enabled?: boolean
webhook_url?: string
webhook_secret?: string
notification_email?: string
@@ -162,6 +167,7 @@ export interface UpdateUserSettingsRequest {
accept_unset_model_ratio_model?: boolean
record_ip_log?: boolean
upstream_model_update_notify_enabled?: boolean
channel_notify_enabled?: boolean
}
/**
@@ -47,6 +47,8 @@ const STATUS_RELATED_KEYS = new Set([
'passkey.rp_id',
'passkey.legacy_rp_ids',
'passkey.origins',
'QuotaRemindEnabled',
'QuotaRemindThreshold',
])
export function useUpdateOption() {
@@ -0,0 +1,125 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { api } from '@/lib/api'
import { SettingsPageProvider } from '../../components/settings-page-context'
import { EmailSettingsSection } from '../email-settings-section'
function Fixture() {
const [container, setContainer] = useState<HTMLDivElement | null>(null)
return (
<>
<div ref={setContainer} />
<SettingsPageProvider actionsContainer={container}>
<EmailSettingsSection
defaultValues={{
SMTPServer: 'smtp.example.com',
SMTPPort: '587',
SMTPAccount: '[email protected]',
SMTPFrom: '',
SMTPToken: '',
SMTPSSLEnabled: false,
SMTPStartTLSEnabled: true,
SMTPInsecureSkipVerify: false,
SMTPForceAuthLogin: false,
QuotaRemindEnabled: true,
QuotaRemindThreshold: '1000',
}}
/>
</SettingsPageProvider>
</>
)
}
function renderSettings() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
render(
<QueryClientProvider client={client}>
<Fixture />
</QueryClientProvider>
)
}
beforeEach(() => {
vi.spyOn(api, 'put').mockResolvedValue({ data: { success: true } })
})
afterEach(() => vi.restoreAllMocks())
test('the default threshold shows as an amount and is not saved again when only SMTP changes', async () => {
const user = userEvent.setup()
renderSettings()
expect(
screen.getByRole('spinbutton', { name: 'Default reminder threshold (USD)' })
).toHaveValue(0.002)
await user.click(screen.getByRole('switch', { name: 'Force AUTH LOGIN' }))
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() =>
expect(api.put).toHaveBeenCalledWith('/api/option/', {
key: 'SMTPForceAuthLogin',
value: true,
})
)
expect(api.put).toHaveBeenCalledTimes(1)
})
test('an edited default threshold is saved in quota units', async () => {
const user = userEvent.setup()
renderSettings()
const input = screen.getByRole('spinbutton', {
name: 'Default reminder threshold (USD)',
})
await user.clear(input)
await user.type(input, '1')
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() =>
expect(api.put).toHaveBeenCalledWith('/api/option/', {
key: 'QuotaRemindThreshold',
value: '500000',
})
)
expect(api.put).toHaveBeenCalledTimes(1)
})
test('turning the reminder off hides the default threshold and saves the switch', async () => {
const user = userEvent.setup()
renderSettings()
await user.click(screen.getByRole('switch', { name: 'Low balance reminder' }))
expect(
screen.queryByRole('spinbutton', {
name: 'Default reminder threshold (USD)',
})
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() =>
expect(api.put).toHaveBeenCalledWith('/api/option/', {
key: 'QuotaRemindEnabled',
value: false,
})
)
expect(api.put).toHaveBeenCalledTimes(1)
})
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { zodResolver } from '@hookform/resolvers/zod'
import { useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import * as z from 'zod'
@@ -34,8 +35,12 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Switch } from '@/components/ui/switch'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
import {
SettingsControlChildren,
SettingsControlGroup,
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
@@ -64,11 +69,19 @@ const createEmailSchema = (t: (key: string) => string) =>
SMTPStartTLSEnabled: z.boolean(),
SMTPInsecureSkipVerify: z.boolean(),
SMTPForceAuthLogin: z.boolean(),
QuotaRemindEnabled: z.boolean(),
// Entered as a display amount; stored in quota units.
QuotaRemindThreshold: z.string().refine((value) => {
const trimmed = value.trim()
if (!trimmed) return true
return Number.isFinite(Number(trimmed)) && Number(trimmed) >= 0
}, t('Must be greater than or equal to 0')),
})
type EmailFormValues = z.infer<ReturnType<typeof createEmailSchema>>
type EmailSettingsSectionProps = {
/** Option values as stored; QuotaRemindThreshold is in quota units */
defaultValues: EmailFormValues
}
@@ -90,12 +103,27 @@ export function EmailSettingsSection({
const updateOption = useUpdateOption()
const emailSchema = createEmailSchema(t)
// The form edits the threshold as a display amount. Comparing amounts on
// save leaves a threshold nobody edited untouched.
const formDefaults = useMemo(
() => ({
...defaultValues,
QuotaRemindThreshold: String(
quotaUnitsToDollars(Number(defaultValues.QuotaRemindThreshold) || 0)
),
}),
[defaultValues]
)
const form = useForm<EmailFormValues>({
resolver: zodResolver(emailSchema),
defaultValues,
defaultValues: formDefaults,
})
useResetForm(form, defaultValues)
useResetForm(form, formDefaults)
const quotaRemindEnabled = form.watch('QuotaRemindEnabled')
const tokensOnly = getCurrencyDisplay().meta.kind === 'tokens'
const onSubmit = async (values: EmailFormValues) => {
const securityMode = getSmtpSecurityMode(values)
@@ -173,19 +201,33 @@ export function EmailSettingsSection({
})
}
if (values.QuotaRemindEnabled !== defaultValues.QuotaRemindEnabled) {
updates.push({
key: 'QuotaRemindEnabled',
value: values.QuotaRemindEnabled,
})
}
const threshold = values.QuotaRemindThreshold.trim()
if (threshold !== formDefaults.QuotaRemindThreshold) {
updates.push({
key: 'QuotaRemindThreshold',
value: String(parseQuotaFromDollars(Number(threshold || 0))),
})
}
for (const update of updates) {
await updateOption.mutateAsync(update)
}
}
return (
<SettingsSection title={t('SMTP Email')}>
<SettingsSection title={t('Email & Notifications')}>
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)} autoComplete='off'>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save SMTP settings'
/>
<FormField
control={form.control}
@@ -405,6 +447,60 @@ export function EmailSettingsSection({
</FormItem>
)}
/>
<SettingsControlGroup>
<FormField
control={form.control}
name='QuotaRemindEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Low balance reminder')}</FormLabel>
<FormDescription>
{t(
'Remind users through their chosen notification method when their balance drops below the threshold'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
{quotaRemindEnabled && (
<SettingsControlChildren>
<FormField
control={form.control}
name='QuotaRemindThreshold'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Default reminder threshold')} ({getCurrencyLabel()})
</FormLabel>
<FormControl>
<Input
autoComplete='off'
type='number'
min={0}
step={tokensOnly ? 1 : 0.000001}
{...field}
onChange={(event) =>
field.onChange(event.target.value)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsControlChildren>
)}
</SettingsControlGroup>
</SettingsForm>
</Form>
</SettingsSection>
@@ -54,14 +54,7 @@ import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
import { safeNumberFieldProps } from '../utils/numeric-field'
const numericString = z.string().refine((value) => {
const trimmed = value.trim()
if (!trimmed) return true
return !Number.isNaN(Number(trimmed)) && Number(trimmed) >= 0
}, 'Enter a non-negative number or leave empty')
const monitoringSchema = z.object({
QuotaRemindThreshold: numericString,
perf_metrics_setting: z.object({
enabled: z.boolean(),
flush_interval: z.coerce.number().min(1),
@@ -74,7 +67,6 @@ type MonitoringFormInput = z.input<typeof monitoringSchema>
type MonitoringFormValues = z.output<typeof monitoringSchema>
type FlatMonitoringDefaults = {
QuotaRemindThreshold: string
'perf_metrics_setting.enabled': boolean
'perf_metrics_setting.flush_interval': number
'perf_metrics_setting.bucket_time': 'minute' | '5min' | 'hour'
@@ -88,7 +80,6 @@ type MonitoringSettingsSectionProps = {
const buildFormDefaults = (
defaults: MonitoringSettingsSectionProps['defaultValues']
): MonitoringFormInput => ({
QuotaRemindThreshold: defaults.QuotaRemindThreshold ?? '',
perf_metrics_setting: {
enabled: defaults['perf_metrics_setting.enabled'],
flush_interval: defaults['perf_metrics_setting.flush_interval'],
@@ -100,7 +91,6 @@ const buildFormDefaults = (
const normalizeDefaults = (
defaults: MonitoringSettingsSectionProps['defaultValues']
): FlatMonitoringDefaults => ({
QuotaRemindThreshold: (defaults.QuotaRemindThreshold ?? '').trim(),
'perf_metrics_setting.enabled': defaults['perf_metrics_setting.enabled'],
'perf_metrics_setting.flush_interval':
defaults['perf_metrics_setting.flush_interval'],
@@ -113,7 +103,6 @@ const normalizeDefaults = (
const normalizeFormValues = (
values: MonitoringFormValues
): FlatMonitoringDefaults => ({
QuotaRemindThreshold: values.QuotaRemindThreshold.trim(),
'perf_metrics_setting.enabled': values.perf_metrics_setting.enabled,
'perf_metrics_setting.flush_interval':
values.perf_metrics_setting.flush_interval,
@@ -179,44 +168,18 @@ export function MonitoringSettingsSection({
}
return (
<SettingsSection title={t('Monitoring & Alerts')}>
<SettingsSection title={t('Model performance metrics')}>
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<FormField
control={form.control}
name='QuotaRemindThreshold'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quota reminder (tokens)')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={1}
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t('Send email alerts when a user falls below this quota')}
</FormDescription>
<FormMessage />
</FormItem>
<p className='text-muted-foreground text-xs'>
{t(
'Collect relay latency and success-rate metrics for the model square.'
)}
/>
<div>
<h4 className='font-medium'>{t('Model performance metrics')}</h4>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'Collect relay latency and success-rate metrics for the model square.'
)}
</p>
</div>
</p>
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
<FormField
@@ -30,6 +30,7 @@ const defaultOperationsSettings: OperationsSettings = {
DefaultCollapseSidebar: false,
DemoSiteEnabled: false,
SelfUseModeEnabled: false,
QuotaRemindEnabled: true,
QuotaRemindThreshold: '',
SMTPServer: '',
SMTPPort: '',
@@ -42,11 +42,10 @@ const OPERATIONS_SECTIONS = [
},
{
id: 'alerts',
titleKey: 'Monitoring & Alerts',
titleKey: 'Model performance metrics',
build: (settings: OperationsSettings) => (
<MonitoringSettingsSection
defaultValues={{
QuotaRemindThreshold: settings.QuotaRemindThreshold,
'perf_metrics_setting.enabled':
settings['perf_metrics_setting.enabled'] ?? true,
'perf_metrics_setting.flush_interval':
@@ -61,10 +60,12 @@ const OPERATIONS_SECTIONS = [
},
{
id: 'email',
titleKey: 'SMTP Email',
titleKey: 'Email & Notifications',
build: (settings: OperationsSettings) => (
<EmailSettingsSection
defaultValues={{
QuotaRemindEnabled: settings.QuotaRemindEnabled,
QuotaRemindThreshold: settings.QuotaRemindThreshold,
SMTPServer: settings.SMTPServer,
SMTPPort: settings.SMTPPort,
SMTPAccount: settings.SMTPAccount,
@@ -361,6 +361,7 @@ export type OperationsSettings = {
DefaultCollapseSidebar: boolean
DemoSiteEnabled: boolean
SelfUseModeEnabled: boolean
QuotaRemindEnabled: boolean
QuotaRemindThreshold: string
SMTPServer: string
SMTPPort: string
+12 -10
View File
@@ -1050,6 +1050,7 @@
"Change your display name and personal settings.": "Change your display name and personal settings.",
"Changed": "Changed",
"Changed / Total": "Changed / Total",
"Channel notifications": "Channel notifications",
"Channel priorities changed. Refresh and try again.": "Channel priorities changed. Refresh and try again.",
"Channel priorities saved": "Channel priorities saved",
"Channel priority is outside the sortable range": "Channel priority is outside the sortable range",
@@ -1785,6 +1786,7 @@
"Default model call chart": "Default model call chart",
"Default placeholder": "Default placeholder",
"Default range": "Default range",
"Default reminder threshold": "Default reminder threshold",
"Default Responses API version, if empty, will use the API version above": "Default Responses API version, if empty, will use the API version above",
"Deleted {{count}} auto-disabled keys": "Deleted {{count}} auto-disabled keys",
"Default session lifetime (seconds)": "Default session lifetime (seconds)",
@@ -2071,6 +2073,7 @@
"e.g. Suitable for light usage": "e.g. Suitable for light usage",
"e.g. This request does not meet access policy": "e.g. This request does not meet access policy",
"e.g., 0.95": "e.g., 0.95",
"Email & Notifications": "Email & Notifications",
"Email address is already in use": "Email address is already in use",
"e.g., 100": "e.g., 100",
"e.g., 123456": "e.g., 123456",
@@ -2339,7 +2342,6 @@
"Enter the website domain, such as example.com.": "Enter the website domain, such as example.com.",
"Export": "Export",
"Export logs": "Export logs",
"Enter threshold": "Enter threshold",
"Exported {{count}} logs": "Exported {{count}} logs",
"Enter token counts to preview the estimated cost (excluding group multipliers).": "Enter token counts to preview the estimated cost (excluding group multipliers).",
"Enter URL...": "Enter URL...",
@@ -2923,7 +2925,6 @@
"Generation was interrupted": "Generation was interrupted",
"Generic cache": "Generic cache",
"Get advice": "Get advice",
"Get notified when balance falls below this value": "Get notified when balance falls below this value",
"Get one here": "Get one here",
"Get started": "Get started",
"Get Started": "Get Started",
@@ -3358,6 +3359,7 @@
"JSON object:": "JSON object:",
"JSON structure is invalid": "JSON structure is invalid",
"JSON Text": "JSON Text",
"Leave empty to use the site default ({{amount}})": "Leave empty to use the site default ({{amount}})",
"Legacy access tokens have been retired. Create a new access token in security settings": "Legacy access tokens have been retired. Create a new access token in security settings",
"JSON-based access control rules. Leave empty to allow all users.": "JSON-based access control rules. Leave empty to allow all users.",
"Just now": "Just now",
@@ -3525,6 +3527,7 @@
"Log IP address for usage and error logs": "Log IP address for usage and error logs",
"Log Maintenance": "Log Maintenance",
"Log Type": "Log Type",
"Low balance reminder": "Low balance reminder",
"Maximum 100 redemption codes can be generated at once": "Maximum 100 redemption codes can be generated at once",
"Logged in successfully via {{method}}": "Logged in successfully via {{method}}",
"Logic": "Logic",
@@ -3812,7 +3815,6 @@
"Monitor": "Monitor",
"Monitor balance, usage, and request volume": "Monitor balance, usage, and request volume",
"Monitored relay requests": "Monitored relay requests",
"Monitoring & Alerts": "Monitoring & Alerts",
"Month": "Month",
"Month number": "Month number",
"Monthly": "Monthly",
@@ -4005,6 +4007,7 @@
"No deployments available. Create one to get started.": "No deployments available. Create one to get started.",
"No Deployments Found": "No Deployments Found",
"No description available.": "No description available.",
"No notifications are available to you right now": "No notifications are available to you right now",
"No permission to access users of same or higher level": "No permission to access users of same or higher level",
"No permission to change the 2FA settings of users at the same or higher level": "No permission to change the 2FA settings of users at the same or higher level",
"No discount tiers configured. Click \"Add discount tier\" to get started.": "No discount tiers configured. Click \"Add discount tier\" to get started.",
@@ -4147,6 +4150,7 @@
"No Upgrade": "No Upgrade",
"No upstream price differences found": "No upstream price differences found",
"No upstream ratio differences found": "No upstream ratio differences found",
"Notifications to receive": "Notifications to receive",
"OAuth provider not found": "OAuth provider not found",
"No uptime data available": "No uptime data available",
"No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "No Uptime Kuma groups yet. Click \"Add Group\" to create one.",
@@ -4964,14 +4968,12 @@
"Redemption failed, please try again later": "Redemption failed, please try again later",
"Redemption quota must be positive": "Redemption quota must be positive",
"Quota Per Unit": "Quota Per Unit",
"Quota reminder (tokens)": "Quota reminder (tokens)",
"Quota Reset": "Quota Reset",
"Quota saturation protection triggered": "Quota saturation protection triggered",
"Quota Settings": "Quota Settings",
"Quota Types": "Quota Types",
"Quota unchanged": "Quota unchanged",
"Quota update failed": "Quota update failed",
"Quota Warning Threshold": "Quota Warning Threshold",
"Quota:": "Quota:",
"Radius": "Radius",
"Random": "Random",
@@ -5009,7 +5011,6 @@
"Reason:": "Reason:",
"Reasoning": "Reasoning",
"Reasoning Effort": "Reasoning Effort",
"Receive Upstream Model Update Notifications": "Receive Upstream Model Update Notifications",
"Received": "Received",
"Regular administrators cannot promote other users to administrator": "Regular administrators cannot promote other users to administrator",
"Received amount": "Received amount",
@@ -5113,6 +5114,10 @@
"Remaining quota units": "Remaining quota units",
"Remaining:": "Remaining:",
"Remark": "Remark",
"Remind me once when my balance drops below the threshold": "Remind me once when my balance drops below the threshold",
"Remind users through their chosen notification method when their balance drops below the threshold": "Remind users through their chosen notification method when their balance drops below the threshold",
"Reminder threshold": "Reminder threshold",
"Reminder threshold cannot be negative": "Reminder threshold cannot be negative",
"Remove": "Remove",
"Remove {{group}}": "Remove {{group}}",
"Remove {{value}}": "Remove {{value}}",
@@ -5473,7 +5478,6 @@
"Save sensitive words": "Save sensitive words",
"Save Settings": "Save Settings",
"Save sidebar modules": "Save sidebar modules",
"Save SMTP settings": "Save SMTP settings",
"Save SSRF settings": "Save SSRF settings",
"Save Stripe settings": "Save Stripe settings",
"Save these backup codes in a safe place. Each code can only be used once.": "Save these backup codes in a safe place. Each code can only be used once.",
@@ -5669,7 +5673,6 @@
"Send a request": "Send a request",
"Send chat completions to the OpenAI-compatible /v1/chat/completions instead of the native Ollama /api/chat": "Send chat completions to the OpenAI-compatible /v1/chat/completions instead of the native Ollama /api/chat",
"Send code": "Send code",
"Send email alerts when a user falls below this quota": "Send email alerts when a user falls below this quota",
"Send reset email": "Send reset email",
"Send the original request body to upstream without conversion. Only available for native forwarding routes.": "Send the original request body to upstream without conversion. Only available for native forwarding routes.",
"Sending...": "Sending...",
@@ -5833,7 +5836,6 @@
"Slug is required": "Slug is required",
"Slug must be less than 100 characters": "Slug must be less than 100 characters",
"Smallest USD amount users can recharge (Epay)": "Smallest USD amount users can recharge (Epay)",
"SMTP Email": "SMTP Email",
"SMTP encryption": "SMTP encryption",
"SMTP Host": "SMTP Host",
"smtp.example.com": "smtp.example.com",
@@ -7081,7 +7083,6 @@
"View channel secrets": "View channel secrets",
"View complete API keys.": "View complete API keys.",
"View deployments and price estimates.": "View deployments and price estimates.",
"Warning threshold must be greater than 0": "Warning threshold must be greater than 0",
"View detailed information about this user including balance, usage statistics, and invitation details.": "View detailed information about this user including balance, usage statistics, and invitation details.",
"View details": "View details",
"View document": "View document",
@@ -7183,6 +7184,7 @@
"Webhook public key (production)": "Webhook public key (production)",
"Webhook public key (production) is required": "Webhook public key (production) is required",
"Webhook public key (sandbox)": "Webhook public key (sandbox)",
"When a channel is disabled or re-enabled automatically, or a test of all channels finishes": "When a channel is disabled or re-enabled automatically, or a test of all channels finishes",
"You can create at most {{count}} access tokens": "You can create at most {{count}} access tokens",
"Webhook public key (sandbox) is required": "Webhook public key (sandbox) is required",
"Webhook secret": "Webhook secret",
+12 -10
View File
@@ -1050,6 +1050,7 @@
"Change your display name and personal settings.": "Modifier votre nom d’affichage et vos paramètres personnels.",
"Changed": "Modifications",
"Changed / Total": "Modifiés / Total",
"Channel notifications": "Notifications des canaux",
"Channel priorities changed. Refresh and try again.": "Les priorités ont changé. Actualisez et réessayez.",
"Channel priorities saved": "Priorités des canaux enregistrées",
"Channel priority is outside the sortable range": "La priorité dépasse la plage de tri",
@@ -1785,6 +1786,7 @@
"Default model call chart": "Graphique d'appels de modèle par défaut",
"Default placeholder": "Icône par défaut",
"Default range": "Plage par défaut",
"Default reminder threshold": "Seuil de rappel par défaut",
"Default Responses API version, if empty, will use the API version above": "Version API des réponses par défaut, si vide, utilisera la version API ci-dessus",
"Deleted {{count}} auto-disabled keys": "{{count}} clés désactivées automatiquement supprimées",
"Default session lifetime (seconds)": "Durée de session par défaut (secondes)",
@@ -2071,6 +2073,7 @@
"e.g. Suitable for light usage": "ex. Adapté à une utilisation légère",
"e.g. This request does not meet access policy": "ex. Cette requête ne satisfait pas la politique d'accès",
"e.g., 0.95": "par ex., 0.95",
"Email & Notifications": "E-mail et notifications",
"Email address is already in use": "Cette adresse e-mail est déjà utilisée",
"e.g., 100": "par ex., 100",
"e.g., 123456": "par ex., 123456",
@@ -2339,7 +2342,6 @@
"Enter the website domain, such as example.com.": "Saisissez le domaine du site, par exemple example.com.",
"Export": "Exporter",
"Export logs": "Exporter les journaux",
"Enter threshold": "Saisir le seuil",
"Exported {{count}} logs": "{{count}} entrées exportées",
"Enter token counts to preview the estimated cost (excluding group multipliers).": "Saisissez le nombre de tokens pour estimer le coût (hors multiplicateurs de groupe).",
"Enter URL...": "Saisir l'URL...",
@@ -2923,7 +2925,6 @@
"Generation was interrupted": "La génération a été interrompue",
"Generic cache": "Cache générique",
"Get advice": "Obtenir des conseils",
"Get notified when balance falls below this value": "Recevoir une notification lorsque le solde tombe en dessous de cette valeur",
"Get one here": "Obtenir ici",
"Get started": "Commencer",
"Get Started": "Commencer",
@@ -3358,6 +3359,7 @@
"JSON object:": "Objet JSON :",
"JSON structure is invalid": "La structure JSON est invalide",
"JSON Text": "Texte JSON",
"Leave empty to use the site default ({{amount}})": "Laissez vide pour utiliser la valeur par défaut du site ({{amount}})",
"Legacy access tokens have been retired. Create a new access token in security settings": "Les anciens jetons d'accès ont été retirés. Créez un nouveau jeton d'accès dans les paramètres de sécurité",
"JSON-based access control rules. Leave empty to allow all users.": "Règles de contrôle d'accès basées sur JSON. Laisser vide pour autoriser tous les utilisateurs.",
"Just now": "À l'instant",
@@ -3525,6 +3527,7 @@
"Log IP address for usage and error logs": "Enregistrer l'adresse IP pour les journaux d'utilisation et d'erreurs",
"Log Maintenance": "Maintenance des journaux",
"Log Type": "Type de journal",
"Low balance reminder": "Rappel de solde faible",
"Maximum 100 redemption codes can be generated at once": "Vous pouvez générer au maximum 100 codes d'échange à la fois",
"Logged in successfully via {{method}}": "Connexion réussie via {{method}}",
"Logic": "Logique",
@@ -3812,7 +3815,6 @@
"Monitor": "Surveiller",
"Monitor balance, usage, and request volume": "Surveillez le solde, l'utilisation et le volume de requêtes",
"Monitored relay requests": "Requêtes relais surveillées",
"Monitoring & Alerts": "Surveillance & Alertes",
"Month": "Mois",
"Month number": "Numéro du mois",
"Monthly": "Mensuel",
@@ -4005,6 +4007,7 @@
"No deployments available. Create one to get started.": "Aucun déploiement disponible. Créez-en un pour commencer.",
"No Deployments Found": "Aucun déploiement trouvé",
"No description available.": "Aucune description disponible.",
"No notifications are available to you right now": "Aucune notification n’est disponible pour vous pour le moment",
"No permission to access users of same or higher level": "Vous n’êtes pas autorisé à consulter les utilisateurs de niveau égal ou supérieur",
"No permission to change the 2FA settings of users at the same or higher level": "Vous n’êtes pas autorisé à modifier les paramètres 2FA des utilisateurs de niveau égal ou supérieur",
"No discount tiers configured. Click \"Add discount tier\" to get started.": "Aucun niveau de réduction configuré. Cliquez sur « Ajouter un niveau de réduction » pour commencer.",
@@ -4147,6 +4150,7 @@
"No Upgrade": "Pas de mise à niveau",
"No upstream price differences found": "Aucune différence de prix amont trouvée",
"No upstream ratio differences found": "Aucune différence de ratio amont trouvée",
"Notifications to receive": "Notifications à recevoir",
"OAuth provider not found": "Fournisseur OAuth introuvable",
"No uptime data available": "Aucune donnée de disponibilité disponible",
"No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "Aucun groupe Uptime Kuma pour l'instant. Cliquez sur « Ajouter un groupe » pour en créer un.",
@@ -4964,14 +4968,12 @@
"Redemption failed, please try again later": "Échec de l’échange, veuillez réessayer plus tard",
"Redemption quota must be positive": "Le quota du code d'échange doit être positif",
"Quota Per Unit": "Quota par unité",
"Quota reminder (tokens)": "Rappel de quota (jetons)",
"Quota Reset": "Réinitialisation du quota",
"Quota saturation protection triggered": "Protection contre la saturation du quota déclenchée",
"Quota Settings": "Paramètres de quota",
"Quota Types": "Types de quotas",
"Quota unchanged": "Quota inchangé",
"Quota update failed": "Échec de la mise à jour du quota",
"Quota Warning Threshold": "Seuil d'avertissement de quota",
"Quota:": "Quota :",
"Radius": "Rayon",
"Random": "Aléatoire",
@@ -5009,7 +5011,6 @@
"Reason:": "Raison :",
"Reasoning": "Raisonnement",
"Reasoning Effort": "Effort de raisonnement",
"Receive Upstream Model Update Notifications": "Recevoir les notifications de mise à jour des modèles en amont",
"Received": "Reçu",
"Regular administrators cannot promote other users to administrator": "Les administrateurs ordinaires ne peuvent pas promouvoir d’autres utilisateurs administrateurs",
"Received amount": "Montant reçu",
@@ -5113,6 +5114,10 @@
"Remaining quota units": "Unités de quota restantes",
"Remaining:": "Restant :",
"Remark": "Remarque",
"Remind me once when my balance drops below the threshold": "Me prévenir une fois lorsque mon solde passe sous le seuil",
"Remind users through their chosen notification method when their balance drops below the threshold": "Prévenir les utilisateurs par le mode de notification de leur choix lorsque leur solde passe sous le seuil",
"Reminder threshold": "Seuil de rappel",
"Reminder threshold cannot be negative": "Le seuil de rappel ne peut pas être négatif",
"Remove": "Supprimer",
"Remove {{group}}": "Supprimer {{group}}",
"Remove {{value}}": "Retirer {{value}}",
@@ -5473,7 +5478,6 @@
"Save sensitive words": "Enregistrer les mots sensibles",
"Save Settings": "Enregistrer les paramètres",
"Save sidebar modules": "Enregistrer les modules de la barre latérale",
"Save SMTP settings": "Enregistrer les paramètres SMTP",
"Save SSRF settings": "Enregistrer les paramètres SSRF",
"Save Stripe settings": "Enregistrer les paramètres Stripe",
"Save these backup codes in a safe place. Each code can only be used once.": "Enregistrez ces codes de secours dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois.",
@@ -5669,7 +5673,6 @@
"Send a request": "Envoyer une requête",
"Send chat completions to the OpenAI-compatible /v1/chat/completions instead of the native Ollama /api/chat": "Envoyer les complétions de chat vers l'endpoint compatible OpenAI /v1/chat/completions au lieu du /api/chat natif d'Ollama",
"Send code": "Envoyer le code",
"Send email alerts when a user falls below this quota": "Envoyer des alertes par e-mail lorsqu'un utilisateur descend en dessous de ce quota",
"Send reset email": "Envoyer l'e-mail de réinitialisation",
"Send the original request body to upstream without conversion. Only available for native forwarding routes.": "Envoyer le corps de la requête d'origine à l'upstream sans conversion. Disponible uniquement pour les routes en transfert natif.",
"Sending...": "Envoi en cours...",
@@ -5833,7 +5836,6 @@
"Slug is required": "Le slug est requis",
"Slug must be less than 100 characters": "Le slug doit contenir moins de 100 caractères",
"Smallest USD amount users can recharge (Epay)": "Montant minimum en USD que les utilisateurs peuvent recharger (Epay)",
"SMTP Email": "E-mail SMTP",
"SMTP encryption": "Chiffrement SMTP",
"SMTP Host": "Hôte SMTP",
"smtp.example.com": "smtp.example.com",
@@ -7081,7 +7083,6 @@
"View channel secrets": "Voir les secrets des canaux",
"View complete API keys.": "Afficher les clés API complètes.",
"View deployments and price estimates.": "Afficher les déploiements et les estimations de prix.",
"Warning threshold must be greater than 0": "Le seuil d’alerte doit être supérieur à 0",
"View detailed information about this user including balance, usage statistics, and invitation details.": "Afficher des informations détaillées sur cet utilisateur, y compris le solde, les statistiques d'utilisation et les détails d'invitation.",
"View details": "Voir les détails",
"View document": "Afficher le document",
@@ -7183,6 +7184,7 @@
"Webhook public key (production)": "Clé publique Webhook (production)",
"Webhook public key (production) is required": "La clé publique Webhook (production) est requise",
"Webhook public key (sandbox)": "Clé publique Webhook (sandbox)",
"When a channel is disabled or re-enabled automatically, or a test of all channels finishes": "Quand un canal est désactivé ou réactivé automatiquement, ou qu’un test de tous les canaux se termine",
"You can create at most {{count}} access tokens": "Vous pouvez créer au maximum {{count}} jetons d’accès",
"Webhook public key (sandbox) is required": "La clé publique Webhook (sandbox) est requise",
"Webhook secret": "Secret du Webhook",
+12 -10
View File
@@ -1050,6 +1050,7 @@
"Change your display name and personal settings.": "表示名と個人設定を変更します。",
"Changed": "変更",
"Changed / Total": "変更件数 / 合計",
"Channel notifications": "チャネル通知",
"Channel priorities changed. Refresh and try again.": "優先度が変更されました。更新して再試行してください。",
"Channel priorities saved": "チャンネルの優先度を保存しました",
"Channel priority is outside the sortable range": "優先度が並べ替え可能な範囲外です",
@@ -1785,6 +1786,7 @@
"Default model call chart": "デフォルトのモデル呼び出しチャート",
"Default placeholder": "既定の代替アイコン",
"Default range": "デフォルト範囲",
"Default reminder threshold": "デフォルトの通知しきい値",
"Default Responses API version, if empty, will use the API version above": "デフォルトの応答APIバージョン。空の場合、上記のAPIバージョンが使用されます",
"Deleted {{count}} auto-disabled keys": "自動無効化された {{count}} 個のキーを削除しました",
"Default session lifetime (seconds)": "セッションの既定の有効期間(秒)",
@@ -2071,6 +2073,7 @@
"e.g. Suitable for light usage": "例:ライトユーザー向け",
"e.g. This request does not meet access policy": "例:このリクエストはアクセスポリシーを満たしていません",
"e.g., 0.95": "例: 0.95",
"Email & Notifications": "メールと通知",
"Email address is already in use": "このメールアドレスは既に使用されています",
"e.g., 100": "例: 100",
"e.g., 123456": "例: 123456",
@@ -2339,7 +2342,6 @@
"Enter the website domain, such as example.com.": "サイトのドメイン(例:example.com)を入力します。",
"Export": "エクスポート",
"Export logs": "ログをエクスポート",
"Enter threshold": "しきい値を入力",
"Exported {{count}} logs": "{{count}} 件のログをエクスポートしました",
"Enter token counts to preview the estimated cost (excluding group multipliers).": "トークン数を入力して、推定コストを表示します(グループ倍率は除く)。",
"Enter URL...": "URLを入力...",
@@ -2923,7 +2925,6 @@
"Generation was interrupted": "生成が中断されました",
"Generic cache": "汎用キャッシュ",
"Get advice": "アドバイスを得る",
"Get notified when balance falls below this value": "残高がこの値を下回ったときに通知を受け取る",
"Get one here": "こちらから取得",
"Get started": "はじめる",
"Get Started": "開始する",
@@ -3358,6 +3359,7 @@
"JSON object:": "JSONオブジェクト:",
"JSON structure is invalid": "JSON 構造が無効です",
"JSON Text": "JSONテキスト",
"Leave empty to use the site default ({{amount}})": "空欄の場合はシステムのデフォルト値({{amount}})を使用します",
"Legacy access tokens have been retired. Create a new access token in security settings": "旧形式のアクセストークンは廃止されました。セキュリティ設定で新しいアクセストークンを作成してください",
"JSON-based access control rules. Leave empty to allow all users.": "JSONベースのアクセス制御ルール。すべてのユーザーを許可する場合は空のままにしてください。",
"Just now": "たった今",
@@ -3525,6 +3527,7 @@
"Log IP address for usage and error logs": "使用状況およびエラーログのためにIPアドレスを記録する",
"Log Maintenance": "ログのメンテナンス",
"Log Type": "ログタイプ",
"Low balance reminder": "残高不足の通知",
"Maximum 100 redemption codes can be generated at once": "一度に生成できる引き換えコードは最大 100 個です",
"Logged in successfully via {{method}}": "{{method}} でログインに成功しました",
"Logic": "ロジック",
@@ -3812,7 +3815,6 @@
"Monitor": "モニタリング",
"Monitor balance, usage, and request volume": "残高、使用量、リクエスト数を監視",
"Monitored relay requests": "監視対象のリレーリクエスト",
"Monitoring & Alerts": "監視とアラート",
"Month": "月",
"Month number": "月番号",
"Monthly": "毎月",
@@ -4005,6 +4007,7 @@
"No deployments available. Create one to get started.": "利用可能なデプロイメントがありません。開始するには1つ作成してください。",
"No Deployments Found": "デプロイメントが見つかりません",
"No description available.": "説明はありません。",
"No notifications are available to you right now": "現在、受け取れる通知はありません",
"No permission to access users of same or higher level": "同等以上のレベルのユーザー情報にアクセスする権限がありません",
"No permission to change the 2FA settings of users at the same or higher level": "同等以上のレベルのユーザーの 2FA 設定を変更する権限がありません",
"No discount tiers configured. Click \"Add discount tier\" to get started.": "割引ティアは設定されていません。「割引ティアを追加」をクリックして開始してください。",
@@ -4147,6 +4150,7 @@
"No Upgrade": "アップグレードなし",
"No upstream price differences found": "上流価格の差異は見つかりませんでした",
"No upstream ratio differences found": "アップストリームの比率の差は見つかりません",
"Notifications to receive": "受け取る通知",
"OAuth provider not found": "OAuth プロバイダーが見つかりません",
"No uptime data available": "稼働率データがありません",
"No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "Uptime Kumaグループはまだありません。「グループを追加」をクリックして作成してください。",
@@ -4964,14 +4968,12 @@
"Redemption failed, please try again later": "引き換えに失敗しました。しばらくしてから再試行してください",
"Redemption quota must be positive": "引き換えコードのクォータは正の値にしてください",
"Quota Per Unit": "ユニットあたりのクォータ",
"Quota reminder (tokens)": "クォータリマインダー (トークン)",
"Quota Reset": "クォータリセット",
"Quota saturation protection triggered": "クォータ飽和保護が作動しました",
"Quota Settings": "クォータ設定",
"Quota Types": "クォータタイプ",
"Quota unchanged": "クォータの変更なし",
"Quota update failed": "クォータの更新に失敗しました",
"Quota Warning Threshold": "クォータ警告しきい値",
"Quota:": "クォータ:",
"Radius": "角丸",
"Random": "ランダム",
@@ -5009,7 +5011,6 @@
"Reason:": "理由:",
"Reasoning": "理由",
"Reasoning Effort": "推論強度",
"Receive Upstream Model Update Notifications": "アップストリームモデル更新通知を受け取る",
"Received": "受信済み",
"Regular administrators cannot promote other users to administrator": "一般の管理者は他のユーザーを管理者に昇格できません",
"Received amount": "受け取り額",
@@ -5113,6 +5114,10 @@
"Remaining quota units": "残りクォータ単位",
"Remaining:": "残り:",
"Remark": "備考",
"Remind me once when my balance drops below the threshold": "残高がしきい値を下回ったときに一度だけ通知します",
"Remind users through their chosen notification method when their balance drops below the threshold": "残高がしきい値を下回ったユーザーに、そのユーザーが選んだ通知方法でお知らせします",
"Reminder threshold": "通知しきい値",
"Reminder threshold cannot be negative": "通知しきい値に負の値は指定できません",
"Remove": "削除",
"Remove {{group}}": "{{group}} を削除",
"Remove {{value}}": "{{value}} を削除",
@@ -5473,7 +5478,6 @@
"Save sensitive words": "敏感な言葉を保存",
"Save Settings": "設定を保存",
"Save sidebar modules": "サイドバーモジュールを保存",
"Save SMTP settings": "SMTP設定を保存",
"Save SSRF settings": "SSRF 設定を保存",
"Save Stripe settings": "Stripe設定を保存",
"Save these backup codes in a safe place. Each code can only be used once.": "これらのバックアップコードを安全な場所に保存してください。各コードは一度だけ使用できます。",
@@ -5669,7 +5673,6 @@
"Send a request": "リクエストを送信",
"Send chat completions to the OpenAI-compatible /v1/chat/completions instead of the native Ollama /api/chat": "チャット補完を Ollama ネイティブの /api/chat ではなく、OpenAI 互換の /v1/chat/completions に送信します",
"Send code": "コードを送信",
"Send email alerts when a user falls below this quota": "ユーザーがこのクォータを下回ったときにメールアラートを送信",
"Send reset email": "リセットメールを送信",
"Send the original request body to upstream without conversion. Only available for native forwarding routes.": "元のリクエストボディを変換せずにアップストリームへ送信します。ネイティブ転送ルートでのみ利用できます。",
"Sending...": "送信中...",
@@ -5833,7 +5836,6 @@
"Slug is required": "スラッグは必須です",
"Slug must be less than 100 characters": "スラッグは100文字以内にしてください",
"Smallest USD amount users can recharge (Epay)": "ユーザーがチャージできる最小USD金額 (Epay)",
"SMTP Email": "SMTPメール",
"SMTP encryption": "SMTP 暗号化方式",
"SMTP Host": "SMTPホスト",
"smtp.example.com": "smtp.example.com",
@@ -7081,7 +7083,6 @@
"View channel secrets": "チャンネルシークレットを表示",
"View complete API keys.": "API キーを完全な形で表示します。",
"View deployments and price estimates.": "デプロイと料金見積もりを表示します。",
"Warning threshold must be greater than 0": "警告しきい値は 0 より大きくする必要があります",
"View detailed information about this user including balance, usage statistics, and invitation details.": "残高、使用統計、招待の詳細など、このユーザーに関する詳細情報を表示します。",
"View details": "詳細を表示",
"View document": "ドキュメントを表示",
@@ -7183,6 +7184,7 @@
"Webhook public key (production)": "Webhook 公開鍵(本番)",
"Webhook public key (production) is required": "本番用 Webhook 公開鍵は必須です",
"Webhook public key (sandbox)": "Webhook 公開鍵(サンドボックス)",
"When a channel is disabled or re-enabled automatically, or a test of all channels finishes": "チャネルが自動で無効化または再有効化されたとき、または全チャネルのテストが完了したとき",
"You can create at most {{count}} access tokens": "アクセストークンは最大 {{count}} 個まで作成できます",
"Webhook public key (sandbox) is required": "サンドボックス用 Webhook 公開鍵は必須です",
"Webhook secret": "Webhookシークレット",
+12 -10
View File
@@ -1050,6 +1050,7 @@
"Change your display name and personal settings.": "Изменение отображаемого имени и личных настроек.",
"Changed": "Изменено",
"Changed / Total": "Изменено / Всего",
"Channel notifications": "Уведомления о каналах",
"Channel priorities changed. Refresh and try again.": "Приоритеты изменились. Обновите страницу и повторите попытку.",
"Channel priorities saved": "Приоритеты каналов сохранены",
"Channel priority is outside the sortable range": "Приоритет канала вне допустимого диапазона сортировки",
@@ -1785,6 +1786,7 @@
"Default model call chart": "График вызовов моделей по умолчанию",
"Default placeholder": "Значок по умолчанию",
"Default range": "Диапазон по умолчанию",
"Default reminder threshold": "Порог напоминания по умолчанию",
"Default Responses API version, if empty, will use the API version above": "Версия API ответов по умолчанию; если пусто, будет использоваться версия API, указанная выше",
"Deleted {{count}} auto-disabled keys": "Удалено автоматически отключённых ключей: {{count}}",
"Default session lifetime (seconds)": "Срок сессии по умолчанию (секунды)",
@@ -2071,6 +2073,7 @@
"e.g. Suitable for light usage": "напр. Подходит для лёгкого использования",
"e.g. This request does not meet access policy": "напр. Этот запрос не соответствует политике доступа",
"e.g., 0.95": "напр., 0.95",
"Email & Notifications": "Почта и уведомления",
"Email address is already in use": "Этот адрес электронной почты уже используется",
"e.g., 100": "напр., 100",
"e.g., 123456": "напр., 123456",
@@ -2339,7 +2342,6 @@
"Enter the website domain, such as example.com.": "Введите домен сайта, например example.com.",
"Export": "Экспорт",
"Export logs": "Экспорт журналов",
"Enter threshold": "Введите порог",
"Exported {{count}} logs": "Экспортировано записей: {{count}}",
"Enter token counts to preview the estimated cost (excluding group multipliers).": "Введите количество токенов для оценки стоимости (множители групп не учитываются).",
"Enter URL...": "Введите URL...",
@@ -2923,7 +2925,6 @@
"Generation was interrupted": "Генерация была прервана",
"Generic cache": "Общий кэш",
"Get advice": "Получить совет",
"Get notified when balance falls below this value": "Получать уведомления, когда баланс опускается ниже этого значения",
"Get one here": "Получить здесь",
"Get started": "Начало работы",
"Get Started": "Начать",
@@ -3358,6 +3359,7 @@
"JSON object:": "Объект JSON:",
"JSON structure is invalid": "Структура JSON недействительна",
"JSON Text": "JSON текст",
"Leave empty to use the site default ({{amount}})": "Оставьте пустым, чтобы использовать значение сайта по умолчанию ({{amount}})",
"Legacy access tokens have been retired. Create a new access token in security settings": "Устаревшие токены доступа отключены. Создайте новый токен доступа в настройках безопасности",
"JSON-based access control rules. Leave empty to allow all users.": "Правила контроля доступа на основе JSON. Оставьте пустым, чтобы разрешить всем пользователям.",
"Just now": "Только что",
@@ -3525,6 +3527,7 @@
"Log IP address for usage and error logs": "Записывать IP-адрес для журналов использования и ошибок",
"Log Maintenance": "Журнал обслуживания",
"Log Type": "Тип журнала",
"Low balance reminder": "Напоминание о низком балансе",
"Maximum 100 redemption codes can be generated at once": "За один раз можно создать не более 100 кодов активации",
"Logged in successfully via {{method}}": "Успешный вход через {{method}}",
"Logic": "Логика",
@@ -3812,7 +3815,6 @@
"Monitor": "Мониторинг",
"Monitor balance, usage, and request volume": "Отслеживайте баланс, расход и объем запросов",
"Monitored relay requests": "Отслеживаемые ретрансляционные запросы",
"Monitoring & Alerts": "Мониторинг и оповещения",
"Month": "Месяц",
"Month number": "Номер месяца",
"Monthly": "Ежемесячно",
@@ -4005,6 +4007,7 @@
"No deployments available. Create one to get started.": "Нет доступных развертываний. Создайте одно, чтобы начать.",
"No Deployments Found": "Развертывания не найдены",
"No description available.": "Описание отсутствует.",
"No notifications are available to you right now": "Сейчас для вас нет доступных уведомлений",
"No permission to access users of same or higher level": "Нет прав на просмотр пользователей того же или более высокого уровня",
"No permission to change the 2FA settings of users at the same or higher level": "Нет прав на изменение настроек 2FA пользователей того же или более высокого уровня",
"No discount tiers configured. Click \"Add discount tier\" to get started.": "Не настроены уровни скидок. Нажмите \"Добавить уровень скидки\", чтобы начать.",
@@ -4147,6 +4150,7 @@
"No Upgrade": "Без повышения",
"No upstream price differences found": "Различий в ценах провайдера не найдено",
"No upstream ratio differences found": "Различия в коэффициентах вышестоящего потока не найдены",
"Notifications to receive": "Получаемые уведомления",
"OAuth provider not found": "Поставщик OAuth не найден",
"No uptime data available": "Данные о доступности недоступны",
"No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "Пока нет групп Uptime Kuma. Нажмите \"Добавить группу\", чтобы создать одну.",
@@ -4964,14 +4968,12 @@
"Redemption failed, please try again later": "Не удалось активировать код, повторите попытку позже",
"Redemption quota must be positive": "Квота кода активации должна быть положительной",
"Quota Per Unit": "Квота на единицу",
"Quota reminder (tokens)": "Напоминание о квоте (токены)",
"Quota Reset": "Сброс квоты",
"Quota saturation protection triggered": "Сработала защита от переполнения квоты",
"Quota Settings": "Настройки квоты",
"Quota Types": "Типы квот",
"Quota unchanged": "Квота не изменилась",
"Quota update failed": "Не удалось обновить квоту",
"Quota Warning Threshold": "Порог предупреждения о квоте",
"Quota:": "Квота:",
"Radius": "Радиус",
"Random": "Случайный",
@@ -5009,7 +5011,6 @@
"Reason:": "Причина:",
"Reasoning": "Обоснование",
"Reasoning Effort": "Интенсивность рассуждения",
"Receive Upstream Model Update Notifications": "Получать уведомления об обновлениях вышестоящих моделей",
"Received": "Получено",
"Regular administrators cannot promote other users to administrator": "Обычные администраторы не могут назначать других пользователей администраторами",
"Received amount": "Полученная сумма",
@@ -5113,6 +5114,10 @@
"Remaining quota units": "Остаток единиц квоты",
"Remaining:": "Осталось:",
"Remark": "Примечания",
"Remind me once when my balance drops below the threshold": "Напомнить один раз, когда баланс опустится ниже порога",
"Remind users through their chosen notification method when their balance drops below the threshold": "Напоминать пользователям выбранным ими способом уведомления, когда баланс опускается ниже порога",
"Reminder threshold": "Порог напоминания",
"Reminder threshold cannot be negative": "Порог напоминания не может быть отрицательным",
"Remove": "Удалить",
"Remove {{group}}": "Удалить {{group}}",
"Remove {{value}}": "Удалить {{value}}",
@@ -5473,7 +5478,6 @@
"Save sensitive words": "Сохранить чувствительные слова",
"Save Settings": "Сохранить настройки",
"Save sidebar modules": "Сохранить модули боковой панели",
"Save SMTP settings": "Сохранить настройки SMTP",
"Save SSRF settings": "Сохранить настройки SSRF",
"Save Stripe settings": "Сохранить настройки Stripe",
"Save these backup codes in a safe place. Each code can only be used once.": "Сохраните эти резервные коды в безопасном месте. Каждый код может быть использован только один раз.",
@@ -5669,7 +5673,6 @@
"Send a request": "Отправить запрос",
"Send chat completions to the OpenAI-compatible /v1/chat/completions instead of the native Ollama /api/chat": "Отправлять chat completions на OpenAI-совместимый /v1/chat/completions вместо нативного /api/chat Ollama",
"Send code": "Отправить код",
"Send email alerts when a user falls below this quota": "Отправлять оповещения по электронной почте, когда пользователь опускается ниже этой квоты",
"Send reset email": "Отправить письмо для сброса пароля",
"Send the original request body to upstream without conversion. Only available for native forwarding routes.": "Отправлять исходное тело запроса вышестоящему серверу без преобразования. Доступно только для маршрутов с нативной пересылкой.",
"Sending...": "Отправка...",
@@ -5833,7 +5836,6 @@
"Slug is required": "Slug обязателен",
"Slug must be less than 100 characters": "Slug должен содержать менее 100 символов",
"Smallest USD amount users can recharge (Epay)": "Минимальная сумма в USD, которую пользователи могут пополнить (Epay)",
"SMTP Email": "Электронная почта SMTP",
"SMTP encryption": "Шифрование SMTP",
"SMTP Host": "Хост SMTP",
"smtp.example.com": "smtp.example.com",
@@ -7081,7 +7083,6 @@
"View channel secrets": "Просматривать секреты каналов",
"View complete API keys.": "Просмотр API-ключей целиком.",
"View deployments and price estimates.": "Просмотр развертываний и оценок стоимости.",
"Warning threshold must be greater than 0": "Порог предупреждения должен быть больше 0",
"View detailed information about this user including balance, usage statistics, and invitation details.": "Просмотр подробной информации об этом пользователе, включая баланс, статистику использования и данные приглашения.",
"View details": "Просмотреть детали",
"View document": "Просмотреть документ",
@@ -7183,6 +7184,7 @@
"Webhook public key (production)": "Публичный ключ webhook (production)",
"Webhook public key (production) is required": "Требуется публичный ключ webhook (production)",
"Webhook public key (sandbox)": "Публичный ключ webhook (sandbox)",
"When a channel is disabled or re-enabled automatically, or a test of all channels finishes": "Когда канал автоматически отключается или снова включается, а также по завершении проверки всех каналов",
"You can create at most {{count}} access tokens": "Можно создать не более {{count}} токенов доступа",
"Webhook public key (sandbox) is required": "Требуется публичный ключ webhook (sandbox)",
"Webhook secret": "Секрет веб-хука",
+12 -10
View File
@@ -1050,6 +1050,7 @@
"Change your display name and personal settings.": "Thay đổi tên hiển thị và cài đặt cá nhân.",
"Changed": "Đã thay đổi",
"Changed / Total": "Đã thay đổi / Tổng số",
"Channel notifications": "Thông báo về kênh",
"Channel priorities changed. Refresh and try again.": "Độ ưu tiên đã thay đổi. Hãy làm mới và thử lại.",
"Channel priorities saved": "Đã lưu độ ưu tiên kênh",
"Channel priority is outside the sortable range": "Độ ưu tiên kênh nằm ngoài phạm vi sắp xếp",
@@ -1785,6 +1786,7 @@
"Default model call chart": "Biểu đồ lượt gọi mô hình mặc định",
"Default placeholder": "Biểu tượng mặc định",
"Default range": "Khoảng mặc định",
"Default reminder threshold": "Ngưỡng nhắc nhở mặc định",
"Default Responses API version, if empty, will use the API version above": "Phiên bản API phản hồi mặc định, nếu để trống, sẽ sử dụng phiên bản API ở trên",
"Deleted {{count}} auto-disabled keys": "Đã xóa {{count}} khóa bị tự động tắt",
"Default session lifetime (seconds)": "Thời hạn phiên mặc định (giây)",
@@ -2071,6 +2073,7 @@
"e.g. Suitable for light usage": "ví dụ: Phù hợp cho sử dụng nhẹ",
"e.g. This request does not meet access policy": "ví dụ: Yêu cầu này không đáp ứng chính sách truy cập",
"e.g., 0.95": "e.g., 0.95",
"Email & Notifications": "Email và thông báo",
"Email address is already in use": "Địa chỉ email đã được sử dụng",
"e.g., 100": "e.g., 100",
"e.g., 123456": "e.g., 123456",
@@ -2339,7 +2342,6 @@
"Enter the website domain, such as example.com.": "Nhập tên miền trang, ví dụ example.com.",
"Export": "Xuất",
"Export logs": "Xuất nhật ký",
"Enter threshold": "Nhập ngưỡng",
"Exported {{count}} logs": "Đã xuất {{count}} bản ghi nhật ký",
"Enter token counts to preview the estimated cost (excluding group multipliers).": "Nhập số lượng token để xem ước tính chi phí (không gồm bội số nhóm).",
"Enter URL...": "Nhập URL...",
@@ -2923,7 +2925,6 @@
"Generation was interrupted": "Quá trình tạo đã bị gián đoạn",
"Generic cache": "Bộ đệm chung",
"Get advice": "Nhận lời khuyên",
"Get notified when balance falls below this value": "Nhận thông báo khi số dư giảm xuống dưới giá trị này",
"Get one here": "Nhận tại đây",
"Get started": "Bắt đầu",
"Get Started": "Bắt đầu",
@@ -3358,6 +3359,7 @@
"JSON object:": "Đối tượng JSON:",
"JSON structure is invalid": "Cấu trúc JSON không hợp lệ",
"JSON Text": "Văn bản JSON",
"Leave empty to use the site default ({{amount}})": "Để trống để dùng giá trị mặc định của trang ({{amount}})",
"Legacy access tokens have been retired. Create a new access token in security settings": "Token truy cập cũ đã ngừng sử dụng. Hãy tạo token truy cập mới trong cài đặt bảo mật",
"JSON-based access control rules. Leave empty to allow all users.": "Quy tắc kiểm soát truy cập dựa trên JSON. Để trống để cho phép tất cả người dùng.",
"Just now": "Vừa nãy",
@@ -3525,6 +3527,7 @@
"Log IP address for usage and error logs": "Ghi lại địa chỉ IP cho nhật ký sử dụng và lỗi",
"Log Maintenance": "Bảo trì Nhật ký",
"Log Type": "Loại nhật ký",
"Low balance reminder": "Nhắc nhở khi số dư thấp",
"Maximum 100 redemption codes can be generated at once": "Mỗi lần chỉ tạo được tối đa 100 mã đổi thưởng",
"Logged in successfully via {{method}}": "Đăng nhập thành công qua {{method}}",
"Logic": "Logic",
@@ -3812,7 +3815,6 @@
"Monitor": "Giám sát",
"Monitor balance, usage, and request volume": "Theo dõi số dư, mức dùng và số lượng yêu cầu",
"Monitored relay requests": "Yêu cầu relay được giám sát",
"Monitoring & Alerts": "Giám sát & Cảnh báo",
"Month": "Tháng",
"Month number": "Số tháng",
"Monthly": "Hàng tháng",
@@ -4005,6 +4007,7 @@
"No deployments available. Create one to get started.": "Không có triển khai nào khả dụng. Tạo một cái để bắt đầu.",
"No Deployments Found": "Không tìm thấy triển khai nào",
"No description available.": "Chưa có mô tả.",
"No notifications are available to you right now": "Hiện không có thông báo nào dành cho bạn",
"No permission to access users of same or higher level": "Không có quyền xem người dùng cùng cấp hoặc cấp cao hơn",
"No permission to change the 2FA settings of users at the same or higher level": "Không có quyền thay đổi cài đặt 2FA của người dùng cùng cấp hoặc cấp cao hơn",
"No discount tiers configured. Click \"Add discount tier\" to get started.": "Chưa cấu hình cấp chiết khấu nào. Nhấp vào \"Thêm cấp chiết khấu\" để bắt đầu.",
@@ -4147,6 +4150,7 @@
"No Upgrade": "Không nâng cấp",
"No upstream price differences found": "Không tìm thấy sự khác biệt về giá upstream",
"No upstream ratio differences found": "Không tìm thấy chênh lệch tỷ lệ thượng nguồn",
"Notifications to receive": "Thông báo muốn nhận",
"OAuth provider not found": "Không tìm thấy nhà cung cấp OAuth",
"No uptime data available": "Không có dữ liệu thời gian hoạt động",
"No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "Chưa có nhóm Uptime Kuma nào. Nhấp vào \"Thêm nhóm\" để tạo một nhóm.",
@@ -4964,14 +4968,12 @@
"Redemption failed, please try again later": "Đổi mã thất bại, vui lòng thử lại sau",
"Redemption quota must be positive": "Hạn mức của mã đổi thưởng phải là số dương",
"Quota Per Unit": "Định mức mỗi đơn vị",
"Quota reminder (tokens)": "Nhắc nhở hạn mức (token)",
"Quota Reset": "Đặt lại hạn mức",
"Quota saturation protection triggered": "Đã kích hoạt bảo vệ chống tràn hạn ngạch",
"Quota Settings": "Cài đặt Hạn mức",
"Quota Types": "Các loại hạn ngạch",
"Quota unchanged": "Hạn mức không thay đổi",
"Quota update failed": "Cập nhật hạn mức thất bại",
"Quota Warning Threshold": "Ngưỡng cảnh báo hạn mức",
"Quota:": "Hạn ngạch:",
"Radius": "Bo góc",
"Random": "Ngẫu nhiên",
@@ -5009,7 +5011,6 @@
"Reason:": "Lý do:",
"Reasoning": "Lý luận",
"Reasoning Effort": "Cường độ suy luận",
"Receive Upstream Model Update Notifications": "Nhận thông báo cập nhật mô hình nguồn",
"Received": "Đã nhận",
"Regular administrators cannot promote other users to administrator": "Quản trị viên thông thường không thể nâng người dùng khác lên quản trị viên",
"Received amount": "Số tiền đã nhận",
@@ -5113,6 +5114,10 @@
"Remaining quota units": "Đơn vị hạn ngạch còn lại",
"Remaining:": "Còn lại:",
"Remark": "Nhận xét",
"Remind me once when my balance drops below the threshold": "Nhắc tôi một lần khi số dư giảm xuống dưới ngưỡng",
"Remind users through their chosen notification method when their balance drops below the threshold": "Nhắc người dùng qua phương thức thông báo họ đã chọn khi số dư giảm xuống dưới ngưỡng",
"Reminder threshold": "Ngưỡng nhắc nhở",
"Reminder threshold cannot be negative": "Ngưỡng nhắc nhở không được là số âm",
"Remove": "Xóa",
"Remove {{group}}": "Xóa {{group}}",
"Remove {{value}}": "Xóa {{value}}",
@@ -5473,7 +5478,6 @@
"Save sensitive words": "Lưu từ nhạy cảm",
"Save Settings": "Lưu Cài đặt",
"Save sidebar modules": "Lưu các mô-đun thanh bên",
"Save SMTP settings": "Lưu cài đặt SMTP",
"Save SSRF settings": "Lưu cài đặt SSRF",
"Save Stripe settings": "Lưu cài đặt Stripe",
"Save these backup codes in a safe place. Each code can only be used once.": "Lưu các mã dự phòng này ở nơi an toàn. Mỗi mã chỉ được sử dụng một lần.",
@@ -5669,7 +5673,6 @@
"Send a request": "Gửi yêu cầu",
"Send chat completions to the OpenAI-compatible /v1/chat/completions instead of the native Ollama /api/chat": "Gửi yêu cầu chat completions tới /v1/chat/completions tương thích OpenAI thay vì /api/chat gốc của Ollama",
"Send code": "Gửi mã",
"Send email alerts when a user falls below this quota": "Gửi cảnh báo email khi người dùng xuống dưới hạn mức này",
"Send reset email": "Gửi email đặt lại",
"Send the original request body to upstream without conversion. Only available for native forwarding routes.": "Gửi phần thân yêu cầu gốc lên upstream mà không chuyển đổi. Chỉ dùng được cho các tuyến chuyển tiếp nguyên bản.",
"Sending...": "Đang gửi...",
@@ -5833,7 +5836,6 @@
"Slug is required": "Slug là bắt buộc",
"Slug must be less than 100 characters": "Slug phải ít hơn 100 ký tự",
"Smallest USD amount users can recharge (Epay)": "Số tiền USD tối thiểu người dùng có thể nạp (Epay)",
"SMTP Email": "Email SMTP",
"SMTP encryption": "Mã hóa SMTP",
"SMTP Host": "Máy chủ SMTP",
"smtp.example.com": "smtp.example.com",
@@ -7081,7 +7083,6 @@
"View channel secrets": "Xem bí mật kênh",
"View complete API keys.": "Xem đầy đủ khóa API.",
"View deployments and price estimates.": "Xem triển khai và ước tính giá.",
"Warning threshold must be greater than 0": "Ngưỡng cảnh báo phải lớn hơn 0",
"View detailed information about this user including balance, usage statistics, and invitation details.": "Xem thông tin chi tiết về người dùng này bao gồm số dư, thống kê sử dụng và chi tiết lời mời.",
"View details": "Xem chi tiết",
"View document": "Xem tài liệu",
@@ -7183,6 +7184,7 @@
"Webhook public key (production)": "Khóa công khai Webhook (production)",
"Webhook public key (production) is required": "Bắt buộc nhập khóa công khai Webhook (production)",
"Webhook public key (sandbox)": "Khóa công khai Webhook (sandbox)",
"When a channel is disabled or re-enabled automatically, or a test of all channels finishes": "Khi một kênh bị tự động tắt hoặc bật lại, hoặc khi kiểm tra tất cả kênh hoàn tất",
"You can create at most {{count}} access tokens": "Bạn có thể tạo tối đa {{count}} token truy cập",
"Webhook public key (sandbox) is required": "Bắt buộc nhập khóa công khai Webhook (sandbox)",
"Webhook secret": "Bí mật webhook",
+12 -10
View File
@@ -1050,6 +1050,7 @@
"Change your display name and personal settings.": "修改顯示名稱和個人設定。",
"Changed": "變更",
"Changed / Total": "已變更 / 總數",
"Channel notifications": "渠道通知",
"Channel priorities changed. Refresh and try again.": "通道優先級已變更,請重新整理後再試。",
"Channel priorities saved": "通道優先級已儲存",
"Channel priority is outside the sortable range": "通道優先級超出可排序範圍",
@@ -1785,6 +1786,7 @@
"Default model call chart": "預設模型呼叫圖",
"Default placeholder": "預設佔位圖示",
"Default range": "預設範圍",
"Default reminder threshold": "預設提醒閾值",
"Default Responses API version, if empty, will use the API version above": "預設回應 API 版本,如果為空,將使用上面的 API 版本",
"Deleted {{count}} auto-disabled keys": "已刪除 {{count}} 個自動停用的金鑰",
"Default session lifetime (seconds)": "預設工作階段有效期(秒)",
@@ -2071,6 +2073,7 @@
"e.g. Suitable for light usage": "例如:適合輕度使用",
"e.g. This request does not meet access policy": "例如:該請求不滿足准入策略",
"e.g., 0.95": "例如,0.95",
"Email & Notifications": "電郵與通知",
"Email address is already in use": "電子郵件地址已被使用",
"e.g., 100": "例如,100",
"e.g., 123456": "例如,123456",
@@ -2339,7 +2342,6 @@
"Enter the website domain, such as example.com.": "填寫網站網域,例如 example.com。",
"Export": "匯出",
"Export logs": "匯出日誌",
"Enter threshold": "輸入閾值",
"Exported {{count}} logs": "已匯出 {{count}} 筆日誌",
"Enter token counts to preview the estimated cost (excluding group multipliers).": "輸入 token 數量以預覽預估費用(不含用戶組倍率)。",
"Enter URL...": "輸入 URL...",
@@ -2923,7 +2925,6 @@
"Generation was interrupted": "生成已中斷",
"Generic cache": "通用緩存",
"Get advice": "獲取建議",
"Get notified when balance falls below this value": "當餘額低於此值時接收通知",
"Get one here": "點此獲取",
"Get started": "開始使用",
"Get Started": "開始使用",
@@ -3358,6 +3359,7 @@
"JSON object:": "JSON 物件:",
"JSON structure is invalid": "JSON 結構無效",
"JSON Text": "JSON 文字",
"Leave empty to use the site default ({{amount}})": "留空則使用系統預設值({{amount}})",
"Legacy access tokens have been retired. Create a new access token in security settings": "舊版存取權杖已停用,請在安全設定中建立新的存取權杖",
"JSON-based access control rules. Leave empty to allow all users.": "基於 JSON 的存取控制規則。留空以允許所有用戶。",
"Just now": "剛剛",
@@ -3525,6 +3527,7 @@
"Log IP address for usage and error logs": "記錄用於使用和錯誤日誌的 IP 地址",
"Log Maintenance": "日誌維護",
"Log Type": "日誌類型",
"Low balance reminder": "餘額不足提醒",
"Maximum 100 redemption codes can be generated at once": "一次兌換碼批量生成的個數不能大於 100",
"Logged in successfully via {{method}}": "登入成功(透過 {{method}})",
"Logic": "邏輯",
@@ -3812,7 +3815,6 @@
"Monitor": "監控",
"Monitor balance, usage, and request volume": "監控餘額、用量和請求量",
"Monitored relay requests": "已監控的中繼請求",
"Monitoring & Alerts": "監控與警報",
"Month": "本月",
"Month number": "月份",
"Monthly": "每月",
@@ -4005,6 +4007,7 @@
"No deployments available. Create one to get started.": "沒有可用的部署。建立一個開始吧。",
"No Deployments Found": "未找到部署",
"No description available.": "暫無描述。",
"No notifications are available to you right now": "目前沒有可接收的通知",
"No permission to access users of same or higher level": "無權取得同級或更高等級使用者的資訊",
"No permission to change the 2FA settings of users at the same or higher level": "無權操作同級或更高級使用者的 2FA 設定",
"No discount tiers configured. Click \"Add discount tier\" to get started.": "未設定折扣等級。點擊「新增折扣等級」即可開始使用。",
@@ -4147,6 +4150,7 @@
"No Upgrade": "不升級",
"No upstream price differences found": "未發現上游價格差異",
"No upstream ratio differences found": "未找到上游比例差異",
"Notifications to receive": "接收哪些通知",
"OAuth provider not found": "找不到該 OAuth 供應商",
"No uptime data available": "暫無可用率數據",
"No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "暫無 Uptime Kuma 分組。點擊「新增分組」來建立一個。",
@@ -4964,14 +4968,12 @@
"Redemption failed, please try again later": "兌換失敗,請稍後再試",
"Redemption quota must be positive": "兌換碼額度必須大於 0",
"Quota Per Unit": "每單位配額",
"Quota reminder (tokens)": "配額提醒(token)",
"Quota Reset": "額度重置",
"Quota saturation protection triggered": "已觸發額度飽和保護",
"Quota Settings": "額度設定",
"Quota Types": "配額類型",
"Quota unchanged": "額度未變更",
"Quota update failed": "額度更新失敗",
"Quota Warning Threshold": "配額警告閾值",
"Quota:": "Quota:",
"Radius": "圓角",
"Random": "隨機",
@@ -5009,7 +5011,6 @@
"Reason:": "原因:",
"Reasoning": "推理",
"Reasoning Effort": "推理強度",
"Receive Upstream Model Update Notifications": "接收上游模型更新通知",
"Received": "獲得",
"Regular administrators cannot promote other users to administrator": "一般管理員無法將其他使用者提升為管理員",
"Received amount": "已收額度",
@@ -5113,6 +5114,10 @@
"Remaining quota units": "剩餘配額單位",
"Remaining:": "剩餘:",
"Remark": "備註",
"Remind me once when my balance drops below the threshold": "餘額低於閾值時提醒我一次",
"Remind users through their chosen notification method when their balance drops below the threshold": "使用者餘額低於閾值時,透過其選擇的通知方式提醒",
"Reminder threshold": "提醒閾值",
"Reminder threshold cannot be negative": "提醒閾值不能為負數",
"Remove": "移除",
"Remove {{group}}": "移除 {{group}}",
"Remove {{value}}": "移除 {{value}}",
@@ -5473,7 +5478,6 @@
"Save sensitive words": "儲存敏感詞",
"Save Settings": "儲存設定",
"Save sidebar modules": "儲存側邊欄模組",
"Save SMTP settings": "儲存 SMTP 設定",
"Save SSRF settings": "儲存 SSRF 設定",
"Save Stripe settings": "儲存 Stripe 設定",
"Save these backup codes in a safe place. Each code can only be used once.": "將這些備份代碼儲存在安全的地方。每個代碼只能使用一次。",
@@ -5669,7 +5673,6 @@
"Send a request": "發送請求",
"Send chat completions to the OpenAI-compatible /v1/chat/completions instead of the native Ollama /api/chat": "將對話補全請求傳送至 OpenAI 相容的 /v1/chat/completions,而不是 Ollama 原生的 /api/chat",
"Send code": "發送驗證碼",
"Send email alerts when a user falls below this quota": "當用戶低於此配額時發送電郵警報",
"Send reset email": "發送重設郵件",
"Send the original request body to upstream without conversion. Only available for native forwarding routes.": "將原始請求體不經轉換直接傳送到上游。僅原生轉發路由可用。",
"Sending...": "發送中...",
@@ -5833,7 +5836,6 @@
"Slug is required": "Slug 不能為空",
"Slug must be less than 100 characters": "Slug 不能超過 100 個字元",
"Smallest USD amount users can recharge (Epay)": "用戶可以儲值的最小美元金額 (Epay)",
"SMTP Email": "SMTP 電郵",
"SMTP encryption": "SMTP 加密方式",
"SMTP Host": "SMTP 主機",
"smtp.example.com": "smtp.example.com",
@@ -7081,7 +7083,6 @@
"View channel secrets": "查看渠道金鑰",
"View complete API keys.": "檢視完整的 API 金鑰。",
"View deployments and price estimates.": "檢視部署和價格估算。",
"Warning threshold must be greater than 0": "預警閾值必須大於 0",
"View detailed information about this user including balance, usage statistics, and invitation details.": "查看此用戶的詳細資訊,包括餘額、使用統計和邀請詳情。",
"View details": "查看詳情",
"View document": "查看文檔",
@@ -7183,6 +7184,7 @@
"Webhook public key (production)": "Webhook 公鑰(生產)",
"Webhook public key (production) is required": "Webhook 公鑰(生產)為必填項",
"Webhook public key (sandbox)": "Webhook 公鑰(沙盒)",
"When a channel is disabled or re-enabled automatically, or a test of all channels finishes": "渠道被自動停用或重新啟用,或全部渠道測試完成時",
"You can create at most {{count}} access tokens": "最多可建立 {{count}} 個存取令牌",
"Webhook public key (sandbox) is required": "Webhook 公鑰(沙盒)為必填項",
"Webhook secret": "Webhook 金鑰",
+12 -10
View File
@@ -1050,6 +1050,7 @@
"Change your display name and personal settings.": "修改显示名称和个人设置。",
"Changed": "变更",
"Changed / Total": "已变更 / 总数",
"Channel notifications": "渠道通知",
"Channel priorities changed. Refresh and try again.": "渠道优先级已变更,请刷新后重试。",
"Channel priorities saved": "渠道优先级已保存",
"Channel priority is outside the sortable range": "渠道优先级超出可排序范围",
@@ -1785,6 +1786,7 @@
"Default model call chart": "默认模型调用图",
"Default placeholder": "默认占位图标",
"Default range": "默认范围",
"Default reminder threshold": "默认提醒阈值",
"Default Responses API version, if empty, will use the API version above": "默认响应 API 版本,如果为空,将使用上面的 API 版本",
"Deleted {{count}} auto-disabled keys": "已删除 {{count}} 个自动禁用的密钥",
"Default session lifetime (seconds)": "默认会话有效期(秒)",
@@ -2071,6 +2073,7 @@
"e.g. Suitable for light usage": "例如:适合轻度使用",
"e.g. This request does not meet access policy": "例如:该请求不满足准入策略",
"e.g., 0.95": "例如,0.95",
"Email & Notifications": "邮件与通知",
"Email address is already in use": "邮箱地址已被占用",
"e.g., 100": "例如,100",
"e.g., 123456": "例如,123456",
@@ -2339,7 +2342,6 @@
"Enter the website domain, such as example.com.": "填写网站域名,例如 example.com。",
"Export": "导出",
"Export logs": "导出日志",
"Enter threshold": "输入阈值",
"Exported {{count}} logs": "已导出 {{count}} 条日志",
"Enter token counts to preview the estimated cost (excluding group multipliers).": "输入 token 数量以预览预估费用(不含用户组倍率)。",
"Enter URL...": "输入 URL...",
@@ -2923,7 +2925,6 @@
"Generation was interrupted": "生成已中断",
"Generic cache": "通用缓存",
"Get advice": "获取建议",
"Get notified when balance falls below this value": "当余额低于此值时接收通知",
"Get one here": "点此获取",
"Get started": "开始使用",
"Get Started": "开始使用",
@@ -3358,6 +3359,7 @@
"JSON object:": "JSON 对象:",
"JSON structure is invalid": "JSON 结构无效",
"JSON Text": "JSON 文本",
"Leave empty to use the site default ({{amount}})": "留空则使用系统默认值({{amount}})",
"Legacy access tokens have been retired. Create a new access token in security settings": "旧版访问令牌已停用,请在安全设置中创建新的访问令牌",
"JSON-based access control rules. Leave empty to allow all users.": "基于 JSON 的访问控制规则。留空以允许所有用户。",
"Just now": "刚刚",
@@ -3525,6 +3527,7 @@
"Log IP address for usage and error logs": "记录用于使用和错误日志的 IP 地址",
"Log Maintenance": "日志维护",
"Log Type": "日志类型",
"Low balance reminder": "余额不足提醒",
"Maximum 100 redemption codes can be generated at once": "一次兑换码批量生成的个数不能大于 100",
"Logged in successfully via {{method}}": "登录成功(通过 {{method}})",
"Logic": "逻辑",
@@ -3812,7 +3815,6 @@
"Monitor": "监控",
"Monitor balance, usage, and request volume": "监控余额、用量和请求量",
"Monitored relay requests": "已监控的中继请求",
"Monitoring & Alerts": "监控与警报",
"Month": "本月",
"Month number": "月份",
"Monthly": "每月",
@@ -4005,6 +4007,7 @@
"No deployments available. Create one to get started.": "没有可用的部署。创建一个开始吧。",
"No Deployments Found": "未找到部署",
"No description available.": "暂无描述。",
"No notifications are available to you right now": "目前没有可接收的通知",
"No permission to access users of same or higher level": "无权获取同级或更高等级用户的信息",
"No permission to change the 2FA settings of users at the same or higher level": "无权操作同级或更高级用户的2FA设置",
"No discount tiers configured. Click \"Add discount tier\" to get started.": "未配置折扣等级。点击“添加折扣等级”即可开始使用。",
@@ -4147,6 +4150,7 @@
"No Upgrade": "不升级",
"No upstream price differences found": "未发现上游价格差异",
"No upstream ratio differences found": "未找到上游比例差异",
"Notifications to receive": "接收哪些通知",
"OAuth provider not found": "未找到该 OAuth 提供商",
"No uptime data available": "暂无可用率数据",
"No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "暂无 Uptime Kuma 分组。点击“添加分组”来创建一个。",
@@ -4964,14 +4968,12 @@
"Redemption failed, please try again later": "兑换失败,请稍后重试",
"Redemption quota must be positive": "兑换码额度必须大于 0",
"Quota Per Unit": "每单位配额",
"Quota reminder (tokens)": "配额提醒(token)",
"Quota Reset": "额度重置",
"Quota saturation protection triggered": "额度饱和保护已触发",
"Quota Settings": "额度设置",
"Quota Types": "配额类型",
"Quota unchanged": "额度未变化",
"Quota update failed": "额度更新失败",
"Quota Warning Threshold": "配额警告阈值",
"Quota:": "Quota:",
"Radius": "圆角",
"Random": "随机",
@@ -5009,7 +5011,6 @@
"Reason:": "原因:",
"Reasoning": "推理",
"Reasoning Effort": "推理强度",
"Receive Upstream Model Update Notifications": "接收上游模型更新通知",
"Received": "获得",
"Regular administrators cannot promote other users to administrator": "普通管理员用户无法提升其他用户为管理员",
"Received amount": "已收额度",
@@ -5113,6 +5114,10 @@
"Remaining quota units": "剩余配额单位",
"Remaining:": "剩余:",
"Remark": "备注",
"Remind me once when my balance drops below the threshold": "余额低于阈值时提醒我一次",
"Remind users through their chosen notification method when their balance drops below the threshold": "用户余额低于阈值时,通过其选择的通知方式提醒",
"Reminder threshold": "提醒阈值",
"Reminder threshold cannot be negative": "提醒阈值不能为负数",
"Remove": "移除",
"Remove {{group}}": "移除 {{group}}",
"Remove {{value}}": "移除 {{value}}",
@@ -5473,7 +5478,6 @@
"Save sensitive words": "保存敏感词",
"Save Settings": "保存设置",
"Save sidebar modules": "保存侧边栏模块",
"Save SMTP settings": "保存 SMTP 设置",
"Save SSRF settings": "保存 SSRF 设置",
"Save Stripe settings": "保存 Stripe 设置",
"Save these backup codes in a safe place. Each code can only be used once.": "将这些备份代码保存在安全的地方。每个代码只能使用一次。",
@@ -5669,7 +5673,6 @@
"Send a request": "发送请求",
"Send chat completions to the OpenAI-compatible /v1/chat/completions instead of the native Ollama /api/chat": "将对话补全请求发送至 OpenAI 兼容的 /v1/chat/completions,而不是 Ollama 原生的 /api/chat",
"Send code": "发送验证码",
"Send email alerts when a user falls below this quota": "当用户低于此配额时发送电子邮件警报",
"Send reset email": "发送重置邮件",
"Send the original request body to upstream without conversion. Only available for native forwarding routes.": "将原始请求体不经转换直接发送到上游。仅原生转发路由可用。",
"Sending...": "发送中...",
@@ -5833,7 +5836,6 @@
"Slug is required": "Slug 不能为空",
"Slug must be less than 100 characters": "Slug 不能超过 100 个字符",
"Smallest USD amount users can recharge (Epay)": "用户可以充值的最小美元金额 (Epay)",
"SMTP Email": "SMTP 邮箱",
"SMTP encryption": "SMTP 加密方式",
"SMTP Host": "SMTP 主机",
"smtp.example.com": "smtp.example.com",
@@ -7081,7 +7083,6 @@
"View channel secrets": "查看渠道密钥",
"View complete API keys.": "查看完整的 API 密钥。",
"View deployments and price estimates.": "查看部署和价格估算。",
"Warning threshold must be greater than 0": "预警阈值必须大于 0",
"View detailed information about this user including balance, usage statistics, and invitation details.": "查看此用户的详细信息,包括余额、使用统计和邀请详情。",
"View details": "查看详情",
"View document": "查看文档",
@@ -7183,6 +7184,7 @@
"Webhook public key (production)": "Webhook 公钥(生产)",
"Webhook public key (production) is required": "Webhook 公钥(生产)为必填项",
"Webhook public key (sandbox)": "Webhook 公钥(沙盒)",
"When a channel is disabled or re-enabled automatically, or a test of all channels finishes": "渠道被自动禁用或重新启用,或全部渠道测试完成时",
"You can create at most {{count}} access tokens": "最多可创建 {{count}} 个访问令牌",
"Webhook public key (sandbox) is required": "Webhook 公钥(沙盒)为必填项",
"Webhook secret": "Webhook 密钥",