From 00c7bc7ffde0558c78ca0d69739a1a225c5649c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com> Date: Fri, 28 Aug 2026 21:31:18 +0800 Subject: [PATCH] feat: compact console account setup, catalogs, and log paging Shorten add-account into type/name then login, persist account routing settings, paginate models and runtime logs, and fix WorkBuddy Global catalogs so the next patch can ship from Unreleased. --- CHANGELOG.md | 20 + docs/DESIGN.md | 13 +- docs/PLAN.md | 4 + docs/PROVIDERS.md | 4 +- frontend/src/api/logs.ts | 6 +- frontend/src/api/overview.ts | 25 +- frontend/src/components/AddAccountModal.tsx | 456 +++++++++++++----- .../src/components/account/AccountCard.tsx | 82 +++- frontend/src/components/ui/ListPager.tsx | 75 +++ frontend/src/components/ui/PageSkeletons.tsx | 40 +- frontend/src/hooks/useOverview.tsx | 2 +- frontend/src/i18n/messages.ts | 22 +- frontend/src/index.css | 13 - frontend/src/pages/AccessPage.tsx | 126 +++-- frontend/src/pages/AccountsPage.tsx | 67 ++- frontend/src/pages/LogsPage.tsx | 121 ++--- frontend/src/pages/ProvidersPage.tsx | 83 +++- internal/accounts/manager.go | 32 +- internal/accounts/manager_test.go | 49 +- internal/accounts/providers_test.go | 4 +- internal/accounts/store.go | 37 +- internal/accounts/store_test.go | 23 + internal/api/accounts.go | 37 +- internal/api/auth_test.go | 17 +- internal/api/chat.go | 7 +- internal/api/logs.go | 29 +- internal/api/logs_test.go | 25 +- internal/api/server.go | 2 +- internal/api/update.go | 2 +- internal/api/workerproxy.go | 37 +- internal/logs/ring.go | 32 +- internal/logs/ring_test.go | 60 ++- internal/providers/workbuddy/client.go | 49 +- internal/providers/workbuddy/client_test.go | 91 +++- internal/providers/workbuddy/credential.go | 22 +- internal/providers/workbuddy/headers.go | 14 +- .../webui/static/assets/index-CUK6_Xcv.js | 28 ++ ...{index-IjHLkWNt.css => index-Cv7fiubD.css} | 2 +- .../webui/static/assets/index-swNhnEq-.js | 28 -- internal/webui/static/index.html | 4 +- 40 files changed, 1382 insertions(+), 408 deletions(-) create mode 100644 frontend/src/components/ui/ListPager.tsx create mode 100644 internal/webui/static/assets/index-CUK6_Xcv.js rename internal/webui/static/assets/{index-IjHLkWNt.css => index-Cv7fiubD.css} (89%) delete mode 100644 internal/webui/static/assets/index-swNhnEq-.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 808efd76..5d4feb64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,28 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`. ### English +- Paginate runtime logs on Logs the same way as request history, newest first +- Filter the Models catalog by provider and paginate the list +- Fix WorkBuddy Global model catalogs: use the Global host from account region, accept CLI agent names besides exact `cli`, and surface catalog errors instead of an empty list +- Replace Access playground account and model tiles with compact side-by-side dropdowns +- Shorten the add-account dialog into two steps: pick type and name first, then sign in; hide concurrency and priority behind advanced options, and switch the type picker to a dropdown when more than six providers are registered +- Set account name, max concurrency, priority, and WorkBuddy drop-system-prompt before login, and edit name, concurrency, and priority on account cards +- Force-refresh account quotas when the console refresh button is used, bypassing the Qoder 15s quota cache +- Keep the Accounts toolbar visible while refresh is loading and show card skeletons instead of stale quota meters +- Drop the colored left accent on account cards; status stays on the chip and runtime meter + ### 中文 +- 日志页运行日志支持分页,交互与请求历史一致,最新记录在前 +- 模型目录可按供应商筛选,并支持分页 +- 修复 WorkBuddy 国际版模型目录:按账号区域打到国际站,识别不止精确 `cli` 的 CLI agent,失败时返回明确错误而不再显示空列表 +- Access 调试台的账号和模型选择改为并排下拉框,避免模型过多时撑开页面 +- 添加账号改为两步:先选类型和名称,再登录;并发和优先级收进高级选项,供应商超过 6 个时改用下拉 +- 添加账号时先设置名称、最大并发、优先级,以及 WorkBuddy 的丢弃系统提示词;卡片上也可改名称、并发和优先级 +- 控制台点刷新时强制重新拉取账号额度,绕过 Qoder 15 秒额度缓存 +- 账号页刷新时保留顶部操作栏,卡片区域显示骨架屏,不再把旧额度留在画面上 +- 去掉账号卡片左侧的彩色状态条,状态只留在 Chip 和运行短柱上 + ## 0.2.14 - 2026-08-28 ### English diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e028f167..ca742d23 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -122,7 +122,8 @@ - 圆角 8px 或更小,除非现有组件已用更大值 - 账号/资源列表卡片优先使用浅边框和平面表面,不强制最小高度,不额外叠加强阴影 -- 账号卡片保持操作台密度:单行身份(名称 + provider/auth/UID),状态 Chip 只出现一次,运行状态用 12 格短柱、额度用 `scaleX` 填充条;并发/优先级/重启收成一行辅助数字 +- 账号卡片保持操作台密度:单行身份(名称可直接改 + provider/auth/UID),状态 Chip 只出现一次,运行状态用 12 格短柱、额度用 `scaleX` 填充条;并发和优先级可在卡片上改,重启次数仍是只读辅助数字 +- 添加账号是两步向导:第一步选类型、名称和可选高级选项,第二步再选登录方式。类型 ≤ 6 用两列 tile,超过则用下拉;未知 provider 直接用后端 descriptor 的 label,不要静默丢掉 - 账号网格用 `lg:grid-cols-2 xl:grid-cols-3`,卡片间距 `gap-2.5`,内边距约 12px - 主卡片只有在确实需要抬升层级时才用 `shadow-card` - 边框克制使用;避免边框 + 阴影 + 有色背景同时出现 @@ -396,7 +397,9 @@ Each Qoder daemon exposes `GET /admin/quota` (console API key required). The dae qodercli `qoderApi` singleton captured via the `quotaApi` needle in `worker/src/compat.mjs`, which fetches `openapi:/api/v2/quota/usage` with a plain Bearer token — no WASM encode. The CLI already caches this endpoint for 15s and de-dupes concurrent fetches, so the daemon does -not add its own cache. +not add its own cache. Console header refresh calls `GET /api/overview?refresh=1`, which +asks the daemon for `/admin/quota?refresh=1` and bypasses that 15s cache. Silent account +mutations still use the cached snapshot. Go `refreshOne` fetches quota after health for hot/ready accounts and stores a `QuotaSnapshot` on the pool item. Quota is display-only: fetch failures are swallowed and @@ -419,8 +422,8 @@ Keep the menu short. Login is a gate, not a nav item. | `/login` | no | Console password | | `/` | Overview | Runtime pulse + request stats | | `/accounts` | Accounts | Qoder login + pool | -| `/providers` | Models | Catalog + per-model context-window defaults | - | `/access` | Access | Base URL + quick chat; model tiles follow the selected account catalog | +| `/providers` | Models | Catalog + per-model context-window defaults; filter by provider and paginate | +| `/access` | Access | Base URL + quick chat; account and model dropdowns follow the selected account catalog | | `/logs` | Logs | Request history + runtime process output | | `/system` | System | Next-version update + SQLite protection | | `/auth` | redirect | Legacy → `/accounts` | @@ -448,7 +451,7 @@ Rules: - `/api/logs/*` requires the SQLite API key. - Request history list accepts `account`, `status`, `stream`, `error_kind`, `model`, `q`, `from`, `to`, `limit`, and `offset`. The console `/logs` page paginates this list and exposes those filters. - `GET /api/logs/stats` aggregates counts, success rate, latency percentiles, tokens, error mix, and a time series for Overview. Windows are 1h / 24h / 7d; series buckets are 15 minutes, hourly, or daily. -- Runtime snapshot accepts `account` in addition to `level` and `q`. +- Runtime snapshot accepts `account`, `level`, `q`, `limit`, and `offset`. Results are newest-first. The console `/logs` runtime tab paginates this list; live polling stays on page 1. ## Managed update diff --git a/docs/PLAN.md b/docs/PLAN.md index 5a6cddda..c75bbf0d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -78,6 +78,9 @@ Sub2API-style SQLite account registry while preserving the working Qoder executi - [x] Add account CRUD endpoints - [x] Add browser OAuth, PAT, native credential import/export endpoints - [x] Add account creation and import UI using HeroUI only +- [x] Split add-account into type/name then login; hide concurrency and priority behind advanced options +- [x] Persist and edit account name, max inflight, priority, and WorkBuddy drop-system-prompt from the console +- [x] Filter the Models catalog by provider and paginate the list - [x] Show UID, auth type, enabled state, runtime status, cooldown, and last error - [x] Require the generated API key for every account and credential operation - [x] Persist per-model context-window defaults and edit them from the Models page @@ -159,6 +162,7 @@ output without copying sub2api billing, audit-body storage, or ops preaggregatio - [x] Add console `/logs` with Request history and Runtime tabs - [x] Purge request history at 7 days or 20_000 rows - [x] Paginate request history and filter by time, account, model, stream, and error kind +- [x] Paginate runtime logs newest-first with `limit` / `offset` ## Phase K — account quota display diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index a5537ef6..8cd86ff0 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -82,7 +82,7 @@ WorkBuddy2API 把 **WorkBuddy CN / CodeBuddy**(`copilot.tencent.com` / `www.co } ``` -也兼容扁平 JSON。`domain` 含 `workbuddy.ai` 视为 global,否则 CN。 +也兼容扁平 JSON。`domain` 含 `workbuddy.ai` 或 `workbuddy`(且不含 `codebuddy.cn`)视为 global,否则 CN。账号 `region=global` 时,catalog / chat / billing 即使凭证 domain 仍是 CN 也会打到 `www.workbuddy.ai`。 刷新:`POST {chatBase}/v2/plugin/auth/token/refresh`,头里带 `X-Refresh-Token`。chat 请求禁止带这个头。`12153` / `Offline user session not found` 视为 session 死亡,账号应禁用并要求重登。 @@ -99,7 +99,7 @@ WorkBuddy2API 把 **WorkBuddy CN / CodeBuddy**(`copilot.tencent.com` / `www.co `GET {chatBase}/console/enterprises/personal/models` -只暴露 `agents[].name == "cli"` 的模型 ID,再和 `data.models` 交叉,跳过 `disabled`。字段用 `maxInputTokens` / `maxOutputTokens`,不是 `contextWindow`。 +只暴露 CLI agent 的模型 ID(`cli` / `CLI` / `codebuddy` / `workbuddy`),再和 `data.models` 交叉,跳过 `disabled`。上游没有 agents 时,退回全部未禁用模型。字段用 `maxInputTokens` / `maxOutputTokens`,不是 `contextWindow`。按账号拉目录失败时,`GET /api/models?account=` 返回明确错误,不把空列表当成「没有模型」。 参考仓库失败时回退静态表。本仓库对 Qoder 的原则是 **catalog 失败就报错,不猜**。WorkBuddy 应沿用同一原则:动态目录失败返回明确错误,不内置一份会过期的模型白名单。 diff --git a/frontend/src/api/logs.ts b/frontend/src/api/logs.ts index f40a8c18..1981ab07 100644 --- a/frontend/src/api/logs.ts +++ b/frontend/src/api/logs.ts @@ -60,6 +60,9 @@ export type RuntimeLogEntry = { export type RuntimeLogList = { items: RuntimeLogEntry[] count: number + total?: number + limit?: number + offset?: number } export type RequestStatsWindow = { @@ -169,10 +172,11 @@ export function clearRequestLogs() { return api<{ ok: boolean; deleted: number }>('/api/logs/requests', { method: 'DELETE' }) } -export function fetchRuntimeLogs(query: { after?: number; limit?: number; level?: string; q?: string; account?: string } = {}) { +export function fetchRuntimeLogs(query: { after?: number; limit?: number; offset?: number; level?: string; q?: string; account?: string } = {}) { const params = new URLSearchParams() if (query.after != null) params.set('after', String(query.after)) if (query.limit != null) params.set('limit', String(query.limit)) + if (query.offset != null) params.set('offset', String(query.offset)) if (query.level) params.set('level', query.level) if (query.q) params.set('q', query.q) if (query.account) params.set('account', query.account) diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts index 40f850a5..b303ed07 100644 --- a/frontend/src/api/overview.ts +++ b/frontend/src/api/overview.ts @@ -1,8 +1,9 @@ import { api } from './client' import type { Overview } from './types' -export function fetchOverview(keyOverride?: string) { - return api('/api/overview', {}, keyOverride) +export function fetchOverview(keyOverride?: string, options?: { refreshQuota?: boolean }) { + const path = options?.refreshQuota ? '/api/overview?refresh=1' : '/api/overview' + return api(path, {}, keyOverride) } export function startDeviceLogin(accountId?: string) { @@ -87,8 +88,24 @@ export function fetchProviders() { return api<{ data?: ProviderDescriptor[] }>('/api/providers') } -export function createAccount(name: string, provider = 'qoder', region = 'global') { - return api('/api/accounts', { method: 'POST', body: JSON.stringify({ name, provider, region, enabled: true, max_inflight: 4 }) }) +export function createAccount( + name: string, + provider = 'qoder', + region = 'global', + options?: { max_inflight?: number; priority?: number; drop_system_prompt?: boolean }, +) { + return api('/api/accounts', { + method: 'POST', + body: JSON.stringify({ + name, + provider, + region, + enabled: true, + max_inflight: options?.max_inflight ?? 4, + priority: options?.priority ?? 50, + drop_system_prompt: options?.drop_system_prompt, + }), + }) } export function updateAccount(accountId: string, input: Record) { diff --git a/frontend/src/components/AddAccountModal.tsx b/frontend/src/components/AddAccountModal.tsx index aa40cf97..1fc52fcd 100644 --- a/frontend/src/components/AddAccountModal.tsx +++ b/frontend/src/components/AddAccountModal.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from 'react' -import { Button, Input, Modal, TextArea } from '@heroui/react' -import { CheckCircle, ArrowSquareOut, FileCode, Globe, Key, MapPin, ShieldCheck, SpinnerGap, X } from '@phosphor-icons/react' +import { Button, Description, Input, Label, ListBox, Modal, Select, TextArea } from '@heroui/react' +import { ArrowSquareOut, CaretLeft, CaretRight, CheckCircle, FileCode, Key, ShieldCheck, SpinnerGap, X } from '@phosphor-icons/react' import { ProviderMark } from '@/components/ProviderMark' +import { CompactSwitch } from '@/components/ui/CompactSwitch' import { OptionTiles } from '@/components/ui/OptionTiles' import { useI18n } from '@/hooks/useI18n' import { @@ -21,27 +22,41 @@ type Props = { } type TabKey = 'browser' | 'pat' | 'import' +type Step = 'method' | 'login' type ProviderOption = { id: string provider: string region: string - labelKey: string - hintKey: string descriptor: ProviderDescriptor } -const labelKeys: Record = { - 'qoder-global': { label: 'accountTypeQoderGlobal', hint: 'accountTypeQoderGlobalHint' }, - 'qoder-cn': { label: 'accountTypeQoderCN', hint: 'accountTypeQoderCNHint' }, - 'workbuddy-cn': { label: 'accountTypeWorkBuddyCN', hint: 'accountTypeWorkBuddyCNHint' }, - 'workbuddy-global': { label: 'accountTypeWorkBuddyGlobal', hint: 'accountTypeWorkBuddyGlobalHint' }, +// Region suffix for descriptors without a dedicated i18n label. +const regionLabels: Record = { + global: 'Global', + cn: 'CN', +} + +// Dedicated i18n labels/hints for known families; unknown provider×region +// combinations fall back to the descriptor, so backend registration alone +// puts a new type on this list. +const labelKeys: Record = { + 'qoder-global': 'accountTypeQoderGlobal', + 'qoder-cn': 'accountTypeQoderCN', + 'workbuddy-cn': 'accountTypeWorkBuddyCN', + 'workbuddy-global': 'accountTypeWorkBuddyGlobal', +} + +const hintKeys: Record = { + 'qoder-global': 'accountTypeQoderGlobalHint', + 'qoder-cn': 'accountTypeQoderCNHint', + 'workbuddy-cn': 'accountTypeWorkBuddyCNHint', + 'workbuddy-global': 'accountTypeWorkBuddyGlobalHint', } const fallbackProviderOptions: ProviderOption[] = [ { id: 'qoder-global', provider: 'qoder', region: 'global', - labelKey: 'accountTypeQoderGlobal', hintKey: 'accountTypeQoderGlobalHint', descriptor: { id: 'qoder', label: 'Qoder', runtime: 'child_process', default_region: 'global', capabilities: { browser_login: true, pat_login: true, import_export: true }, @@ -56,20 +71,28 @@ async function loadProviderOptions(): Promise { const options: ProviderOption[] = [] for (const descriptor of descriptors) { for (const region of descriptor.regions) { - const id = `${descriptor.id}-${region.id}` - const keys = labelKeys[id] - if (!keys) continue - options.push({ - id, provider: descriptor.id, region: region.id, - labelKey: keys.label, hintKey: keys.hint, descriptor, - }) + options.push({ id: `${descriptor.id}-${region.id}`, provider: descriptor.id, region: region.id, descriptor }) } } return options.length ? options : fallbackProviderOptions } -function providerBadge(option: ProviderOption) { - return +function optionLabel(option: ProviderOption, t: (key: string) => string) { + const key = labelKeys[option.id] + if (key) { + const localized = t(key) + if (localized !== key) return localized + } + const regionLabel = option.descriptor.regions.find((r) => r.id === option.region)?.label || regionLabels[option.region] || '' + const suffix = option.descriptor.regions.length > 1 && regionLabel ? ` ${regionLabel}` : '' + return `${option.descriptor.label}${suffix}`.trim() +} + +function optionHint(option: ProviderOption, t: (key: string) => string) { + const key = hintKeys[option.id] + if (!key) return '' + const localized = t(key) + return localized === key ? '' : localized } function StatusIcon({ phase, busy, tab, forTab }: { phase: Phase; busy: boolean; tab: TabKey; forTab: TabKey }) { @@ -78,20 +101,23 @@ function StatusIcon({ phase, busy, tab, forTab }: { phase: Phase; busy: boolean; return null } -type Phase = 'idle' | 'busy' | 'polling' | 'starting' | 'done' +type Phase = 'idle' | 'busy' | 'polling' | 'done' const POLL_ATTEMPTS = 90 const POLL_INTERVAL = 2000 - -// Fixed step height keeps the dialog from jumping between login methods. -const STEP_HEIGHT = 328 +const TILE_LIMIT = 6 export function AddAccountModal({ isOpen, onClose, onAdded }: Props) { const { t } = useI18n() + const [step, setStep] = useState('method') const [tab, setTab] = useState('browser') const [accountType, setAccountType] = useState('qoder-global') const [providerOptions, setProviderOptions] = useState(fallbackProviderOptions) + const [advancedOpen, setAdvancedOpen] = useState(false) const [name, setName] = useState('') + const [maxInFlight, setMaxInFlight] = useState('4') + const [priority, setPriority] = useState('50') + const [dropSystemPrompt, setDropSystemPrompt] = useState(true) const [pat, setPat] = useState('') const [json, setJson] = useState('') const [phase, setPhase] = useState('idle') @@ -126,14 +152,50 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) { const activeOption = providerOptions.find((option) => option.id === accountType) || fallbackProviderOptions[0] const showPatTab = activeOption.descriptor.capabilities?.pat_login !== false + const showImportTab = activeOption.descriptor.capabilities?.import_export !== false + const showDropSystem = activeOption.provider === 'workbuddy' + const busy = phase === 'busy' || phase === 'polling' + const settingsLocked = Boolean(createdId.current) || busy + const isDone = phase === 'done' + const hint = optionHint(activeOption, t) + + useEffect(() => { + if (tab === 'pat' && !showPatTab) setTab('browser') + if (tab === 'import' && !showImportTab) setTab('browser') + }, [showImportTab, showPatTab, tab]) + + function parsedMaxInFlight() { + const value = Number(maxInFlight) + if (!Number.isInteger(value) || value < 1 || value > 32) return 4 + return value + } + + function parsedPriority() { + const value = Number(priority) + if (!Number.isInteger(value) || value < 1 || value > 100) return 50 + return value + } + + function accountOptions() { + return { + max_inflight: parsedMaxInFlight(), + priority: parsedPriority(), + drop_system_prompt: showDropSystem ? dropSystemPrompt : true, + } + } function reset() { stopPolling() + setStep('method') setTab('browser') setAccountType('qoder-global') setName('') + setMaxInFlight('4') + setPriority('50') + setDropSystemPrompt(true) setPat('') setJson('') + setAdvancedOpen(false) setPhase('idle') setMessage('') setAuthUrl('') @@ -141,19 +203,22 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) { } function close() { - if (phase === 'busy' || phase === 'polling' || phase === 'starting') return + if (busy) return reset() onClose() } - function switchTab(next: TabKey) { - if (busy || next === 'pat' && !showPatTab) return + function goLogin(next: TabKey) { + if (busy) return setTab(next) + setMessage('') + setStep('login') } async function ensureAccount(): Promise { if (createdId.current) return createdId.current - const account = await createAccount(name.trim() || t('account'), activeOption.provider, activeOption.region) + const options = accountOptions() + const account = await createAccount(name.trim() || t('account'), activeOption.provider, activeOption.region, options) const id = account?.id || account?.data?.id if (!id) throw new Error('create account returned no id') createdId.current = id @@ -183,9 +248,6 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) { if (login.status === 'error') throw new Error(login.message || 'login failed') if (attempt === POLL_ATTEMPTS - 1) throw new Error(t('wizardLoginTimeout')) } - setPhase('starting') - setMessage(t('wizardStartingWorker')) - await new Promise((resolve) => { pollTimer.current = window.setTimeout(resolve, 1500) }) setPhase('done') setMessage(t('wizardAccountReady')) onAdded() @@ -235,7 +297,17 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) { if (!bundle.format) bundle.format = activeOption.descriptor.id === 'workbuddy' ? 'workbuddy-oauth-v1' : 'qoder-native-v1' try { setPhase('busy') - await importAccount({ ...bundle, name: name.trim() || bundle.name, enabled: true, provider: activeOption.provider, region: activeOption.region }) + const options = accountOptions() + await importAccount({ + ...bundle, + name: name.trim() || bundle.name, + enabled: true, + provider: activeOption.provider, + region: activeOption.region, + max_inflight: options.max_inflight, + priority: options.priority, + drop_system_prompt: options.drop_system_prompt, + }) setPhase('done') setMessage(t('accountImported')) onAdded() @@ -259,14 +331,27 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) { event.target.value = '' } - const busy = phase === 'busy' || phase === 'polling' || phase === 'starting' const tabPending = (key: TabKey) => busy && tab === key + const useTypeSelect = providerOptions.length > TILE_LIMIT + const typeOptions = providerOptions.map((option) => ({ + value: option.id, + label: optionLabel(option, t), + hint: optionHint(option, t), + icon: , + disabled: settingsLocked, + })) const methodOptions = [ - { value: 'browser' as const, label: t('tabBrowser'), icon: , disabled: busy }, - { value: 'pat' as const, label: t('tabPat'), icon: , disabled: busy || !showPatTab }, - { value: 'import' as const, label: t('tabImport'), icon: , disabled: busy }, - ] + { value: 'browser' as const, label: t('tabBrowser'), icon: }, + showPatTab ? { value: 'pat' as const, label: t('tabPat'), icon: } : null, + showImportTab ? { value: 'import' as const, label: t('tabImport'), icon: } : null, + ].filter(Boolean) as Array<{ value: TabKey; label: string; icon: React.ReactNode }> + + const tabLead = tab === 'browser' + ? t('wizardBrowserLead') + : tab === 'pat' + ? t(activeOption.region === 'cn' && activeOption.provider === 'qoder' ? 'wizardPatLeadCN' : 'wizardPatLead') + : t('wizardImportLead') return ( { if (!next) close() }}> @@ -276,105 +361,227 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
{t('addAccountTitle')} -

{t('addAccountDesc')}

+

+ {step === 'method' ? t('addAccountDesc') : (hint || t('addAccountDesc'))} +

-
-
- {t('accountType')} - - {activeOption.provider === 'qoder' ? : } - {activeOption.provider === 'qoder' ? 'WASM runtime' : 'HTTP runtime'} - -
- ({ - value: option.id, - label: t(option.labelKey), - icon: providerBadge(option), - }))} - /> -

{t(activeOption.hintKey)}

-
- -
- {t('loginMethod')} - switchTab(next)} - options={methodOptions} - /> -

- {tab === 'browser' ? t('wizardBrowserLead') : tab === 'pat' ? t(activeOption.region === 'cn' && activeOption.provider === 'qoder' ? 'wizardPatLeadCN' : 'wizardPatLead') : t('wizardImportLead')} -

-
- -
-
- {tab === 'browser' ? ( - <> - setName(event.target.value)} placeholder={t('wizardNamePh')} aria-label={t('wizardNamePh')} disabled={busy} /> - {authUrl ? ( -
-
- - {message || t('loginOpenMsg')} -
- + {step === 'method' ? ( + <> +
+ {useTypeSelect ? ( + + ) : ( + <> + {t('accountType')} + { if (!settingsLocked) setAccountType(next) }} + options={typeOptions} + /> + + )} + {hint ?

{hint}

: null} +
+ +
+ setName(event.target.value)} + placeholder={t('wizardNamePh')} + aria-label={t('accountName')} + disabled={settingsLocked} + /> + + {advancedOpen ? ( +
+
+ +
- ) : null} -
+ {showDropSystem ? ( +
+
+
{t('dropSystemPrompt')}
+

{t('dropSystemPromptCreateHint')}

+
+ +
+ ) : null} +
+ ) : null} +
+ +
+ +
+ + ) : ( + <> +
+ +
+
{optionLabel(activeOption, t)}
+
{name.trim() || t('account')}
+
+ +
+ +
+ {methodOptions.map((option) => ( + + ))} +
+ +

{tabLead}

+ +
+ {tab === 'browser' ? ( + <> + {authUrl ? ( +
+
+ + {message || t('loginOpenMsg')} +
+ +
+ ) : null} + {message && !authUrl ? ( +

{message}

+ ) : null} -
- - ) : tab === 'pat' ? ( - <> - setName(event.target.value)} placeholder={t('wizardNamePh')} aria-label={t('wizardNamePh')} disabled={busy} /> - setPat(event.target.value)} placeholder={t('wizardPatPh')} aria-label={t('wizardPatPh')} disabled={busy} /> -
+ + ) : tab === 'pat' ? ( + <> + setPat(event.target.value)} placeholder={t('wizardPatPh')} aria-label={t('wizardPatPh')} disabled={busy} /> + {message ? ( +

{message}

+ ) : null} -
- - ) : ( - <> - setName(event.target.value)} placeholder={t('wizardNamePh')} aria-label={t('wizardNamePh')} disabled={busy} /> -
- JSON - - -
-