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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Filter Access playground models to the selected account's live catalog instead of the global union
- Add an available-models button on account cards that opens that account's live catalog
- Show request volume, success rate, latency, tokens, and hourly traffic on Overview, with 1h / 24h / 7d windows from SQLite request history
- Shrink Accounts cards: denser identity row, a compact runtime meter, and a quota fill that animates remaining credits
- Show page skeletons again when the console refresh button is used, instead of leaving stale cards on screen
- Show a list skeleton on Logs while filters, pagination, or refresh are loading, without replacing the filter bar

### 中文

- Access 调试台选了账号后,模型列表改为该账号的实时目录,不再用全局并集
- 账号卡片增加「可用模型」按钮,弹窗查看该账号当前目录
- 概览页展示请求量、成功率、延迟、token 与按小时流量,时间窗口为 1 小时 / 24 小时 / 7 天,数据来自 SQLite 请求历史
- 账号卡片改为更紧凑的身份行:运行状态用短柱状指示,额度用填充条显示剩余量
- 控制台点刷新时重新显示骨架屏,不再把旧卡片留在页面上
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ For an end-to-end run, use the Docker Compose flow in `deploy/README.md`.

User-facing changes should add matching bullets to `CHANGELOG.md` under
`## Unreleased` in both `### English` and `### 中文`. The release workflow
copies those notes into the GitHub Release body.
copies those notes into the GitHub Release body. Maintainers publish tags
from `docs/DEVELOPMENT.md`; do not create version tags by hand.

## Rules

Expand All @@ -66,4 +67,4 @@ copies those notes into the GitHub Release body.
`docs/PRIVATE_DEPLOYMENT.md`

Architecture and UI decisions live in `docs/DESIGN.md`. Work in progress lives in
`docs/PLAN.md`.
`docs/PLAN.md`. Maintainer release and tag steps live in `docs/DEVELOPMENT.md`.
4 changes: 2 additions & 2 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ Keep the menu short. Login is a gate, not a nav item.
| `/` | Overview | Runtime pulse + request stats |
| `/accounts` | Accounts | Qoder login + pool |
| `/providers` | Models | Catalog + per-model context-window defaults |
| `/access` | Access | Base URL + quick chat |
| `/access` | Access | Base URL + quick chat; model tiles follow the selected account catalog |
| `/logs` | Logs | Request history + runtime process output |
| `/system` | System | Next-version update + SQLite protection |
| `/auth` | redirect | Legacy → `/accounts` |
Expand Down Expand Up @@ -460,7 +460,7 @@ Release packaging keeps the application as a Linux container and publishes a `li

Maintainers do not create version tags manually. A serialized `workflow_dispatch` release waits for CI on the exact `main` commit, calculates the next patch after the latest published stable release, creates or resumes an invisible draft release, uploads all updater assets, builds a candidate multi-architecture image, promotes and verifies immutable version tags, and finally publishes the release. Mutable `latest` and release-series aliases move only after publication. Failed pre-publication runs leave a resumable draft rather than exposing an update to the console.

Release notes come from `CHANGELOG.md`, not generated commit lists. Maintainers write matching `### English` and `### 中文` bullets under `## Unreleased`; the workflow copies that bilingual body onto the GitHub Release. The console System page extracts the current UI language, renders the markdown (lists, inline code, links), and shows it in a box that grows with the notes then scrolls. After the release is public, a follow-up commit freezes those notes under the new version heading so the next patch starts from an empty Unreleased section.
Release notes come from `CHANGELOG.md`, not generated commit lists. Maintainers write matching `### English` and `### 中文` bullets under `## Unreleased`; the workflow copies that bilingual body onto the GitHub Release. The console System page extracts the current UI language, renders the markdown (lists, inline code, links), and shows it in a box that grows with the notes then scrolls. After the release is public, freeze those notes under the new version heading through a pull request — `main` is protected, so the workflow cannot push the freeze commit itself. Operator steps live in `docs/DEVELOPMENT.md`.

The host boundary is explicitly versioned through `protocol_version`. Version `1` is current; version `0` is temporarily accepted for an older updater that omitted the field. Any other version is rejected before an update request is submitted. New updater releases must remain backward-compatible with the immediately previous application release so the latest-asset bootstrap path stays safe.

Expand Down
34 changes: 29 additions & 5 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Development

last-updated: 2026-08-27
last-updated: 2026-08-28

