Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Persist account quota snapshots and quota-exhausted state in SQLite so account filters and pagination use stored state without refreshing every account on page load
- Add compact provider filters with account counts and make the Accounts page show 20 accounts per page by default
- Simplify the Access page layout and tighten supported endpoint cards
- Accept completed Qoder SSE streams that omit a final [DONE] marker when a finish reason was already received

### 中文

- 将账号额度快照和「额度已用尽」状态持久化到 SQLite,账号筛选和分页直接使用已存状态,不再在页面加载时逐个刷新
- 账号页新增带数量的供应商筛选,并将默认每页显示改为 20 个账号
- 简化 Access 页面布局,收紧支持端点卡片
- Qoder SSE 已收到结束原因但缺少最终 [DONE] 标记时,仍按正常完成处理

## 0.3.4 - 2026-09-07

### English
Expand Down
5 changes: 4 additions & 1 deletion deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ the same conversation prefer the same account from the first user message
| `QODER_DATA_DIR` | `/data` | SQLite database and durable account credentials |
| `QODER_RUNTIME_DIR` | `/run/cli2api` | Ephemeral per-account runtime homes for providers that use child processes |
| `QODER_MAX_RETRY_ACCOUNTS` | `4` | Maximum accounts attempted for one request (1-64) |
| `QODER_SSE_DIAGNOSTIC_MODELS` | empty | Comma-separated Qoder model IDs for redacted SSE diagnostics in Runtime Logs; `*` enables all |
| `QODER_WORKER_BASE_PORT` | `32100` | Internal child-runtime port range |
| `QODERCLI_JS` | image default | Pinned Qoder Global CLI bundle |
| `QODERCNCLI_JS` | image default | Pinned Qoder CN CLI bundle |
Expand All @@ -105,6 +106,8 @@ the same conversation prefer the same account from the first user message
| `UPDATE_AGENT_TOKEN` | empty | Docker Desktop updater token, written by the installer |
| `CLI2API_UPDATER_SOCKET_DIR` | platform-specific | Host directory mounted read-only for the Linux updater socket |

For a problematic Qoder model, set `QODER_SSE_DIAGNOSTIC_MODELS` to the exact model ID shown by `/v1/models`, for example `qoder-extreme`, then recreate the container. Diagnostics record only SSE shape, event counts, status codes, finish signals, and field lengths; they do not record prompt, response, tool arguments, or token contents.

Per-account concurrency is configured through `max_inflight` in the console; it is
persisted with each account and passed to its runtime.

Expand Down Expand Up @@ -202,4 +205,4 @@ accepted for staged-update hosts; unknown versions fail closed.
- Published host updater assets cover Linux, macOS, and Windows on `amd64` and `arm64`.
- Run the Windows updater installer as the same signed-in user that runs Docker Desktop; do not install it as `LocalSystem`.
- Keep `deploy/.env` private because Docker Desktop mode stores the updater token there.
- Do not remove `qoder-data` unless you intentionally want to delete SQLite accounts and credentials.
- Do not remove `qoder-data` unless you intentionally want to delete SQLite accounts and credentials.
3 changes: 2 additions & 1 deletion deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ services:
PORT: "3010"
QODER_DATA_DIR: "/data"
QODER_RUNTIME_DIR: "/run/cli2api"
QODER_SSE_DIAGNOSTIC_MODELS: "${QODER_SSE_DIAGNOSTIC_MODELS:-}"
QODER_WORKER_BASE_PORT: "${QODER_WORKER_BASE_PORT:-32100}"
UPDATE_SOCKET_PATH: "/run/cli2api-updater/updater.sock"
UPDATE_AGENT_URL: "${UPDATE_AGENT_URL:-}"
Expand All @@ -31,4 +32,4 @@ services:
restart: unless-stopped

volumes:
qoder-data:
qoder-data:
24 changes: 10 additions & 14 deletions frontend/src/components/EndpointList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,35 +23,31 @@ export function EndpointList({ access }: EndpointListProps) {
], [access, base, t])

