diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index d4bd589..1707a40 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -2193,6 +2193,18 @@ func (p *AccountPool) ClearCooldownIfGeneration( return err } +// ClearCooldown 清除账户在作用域上的冷却,作用域为全局 *、模型 ID 或 build:<模型 ID> +func (p *AccountPool) ClearCooldown(accountID string, scope string) error { + _, err := p.updateRuntime(accountID, func(_ *Account, runtimeState *accountRuntimeState) (bool, func(*Account), error) { + if _, exists := runtimeState.Cooldowns[scope]; !exists { + return false, nil, nil + } + delete(runtimeState.Cooldowns, scope) + return true, nil, nil + }) + return err +} + // BindResource 将资源 ID 固定到创建账户 func (p *AccountPool) BindResource(resourceID string, accountID string) error { return p.BindResourceKind(resourceID, accountID, "") diff --git a/internal/aistudio/auth.go b/internal/aistudio/auth.go index 205dcbb..be1e3d8 100644 --- a/internal/aistudio/auth.go +++ b/internal/aistudio/auth.go @@ -112,6 +112,7 @@ type IsolatedLoginResult struct { Email string VerifiedAt time.Time DriveError string + CloseError string } // IsolatedLoginDriver 定义独立浏览器登录 diff --git a/internal/aistudio/login_native.go b/internal/aistudio/login_native.go index 158509c..ea818e1 100644 --- a/internal/aistudio/login_native.go +++ b/internal/aistudio/login_native.go @@ -66,7 +66,8 @@ func (driver *NativeLoginDriver) Login(ctx context.Context, request IsolatedLogi return IsolatedLoginResult{}, err } return IsolatedLoginResult{ - StorageState: state, Email: result.Email, VerifiedAt: result.VerifiedAt, DriveError: result.DriveError, + StorageState: state, Email: result.Email, VerifiedAt: result.VerifiedAt, + DriveError: result.DriveError, CloseError: result.CloseError, }, nil } diff --git a/internal/api/admin.go b/internal/api/admin.go index e250128..20b570d 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -28,6 +28,7 @@ type AdminService interface { RuntimeConfig(context.Context) (RuntimeConfig, error) UpdateRuntimeConfig(context.Context, RuntimeConfig) (RuntimeConfig, error) Cooldowns(context.Context) ([]AdminCooldown, error) + ClearCooldown(ctx context.Context, accountID string, scope string) error Requests(context.Context) ([]AdminRequest, error) CancelRequest(context.Context, string) error Events(context.Context) (<-chan AdminEvent, error) @@ -241,6 +242,7 @@ type AdminCooldown struct { AccountLabel string `json:"account_label"` Channel string `json:"channel"` ModelID string `json:"model_id"` + Scope string `json:"scope"` Until time.Time `json:"until"` Reason string `json:"reason,omitempty"` } @@ -278,6 +280,7 @@ func (s *server) registerAdmin(mux *http.ServeMux) { mux.HandleFunc("GET /api/config", s.handleRuntimeConfig) mux.HandleFunc("PUT /api/config", s.handleUpdateRuntimeConfig) mux.HandleFunc("GET /api/cooldowns", s.handleCooldowns) + mux.HandleFunc("DELETE /api/accounts/{id}/cooldowns/{scope}", s.handleClearCooldown) mux.HandleFunc("GET /api/requests", s.handleRequests) mux.HandleFunc("POST /api/requests/{id}/cancel", s.handleCancelRequest) mux.HandleFunc("GET /api/events", s.handleAdminEvents) @@ -457,6 +460,14 @@ func (s *server) handleCooldowns(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string][]AdminCooldown{"cooldowns": cooldowns}) } +func (s *server) handleClearCooldown(w http.ResponseWriter, r *http.Request) { + if err := s.config.Admin.ClearCooldown(r.Context(), r.PathValue("id"), r.PathValue("scope")); err != nil { + writeAdminUpstreamError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *server) handleRequests(w http.ResponseWriter, r *http.Request) { requests, err := s.config.Admin.Requests(r.Context()) if err != nil { diff --git a/internal/app/admin.go b/internal/app/admin.go index 2f3de19..d4b6771 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -193,6 +193,9 @@ func (admin *runtimeAdmin) CreateAccount(ctx context.Context, input api.AccountC if result.DriveError != "" { admin.requests.log("auth", "WARN", "账户添加 | Drive 授权失败 | 错误="+result.DriveError) } + if result.CloseError != "" { + admin.requests.log("auth", "WARN", "账户添加 | 关闭 Camoufox 失败 | 错误="+result.CloseError) + } admin.requests.log("auth", "INFO", "账户添加 | 2/2 | 保存认证状态") if _, err := aistudio.NewSigner().Sign(result.StorageState); err != nil { return api.AdminAccount{}, fmt.Errorf("认证状态无法用于 AI Studio: %w", err) @@ -507,6 +510,9 @@ func (admin *runtimeAdmin) LoginAccount(ctx context.Context, accountID string) ( if result.DriveError != "" { admin.requests.log(account.Config.Label, "WARN", "账户登录 | Drive 授权失败 | 错误="+result.DriveError) } + if result.CloseError != "" { + admin.requests.log(account.Config.Label, "WARN", "账户登录 | 关闭 Camoufox 失败 | 错误="+result.CloseError) + } admin.requests.log(account.Config.Label, "INFO", "账户登录 | 2/2 | 保存认证状态") if _, err := aistudio.NewSigner().Sign(result.StorageState); err != nil { return api.AdminAccount{}, errors.Join(fmt.Errorf("认证状态无法用于 AI Studio: %w", err), lease.Release()) @@ -840,9 +846,11 @@ func (admin *runtimeAdmin) Cooldowns(context.Context) ([]api.AdminCooldown, erro models[modelID] = struct{}{} } effective := make(map[string]aistudio.CooldownState) + scopes := make(map[string]string) if global, ok := account.Cooldowns["*"]; ok && global.Active(now) { for modelID := range models { effective[modelID] = global + scopes[modelID] = "*" } } for key, cooldown := range account.Cooldowns { @@ -855,6 +863,7 @@ func (admin *runtimeAdmin) Cooldowns(context.Context) ([]api.AdminCooldown, erro } if current, ok := effective[key]; !ok || cooldown.Until.After(current.Until) { effective[key] = cooldown + scopes[key] = key } } modelIDs := make([]string, 0, len(effective)) @@ -873,13 +882,32 @@ func (admin *runtimeAdmin) Cooldowns(context.Context) ([]api.AdminCooldown, erro } cooldowns = append(cooldowns, api.AdminCooldown{ AccountID: account.ID, AccountLabel: account.Label, Channel: channel, - ModelID: modelID, Until: cooldown.Until, Reason: cooldown.Reason, + ModelID: modelID, Scope: scopes[key], Until: cooldown.Until, Reason: cooldown.Reason, }) } } return cooldowns, nil } +// ClearCooldown 手动清除账户在一个作用域上的冷却,并推送新的冷却列表与账户状态 +func (admin *runtimeAdmin) ClearCooldown(ctx context.Context, accountID string, scope string) error { + account, err := admin.account(accountID) + if err != nil { + return err + } + if err := admin.pool.ClearCooldown(accountID, scope); err != nil { + return err + } + admin.requests.log(account.Label, "INFO", "冷却已手动清除 | 范围="+scope) + if cooldowns, err := admin.Cooldowns(ctx); err == nil { + admin.requests.publish(api.AdminEvent{Type: "cooldowns", Data: cooldowns}) + } + if accounts, err := admin.Accounts(ctx); err == nil { + admin.requests.publish(api.AdminEvent{Type: "accounts", Data: map[string]any{"accounts": accounts}}) + } + return nil +} + func (admin *runtimeAdmin) Requests(context.Context) ([]api.AdminRequest, error) { return admin.requests.list(), nil } diff --git a/internal/app/lifecycle.go b/internal/app/lifecycle.go index 6286626..6a987df 100644 --- a/internal/app/lifecycle.go +++ b/internal/app/lifecycle.go @@ -468,6 +468,13 @@ func (manager *runtimeManager) Cooldowns(ctx context.Context) ([]api.AdminCooldo return manager.current.admin.Cooldowns(ctx) } +// ClearCooldown 手动清除账户在一个作用域上的冷却 +func (manager *runtimeManager) ClearCooldown(ctx context.Context, accountID string, scope string) error { + manager.mu.RLock() + defer manager.mu.RUnlock() + return manager.current.admin.ClearCooldown(ctx, accountID, scope) +} + // Requests 返回进程级活动请求 func (manager *runtimeManager) Requests(ctx context.Context) ([]api.AdminRequest, error) { manager.mu.RLock() diff --git a/internal/camoufoxnative/launcher.go b/internal/camoufoxnative/launcher.go index 8ead34c..e1b1a54 100644 --- a/internal/camoufoxnative/launcher.go +++ b/internal/camoufoxnative/launcher.go @@ -28,7 +28,7 @@ var bidiEndpointPattern = regexp.MustCompile(`ws://[^\s]+`) const browserProcessCloseTimeout = 5 * time.Second -type browserProcessTerminator func(context.Context, *exec.Cmd) error +type browserProcessTerminator func(context.Context, *browserProcess) error type browserProcess struct { command *exec.Cmd @@ -36,6 +36,7 @@ type browserProcess struct { waitErr error profile string lock *flock.Flock + tree browserTree mu sync.Mutex closed bool } @@ -140,7 +141,8 @@ func launchBrowser(ctx context.Context, options Options, config map[string]any) _ = removeProfile(profile, profileLock) return nil, "", fmt.Errorf("启动 Camoufox: %w", err) } - if err := attachBrowserProcess(command); err != nil { + tree, err := attachBrowserProcess(command) + if err != nil { _ = command.Process.Kill() _ = command.Wait() _ = removeProfile(profile, profileLock) @@ -151,6 +153,7 @@ func launchBrowser(ctx context.Context, options Options, config map[string]any) done: make(chan struct{}), profile: profile, lock: profileLock, + tree: tree, } launched = true go func() { @@ -181,9 +184,8 @@ func launchBrowser(ctx context.Context, options Options, config map[string]any) case <-process.done: process.mu.Lock() err := process.waitErr - process.closed = true process.mu.Unlock() - _ = removeProfile(profile, profileLock) + _ = process.Close() if err == nil { err = errors.New("Camoufox 在报告 BiDi 端点前退出") } @@ -217,16 +219,11 @@ func (process *browserProcess) close(timeout time.Duration, terminate browserPro } closeCtx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - var closeErr error + closeErr := terminate(closeCtx, process) select { case <-process.done: - default: - closeErr = terminate(closeCtx, process.command) - select { - case <-process.done: - case <-closeCtx.Done(): - closeErr = errors.Join(closeErr, fmt.Errorf("等待 Camoufox 进程退出: %w", closeCtx.Err())) - } + case <-closeCtx.Done(): + closeErr = errors.Join(closeErr, fmt.Errorf("等待 Camoufox 进程退出: %w", closeCtx.Err())) } closeErr = errors.Join(closeErr, removeProfile(process.profile, process.lock)) if closeErr != nil { @@ -235,6 +232,7 @@ func (process *browserProcess) close(timeout time.Duration, terminate browserPro process.mu.Lock() process.closed = true process.mu.Unlock() + releaseBrowserTree(process.tree) return nil } diff --git a/internal/camoufoxnative/login.go b/internal/camoufoxnative/login.go index 69342f4..9818a51 100644 --- a/internal/camoufoxnative/login.go +++ b/internal/camoufoxnative/login.go @@ -43,6 +43,7 @@ type LoginResult struct { PageURL string VerifiedAt time.Time DriveError string + CloseError string } type loginSession struct { @@ -65,7 +66,12 @@ func Login(ctx context.Context, options LoginOptions) (result LoginResult, err e return LoginResult{}, err } defer func() { - err = errors.Join(err, session.Close()) + closeErr := session.Close() + if err == nil && closeErr != nil { + result.CloseError = closeErr.Error() + return + } + err = errors.Join(err, closeErr) }() origins := make(map[string]storageOrigin) pageURL, err := session.waitLogin(loginCtx, origins) diff --git a/internal/camoufoxnative/process_other.go b/internal/camoufoxnative/process_other.go index fb13bd2..1b1f723 100644 --- a/internal/camoufoxnative/process_other.go +++ b/internal/camoufoxnative/process_other.go @@ -15,13 +15,20 @@ func configureBrowserProcess(command *exec.Cmd, _ bool) { command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } +// browserTree 在非 Windows 平台由进程组表示,不需要额外句柄 +type browserTree struct{} + // attachBrowserProcess 在非 Windows 平台由进程组负责回收 -func attachBrowserProcess(*exec.Cmd) error { - return nil +func attachBrowserProcess(*exec.Cmd) (browserTree, error) { + return browserTree{}, nil } +// releaseBrowserTree 在非 Windows 平台无需释放 +func releaseBrowserTree(browserTree) {} + // terminateBrowserProcess 结束 Camoufox 进程组 -func terminateBrowserProcess(ctx context.Context, command *exec.Cmd) error { +func terminateBrowserProcess(ctx context.Context, process *browserProcess) error { + command := process.command if command == nil || command.Process == nil { return nil } diff --git a/internal/camoufoxnative/process_windows.go b/internal/camoufoxnative/process_windows.go index 35995b9..c762340 100644 --- a/internal/camoufoxnative/process_windows.go +++ b/internal/camoufoxnative/process_windows.go @@ -4,20 +4,16 @@ package camoufoxnative import ( "context" - "errors" "fmt" - "os" "os/exec" - "strconv" "sync" "syscall" + "time" "unsafe" "golang.org/x/sys/windows" ) -const windowsStillActive = 259 - var ( browserJobOnce sync.Once browserJob windows.Handle @@ -58,67 +54,75 @@ func serviceBrowserJob() (windows.Handle, error) { return browserJob, browserJobErr } -// attachBrowserProcess 把挂起的 Camoufox 加入服务 Job 后恢复执行 -func attachBrowserProcess(command *exec.Cmd) error { +// browserTree 是只含一个 Camoufox 及其全部子孙进程的嵌套 Job +type browserTree windows.Handle + +// jobAccountingInformation 是 JOBOBJECT_BASIC_ACCOUNTING_INFORMATION +type jobAccountingInformation struct { + TotalUserTime int64 + TotalKernelTime int64 + ThisPeriodTotalUserTime int64 + ThisPeriodTotalKernelTime int64 + TotalPageFaultCount uint32 + TotalProcesses uint32 + ActiveProcesses uint32 + TotalTerminatedProcesses uint32 +} + +// attachBrowserProcess 把挂起的 Camoufox 加入服务 Job 与它自己的嵌套 Job 后恢复执行,返回该嵌套 Job +func attachBrowserProcess(command *exec.Cmd) (browserTree, error) { job, err := serviceBrowserJob() if err != nil { - return err + return 0, err } process, err := windows.OpenProcess(browserJobAccess, false, uint32(command.Process.Pid)) if err != nil { - return fmt.Errorf("打开 Camoufox 进程: %w", err) + return 0, fmt.Errorf("打开 Camoufox 进程: %w", err) } defer windows.CloseHandle(process) if err := windows.AssignProcessToJobObject(job, process); err != nil { - return fmt.Errorf("加入 Camoufox Job: %w", err) + return 0, fmt.Errorf("加入 Camoufox Job: %w", err) + } + tree, err := windows.CreateJobObject(nil, nil) + if err != nil { + return 0, fmt.Errorf("创建 Camoufox 进程树 Job: %w", err) + } + if err := windows.AssignProcessToJobObject(tree, process); err != nil { + windows.CloseHandle(tree) + return 0, fmt.Errorf("加入 Camoufox 进程树 Job: %w", err) } if status, _, _ := ntResumeProcess.Call(uintptr(process)); status != 0 { - return fmt.Errorf("恢复 Camoufox 进程: NTSTATUS 0x%x", status) + windows.CloseHandle(tree) + return 0, fmt.Errorf("恢复 Camoufox 进程: NTSTATUS 0x%x", status) } - return nil + return browserTree(tree), nil } -// terminateBrowserProcess 结束 Camoufox 及其全部子进程 -func terminateBrowserProcess(ctx context.Context, command *exec.Cmd) error { - if command == nil || command.Process == nil { - return nil +// terminateBrowserProcess 结束 Camoufox 嵌套 Job 中的全部进程并等待它们退出 +func terminateBrowserProcess(ctx context.Context, process *browserProcess) error { + tree := windows.Handle(process.tree) + if err := windows.TerminateJobObject(tree, 1); err != nil { + return fmt.Errorf("结束 Camoufox 进程树: %w", err) } - if err := ctx.Err(); err != nil { - return err - } - pid := command.Process.Pid - if !browserProcessActive(pid) { - return nil - } - kill := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F") - kill.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} - if output, err := kill.CombinedOutput(); err != nil { - if !browserProcessActive(pid) { - return nil + for { + var info jobAccountingInformation + if err := windows.QueryInformationJobObject( + tree, windows.JobObjectBasicAccountingInformation, uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), nil, + ); err != nil { + return fmt.Errorf("查询 Camoufox 进程树: %w", err) } - taskkillErr := fmt.Errorf("taskkill 结束 Camoufox 进程树 PID=%d: %w: %s", pid, err, output) - directKillErr := command.Process.Kill() - if directKillErr == nil || errors.Is(directKillErr, os.ErrProcessDone) || !browserProcessActive(pid) { + if info.ActiveProcesses == 0 { return nil } - terminateErr := errors.Join(taskkillErr, fmt.Errorf("Process.Kill 结束 Camoufox PID=%d: %w", pid, directKillErr)) - if ctxErr := ctx.Err(); ctxErr != nil { - return errors.Join(terminateErr, ctxErr) + select { + case <-ctx.Done(): + return fmt.Errorf("等待 Camoufox 进程树退出: %d 个进程仍在运行: %w", info.ActiveProcesses, ctx.Err()) + case <-time.After(20 * time.Millisecond): } - return terminateErr } - return nil } -func browserProcessActive(pid int) bool { - handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) - if err != nil { - return !errors.Is(err, windows.ERROR_INVALID_PARAMETER) - } - defer windows.CloseHandle(handle) - var exitCode uint32 - if err := windows.GetExitCodeProcess(handle, &exitCode); err != nil { - return true - } - return exitCode == windowsStillActive +// releaseBrowserTree 关闭嵌套 Job 句柄 +func releaseBrowserTree(tree browserTree) { + windows.CloseHandle(windows.Handle(tree)) } diff --git a/internal/setup/setup.go b/internal/setup/setup.go index aa860ab..335d40e 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -262,6 +262,9 @@ func importIsolatedLogin( if result.DriveError != "" { fmt.Fprintf(os.Stdout, "Drive 授权失败: %s\n", result.DriveError) } + if result.CloseError != "" { + fmt.Fprintf(os.Stdout, "关闭 Camoufox 失败: %s\n", result.CloseError) + } return nil } diff --git a/web/src/api.ts b/web/src/api.ts index e4c2732..97baf88 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -211,6 +211,11 @@ export const api = { requestCommand(`/api/requests/${encodeURIComponent(id)}/cancel`, { method: 'POST', }), + clearCooldown: (accountID: string, scope: string) => + requestCommand( + `/api/accounts/${encodeURIComponent(accountID)}/cooldowns/${encodeURIComponent(scope)}`, + { method: 'DELETE' }, + ), } // openAdminEvents 建立唯一的管理状态 SSE 连接 diff --git a/web/src/components/RequestsPanel.vue b/web/src/components/RequestsPanel.vue index 74e9cf5..8a945e1 100644 --- a/web/src/components/RequestsPanel.vue +++ b/web/src/components/RequestsPanel.vue @@ -25,6 +25,7 @@ const cancelling = ref('') const refreshing = ref(false) const selectedCategory = ref('') const detail = ref(null) +const clearing = ref(false) // splitReason 把冷却原因拆成冷却类型与上游错误原文 function splitReason(reason: string | undefined): { category: string; text: string } { @@ -117,6 +118,21 @@ async function cancelRequest(request: RequestSummary): Promise { cancelling.value = '' } } + +// clearCooldown 手动清除详情中的冷却,成功后关闭详情并刷新列表 +async function clearCooldown(cooldown: Cooldown): Promise { + clearing.value = true + try { + await api.clearCooldown(cooldown.account_id, cooldown.scope) + detail.value = null + emit('notice', t('cooldowns.cleared'), 'success') + emit('refresh') + } catch (error) { + emit('notice', errorText(error), 'error') + } finally { + clearing.value = false + } +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 08917ce..bfa2ab2 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -193,6 +193,10 @@ export const messages: Record = { 'requests.empty': 'Keine laufenden Anfragen', 'cooldowns.title': 'Modell-Cooldowns', 'cooldowns.until': 'Verfügbar ab', + 'cooldowns.clear': 'Cooldown aufheben', + 'cooldowns.cleared': 'Cooldown aufgehoben', + 'cooldowns.clearAccountHint': + 'Cooldown für das ganze Konto, gilt für alle Modelle dieses Kontos; modellspezifische Cooldowns stehen separat', 'cooldowns.all': 'Alle', 'cooldowns.other': 'Sonstige', 'cooldowns.category': 'Kategorie', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 453f17a..5b803b8 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -190,6 +190,10 @@ export const messages: Record = { 'requests.empty': 'No current requests', 'cooldowns.title': 'Model cooldowns', 'cooldowns.until': 'Available at', + 'cooldowns.clear': 'Clear cooldown', + 'cooldowns.cleared': 'Cooldown cleared', + 'cooldowns.clearAccountHint': + 'Account-wide cooldown that applies to every model on this account; model-specific cooldowns are listed separately', 'cooldowns.all': 'All', 'cooldowns.other': 'Other', 'cooldowns.category': 'Category', diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 0bcee2f..8fb5f62 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -193,6 +193,10 @@ export const messages: Record = { 'requests.empty': 'Aucune requête en cours', 'cooldowns.title': 'Refroidissements par modèle', 'cooldowns.until': 'Disponible à', + 'cooldowns.clear': 'Lever le refroidissement', + 'cooldowns.cleared': 'Refroidissement levé', + 'cooldowns.clearAccountHint': + 'Refroidissement de tout le compte, appliqué à tous les modèles de ce compte ; les refroidissements propres à un modèle sont listés à part', 'cooldowns.all': 'Tout', 'cooldowns.other': 'Autre', 'cooldowns.category': 'Catégorie', diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 1db77ca..8219a48 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -193,6 +193,10 @@ export const messages: Record = { 'requests.empty': '現在リクエストはありません', 'cooldowns.title': 'モデルのクールダウン', 'cooldowns.until': '回復予定', + 'cooldowns.clear': 'クールダウンを解除', + 'cooldowns.cleared': 'クールダウンを解除しました', + 'cooldowns.clearAccountHint': + 'アカウント全体のクールダウンで、このアカウントのすべてのモデルに適用されます。モデル個別のクールダウンは別に表示されます', 'cooldowns.all': 'すべて', 'cooldowns.other': 'その他', 'cooldowns.category': '種類', diff --git a/web/src/locales/ko.ts b/web/src/locales/ko.ts index e32dd8b..2e0661c 100644 --- a/web/src/locales/ko.ts +++ b/web/src/locales/ko.ts @@ -191,6 +191,10 @@ export const messages: Record = { 'requests.empty': '현재 요청이 없습니다', 'cooldowns.title': '모델 쿨다운', 'cooldowns.until': '복구 시간', + 'cooldowns.clear': '쿨다운 해제', + 'cooldowns.cleared': '쿨다운을 해제했습니다', + 'cooldowns.clearAccountHint': + '계정 전체 쿨다운으로 이 계정의 모든 모델에 적용됩니다. 모델별 쿨다운은 따로 표시됩니다', 'cooldowns.all': '전체', 'cooldowns.other': '기타', 'cooldowns.category': '유형', diff --git a/web/src/locales/zh-CN.ts b/web/src/locales/zh-CN.ts index 9792418..cda9ffc 100644 --- a/web/src/locales/zh-CN.ts +++ b/web/src/locales/zh-CN.ts @@ -185,6 +185,9 @@ export const messages = { 'requests.empty': '当前没有请求', 'cooldowns.title': '模型冷却', 'cooldowns.until': '恢复时间', + 'cooldowns.clear': '清除冷却', + 'cooldowns.cleared': '已清除冷却', + 'cooldowns.clearAccountHint': '账户级冷却,作用于该账户全部模型;模型自身的冷却单独列出', 'cooldowns.all': '全部', 'cooldowns.other': '其他', 'cooldowns.category': '类别', diff --git a/web/src/locales/zh-TW.ts b/web/src/locales/zh-TW.ts index cdd7d04..9355da7 100644 --- a/web/src/locales/zh-TW.ts +++ b/web/src/locales/zh-TW.ts @@ -188,6 +188,9 @@ export const messages: Record = { 'requests.empty': '目前沒有請求', 'cooldowns.title': '模型冷卻', 'cooldowns.until': '恢復時間', + 'cooldowns.clear': '清除冷卻', + 'cooldowns.cleared': '已清除冷卻', + 'cooldowns.clearAccountHint': '帳號層級冷卻,作用於該帳號所有模型;模型本身的冷卻另行列出', 'cooldowns.all': '全部', 'cooldowns.other': '其他', 'cooldowns.category': '類別', diff --git a/web/src/types.ts b/web/src/types.ts index e5c07cc..dc174a5 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -159,6 +159,7 @@ export interface Cooldown { account_label: string channel: UpstreamChannel model_id: string + scope: string until: string reason?: string }