luckfun233 commited on
Commit ·
766bd1c
1
Parent(s): 534def0
feat: Implement account role management and banning functionality
Browse files- Added role management for accounts, allowing accounts to be set as "normal" or "standby".
- Introduced banning functionality, marking accounts as banned and preventing their use.
- Updated account pool logic to exclude banned and standby accounts from active usage.
- Implemented API endpoints for setting account roles and banning/unbanning accounts.
- Enhanced UI components to support role selection and display account statuses (banned, standby).
- Added localization strings for new features in English and Chinese.
- Updated runtime settings to include a backup release count for standby account promotions.
- internal/account/pool_core.go +26 -3
- internal/auth/request.go +37 -0
- internal/config/config.go +3 -0
- internal/config/store_accessors.go +107 -0
- internal/config/validation.go +3 -0
- internal/httpapi/admin/accounts/handler_account_role.go +86 -0
- internal/httpapi/admin/accounts/handler_accounts_crud.go +2 -0
- internal/httpapi/admin/accounts/routes.go +3 -0
- internal/httpapi/admin/settings/handler_settings_parse.go +7 -0
- internal/httpapi/admin/settings/handler_settings_read.go +2 -0
- internal/httpapi/admin/settings/handler_settings_runtime.go +3 -0
- internal/httpapi/admin/settings/handler_settings_write.go +3 -0
- internal/httpapi/admin/shared/deps.go +6 -0
- internal/httpapi/admin/shared/helpers.go +9 -0
- webui/src/features/account/AccountManagerContainer.jsx +4 -0
- webui/src/features/account/AccountsTable.jsx +37 -4
- webui/src/features/account/AddAccountModal.jsx +51 -0
- webui/src/features/account/QueueCards.jsx +24 -4
- webui/src/features/account/useAccountActions.js +55 -3
- webui/src/features/settings/RuntimeSection.jsx +16 -1
- webui/src/features/settings/useSettingsForm.js +3 -1
- webui/src/locales/en.json +17 -0
- webui/src/locales/zh.json +17 -0
internal/account/pool_core.go
CHANGED
|
@@ -46,9 +46,14 @@ func (p *Pool) Reset() {
|
|
| 46 |
ids := make([]string, 0, len(accounts))
|
| 47 |
for _, a := range accounts {
|
| 48 |
id := a.Identifier()
|
| 49 |
-
if id
|
| 50 |
-
|
| 51 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
if p.store != nil {
|
| 54 |
p.maxInflightPerAccount = p.store.RuntimeAccountMaxInflight()
|
|
@@ -117,10 +122,25 @@ func (p *Pool) Status() map[string]any {
|
|
| 117 |
}
|
| 118 |
}
|
| 119 |
sort.Strings(inUseAccounts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
return map[string]any{
|
| 121 |
"available": len(available),
|
| 122 |
"in_use": inUseSlots,
|
| 123 |
-
"total": len(
|
| 124 |
"available_accounts": available,
|
| 125 |
"in_use_accounts": inUseAccounts,
|
| 126 |
"max_inflight_per_account": p.maxInflightPerAccount,
|
|
@@ -128,5 +148,8 @@ func (p *Pool) Status() map[string]any {
|
|
| 128 |
"recommended_concurrency": p.recommendedConcurrency,
|
| 129 |
"waiting": len(p.waiters),
|
| 130 |
"max_queue_size": p.maxQueueSize,
|
|
|
|
|
|
|
|
|
|
| 131 |
}
|
| 132 |
}
|
|
|
|
| 46 |
ids := make([]string, 0, len(accounts))
|
| 47 |
for _, a := range accounts {
|
| 48 |
id := a.Identifier()
|
| 49 |
+
if id == "" {
|
| 50 |
+
continue
|
| 51 |
}
|
| 52 |
+
// Only include normal (non-standby) and non-banned accounts in the pool.
|
| 53 |
+
if a.Role == "standby" || a.Banned {
|
| 54 |
+
continue
|
| 55 |
+
}
|
| 56 |
+
ids = append(ids, id)
|
| 57 |
}
|
| 58 |
if p.store != nil {
|
| 59 |
p.maxInflightPerAccount = p.store.RuntimeAccountMaxInflight()
|
|
|
|
| 122 |
}
|
| 123 |
}
|
| 124 |
sort.Strings(inUseAccounts)
|
| 125 |
+
allAccounts := p.store.Accounts()
|
| 126 |
+
normalCount := 0
|
| 127 |
+
standbyCount := 0
|
| 128 |
+
bannedCount := 0
|
| 129 |
+
for _, acc := range allAccounts {
|
| 130 |
+
if acc.Banned {
|
| 131 |
+
bannedCount++
|
| 132 |
+
continue
|
| 133 |
+
}
|
| 134 |
+
if acc.Role == "standby" {
|
| 135 |
+
standbyCount++
|
| 136 |
+
} else {
|
| 137 |
+
normalCount++
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
return map[string]any{
|
| 141 |
"available": len(available),
|
| 142 |
"in_use": inUseSlots,
|
| 143 |
+
"total": len(allAccounts),
|
| 144 |
"available_accounts": available,
|
| 145 |
"in_use_accounts": inUseAccounts,
|
| 146 |
"max_inflight_per_account": p.maxInflightPerAccount,
|
|
|
|
| 148 |
"recommended_concurrency": p.recommendedConcurrency,
|
| 149 |
"waiting": len(p.waiters),
|
| 150 |
"max_queue_size": p.maxQueueSize,
|
| 151 |
+
"normal_count": normalCount,
|
| 152 |
+
"standby_count": standbyCount,
|
| 153 |
+
"banned_count": bannedCount,
|
| 154 |
}
|
| 155 |
}
|
internal/auth/request.go
CHANGED
|
@@ -152,6 +152,10 @@ func FromContext(ctx context.Context) (*RequestAuth, bool) {
|
|
| 152 |
func (r *Resolver) loginAndPersist(ctx context.Context, a *RequestAuth) error {
|
| 153 |
token, err := r.Login(ctx, a.Account)
|
| 154 |
if err != nil {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
return err
|
| 156 |
}
|
| 157 |
a.Account.Token = token
|
|
@@ -160,6 +164,39 @@ func (r *Resolver) loginAndPersist(ctx context.Context, a *RequestAuth) error {
|
|
| 160 |
return r.Store.UpdateAccountToken(a.AccountID, token)
|
| 161 |
}
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
func (r *Resolver) RefreshToken(ctx context.Context, a *RequestAuth) bool {
|
| 164 |
if !a.UseConfigToken || a.AccountID == "" {
|
| 165 |
return false
|
|
|
|
| 152 |
func (r *Resolver) loginAndPersist(ctx context.Context, a *RequestAuth) error {
|
| 153 |
token, err := r.Login(ctx, a.Account)
|
| 154 |
if err != nil {
|
| 155 |
+
// Detect USER_IS_BANNED: auto-ban the account and promote standby accounts.
|
| 156 |
+
if strings.Contains(strings.ToUpper(err.Error()), "USER_IS_BANNED") {
|
| 157 |
+
r.handleBannedAccount(a.AccountID)
|
| 158 |
+
}
|
| 159 |
return err
|
| 160 |
}
|
| 161 |
a.Account.Token = token
|
|
|
|
| 164 |
return r.Store.UpdateAccountToken(a.AccountID, token)
|
| 165 |
}
|
| 166 |
|
| 167 |
+
// handleBannedAccount marks an account as banned and promotes standby accounts
|
| 168 |
+
// based on the segment-based rule: if active normal accounts fall below half of
|
| 169 |
+
// total normal accounts (per segment), promote standby accounts.
|
| 170 |
+
func (r *Resolver) handleBannedAccount(accountID string) {
|
| 171 |
+
if r.Store == nil || r.Pool == nil {
|
| 172 |
+
return
|
| 173 |
+
}
|
| 174 |
+
acc, err := r.Store.SetAccountBanned(accountID, true)
|
| 175 |
+
if err != nil {
|
| 176 |
+
config.Logger.Error("[banned] failed to mark account as banned", "account", accountID, "error", err)
|
| 177 |
+
return
|
| 178 |
+
}
|
| 179 |
+
// Only auto-promote if the banned account was a normal (non-standby) account.
|
| 180 |
+
if acc.Role == "standby" {
|
| 181 |
+
return
|
| 182 |
+
}
|
| 183 |
+
config.Logger.Warn("[banned] account banned, checking standby promotion", "account", accountID)
|
| 184 |
+
|
| 185 |
+
activeNormal := r.Store.ActiveNormalAccounts()
|
| 186 |
+
totalNormal := r.Store.TotalNormalAccounts()
|
| 187 |
+
// Segment rule: promote when active normal accounts < half of total normal accounts.
|
| 188 |
+
// e.g. 2 normal -> both must be banned; 4 normal -> promote when only 1 active.
|
| 189 |
+
threshold := (totalNormal + 1) / 2 // ceiling of totalNormal/2
|
| 190 |
+
if activeNormal < threshold {
|
| 191 |
+
releaseCount := r.Store.RuntimeBackupReleaseCount()
|
| 192 |
+
promoted := r.Store.PromoteStandbyAccounts(releaseCount)
|
| 193 |
+
if len(promoted) > 0 {
|
| 194 |
+
config.Logger.Info("[banned] promoted standby accounts", "count", len(promoted), "accounts", promoted)
|
| 195 |
+
r.Pool.Reset()
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
func (r *Resolver) RefreshToken(ctx context.Context, a *RequestAuth) bool {
|
| 201 |
if !a.UseConfigToken || a.AccountID == "" {
|
| 202 |
return false
|
internal/config/config.go
CHANGED
|
@@ -34,6 +34,8 @@ type Account struct {
|
|
| 34 |
Password string `json:"password,omitempty"`
|
| 35 |
Token string `json:"token,omitempty"`
|
| 36 |
ProxyID string `json:"proxy_id,omitempty"`
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
type APIKey struct {
|
|
@@ -154,6 +156,7 @@ type RuntimeConfig struct {
|
|
| 154 |
AccountMaxQueue int `json:"account_max_queue,omitempty"`
|
| 155 |
GlobalMaxInflight int `json:"global_max_inflight,omitempty"`
|
| 156 |
TokenRefreshIntervalHours int `json:"token_refresh_interval_hours,omitempty"`
|
|
|
|
| 157 |
}
|
| 158 |
|
| 159 |
type ResponsesConfig struct {
|
|
|
|
| 34 |
Password string `json:"password,omitempty"`
|
| 35 |
Token string `json:"token,omitempty"`
|
| 36 |
ProxyID string `json:"proxy_id,omitempty"`
|
| 37 |
+
Role string `json:"role,omitempty"` // "normal" (default) or "standby"
|
| 38 |
+
Banned bool `json:"banned,omitempty"` // true when USER_IS_BANNED detected
|
| 39 |
}
|
| 40 |
|
| 41 |
type APIKey struct {
|
|
|
|
| 156 |
AccountMaxQueue int `json:"account_max_queue,omitempty"`
|
| 157 |
GlobalMaxInflight int `json:"global_max_inflight,omitempty"`
|
| 158 |
TokenRefreshIntervalHours int `json:"token_refresh_interval_hours,omitempty"`
|
| 159 |
+
BackupReleaseCount int `json:"backup_release_count,omitempty"`
|
| 160 |
}
|
| 161 |
|
| 162 |
type ResponsesConfig struct {
|
internal/config/store_accessors.go
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
package config
|
| 2 |
|
| 3 |
import (
|
|
|
|
|
|
|
| 4 |
"os"
|
| 5 |
"strconv"
|
| 6 |
"strings"
|
|
@@ -174,3 +176,108 @@ func (s *Store) ThinkingInjectionPrompt() string {
|
|
| 174 |
defer s.mu.RUnlock()
|
| 175 |
return strings.TrimSpace(s.cfg.ThinkingInjection.Prompt)
|
| 176 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
package config
|
| 2 |
|
| 3 |
import (
|
| 4 |
+
"errors"
|
| 5 |
+
"fmt"
|
| 6 |
"os"
|
| 7 |
"strconv"
|
| 8 |
"strings"
|
|
|
|
| 176 |
defer s.mu.RUnlock()
|
| 177 |
return strings.TrimSpace(s.cfg.ThinkingInjection.Prompt)
|
| 178 |
}
|
| 179 |
+
|
| 180 |
+
func (s *Store) RuntimeBackupReleaseCount() int {
|
| 181 |
+
s.mu.RLock()
|
| 182 |
+
defer s.mu.RUnlock()
|
| 183 |
+
if s.cfg.Runtime.BackupReleaseCount > 0 {
|
| 184 |
+
return s.cfg.Runtime.BackupReleaseCount
|
| 185 |
+
}
|
| 186 |
+
return 2
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// SetAccountBanned marks an account as banned or unbanned. Returns the updated account.
|
| 190 |
+
func (s *Store) SetAccountBanned(identifier string, banned bool) (Account, error) {
|
| 191 |
+
identifier = strings.TrimSpace(identifier)
|
| 192 |
+
s.mu.Lock()
|
| 193 |
+
defer s.mu.Unlock()
|
| 194 |
+
idx, ok := s.findAccountIndexLocked(identifier)
|
| 195 |
+
if !ok {
|
| 196 |
+
return Account{}, errors.New("account not found")
|
| 197 |
+
}
|
| 198 |
+
s.cfg.Accounts[idx].Banned = banned
|
| 199 |
+
if banned {
|
| 200 |
+
s.cfg.Accounts[idx].Token = ""
|
| 201 |
+
}
|
| 202 |
+
return s.cfg.Accounts[idx], s.saveLocked()
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// SetAccountRole changes an account's role. Returns the updated account.
|
| 206 |
+
func (s *Store) SetAccountRole(identifier string, role string) (Account, error) {
|
| 207 |
+
identifier = strings.TrimSpace(identifier)
|
| 208 |
+
role = strings.ToLower(strings.TrimSpace(role))
|
| 209 |
+
if role != "normal" && role != "standby" {
|
| 210 |
+
return Account{}, fmt.Errorf("invalid role: %s, must be normal or standby", role)
|
| 211 |
+
}
|
| 212 |
+
s.mu.Lock()
|
| 213 |
+
defer s.mu.Unlock()
|
| 214 |
+
idx, ok := s.findAccountIndexLocked(identifier)
|
| 215 |
+
if !ok {
|
| 216 |
+
return Account{}, errors.New("account not found")
|
| 217 |
+
}
|
| 218 |
+
s.cfg.Accounts[idx].Role = role
|
| 219 |
+
return s.cfg.Accounts[idx], s.saveLocked()
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// ActiveNormalAccounts returns the count of normal accounts that are not banned.
|
| 223 |
+
func (s *Store) ActiveNormalAccounts() int {
|
| 224 |
+
s.mu.RLock()
|
| 225 |
+
defer s.mu.RUnlock()
|
| 226 |
+
count := 0
|
| 227 |
+
for _, acc := range s.cfg.Accounts {
|
| 228 |
+
if acc.Role != "standby" && !acc.Banned {
|
| 229 |
+
count++
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
return count
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// TotalNormalAccounts returns the total count of normal accounts (including banned).
|
| 236 |
+
func (s *Store) TotalNormalAccounts() int {
|
| 237 |
+
s.mu.RLock()
|
| 238 |
+
defer s.mu.RUnlock()
|
| 239 |
+
count := 0
|
| 240 |
+
for _, acc := range s.cfg.Accounts {
|
| 241 |
+
if acc.Role != "standby" {
|
| 242 |
+
count++
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
return count
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// StandbyAccounts returns all standby accounts that are not banned.
|
| 249 |
+
func (s *Store) StandbyAccounts() []Account {
|
| 250 |
+
s.mu.RLock()
|
| 251 |
+
defer s.mu.RUnlock()
|
| 252 |
+
var result []Account
|
| 253 |
+
for _, acc := range s.cfg.Accounts {
|
| 254 |
+
if acc.Role == "standby" && !acc.Banned {
|
| 255 |
+
result = append(result, acc)
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
return result
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// PromoteStandbyAccounts promotes up to n standby accounts to normal role.
|
| 262 |
+
// Returns the identifiers of promoted accounts.
|
| 263 |
+
func (s *Store) PromoteStandbyAccounts(n int) []string {
|
| 264 |
+
if n <= 0 {
|
| 265 |
+
return nil
|
| 266 |
+
}
|
| 267 |
+
s.mu.Lock()
|
| 268 |
+
defer s.mu.Unlock()
|
| 269 |
+
var promoted []string
|
| 270 |
+
for i := range s.cfg.Accounts {
|
| 271 |
+
if s.cfg.Accounts[i].Role == "standby" && !s.cfg.Accounts[i].Banned {
|
| 272 |
+
s.cfg.Accounts[i].Role = "normal"
|
| 273 |
+
promoted = append(promoted, s.cfg.Accounts[i].Identifier())
|
| 274 |
+
if len(promoted) >= n {
|
| 275 |
+
break
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
if len(promoted) > 0 {
|
| 280 |
+
_ = s.saveLocked()
|
| 281 |
+
}
|
| 282 |
+
return promoted
|
| 283 |
+
}
|
internal/config/validation.go
CHANGED
|
@@ -96,6 +96,9 @@ func ValidateRuntimeConfig(runtime RuntimeConfig) error {
|
|
| 96 |
if err := ValidateIntRange("runtime.token_refresh_interval_hours", runtime.TokenRefreshIntervalHours, 1, 720, false); err != nil {
|
| 97 |
return err
|
| 98 |
}
|
|
|
|
|
|
|
|
|
|
| 99 |
if runtime.AccountMaxInflight > 0 && runtime.GlobalMaxInflight > 0 && runtime.GlobalMaxInflight < runtime.AccountMaxInflight {
|
| 100 |
return fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
|
| 101 |
}
|
|
|
|
| 96 |
if err := ValidateIntRange("runtime.token_refresh_interval_hours", runtime.TokenRefreshIntervalHours, 1, 720, false); err != nil {
|
| 97 |
return err
|
| 98 |
}
|
| 99 |
+
if err := ValidateIntRange("runtime.backup_release_count", runtime.BackupReleaseCount, 1, 100, false); err != nil {
|
| 100 |
+
return err
|
| 101 |
+
}
|
| 102 |
if runtime.AccountMaxInflight > 0 && runtime.GlobalMaxInflight > 0 && runtime.GlobalMaxInflight < runtime.AccountMaxInflight {
|
| 103 |
return fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
|
| 104 |
}
|
internal/httpapi/admin/accounts/handler_account_role.go
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package accounts
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"net/http"
|
| 6 |
+
"net/url"
|
| 7 |
+
|
| 8 |
+
"github.com/go-chi/chi/v5"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
func (h *Handler) setAccountRole(w http.ResponseWriter, r *http.Request) {
|
| 12 |
+
identifier := chi.URLParam(r, "identifier")
|
| 13 |
+
if decoded, err := url.PathUnescape(identifier); err == nil {
|
| 14 |
+
identifier = decoded
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
var req struct {
|
| 18 |
+
Role string `json:"role"`
|
| 19 |
+
}
|
| 20 |
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
| 21 |
+
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
|
| 22 |
+
return
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
acc, err := h.Store.SetAccountRole(identifier, req.Role)
|
| 26 |
+
if err != nil {
|
| 27 |
+
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
| 28 |
+
return
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
h.Pool.Reset()
|
| 32 |
+
writeJSON(w, http.StatusOK, map[string]any{
|
| 33 |
+
"success": true,
|
| 34 |
+
"identifier": acc.Identifier(),
|
| 35 |
+
"role": acc.Role,
|
| 36 |
+
})
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
func (h *Handler) setAccountBanned(w http.ResponseWriter, r *http.Request) {
|
| 40 |
+
identifier := chi.URLParam(r, "identifier")
|
| 41 |
+
if decoded, err := url.PathUnescape(identifier); err == nil {
|
| 42 |
+
identifier = decoded
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
var req struct {
|
| 46 |
+
Banned bool `json:"banned"`
|
| 47 |
+
}
|
| 48 |
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
| 49 |
+
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
|
| 50 |
+
return
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
acc, err := h.Store.SetAccountBanned(identifier, req.Banned)
|
| 54 |
+
if err != nil {
|
| 55 |
+
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
| 56 |
+
return
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
h.Pool.Reset()
|
| 60 |
+
writeJSON(w, http.StatusOK, map[string]any{
|
| 61 |
+
"success": true,
|
| 62 |
+
"identifier": acc.Identifier(),
|
| 63 |
+
"banned": acc.Banned,
|
| 64 |
+
})
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
func (h *Handler) promoteStandby(w http.ResponseWriter, r *http.Request) {
|
| 68 |
+
var req struct {
|
| 69 |
+
Count int `json:"count"`
|
| 70 |
+
}
|
| 71 |
+
_ = json.NewDecoder(r.Body).Decode(&req)
|
| 72 |
+
if req.Count <= 0 {
|
| 73 |
+
req.Count = h.Store.RuntimeBackupReleaseCount()
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
promoted := h.Store.PromoteStandbyAccounts(req.Count)
|
| 77 |
+
if len(promoted) > 0 {
|
| 78 |
+
h.Pool.Reset()
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
writeJSON(w, http.StatusOK, map[string]any{
|
| 82 |
+
"success": true,
|
| 83 |
+
"promoted": promoted,
|
| 84 |
+
"count": len(promoted),
|
| 85 |
+
})
|
| 86 |
+
}
|
internal/httpapi/admin/accounts/handler_accounts_crud.go
CHANGED
|
@@ -69,6 +69,8 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
|
|
| 69 |
"has_token": token != "",
|
| 70 |
"token_preview": maskSecretPreview(token),
|
| 71 |
"test_status": testStatus,
|
|
|
|
|
|
|
| 72 |
})
|
| 73 |
}
|
| 74 |
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
|
|
|
|
| 69 |
"has_token": token != "",
|
| 70 |
"token_preview": maskSecretPreview(token),
|
| 71 |
"test_status": testStatus,
|
| 72 |
+
"role": acc.Role,
|
| 73 |
+
"banned": acc.Banned,
|
| 74 |
})
|
| 75 |
}
|
| 76 |
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
|
internal/httpapi/admin/accounts/routes.go
CHANGED
|
@@ -14,6 +14,9 @@ func RegisterRoutes(r chi.Router, h *Handler) {
|
|
| 14 |
r.Post("/accounts", h.addAccount)
|
| 15 |
r.Put("/accounts/{identifier}", h.updateAccount)
|
| 16 |
r.Delete("/accounts/{identifier}", h.deleteAccount)
|
|
|
|
|
|
|
|
|
|
| 17 |
r.Get("/queue/status", h.queueStatus)
|
| 18 |
r.Post("/accounts/test", h.testSingleAccount)
|
| 19 |
r.Post("/accounts/test-all", h.testAllAccounts)
|
|
|
|
| 14 |
r.Post("/accounts", h.addAccount)
|
| 15 |
r.Put("/accounts/{identifier}", h.updateAccount)
|
| 16 |
r.Delete("/accounts/{identifier}", h.deleteAccount)
|
| 17 |
+
r.Put("/accounts/{identifier}/role", h.setAccountRole)
|
| 18 |
+
r.Put("/accounts/{identifier}/banned", h.setAccountBanned)
|
| 19 |
+
r.Post("/accounts/promote-standby", h.promoteStandby)
|
| 20 |
r.Get("/queue/status", h.queueStatus)
|
| 21 |
r.Post("/accounts/test", h.testSingleAccount)
|
| 22 |
r.Post("/accounts/test-all", h.testAllAccounts)
|
internal/httpapi/admin/settings/handler_settings_parse.go
CHANGED
|
@@ -75,6 +75,13 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
|
| 75 |
}
|
| 76 |
cfg.TokenRefreshIntervalHours = n
|
| 77 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
|
| 79 |
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
|
| 80 |
}
|
|
|
|
| 75 |
}
|
| 76 |
cfg.TokenRefreshIntervalHours = n
|
| 77 |
}
|
| 78 |
+
if v, exists := raw["backup_release_count"]; exists {
|
| 79 |
+
n := intFrom(v)
|
| 80 |
+
if err := config.ValidateIntRange("runtime.backup_release_count", n, 1, 100, true); err != nil {
|
| 81 |
+
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
| 82 |
+
}
|
| 83 |
+
cfg.BackupReleaseCount = n
|
| 84 |
+
}
|
| 85 |
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
|
| 86 |
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
|
| 87 |
}
|
internal/httpapi/admin/settings/handler_settings_read.go
CHANGED
|
@@ -24,6 +24,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
|
|
| 24 |
"account_max_queue": snap.Runtime.AccountMaxQueue,
|
| 25 |
"global_max_inflight": snap.Runtime.GlobalMaxInflight,
|
| 26 |
"token_refresh_interval_hours": snap.Runtime.TokenRefreshIntervalHours,
|
|
|
|
| 27 |
},
|
| 28 |
"responses": snap.Responses,
|
| 29 |
"embeddings": snap.Embeddings,
|
|
@@ -49,6 +50,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
|
|
| 49 |
"account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
|
| 50 |
"global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
|
| 51 |
"token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(),
|
|
|
|
| 52 |
},
|
| 53 |
"responses": snap.Responses,
|
| 54 |
"embeddings": snap.Embeddings,
|
|
|
|
| 24 |
"account_max_queue": snap.Runtime.AccountMaxQueue,
|
| 25 |
"global_max_inflight": snap.Runtime.GlobalMaxInflight,
|
| 26 |
"token_refresh_interval_hours": snap.Runtime.TokenRefreshIntervalHours,
|
| 27 |
+
"backup_release_count": snap.Runtime.BackupReleaseCount,
|
| 28 |
},
|
| 29 |
"responses": snap.Responses,
|
| 30 |
"embeddings": snap.Embeddings,
|
|
|
|
| 50 |
"account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
|
| 51 |
"global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
|
| 52 |
"token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(),
|
| 53 |
+
"backup_release_count": h.Store.RuntimeBackupReleaseCount(),
|
| 54 |
},
|
| 55 |
"responses": snap.Responses,
|
| 56 |
"embeddings": snap.Embeddings,
|
internal/httpapi/admin/settings/handler_settings_runtime.go
CHANGED
|
@@ -17,6 +17,9 @@ func validateMergedRuntimeSettings(current config.RuntimeConfig, incoming *confi
|
|
| 17 |
if incoming.TokenRefreshIntervalHours > 0 {
|
| 18 |
merged.TokenRefreshIntervalHours = incoming.TokenRefreshIntervalHours
|
| 19 |
}
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
return validateRuntimeSettings(merged)
|
| 22 |
}
|
|
|
|
| 17 |
if incoming.TokenRefreshIntervalHours > 0 {
|
| 18 |
merged.TokenRefreshIntervalHours = incoming.TokenRefreshIntervalHours
|
| 19 |
}
|
| 20 |
+
if incoming.BackupReleaseCount > 0 {
|
| 21 |
+
merged.BackupReleaseCount = incoming.BackupReleaseCount
|
| 22 |
+
}
|
| 23 |
}
|
| 24 |
return validateRuntimeSettings(merged)
|
| 25 |
}
|
internal/httpapi/admin/settings/handler_settings_write.go
CHANGED
|
@@ -52,6 +52,9 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|
| 52 |
if runtimeCfg.TokenRefreshIntervalHours > 0 {
|
| 53 |
c.Runtime.TokenRefreshIntervalHours = runtimeCfg.TokenRefreshIntervalHours
|
| 54 |
}
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
if responsesCfg != nil && responsesCfg.StoreTTLSeconds > 0 {
|
| 57 |
c.Responses.StoreTTLSeconds = responsesCfg.StoreTTLSeconds
|
|
|
|
| 52 |
if runtimeCfg.TokenRefreshIntervalHours > 0 {
|
| 53 |
c.Runtime.TokenRefreshIntervalHours = runtimeCfg.TokenRefreshIntervalHours
|
| 54 |
}
|
| 55 |
+
if runtimeCfg.BackupReleaseCount > 0 {
|
| 56 |
+
c.Runtime.BackupReleaseCount = runtimeCfg.BackupReleaseCount
|
| 57 |
+
}
|
| 58 |
}
|
| 59 |
if responsesCfg != nil && responsesCfg.StoreTTLSeconds > 0 {
|
| 60 |
c.Responses.StoreTTLSeconds = responsesCfg.StoreTTLSeconds
|
internal/httpapi/admin/shared/deps.go
CHANGED
|
@@ -32,12 +32,18 @@ type ConfigStore interface {
|
|
| 32 |
RuntimeAccountMaxQueue(defaultSize int) int
|
| 33 |
RuntimeGlobalMaxInflight(defaultSize int) int
|
| 34 |
RuntimeTokenRefreshIntervalHours() int
|
|
|
|
| 35 |
AutoDeleteMode() string
|
| 36 |
CurrentInputFileEnabled() bool
|
| 37 |
CurrentInputFileMinChars() int
|
| 38 |
ThinkingInjectionEnabled() bool
|
| 39 |
ThinkingInjectionPrompt() string
|
| 40 |
AutoDeleteSessions() bool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
type PoolController interface {
|
|
|
|
| 32 |
RuntimeAccountMaxQueue(defaultSize int) int
|
| 33 |
RuntimeGlobalMaxInflight(defaultSize int) int
|
| 34 |
RuntimeTokenRefreshIntervalHours() int
|
| 35 |
+
RuntimeBackupReleaseCount() int
|
| 36 |
AutoDeleteMode() string
|
| 37 |
CurrentInputFileEnabled() bool
|
| 38 |
CurrentInputFileMinChars() int
|
| 39 |
ThinkingInjectionEnabled() bool
|
| 40 |
ThinkingInjectionPrompt() string
|
| 41 |
AutoDeleteSessions() bool
|
| 42 |
+
SetAccountBanned(identifier string, banned bool) (config.Account, error)
|
| 43 |
+
SetAccountRole(identifier string, role string) (config.Account, error)
|
| 44 |
+
ActiveNormalAccounts() int
|
| 45 |
+
TotalNormalAccounts() int
|
| 46 |
+
PromoteStandbyAccounts(n int) []string
|
| 47 |
}
|
| 48 |
|
| 49 |
type PoolController interface {
|
internal/httpapi/admin/shared/helpers.go
CHANGED
|
@@ -161,6 +161,10 @@ func toStringSlice(v any) ([]string, bool) {
|
|
| 161 |
func toAccount(m map[string]any) config.Account {
|
| 162 |
email := fieldString(m, "email")
|
| 163 |
mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile"))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
return config.Account{
|
| 165 |
Name: fieldString(m, "name"),
|
| 166 |
Remark: fieldString(m, "remark"),
|
|
@@ -168,6 +172,7 @@ func toAccount(m map[string]any) config.Account {
|
|
| 168 |
Mobile: mobile,
|
| 169 |
Password: fieldString(m, "password"),
|
| 170 |
ProxyID: fieldString(m, "proxy_id"),
|
|
|
|
| 171 |
}
|
| 172 |
}
|
| 173 |
|
|
@@ -324,6 +329,10 @@ func normalizeAccountForStorage(acc config.Account) config.Account {
|
|
| 324 |
acc.Email = strings.TrimSpace(acc.Email)
|
| 325 |
acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile)
|
| 326 |
acc.ProxyID = strings.TrimSpace(acc.ProxyID)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
return acc
|
| 328 |
}
|
| 329 |
|
|
|
|
| 161 |
func toAccount(m map[string]any) config.Account {
|
| 162 |
email := fieldString(m, "email")
|
| 163 |
mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile"))
|
| 164 |
+
role := strings.ToLower(strings.TrimSpace(fieldString(m, "role")))
|
| 165 |
+
if role != "normal" && role != "standby" {
|
| 166 |
+
role = "" // default to normal (empty string treated as normal)
|
| 167 |
+
}
|
| 168 |
return config.Account{
|
| 169 |
Name: fieldString(m, "name"),
|
| 170 |
Remark: fieldString(m, "remark"),
|
|
|
|
| 172 |
Mobile: mobile,
|
| 173 |
Password: fieldString(m, "password"),
|
| 174 |
ProxyID: fieldString(m, "proxy_id"),
|
| 175 |
+
Role: role,
|
| 176 |
}
|
| 177 |
}
|
| 178 |
|
|
|
|
| 329 |
acc.Email = strings.TrimSpace(acc.Email)
|
| 330 |
acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile)
|
| 331 |
acc.ProxyID = strings.TrimSpace(acc.ProxyID)
|
| 332 |
+
acc.Role = strings.ToLower(strings.TrimSpace(acc.Role))
|
| 333 |
+
if acc.Role != "normal" && acc.Role != "standby" {
|
| 334 |
+
acc.Role = ""
|
| 335 |
+
}
|
| 336 |
return acc
|
| 337 |
}
|
| 338 |
|
webui/src/features/account/AccountManagerContainer.jsx
CHANGED
|
@@ -66,6 +66,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
|
| 66 |
testAllAccounts,
|
| 67 |
deleteAllSessions,
|
| 68 |
updateAccountProxy,
|
|
|
|
|
|
|
| 69 |
} = useAccountActions({
|
| 70 |
apiFetch,
|
| 71 |
t,
|
|
@@ -136,6 +138,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
|
| 136 |
onDeleteAccount={deleteAccount}
|
| 137 |
onDeleteAllSessions={deleteAllSessions}
|
| 138 |
onUpdateAccountProxy={updateAccountProxy}
|
|
|
|
|
|
|
| 139 |
onPrevPage={() => fetchAccounts(page - 1)}
|
| 140 |
onNextPage={() => fetchAccounts(page + 1)}
|
| 141 |
onPageSizeChange={changePageSize}
|
|
|
|
| 66 |
testAllAccounts,
|
| 67 |
deleteAllSessions,
|
| 68 |
updateAccountProxy,
|
| 69 |
+
toggleRole,
|
| 70 |
+
toggleBanned,
|
| 71 |
} = useAccountActions({
|
| 72 |
apiFetch,
|
| 73 |
t,
|
|
|
|
| 138 |
onDeleteAccount={deleteAccount}
|
| 139 |
onDeleteAllSessions={deleteAllSessions}
|
| 140 |
onUpdateAccountProxy={updateAccountProxy}
|
| 141 |
+
onToggleRole={toggleRole}
|
| 142 |
+
onToggleBanned={toggleBanned}
|
| 143 |
onPrevPage={() => fetchAccounts(page - 1)}
|
| 144 |
onNextPage={() => fetchAccounts(page + 1)}
|
| 145 |
onPageSizeChange={changePageSize}
|
webui/src/features/account/AccountsTable.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useState } from 'react'
|
| 2 |
-
import { ChevronLeft, ChevronRight, Check, Copy, Pencil, Play, Plus, Trash2, FolderX } from 'lucide-react'
|
| 3 |
import clsx from 'clsx'
|
| 4 |
|
| 5 |
export default function AccountsTable({
|
|
@@ -25,6 +25,8 @@ export default function AccountsTable({
|
|
| 25 |
onDeleteAccount,
|
| 26 |
onDeleteAllSessions,
|
| 27 |
onUpdateAccountProxy,
|
|
|
|
|
|
|
| 28 |
onPrevPage,
|
| 29 |
onNextPage,
|
| 30 |
onPageSizeChange,
|
|
@@ -109,13 +111,20 @@ export default function AccountsTable({
|
|
| 109 |
const assignedProxy = proxies.find(proxy => proxy.id === acc.proxy_id)
|
| 110 |
const runtimeUnknown = envBacked && !acc.test_status
|
| 111 |
const isActive = acc.test_status === 'ok' || acc.has_token
|
|
|
|
|
|
|
| 112 |
return (
|
| 113 |
-
<div key={i} className=
|
|
|
|
|
|
|
|
|
|
| 114 |
<div className="flex items-center gap-3 min-w-0">
|
| 115 |
<div className={clsx(
|
| 116 |
"w-2 h-2 rounded-full shrink-0",
|
|
|
|
| 117 |
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
|
| 118 |
-
isActive ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
|
|
|
|
| 119 |
runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
|
| 120 |
)} />
|
| 121 |
<div className="min-w-0">
|
|
@@ -134,7 +143,17 @@ export default function AccountsTable({
|
|
| 134 |
<div className="text-xs text-muted-foreground truncate mt-0.5">{acc.remark}</div>
|
| 135 |
)}
|
| 136 |
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
| 137 |
-
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
{acc.token_preview && (
|
| 139 |
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
| 140 |
{acc.token_preview}
|
|
@@ -181,6 +200,20 @@ export default function AccountsTable({
|
|
| 181 |
</option>
|
| 182 |
))}
|
| 183 |
</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
<button
|
| 185 |
onClick={() => onEditAccount(acc)}
|
| 186 |
disabled={!id}
|
|
|
|
| 1 |
import { useState } from 'react'
|
| 2 |
+
import { ChevronLeft, ChevronRight, Check, Copy, Pencil, Play, Plus, Trash2, FolderX, Shield, ShieldOff, ArrowLeftRight } from 'lucide-react'
|
| 3 |
import clsx from 'clsx'
|
| 4 |
|
| 5 |
export default function AccountsTable({
|
|
|
|
| 25 |
onDeleteAccount,
|
| 26 |
onDeleteAllSessions,
|
| 27 |
onUpdateAccountProxy,
|
| 28 |
+
onToggleRole,
|
| 29 |
+
onToggleBanned,
|
| 30 |
onPrevPage,
|
| 31 |
onNextPage,
|
| 32 |
onPageSizeChange,
|
|
|
|
| 111 |
const assignedProxy = proxies.find(proxy => proxy.id === acc.proxy_id)
|
| 112 |
const runtimeUnknown = envBacked && !acc.test_status
|
| 113 |
const isActive = acc.test_status === 'ok' || acc.has_token
|
| 114 |
+
const isStandby = acc.role === 'standby'
|
| 115 |
+
const isBanned = acc.banned
|
| 116 |
return (
|
| 117 |
+
<div key={i} className={clsx(
|
| 118 |
+
"p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 transition-colors",
|
| 119 |
+
isBanned ? "bg-red-500/5 hover:bg-red-500/10" : isStandby ? "bg-amber-500/5 hover:bg-amber-500/10" : "hover:bg-muted/50"
|
| 120 |
+
)}>
|
| 121 |
<div className="flex items-center gap-3 min-w-0">
|
| 122 |
<div className={clsx(
|
| 123 |
"w-2 h-2 rounded-full shrink-0",
|
| 124 |
+
isBanned ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
|
| 125 |
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
|
| 126 |
+
isActive && !isStandby ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
|
| 127 |
+
isStandby ? "bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.5)]" :
|
| 128 |
runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
|
| 129 |
)} />
|
| 130 |
<div className="min-w-0">
|
|
|
|
| 143 |
<div className="text-xs text-muted-foreground truncate mt-0.5">{acc.remark}</div>
|
| 144 |
)}
|
| 145 |
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
| 146 |
+
<span>{isBanned ? t('accountManager.bannedStatus') : isStandby ? t('accountManager.standbyStatus') : acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
|
| 147 |
+
{isStandby && !isBanned && (
|
| 148 |
+
<span className="font-mono bg-amber-500/10 text-amber-500 px-1.5 py-0.5 rounded text-[10px]">
|
| 149 |
+
{t('accountManager.roleStandby')}
|
| 150 |
+
</span>
|
| 151 |
+
)}
|
| 152 |
+
{isBanned && (
|
| 153 |
+
<span className="font-mono bg-red-500/10 text-red-500 px-1.5 py-0.5 rounded text-[10px]">
|
| 154 |
+
{t('accountManager.bannedBadge')}
|
| 155 |
+
</span>
|
| 156 |
+
)}
|
| 157 |
{acc.token_preview && (
|
| 158 |
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
| 159 |
{acc.token_preview}
|
|
|
|
| 200 |
</option>
|
| 201 |
))}
|
| 202 |
</select>
|
| 203 |
+
<button
|
| 204 |
+
onClick={() => onToggleRole(id, isStandby ? 'normal' : 'standby')}
|
| 205 |
+
className="p-1 lg:p-1.5 text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 rounded-md transition-colors"
|
| 206 |
+
title={isStandby ? t('accountManager.switchToNormal') : t('accountManager.switchToStandby')}
|
| 207 |
+
>
|
| 208 |
+
<ArrowLeftRight className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
| 209 |
+
</button>
|
| 210 |
+
<button
|
| 211 |
+
onClick={() => onToggleBanned(id, !isBanned)}
|
| 212 |
+
className="p-1 lg:p-1.5 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-md transition-colors"
|
| 213 |
+
title={isBanned ? t('accountManager.unbanAccount') : t('accountManager.banAccount')}
|
| 214 |
+
>
|
| 215 |
+
{isBanned ? <ShieldOff className="w-3.5 h-3.5 lg:w-4 lg:h-4" /> : <Shield className="w-3.5 h-3.5 lg:w-4 lg:h-4" />}
|
| 216 |
+
</button>
|
| 217 |
<button
|
| 218 |
onClick={() => onEditAccount(acc)}
|
| 219 |
disabled={!id}
|
webui/src/features/account/AddAccountModal.jsx
CHANGED
|
@@ -23,6 +23,57 @@ export default function AddAccountModal({
|
|
| 23 |
</button>
|
| 24 |
</div>
|
| 25 |
<div className="p-6 space-y-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
<div>
|
| 27 |
<label className="block text-sm font-medium mb-1.5">{t('accountManager.nameOptional')}</label>
|
| 28 |
<input
|
|
|
|
| 23 |
</button>
|
| 24 |
</div>
|
| 25 |
<div className="p-6 space-y-4">
|
| 26 |
+
<div>
|
| 27 |
+
<label className="block text-sm font-medium mb-1.5">{t('accountManager.accountTypeLabel')}</label>
|
| 28 |
+
<div className="flex gap-3">
|
| 29 |
+
<label className={`flex-1 flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${
|
| 30 |
+
(newAccount.role || 'normal') === 'normal'
|
| 31 |
+
? 'border-primary bg-primary/10 text-primary'
|
| 32 |
+
: 'border-border hover:border-primary/50'
|
| 33 |
+
}`}>
|
| 34 |
+
<input
|
| 35 |
+
type="radio"
|
| 36 |
+
name="account-role"
|
| 37 |
+
value="normal"
|
| 38 |
+
checked={(newAccount.role || 'normal') === 'normal'}
|
| 39 |
+
onChange={() => setNewAccount({ ...newAccount, role: 'normal' })}
|
| 40 |
+
className="sr-only"
|
| 41 |
+
/>
|
| 42 |
+
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
| 43 |
+
(newAccount.role || 'normal') === 'normal' ? 'border-primary' : 'border-muted-foreground'
|
| 44 |
+
}`}>
|
| 45 |
+
{(newAccount.role || 'normal') === 'normal' && <div className="w-2 h-2 rounded-full bg-primary" />}
|
| 46 |
+
</div>
|
| 47 |
+
<div>
|
| 48 |
+
<div className="text-sm font-medium">{t('accountManager.roleNormal')}</div>
|
| 49 |
+
<div className="text-xs text-muted-foreground">{t('accountManager.roleNormalDesc')}</div>
|
| 50 |
+
</div>
|
| 51 |
+
</label>
|
| 52 |
+
<label className={`flex-1 flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${
|
| 53 |
+
newAccount.role === 'standby'
|
| 54 |
+
? 'border-amber-500 bg-amber-500/10 text-amber-500'
|
| 55 |
+
: 'border-border hover:border-amber-500/50'
|
| 56 |
+
}`}>
|
| 57 |
+
<input
|
| 58 |
+
type="radio"
|
| 59 |
+
name="account-role"
|
| 60 |
+
value="standby"
|
| 61 |
+
checked={newAccount.role === 'standby'}
|
| 62 |
+
onChange={() => setNewAccount({ ...newAccount, role: 'standby' })}
|
| 63 |
+
className="sr-only"
|
| 64 |
+
/>
|
| 65 |
+
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
| 66 |
+
newAccount.role === 'standby' ? 'border-amber-500' : 'border-muted-foreground'
|
| 67 |
+
}`}>
|
| 68 |
+
{newAccount.role === 'standby' && <div className="w-2 h-2 rounded-full bg-amber-500" />}
|
| 69 |
+
</div>
|
| 70 |
+
<div>
|
| 71 |
+
<div className="text-sm font-medium">{t('accountManager.roleStandby')}</div>
|
| 72 |
+
<div className="text-xs text-muted-foreground">{t('accountManager.roleStandbyDesc')}</div>
|
| 73 |
+
</div>
|
| 74 |
+
</label>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
<div>
|
| 78 |
<label className="block text-sm font-medium mb-1.5">{t('accountManager.nameOptional')}</label>
|
| 79 |
<input
|
webui/src/features/account/QueueCards.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { CheckCircle2, Server, ShieldCheck } from 'lucide-react'
|
| 2 |
|
| 3 |
export default function QueueCards({ queueStatus, t }) {
|
| 4 |
if (!queueStatus) {
|
|
@@ -6,7 +6,7 @@ export default function QueueCards({ queueStatus, t }) {
|
|
| 6 |
}
|
| 7 |
|
| 8 |
return (
|
| 9 |
-
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 10 |
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
|
| 11 |
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
| 12 |
<CheckCircle2 className="w-16 h-16" />
|
|
@@ -31,9 +31,29 @@ export default function QueueCards({ queueStatus, t }) {
|
|
| 31 |
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
| 32 |
<ShieldCheck className="w-16 h-16" />
|
| 33 |
</div>
|
| 34 |
-
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.
|
| 35 |
<div className="mt-2 flex items-baseline gap-2">
|
| 36 |
-
<span className="text-3xl font-bold text-foreground">{queueStatus.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
|
| 38 |
</div>
|
| 39 |
</div>
|
|
|
|
| 1 |
+
import { CheckCircle2, Server, ShieldCheck, ShieldAlert, ShieldOff } from 'lucide-react'
|
| 2 |
|
| 3 |
export default function QueueCards({ queueStatus, t }) {
|
| 4 |
if (!queueStatus) {
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
return (
|
| 9 |
+
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
| 10 |
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
|
| 11 |
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
| 12 |
<CheckCircle2 className="w-16 h-16" />
|
|
|
|
| 31 |
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
| 32 |
<ShieldCheck className="w-16 h-16" />
|
| 33 |
</div>
|
| 34 |
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">{t('accountManager.roleNormal')}</p>
|
| 35 |
<div className="mt-2 flex items-baseline gap-2">
|
| 36 |
+
<span className="text-3xl font-bold text-foreground">{queueStatus.normal_count ?? '-'}</span>
|
| 37 |
+
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
<div className="bg-card border border-amber-500/20 rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
|
| 41 |
+
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
| 42 |
+
<ShieldAlert className="w-16 h-16 text-amber-500" />
|
| 43 |
+
</div>
|
| 44 |
+
<p className="text-xs font-medium text-amber-500/80 uppercase tracking-widest">{t('accountManager.roleStandby')}</p>
|
| 45 |
+
<div className="mt-2 flex items-baseline gap-2">
|
| 46 |
+
<span className="text-3xl font-bold text-amber-500">{queueStatus.standby_count ?? '-'}</span>
|
| 47 |
+
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
<div className="bg-card border border-red-500/20 rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
|
| 51 |
+
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
| 52 |
+
<ShieldOff className="w-16 h-16 text-red-500" />
|
| 53 |
+
</div>
|
| 54 |
+
<p className="text-xs font-medium text-red-500/80 uppercase tracking-widest">{t('accountManager.bannedBadge')}</p>
|
| 55 |
+
<div className="mt-2 flex items-baseline gap-2">
|
| 56 |
+
<span className="text-3xl font-bold text-red-500">{queueStatus.banned_count ?? '-'}</span>
|
| 57 |
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
|
| 58 |
</div>
|
| 59 |
</div>
|
webui/src/features/account/useAccountActions.js
CHANGED
|
@@ -8,7 +8,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
|
| 8 |
const [editingAccount, setEditingAccount] = useState(null)
|
| 9 |
const [newKey, setNewKey] = useState({ key: '', name: '', remark: '' })
|
| 10 |
const [copiedKey, setCopiedKey] = useState(null)
|
| 11 |
-
const [newAccount, setNewAccount] = useState({ name: '', remark: '', email: '', mobile: '', password: '' })
|
| 12 |
const [editAccount, setEditAccount] = useState({ name: '', remark: '' })
|
| 13 |
const [loading, setLoading] = useState(false)
|
| 14 |
const [testing, setTesting] = useState({})
|
|
@@ -45,13 +45,13 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
|
| 45 |
setShowEditAccount(false)
|
| 46 |
setEditingAccount(null)
|
| 47 |
setEditAccount({ name: '', remark: '' })
|
| 48 |
-
setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' })
|
| 49 |
setShowAddAccount(true)
|
| 50 |
}
|
| 51 |
|
| 52 |
const closeAddAccount = () => {
|
| 53 |
setShowAddAccount(false)
|
| 54 |
-
setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' })
|
| 55 |
}
|
| 56 |
|
| 57 |
const openEditAccount = (account) => {
|
|
@@ -345,6 +345,56 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
|
| 345 |
}
|
| 346 |
}
|
| 347 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
return {
|
| 349 |
showAddKey,
|
| 350 |
openAddKey,
|
|
@@ -382,5 +432,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
|
| 382 |
testAllAccounts,
|
| 383 |
deleteAllSessions,
|
| 384 |
updateAccountProxy,
|
|
|
|
|
|
|
| 385 |
}
|
| 386 |
}
|
|
|
|
| 8 |
const [editingAccount, setEditingAccount] = useState(null)
|
| 9 |
const [newKey, setNewKey] = useState({ key: '', name: '', remark: '' })
|
| 10 |
const [copiedKey, setCopiedKey] = useState(null)
|
| 11 |
+
const [newAccount, setNewAccount] = useState({ name: '', remark: '', email: '', mobile: '', password: '', role: 'normal' })
|
| 12 |
const [editAccount, setEditAccount] = useState({ name: '', remark: '' })
|
| 13 |
const [loading, setLoading] = useState(false)
|
| 14 |
const [testing, setTesting] = useState({})
|
|
|
|
| 45 |
setShowEditAccount(false)
|
| 46 |
setEditingAccount(null)
|
| 47 |
setEditAccount({ name: '', remark: '' })
|
| 48 |
+
setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '', role: 'normal' })
|
| 49 |
setShowAddAccount(true)
|
| 50 |
}
|
| 51 |
|
| 52 |
const closeAddAccount = () => {
|
| 53 |
setShowAddAccount(false)
|
| 54 |
+
setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '', role: 'normal' })
|
| 55 |
}
|
| 56 |
|
| 57 |
const openEditAccount = (account) => {
|
|
|
|
| 345 |
}
|
| 346 |
}
|
| 347 |
|
| 348 |
+
const toggleRole = async (identifier, newRole) => {
|
| 349 |
+
const accountID = String(identifier || '').trim()
|
| 350 |
+
if (!accountID) {
|
| 351 |
+
onMessage('error', t('accountManager.invalidIdentifier'))
|
| 352 |
+
return
|
| 353 |
+
}
|
| 354 |
+
try {
|
| 355 |
+
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(accountID)}/role`, {
|
| 356 |
+
method: 'PUT',
|
| 357 |
+
headers: { 'Content-Type': 'application/json' },
|
| 358 |
+
body: JSON.stringify({ role: newRole }),
|
| 359 |
+
})
|
| 360 |
+
const data = await res.json()
|
| 361 |
+
if (!res.ok) {
|
| 362 |
+
onMessage('error', data.detail || t('messages.requestFailed'))
|
| 363 |
+
return
|
| 364 |
+
}
|
| 365 |
+
onMessage('success', newRole === 'standby' ? t('accountManager.switchToStandbySuccess') : t('accountManager.switchToNormalSuccess'))
|
| 366 |
+
fetchAccounts()
|
| 367 |
+
onRefresh()
|
| 368 |
+
} catch (_err) {
|
| 369 |
+
onMessage('error', t('messages.networkError'))
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
const toggleBanned = async (identifier, banned) => {
|
| 374 |
+
const accountID = String(identifier || '').trim()
|
| 375 |
+
if (!accountID) {
|
| 376 |
+
onMessage('error', t('accountManager.invalidIdentifier'))
|
| 377 |
+
return
|
| 378 |
+
}
|
| 379 |
+
try {
|
| 380 |
+
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(accountID)}/banned`, {
|
| 381 |
+
method: 'PUT',
|
| 382 |
+
headers: { 'Content-Type': 'application/json' },
|
| 383 |
+
body: JSON.stringify({ banned }),
|
| 384 |
+
})
|
| 385 |
+
const data = await res.json()
|
| 386 |
+
if (!res.ok) {
|
| 387 |
+
onMessage('error', data.detail || t('messages.requestFailed'))
|
| 388 |
+
return
|
| 389 |
+
}
|
| 390 |
+
onMessage('success', banned ? t('accountManager.banSuccess') : t('accountManager.unbanSuccess'))
|
| 391 |
+
fetchAccounts()
|
| 392 |
+
onRefresh()
|
| 393 |
+
} catch (_err) {
|
| 394 |
+
onMessage('error', t('messages.networkError'))
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
return {
|
| 399 |
showAddKey,
|
| 400 |
openAddKey,
|
|
|
|
| 432 |
testAllAccounts,
|
| 433 |
deleteAllSessions,
|
| 434 |
updateAccountProxy,
|
| 435 |
+
toggleRole,
|
| 436 |
+
toggleBanned,
|
| 437 |
}
|
| 438 |
}
|
webui/src/features/settings/RuntimeSection.jsx
CHANGED
|
@@ -2,7 +2,7 @@ export default function RuntimeSection({ t, form, setForm }) {
|
|
| 2 |
return (
|
| 3 |
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
|
| 4 |
<h3 className="font-semibold">{t('settings.runtimeTitle')}</h3>
|
| 5 |
-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-
|
| 6 |
<label className="text-sm space-y-2">
|
| 7 |
<span className="text-muted-foreground">{t('settings.accountMaxInflight')}</span>
|
| 8 |
<input
|
|
@@ -57,6 +57,21 @@ export default function RuntimeSection({ t, form, setForm }) {
|
|
| 57 |
className="w-full bg-background border border-border rounded-lg px-3 py-2"
|
| 58 |
/>
|
| 59 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
)
|
|
|
|
| 2 |
return (
|
| 3 |
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
|
| 4 |
<h3 className="font-semibold">{t('settings.runtimeTitle')}</h3>
|
| 5 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
| 6 |
<label className="text-sm space-y-2">
|
| 7 |
<span className="text-muted-foreground">{t('settings.accountMaxInflight')}</span>
|
| 8 |
<input
|
|
|
|
| 57 |
className="w-full bg-background border border-border rounded-lg px-3 py-2"
|
| 58 |
/>
|
| 59 |
</label>
|
| 60 |
+
<label className="text-sm space-y-2">
|
| 61 |
+
<span className="text-muted-foreground">{t('settings.backupReleaseCount')}</span>
|
| 62 |
+
<input
|
| 63 |
+
type="number"
|
| 64 |
+
min={1}
|
| 65 |
+
max={100}
|
| 66 |
+
step={1}
|
| 67 |
+
value={form.runtime.backup_release_count}
|
| 68 |
+
onChange={(e) => setForm((prev) => ({
|
| 69 |
+
...prev,
|
| 70 |
+
runtime: { ...prev.runtime, backup_release_count: Number(e.target.value || 2) },
|
| 71 |
+
}))}
|
| 72 |
+
className="w-full bg-background border border-border rounded-lg px-3 py-2"
|
| 73 |
+
/>
|
| 74 |
+
</label>
|
| 75 |
</div>
|
| 76 |
</div>
|
| 77 |
)
|
webui/src/features/settings/useSettingsForm.js
CHANGED
|
@@ -12,7 +12,7 @@ const MAX_AUTO_FETCH_FAILURES = 3
|
|
| 12 |
|
| 13 |
const DEFAULT_FORM = {
|
| 14 |
admin: { jwt_expire_hours: 24 },
|
| 15 |
-
runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10, token_refresh_interval_hours: 6 },
|
| 16 |
responses: { store_ttl_seconds: 900 },
|
| 17 |
embeddings: { provider: '' },
|
| 18 |
auto_delete: { mode: 'none' },
|
|
@@ -67,6 +67,7 @@ function fromServerForm(data) {
|
|
| 67 |
account_max_queue: Number(runtimeSource?.account_max_queue || 10),
|
| 68 |
global_max_inflight: Number(runtimeSource?.global_max_inflight || 10),
|
| 69 |
token_refresh_interval_hours: Number(runtimeSource?.token_refresh_interval_hours || 6),
|
|
|
|
| 70 |
},
|
| 71 |
responses: {
|
| 72 |
store_ttl_seconds: Number(responsesSource?.store_ttl_seconds || 900),
|
|
@@ -99,6 +100,7 @@ function toServerPayload(form) {
|
|
| 99 |
account_max_queue: Number(form.runtime.account_max_queue),
|
| 100 |
global_max_inflight: Number(form.runtime.global_max_inflight),
|
| 101 |
token_refresh_interval_hours: Number(form.runtime.token_refresh_interval_hours),
|
|
|
|
| 102 |
},
|
| 103 |
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
|
| 104 |
embeddings: { provider: String(form.embeddings.provider || '').trim() },
|
|
|
|
| 12 |
|
| 13 |
const DEFAULT_FORM = {
|
| 14 |
admin: { jwt_expire_hours: 24 },
|
| 15 |
+
runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10, token_refresh_interval_hours: 6, backup_release_count: 2 },
|
| 16 |
responses: { store_ttl_seconds: 900 },
|
| 17 |
embeddings: { provider: '' },
|
| 18 |
auto_delete: { mode: 'none' },
|
|
|
|
| 67 |
account_max_queue: Number(runtimeSource?.account_max_queue || 10),
|
| 68 |
global_max_inflight: Number(runtimeSource?.global_max_inflight || 10),
|
| 69 |
token_refresh_interval_hours: Number(runtimeSource?.token_refresh_interval_hours || 6),
|
| 70 |
+
backup_release_count: Number(runtimeSource?.backup_release_count || 2),
|
| 71 |
},
|
| 72 |
responses: {
|
| 73 |
store_ttl_seconds: Number(responsesSource?.store_ttl_seconds || 900),
|
|
|
|
| 100 |
account_max_queue: Number(form.runtime.account_max_queue),
|
| 101 |
global_max_inflight: Number(form.runtime.global_max_inflight),
|
| 102 |
token_refresh_interval_hours: Number(form.runtime.token_refresh_interval_hours),
|
| 103 |
+
backup_release_count: Number(form.runtime.backup_release_count),
|
| 104 |
},
|
| 105 |
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
|
| 106 |
embeddings: { provider: String(form.embeddings.provider || '').trim() },
|
webui/src/locales/en.json
CHANGED
|
@@ -171,6 +171,22 @@
|
|
| 171 |
"proxyNone": "Direct connection",
|
| 172 |
"proxyBadge": "Proxy: {name}",
|
| 173 |
"proxyUpdateSuccess": "Account proxy updated.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
"envModeRiskTitle": "Environment-variable config mode detected (persistence risk)",
|
| 175 |
"envModeRiskDesc": "Detected DS2API_CONFIG_JSON. If DS2API_ENV_WRITEBACK is not enabled, Admin UI edits are in-memory only and may be lost after restart.",
|
| 176 |
"envModeWritebackPendingTitle": "Env mode + auto-persistence enabled (pending file handoff)",
|
|
@@ -403,6 +419,7 @@
|
|
| 403 |
"accountMaxQueue": "Account max queue size",
|
| 404 |
"globalMaxInflight": "Global max inflight",
|
| 405 |
"tokenRefreshIntervalHours": "Managed token refresh interval (hours)",
|
|
|
|
| 406 |
"behaviorTitle": "Behavior",
|
| 407 |
"responsesTTL": "Responses store TTL (seconds)",
|
| 408 |
"embeddingsProvider": "Embeddings provider",
|
|
|
|
| 171 |
"proxyNone": "Direct connection",
|
| 172 |
"proxyBadge": "Proxy: {name}",
|
| 173 |
"proxyUpdateSuccess": "Account proxy updated.",
|
| 174 |
+
"accountTypeLabel": "Account type",
|
| 175 |
+
"roleNormal": "Normal",
|
| 176 |
+
"roleNormalDesc": "Participates in request rotation",
|
| 177 |
+
"roleStandby": "Standby",
|
| 178 |
+
"roleStandbyDesc": "Activated when normal accounts are banned",
|
| 179 |
+
"standbyStatus": "Standby",
|
| 180 |
+
"bannedStatus": "Banned",
|
| 181 |
+
"bannedBadge": "Banned",
|
| 182 |
+
"switchToNormal": "Switch to normal",
|
| 183 |
+
"switchToStandby": "Switch to standby",
|
| 184 |
+
"switchToNormalSuccess": "Switched to normal account",
|
| 185 |
+
"switchToStandbySuccess": "Switched to standby account",
|
| 186 |
+
"banAccount": "Ban account",
|
| 187 |
+
"unbanAccount": "Unban account",
|
| 188 |
+
"banSuccess": "Account banned",
|
| 189 |
+
"unbanSuccess": "Account unbanned",
|
| 190 |
"envModeRiskTitle": "Environment-variable config mode detected (persistence risk)",
|
| 191 |
"envModeRiskDesc": "Detected DS2API_CONFIG_JSON. If DS2API_ENV_WRITEBACK is not enabled, Admin UI edits are in-memory only and may be lost after restart.",
|
| 192 |
"envModeWritebackPendingTitle": "Env mode + auto-persistence enabled (pending file handoff)",
|
|
|
|
| 419 |
"accountMaxQueue": "Account max queue size",
|
| 420 |
"globalMaxInflight": "Global max inflight",
|
| 421 |
"tokenRefreshIntervalHours": "Managed token refresh interval (hours)",
|
| 422 |
+
"backupReleaseCount": "Standby accounts released per batch",
|
| 423 |
"behaviorTitle": "Behavior",
|
| 424 |
"responsesTTL": "Responses store TTL (seconds)",
|
| 425 |
"embeddingsProvider": "Embeddings provider",
|
webui/src/locales/zh.json
CHANGED
|
@@ -171,6 +171,22 @@
|
|
| 171 |
"proxyNone": "不走代理",
|
| 172 |
"proxyBadge": "代理: {name}",
|
| 173 |
"proxyUpdateSuccess": "账号代理已更新",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
"envModeRiskTitle": "当前为环境变量配置模式(有持久化风险)",
|
| 175 |
"envModeRiskDesc": "检测到 DS2API_CONFIG_JSON。若未开启 DS2API_ENV_WRITEBACK,管理台改动仅在内存生效,重启可能丢失。",
|
| 176 |
"envModeWritebackPendingTitle": "环境变量模式 + 自动持久化已开启(等待落盘)",
|
|
@@ -403,6 +419,7 @@
|
|
| 403 |
"accountMaxQueue": "账号等待队列上限",
|
| 404 |
"globalMaxInflight": "全局并发上限",
|
| 405 |
"tokenRefreshIntervalHours": "托管账号 Token 刷新间隔(小时)",
|
|
|
|
| 406 |
"behaviorTitle": "行为设置",
|
| 407 |
"responsesTTL": "Responses 缓存 TTL(秒)",
|
| 408 |
"embeddingsProvider": "Embeddings Provider",
|
|
|
|
| 171 |
"proxyNone": "不走代理",
|
| 172 |
"proxyBadge": "代理: {name}",
|
| 173 |
"proxyUpdateSuccess": "账号代理已更新",
|
| 174 |
+
"accountTypeLabel": "账号类型",
|
| 175 |
+
"roleNormal": "正常账号",
|
| 176 |
+
"roleNormalDesc": "参与请求轮询",
|
| 177 |
+
"roleStandby": "后备账号",
|
| 178 |
+
"roleStandbyDesc": "正常账号被封时启用",
|
| 179 |
+
"standbyStatus": "后备待命",
|
| 180 |
+
"bannedStatus": "已被封禁",
|
| 181 |
+
"bannedBadge": "封禁",
|
| 182 |
+
"switchToNormal": "切换为正常账号",
|
| 183 |
+
"switchToStandby": "切换为后备账号",
|
| 184 |
+
"switchToNormalSuccess": "已切换为正常账号",
|
| 185 |
+
"switchToStandbySuccess": "已切换为后备账号",
|
| 186 |
+
"banAccount": "封禁账号",
|
| 187 |
+
"unbanAccount": "解封账号",
|
| 188 |
+
"banSuccess": "账号已封禁",
|
| 189 |
+
"unbanSuccess": "账号已解封",
|
| 190 |
"envModeRiskTitle": "当前为环境变量配置模式(有持久化风险)",
|
| 191 |
"envModeRiskDesc": "检测到 DS2API_CONFIG_JSON。若未开启 DS2API_ENV_WRITEBACK,管理台改动仅在内存生效,重启可能丢失。",
|
| 192 |
"envModeWritebackPendingTitle": "环境变量模式 + 自动持久化已开启(等待落盘)",
|
|
|
|
| 419 |
"accountMaxQueue": "账号等待队列上限",
|
| 420 |
"globalMaxInflight": "全局并发上限",
|
| 421 |
"tokenRefreshIntervalHours": "托管账号 Token 刷新间隔(小时)",
|
| 422 |
+
"backupReleaseCount": "后备账号每次释放个数",
|
| 423 |
"behaviorTitle": "行为设置",
|
| 424 |
"responsesTTL": "Responses 缓存 TTL(秒)",
|
| 425 |
"embeddingsProvider": "Embeddings Provider",
|