return (
<Card data-gsap-reveal className="overflow-hidden p-0">
<div className="flex items-start justify-between gap-4 border-b border-separator px-5 py-5 sm:px-6">
<Card data-gsap-reveal className="overflow-hidden border-border p-0 shadow-none">
<div className="flex items-start justify-between gap-4 border-b border-separator px-5 py-4 sm:px-6">
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold tracking-[-0.015em]">{t('endpoints')}</h3>
<Chip size="sm" variant="soft">{t('endpointCount', { count: endpoints.length - 1 })}</Chip>
</div>
<p className="mt-1 text-xs leading-5 text-muted">{t('routesHint')}</p>
</div>
<div className="hidden items-center gap-2 text-xs text-muted sm:flex">
<span className="status-dot" data-state="ok" />
{t('endpointReady')}
</div>
</div>
<div className="grid gap-3 p-4 sm:grid-cols-2 sm:p-5">
<div className="grid gap-2.5 p-3 sm:grid-cols-2 sm:p-4 lg:grid-cols-3">
{endpoints.map((item) => (
<div key={item.name} className="group min-w-0 rounded-2xl border border-border bg-surface-secondary/35 p-4 transition-colors hover:border-foreground/20">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-surface text-muted">{item.icon}</span>
<div key={item.name} className="group min-w-0 rounded-lg bg-surface-secondary/45 p-3 transition-colors hover:bg-surface-secondary/65">
<div className="flex items-start justify-between gap-2.5">
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid size-7 shrink-0 place-items-center rounded-lg bg-surface text-muted">{item.icon}</span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{item.name}</div>
<code className="mono mt-1 block truncate text-[11px] text-muted">{item.url}</code>
<code className="mono mt-0.5 block truncate text-[11px] text-muted">{item.url}</code>
</div>
</div>
<Chip size="sm" variant="soft">{item.method}</Chip>
</div>
<p className="mt-3 min-h-10 text-xs leading-5 text-muted">{item.hint}</p>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-separator pt-3">
<p className="mt-2 min-h-0 text-xs leading-5 text-muted">{item.hint}</p>
<div className="mt-2 flex items-center justify-between gap-2 border-t border-separator pt-2">
<span className="text-[10px] font-medium text-muted">{item.method === 'BASE' ? t('endpointBaseLabel') : t('endpointAuthLabel')}</span>
<div className="flex gap-1">
<Button isIconOnly size="sm" variant="ghost" aria-label={t('copy')} onPress={() => { void navigator.clipboard.writeText(item.url); setCopiedEndpoint(item.name); window.setTimeout(() => setCopiedEndpoint(''), 1100) }}>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/account/AccountCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function stateCopyFor(state: ReturnType<typeof accountState>, cooldown: string,
if (state === 'hot') return t('signedIn')
if (state === 'ready') return t('ready')
if (state === 'cooling') return cooldown ? `${t('cooling')} ${cooldown}` : t('cooling')
if (state === 'quota_exhausted') return t('quotaExceeded')
if (state === 'starting') return t('starting')
if (state === 'dead') return cooldown ? `${t('dead')} ${cooldown}` : t('dead')
if (state === 'auth_failed') return t('authFailed')
Expand Down Expand Up @@ -126,6 +127,8 @@ export function AccountCard({
? 'success'
: state === 'cooling'
? 'warning'
: state === 'quota_exhausted'
? 'danger'
: state === 'login' || state === 'dead' || state === 'auth_failed'
? 'danger'
: undefined
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ui/ListPager.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Pagination, ToggleButton, ToggleButtonGroup } from '@heroui/react'

export const PAGE_SIZES = [20, 50, 100] as const
export const ACCOUNT_PAGE_SIZES = [5, 20, 50, 100] as const
export const ACCOUNT_PAGE_SIZES = [20, 50, 100] as const
export type PageSize = (typeof ACCOUNT_PAGE_SIZES)[number]

function pageTokens(page: number, pageCount: number): Array<number | 'gap'> {
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/lib/account.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AccountQuota, Overview } from '@/api/types'

export type AccountRow = NonNullable<Overview['accounts']>[number]
export type AccountState = 'disabled' | 'cooling' | 'hot' | 'ready' | 'login' | 'starting' | 'dead' | 'auth_failed'
export type AccountState = 'disabled' | 'quota_exhausted' | 'cooling' | 'hot' | 'ready' | 'login' | 'starting' | 'dead' | 'auth_failed'
export type QuotaTone = 'ok' | 'warn' | 'danger'

export function cooldownLabel(until?: string | null) {
Expand All @@ -20,10 +20,12 @@ export function modelCooldownEntries(account: AccountRow) {

export function accountState(account: AccountRow): AccountState {
if (!account.enabled) return 'disabled'
if (cooldownLabel(account.down_until || account.cooldown_until)) return 'cooling'
if (account.runtime_state === 'dead') return 'dead'
if (account.runtime_state === 'auth_failed') return 'auth_failed'
if (account.runtime_state === 'starting' && !account.hot && !account.ready) return 'starting'
if (account.status === 'quota_exhausted' || account.quota?.exceeded) return 'quota_exhausted'
if (account.status === 'cooling' || cooldownLabel(account.down_until || account.cooldown_until)) return 'cooling'
if (account.status === 'dead' || account.runtime_state === 'dead') return 'dead'
if (account.status === 'auth_failed' || account.runtime_state === 'auth_failed') return 'auth_failed'
if (account.status === 'starting' && !account.hot && !account.ready) return 'starting'
if (account.status === 'ready') return account.hot ? 'hot' : 'ready'
if (account.hot) return 'hot'
if (account.ready) return 'ready'
return 'login'
Expand All @@ -47,7 +49,7 @@ export function runtimeSegments(state: AccountState) {

export function runtimeTone(state: AccountState): 'ok' | 'warn' | 'danger' | 'muted' {
if (state === 'hot' || state === 'ready') return 'ok'
if (state === 'cooling') return 'warn'
if (state === 'cooling' || state === 'quota_exhausted') return 'warn'
if (state === 'starting') return 'warn'
if (state === 'auth_failed' || state === 'dead') return 'danger'
if (state === 'login') return 'danger'
Expand Down
50 changes: 25 additions & 25 deletions frontend/src/pages/AccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export function AccessPage() {
: t('requestIdle')

return (
<div className="space-y-6">
<div className="space-y-5">
<section className="grid gap-5 border-b border-separator pb-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div data-gsap-reveal>
<h2 className="text-2xl font-semibold tracking-[-0.035em]">{t('apiPlayground')}</h2>
Expand All @@ -203,41 +203,41 @@ export function AccessPage() {
</div>
</section>

<section data-gsap-reveal className="grid overflow-hidden rounded-3xl border border-border bg-surface sm:grid-cols-3">
<div className="border-b border-separator px-5 py-4 sm:col-span-3">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="font-semibold tracking-[-0.015em]">{t('connection')}</h3>
<p className="mt-0.5 text-xs leading-5 text-muted">{t('clientConfigHint')}</p>
</div>
<Chip size="sm" variant="soft" color={readyAccounts.length ? 'success' : 'warning'}>
{readyAccounts.length ? t('endpointReady') : t('degraded')}
</Chip>
<section data-gsap-reveal className="overflow-hidden rounded-2xl border border-border bg-surface">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-separator px-4 py-3">
<div className="min-w-0">
<div className="text-xs font-medium text-muted">{t('baseUrl')}</div>
<code className="mono mt-0.5 block truncate text-sm font-medium text-foreground">{base}</code>
</div>
</div>
<div className="min-w-0 border-b border-separator p-4 sm:col-span-2 sm:border-r sm:border-b-0 sm:p-5">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-medium text-muted">{t('baseUrl')}</span>
<div className="flex items-center gap-3">
<div className="hidden text-right sm:block">
<div className="text-[10px] font-medium text-muted">{t('protocol')}</div>
<div className="text-xs font-medium">HTTP / SSE</div>
</div>
<div className="hidden text-right sm:block">
<div className="text-[10px] font-medium text-muted">{t('authentication')}</div>
<div className="mono text-[11px] font-medium">Bearer API key</div>
</div>
<Button isIconOnly size="sm" variant="ghost" aria-label={t('copyBaseUrl')} onPress={() => void copy(base, 'base')}>
{copied === 'base' ? <Check size={15} /> : <Copy size={15} />}
</Button>
<Chip size="sm" variant="soft" color={readyAccounts.length ? 'success' : 'warning'}>
{readyAccounts.length ? t('endpointReady') : t('degraded')}
</Chip>
</div>
<code className="mono block truncate text-sm font-medium text-foreground">{base}</code>
</div>
<div className="grid grid-cols-2 divide-x divide-separator sm:grid-cols-1 sm:divide-x-0 sm:divide-y">
<div className="p-4 sm:px-5 sm:py-3">
<div className="text-xs font-medium text-muted">{t('protocol')}</div>
<div className="mt-1 text-sm font-medium">HTTP / SSE</div>
<div className="grid grid-cols-2 divide-x divide-separator sm:hidden">
<div className="p-3">
<div className="text-[10px] font-medium text-muted">{t('protocol')}</div>
<div className="mt-0.5 text-xs font-medium">HTTP / SSE</div>
</div>
<div className="p-4 sm:px-5 sm:py-3">
<div className="text-xs font-medium text-muted">{t('authentication')}</div>
<div className="mono mt-1 truncate text-xs font-medium">Bearer API key</div>
<div className="p-3">
<div className="text-[10px] font-medium text-muted">{t('authentication')}</div>
<div className="mono mt-0.5 truncate text-[11px] font-medium">Bearer API key</div>
</div>
</div>
</section>

<p data-gsap-reveal className="max-w-3xl text-sm leading-6 text-muted">{t('providerPrefixHint')}</p>

<EndpointList access={overview?.access} />

<Card data-gsap-reveal className="overflow-hidden p-0">
Expand Down
41 changes: 21 additions & 20 deletions frontend/src/pages/AccountsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ export function AccountsPage() {
const [authPanelId, setAuthPanelId] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<AccountFilter>('all')
const [providerFilter, setProviderFilter] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState<PageSize>(5)
const [pageSize, setPageSize] = useState<PageSize>(20)
const [enabledById, setEnabledById] = useState<Record<string, boolean>>({})
const [dropSystemById, setDropSystemById] = useState<Record<string, boolean>>({})
const [autoCheckinById, setAutoCheckinById] = useState<Record<string, boolean>>({})
Expand Down Expand Up @@ -99,16 +100,8 @@ export function AccountsPage() {
}, [])

useEffect(() => {
let active = true
void (async () => {
try {
const initialAccounts = await reloadAccounts(false)
if (active) void refreshAccountsInBatches(initialAccounts.map((account) => account.id)).catch(() => undefined)
} catch {
}
})()
return () => { active = false }
}, [refreshAccountsInBatches, reloadAccounts])
void reloadAccounts(false).catch(() => undefined)
}, [reloadAccounts])
const displayRows = useMemo(() => rows.map((account) => {
const enabled = enabledById[account.id]
const dropSystem = dropSystemById[account.id]
Expand Down Expand Up @@ -150,12 +143,13 @@ export function AccountsPage() {
|| (filter === 'attention' && account.enabled && state !== 'hot' && state !== 'ready')
|| (filter === 'disabled' && state === 'disabled')
if (!matchesFilter) return false
if (providerFilter && String(account.provider || 'qoder').toLowerCase() !== providerFilter) return false
if (!normalized) return true
return [account.name, account.id, account.remote_uid, account.provider, account.auth_type]
.some((value) => String(value || '').toLowerCase().includes(normalized))
})
}, [displayRows, filter, query])
const filterKey = [query, filter, pageSize].join('\0')
}, [displayRows, filter, providerFilter, query])
const filterKey = [query, filter, providerFilter, pageSize].join('\0')
const [appliedFilterKey, setAppliedFilterKey] = useState(filterKey)
if (appliedFilterKey !== filterKey) {
setAppliedFilterKey(filterKey)
Expand Down Expand Up @@ -374,13 +368,7 @@ export function AccountsPage() {
<span className="text-muted">{t('inFlight')}</span>
<span className="mono font-medium">{inFlightCount}</span>
</div>
{providerCounts.map(([provider, count]) => (
<div key={provider} className="flex items-center gap-1.5">
<ProviderMark provider={provider} size={12} />
<span className="text-muted">{accountProviderFamilyLabel(provider, t)}</span>
<span className="mono font-medium">{count}</span>
</div>
))}

</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="secondary" isPending={quotaRefreshing} onPress={() => void onRefreshCredits()}>
Expand Down Expand Up @@ -434,6 +422,19 @@ export function AccountsPage() {
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<FilterToggle
value={providerFilter}
onChange={setProviderFilter}
ariaLabel={t('providerFilterAll')}
options={[
{ id: '', label: t('providerFilterAll') + ' ' + rows.length },
...providerCounts.map(([provider, count]) => ({
id: provider,
label: accountProviderFamilyLabel(provider, t) + ' ' + count,
icon: <ProviderMark provider={provider} size={13} />,
})),
]}
/>
<FilterToggle
value={filter}
onChange={(next) => setFilter(next as AccountFilter)}
Expand Down
Loading
Loading