Repository rules, layering constraints, and validation checklists live in
[CONTRIBUTING.md](../CONTRIBUTING.md). This page covers the local build loop
Expand Down Expand Up @@ -44,14 +44,38 @@ docker compose up -d --build

## Maintainer release

After `main` passes CI, publish the next patch release with one command:
Do not create or push version tags by hand. The tag, GitHub Release, updater assets, and GHCR image aliases all come from one serialized `workflow_dispatch` on `main`.

### Before you run it

1. `main` is the commit you want to ship. The workflow waits for CI on that exact SHA.
2. `CHANGELOG.md` `## Unreleased` has matching `### English` and `### 中文` bullets for every user-facing change on `main` since the last published tag. The workflow copies that section into the GitHub Release and the console System page. `validate` allows an empty Unreleased section; `extract-for-release` fails if Unreleased is empty and the new version heading does not already exist.
3. Do not freeze Unreleased yourself before the run. The workflow reads Unreleased first; freeze only after the tag is public.

```bash
gh workflow run release.yml --ref main
```

Write bilingual user-facing notes in `CHANGELOG.md` under `## Unreleased` before publishing. Each change needs a matching bullet in `### English` and `### 中文`; the workflow copies those notes into the GitHub Release and the console update page.
You can also use **Actions → Release → Run workflow**.

The workflow calculates the next patch from the latest published stable release, creates an invisible draft, builds six checksum-verified updater binaries plus `cli2api-updater_checksums.txt`, publishes `linux/amd64` + `linux/arm64` images, then makes the GitHub Release latest and moves `latest` / series aliases. Console update checks ignore drafts, so a failed pre-publication run stays invisible.

### After it publishes

The `changelog` job tries to freeze Unreleased under the new version heading and push straight to `main`. That push is rejected: `main` requires a pull request and status checks. Treat a red Release run as expected when `publish` and `aliases` already succeeded.

Freeze by PR, not by re-running the release workflow:

1. Branch from current `main` (it may already have commits after the tag).
2. Move only the bullets that shipped in that tag under `## 0.x.y - YYYY-MM-DD`. Leave later `main` work in `## Unreleased`.
3. Open a `docs: freeze changelog for v0.x.y` PR. If `CHANGELOG.md` conflicts, keep post-tag features in Unreleased.
4. Merge the PR. Do not run `release.yml` again to fix the freeze — a second run would mint the next patch.

Expected artifacts for a published tag:

The workflow waits for the exact `main` commit to pass CI, calculates the next patch from the latest published stable release, creates an invisible draft release, builds six checksum-verified updater binaries, verifies the `linux/amd64` and `linux/arm64` image manifest, and only then publishes the GitHub Release and moves the stable image aliases. After publication it freezes the Unreleased notes under the new version heading. Do not create or push the version tag manually.
| Kind | Names |
|------|--------|
| GitHub Release assets (7) | six `cli2api-updater_{os}_{arch}` binaries and `cli2api-updater_checksums.txt` |
| GHCR (`ghcr.io/caigee-cmd/cli2api`) | `v0.x.y`, `0.x.y`, series (`0.2`), `latest`, all the same multi-arch digest |

You can also use **Actions → Release → Run workflow**. If a pre-publication job fails, use **Re-run failed jobs** on the same run; the draft release remains invisible to application update checks.
If a **pre-publication** job fails (`prepare` / `assets` / `draft` / `image` / `promote`), use **Re-run failed jobs** on the same run. The draft remains unpublished. Do not create the tag locally while that draft exists.
12 changes: 9 additions & 3 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,16 @@ export function loginWithPat(pat: string, accountId?: string) {
})
}

export function refreshModels(accountId?: string) {
const q = new URLSearchParams({ refresh: '1' })
export function fetchModels(accountId?: string, refresh = false) {
const q = new URLSearchParams()
if (refresh) q.set('refresh', '1')
if (accountId) q.set('account', accountId)
return api<{ data?: Overview['models'] }>(`/api/models?${q.toString()}`)
const query = q.toString()
return api<{ data?: Overview['models'] }>(`/api/models${query ? `?${query}` : ''}`)
}

export function refreshModels(accountId?: string) {
return fetchModels(accountId, true)
}

export function updateModelContext(modelKey: string, contextLength: number) {
Expand Down
30 changes: 16 additions & 14 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ export type AccountQuota = {
fetched_at?: string
}

export type ModelInfo = {
id: string
display_name?: string
mapped_key?: string
route_display_name?: string
settings_key?: string
provider?: string
owned_by?: string
native_model?: string
stale?: boolean
context_length?: number
default_context_length?: number
context_custom?: boolean
}

export type Overview = {
ok?: boolean
time?: string
Expand Down Expand Up @@ -40,20 +55,7 @@ export type Overview = {
machine_id?: string
}
login?: any
models?: Array<{
id: string
display_name?: string
mapped_key?: string
route_display_name?: string
settings_key?: string
provider?: string
owned_by?: string
native_model?: string
stale?: boolean
context_length?: number
default_context_length?: number
context_custom?: boolean
}>
models?: ModelInfo[]
accounts?: Array<{
id: string
provider?: string
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/account/AccountCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ArrowClockwise,
ArrowSquareOut,
Copy,
Cube,
Key,
ShieldCheck,
TrashSimple,
Expand Down Expand Up @@ -48,6 +49,7 @@ type Props = {
onToggle: (selected: boolean) => void
onToggleDropSystem: (selected: boolean) => void
onToggleAuthPanel: () => void
onViewModels: () => void
}

function stateCopyFor(state: ReturnType<typeof accountState>, cooldown: string, t: Translate) {
Expand Down Expand Up @@ -75,6 +77,7 @@ export function AccountCard({
onToggle,
onToggleDropSystem,
onToggleAuthPanel,
onViewModels,
}: Props) {
const cardRef = useRef<HTMLElement>(null)
const chipRef = useRef<HTMLSpanElement>(null)
Expand Down Expand Up @@ -258,6 +261,14 @@ export function AccountCard({
>
<Key size={14} />{t('authentication')}
</Button>
<Tooltip>
<Tooltip.Trigger>
<Button className={ACCOUNT_ICON_BUTTON_CLASS} isIconOnly size="sm" variant="secondary" onPress={onViewModels} aria-label={t('accountModels')}>
<Cube size={14} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{t('accountModels')}</Tooltip.Content>
</Tooltip>
<Tooltip>
<Tooltip.Trigger>
<Button className={ACCOUNT_ICON_BUTTON_CLASS} isIconOnly size="sm" variant="secondary" isDisabled={!account.enabled} isPending={busyKind === 'rewarm'} onPress={onRewarm} aria-label={t('rewarm')}>
Expand Down
162 changes: 162 additions & 0 deletions frontend/src/components/account/AccountModelsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import { Button, Chip, Modal, Skeleton } from '@heroui/react'
import { ArrowClockwise, Cube, WarningCircle, X } from '@phosphor-icons/react'
import { fetchModels, refreshModels } from '@/api/overview'
import type { ModelInfo } from '@/api/types'
import { ProviderMark } from '@/components/ProviderMark'
import type { AccountRow } from '@/lib/account'
import { accountProviderLabel } from '@/lib/provider'

const ACCOUNT_BUTTON_CLASS = 'account-button'

type Translate = (key: string, vars?: Record<string, string | number>) => string

type Props = {
account: AccountRow | null
t: Translate
onClose: () => void
}

function routedModelName(model: ModelInfo) {
const routeName = model.route_display_name || ''
return routeName && routeName !== (model.display_name || model.id) ? routeName : ''
}

export function AccountModelsModal({ account, t, onClose }: Props) {
const [models, setModels] = useState<ModelInfo[]>([])
const [loading, setLoading] = useState(Boolean(account?.id))
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState('')
const accountId = account?.id || ''
const title = t('accountModelsTitle', { name: account?.name || account?.id || '' })
const provider = account ? accountProviderLabel(account.provider, account.region, t) : ''

async function load(refresh = false) {
if (!accountId) return
if (refresh) setRefreshing(true)
else {
setLoading(true)
setModels([])
}
setError('')
try {
const data = await (refresh ? refreshModels(accountId) : fetchModels(accountId))
setModels(data.data || [])
} catch (err) {
setModels([])
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
setRefreshing(false)
}
}

useEffect(() => {
if (!accountId) return
let cancelled = false
void fetchModels(accountId)
.then((data) => {
if (cancelled) return
setModels(data.data || [])
setError('')
setLoading(false)
})
.catch((err) => {
if (cancelled) return
setModels([])
setError(err instanceof Error ? err.message : String(err))
setLoading(false)
})
return () => {
cancelled = true
}
}, [accountId])

return (
<Modal.Root isOpen={Boolean(account)} onOpenChange={(next: boolean) => { if (!next) onClose() }}>
<Modal.Backdrop variant="blur">
<Modal.Container size="lg" scroll="inside">
<Modal.Dialog>
<Modal.Header className="items-start justify-between gap-4 px-5 pt-5">
<div className="min-w-0">
<Modal.Heading className="text-lg font-semibold tracking-[-0.01em]">{title}</Modal.Heading>
<p className="mt-1 text-xs font-normal leading-5 text-[var(--app-faint)]">{t('accountModelsHint')}</p>
{account ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-[var(--app-muted)]">
<ProviderMark provider={account.provider} size={14} />
<span>{provider}</span>
<span className="text-[var(--app-line-strong)]">·</span>
<span className="mono">{account.id}</span>
</div>
) : null}
</div>
<Modal.CloseTrigger aria-label={t('close')} className="grid size-8 shrink-0 place-items-center rounded-lg text-[var(--app-muted)] transition-colors hover:bg-[var(--app-surface-muted)] hover:text-[var(--app-ink)]">
<X size={16} />
</Modal.CloseTrigger>
</Modal.Header>
<Modal.Body className="px-5 pb-2">
{error ? (
<div className="mb-3 flex gap-2 rounded-lg border border-[color-mix(in_srgb,var(--app-danger)_24%,transparent)] bg-[color-mix(in_srgb,var(--app-danger)_7%,transparent)] px-3 py-2.5 text-xs leading-5 text-[var(--app-danger)]">
<WarningCircle size={14} className="mt-0.5 shrink-0" />
<span>{t('accountModelsError', { msg: error })}</span>
</div>
) : null}

{loading ? (
<div className="space-y-2 py-1">
<Skeleton className="h-14 rounded-lg" />
<Skeleton className="h-14 rounded-lg" />
<Skeleton className="h-14 rounded-lg" />
</div>
) : models.length ? (
<ul className="divide-y divide-[var(--app-line)] overflow-hidden rounded-lg border border-[var(--app-line)]">
{models.map((model) => {
const ownedBy = model.owned_by || model.provider || account?.provider || 'qoder'
const routed = routedModelName(model)
return (
<li key={model.id} className="flex items-start gap-3 px-3 py-2.5">
<span className="status-dot mt-1.5" data-state={model.stale ? undefined : 'ok'} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{model.display_name || model.id}</span>
{model.stale ? <Chip size="sm" variant="soft" color="warning">{t('fallback')}</Chip> : null}
</div>
<div className="mono mt-0.5 truncate text-[11px] text-[var(--app-faint)]">{model.id}</div>
{routed ? <div className="mt-0.5 text-[11px] text-[var(--app-faint)]">{t('routedTo', { model: routed })}</div> : null}
</div>
<div className="flex shrink-0 items-center gap-1.5 pt-0.5 text-[11px] text-[var(--app-muted)]">
<ProviderMark provider={ownedBy} size={12} />
<span>{ownedBy}</span>
</div>
</li>
)
})}
</ul>
) : (
<div className="grid min-h-48 place-items-center rounded-lg border border-dashed border-[var(--app-line-strong)] text-center">
<div className="max-w-xs px-6">
<Cube size={22} className="mx-auto text-[var(--app-faint)]" />
<div className="mt-3 text-sm font-medium">{t('accountModelsEmpty')}</div>
<div className="mt-1 text-xs leading-5 text-[var(--app-faint)]">{t('noModelsYet')}</div>
</div>
</div>
)}
</Modal.Body>
<Modal.Footer className="justify-between px-5 pb-5">
<span className="mono text-xs text-[var(--app-faint)]">
{loading ? t('refreshing') : t('shownTotal', { shown: models.length, total: models.length })}
</span>
<div className="flex items-center gap-2">
<Button className={ACCOUNT_BUTTON_CLASS} size="sm" variant="ghost" onPress={onClose}>{t('close')}</Button>
<Button className={ACCOUNT_BUTTON_CLASS} size="sm" variant="secondary" isPending={refreshing} onPress={() => void load(true)}>
<ArrowClockwise size={14} />
{refreshing ? t('refreshing') : t('refresh')}
</Button>
</div>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
)
}
Loading
Loading