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

### English

- 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
- Upgrade the pinned Qoder CLIs from 1.1.27 to 1.1.32 (`@qoder-ai/qodercli` and `@qodercn-ai/qoderclicn`), with all worker compat needles re-verified against both new bundles
- Route chat by each account's live model catalog so a request like `hy3` only hits accounts that actually serve it; unknown models return `model_not_available` without cooling healthy accounts
- Render System page release notes as markdown for the current console language, in a box that grows with the text and scrolls when it overflows
- Paginate request history on Logs and filter by time range, account, model, stream mode, and error kind; runtime logs can also filter by account

### 中文

- 账号卡片改为更紧凑的身份行:运行状态用短柱状指示,额度用填充条显示剩余量
- 控制台点刷新时重新显示骨架屏,不再把旧卡片留在页面上
- 日志页筛选、翻页或刷新时,下方列表显示骨架屏,筛选栏保持不动
- 钉的 Qoder CLI 从 1.1.27 升级到 1.1.32(`@qoder-ai/qodercli` 与 `@qodercn-ai/qoderclicn`),worker 全部兼容 needles 已在两个新版 bundle 上重新验证
- 聊天按每个账号的动态模型目录选号,`hy3` 这类请求只会打到真正有该模型的账号;全池都没有时返回 `model_not_available`,不再给健康账号打冷却
- 控制台 System 页版本说明按当前语言渲染 markdown,说明框随文本增高,超出后可向下滚动
Expand Down
10 changes: 8 additions & 2 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@

- 圆角 8px 或更小,除非现有组件已用更大值
- 账号/资源列表卡片优先使用浅边框和平面表面,不强制最小高度,不额外叠加强阴影
- 账号卡片保持操作台密度:单行身份(名称 + provider/auth/UID),状态 Chip 只出现一次,运行状态用 12 格短柱、额度用 `scaleX` 填充条;并发/优先级/重启收成一行辅助数字
- 账号网格用 `lg:grid-cols-2 xl:grid-cols-3`,卡片间距 `gap-2.5`,内边距约 12px
- 主卡片只有在确实需要抬升层级时才用 `shadow-card`
- 边框克制使用;避免边框 + 阴影 + 有色背景同时出现
- 不要为了简单分组把卡片嵌套在卡片里
Expand Down Expand Up @@ -401,8 +403,9 @@ Go `refreshOne` fetches quota after health for hot/ready accounts and stores a
never flip account readiness, cooldown, or scheduling. Qoder reads quota from the daemon;
WorkBuddy reads remaining credits from its billing meter API (`unit: credits`). An
in-process provider without a quota surface simply omits the block. The account card
renders one progress bar with `remaining/total <unit>`, `--danger` at 100% / exceeded,
`--warning` at ≥80%, plus an optional add-on line.
renders a compact `scaleX` fill with `remaining/total <unit>`, `--danger` at 100% / exceeded,
`--warning` at ≥80%, plus an optional add-on line. Quota and runtime meters animate only
on state change (`power2.out`, 180–220ms); they do not loop.

Distinguish this account-level quota from the request-level `insufficient_quota` error kind:
that error means a per-request token/model limit, not a zero account balance.
Expand Down Expand Up @@ -507,6 +510,8 @@ The console follows the frontend design baseline in the first half of this doc,
### Interaction and motion

- Loading, empty, and error states are required for data surfaces.
- Header refresh is a user-initiated reload: show the page skeleton. Account create/login/rewarm/delete refreshes stay silent so the card does not disappear under the operator.
- Logs keep the filter chrome visible; request and runtime lists show a skeleton while filters, pagination, tab switches, or refresh are in flight. Runtime live polling stays silent.
- Labels sit above form controls; helper text stays quiet and errors sit below the field.
- Primary actions use HeroUI default buttons with cream/ink fill. Secondary actions use bordered ghost/secondary chips; header utility actions sit in a clustered icon group. Inline destructive actions use `danger-soft`; solid danger is reserved for confirmation dialogs.
- Compact console controls use a 32px button/input baseline, 12px medium button text, 14px action icons, 20px chips, and 6–8px radii. Icon buttons are 32 × 32px.
Expand Down Expand Up @@ -537,6 +542,7 @@ After UI edits:
|------|------|
| `frontend/src/pages/LoginPage.tsx` | Console gate |
| `frontend/src/pages/AccountsPage.tsx` | Qoder login + pool |
| `frontend/src/components/account/` | Compact account card, runtime meter, quota fill |
| `frontend/src/pages/ProvidersPage.tsx` | Model catalog + context-window defaults |
| `frontend/src/pages/LogsPage.tsx` | Request history + runtime logs |
| `frontend/src/pages/SystemPage.tsx` | Managed next-version update |
Expand Down
299 changes: 299 additions & 0 deletions frontend/src/components/account/AccountCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
import { useLayoutEffect, useRef } from 'react'
import { Button, Chip, Input, Tooltip } from '@heroui/react'
import {
ArrowClockwise,
ArrowSquareOut,
Copy,
Key,
ShieldCheck,
TrashSimple,
WarningCircle,
} from '@phosphor-icons/react'
import gsap from 'gsap'
import { ProviderMark } from '@/components/ProviderMark'
import { CompactSwitch } from '@/components/ui/CompactSwitch'
import { QuotaMeter } from '@/components/account/QuotaMeter'
import { RuntimeMeter } from '@/components/account/RuntimeMeter'
import { hoverLift } from '@/hooks/useGsapReveal'
import {
accountState,
cooldownLabel,
type AccountRow,
} from '@/lib/account'
import { accountProviderLabel } from '@/lib/provider'

const ACCOUNT_BUTTON_CLASS = 'account-button'
const ACCOUNT_ICON_BUTTON_CLASS = 'account-button account-icon-button'
const ACCOUNT_CHIP_CLASS = 'account-chip'
const ACCOUNT_INPUT_CLASS = 'account-input'

export type AccountBusyKind = 'create' | 'import' | 'device' | 'pat' | 'rewarm' | 'toggle' | 'delete' | 'export'

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

type Props = {
account: AccountRow
busyKind: AccountBusyKind | ''
authPanelOpen: boolean
authUrl?: string
note?: string
pat: string
t: Translate
onPatChange: (value: string) => void
onDeviceLogin: () => void
onPatLogin: () => void
onExport: () => void
onRewarm: () => void
onDelete: () => void
onToggle: (selected: boolean) => void
onToggleDropSystem: (selected: boolean) => void
onToggleAuthPanel: () => void
}

function stateCopyFor(state: ReturnType<typeof accountState>, cooldown: string, t: Translate) {
if (state === 'hot') return t('signedIn')
if (state === 'ready') return t('ready')
if (state === 'cooling') return cooldown ? `${t('cooling')} ${cooldown}` : t('cooling')
if (state === 'disabled') return t('disabled')
return t('needQoderLogin')
}

export function AccountCard({
account,
busyKind,
authPanelOpen,
authUrl,
note,
pat,
t,
onPatChange,
onDeviceLogin,
onPatLogin,
onExport,
onRewarm,
onDelete,
onToggle,
onToggleDropSystem,
onToggleAuthPanel,
}: Props) {
const cardRef = useRef<HTMLElement>(null)
const chipRef = useRef<HTMLSpanElement>(null)
const authRef = useRef<HTMLElement>(null)
const lastStateRef = useRef<string | null>(null)
const state = accountState(account)
const cooldown = cooldownLabel(account.down_until || account.cooldown_until)
const stateCopy = stateCopyFor(state, cooldown, t)
const stateColor = state === 'hot' || state === 'ready'
? 'success'
: state === 'cooling'
? 'warning'
: state === 'login'
? 'danger'
: undefined
const inFlight = account.in_flight ?? account.inFlight ?? 0
const lastError = account.last_error || account.lastError
const errorKind = account.last_error_kind || account.kind
const provider = accountProviderLabel(account.provider, account.region, t)
const meta = [
provider,
account.auth_type || 'none',
account.remote_uid ? `UID ${account.remote_uid}` : account.id,
].join(' · ')

useLayoutEffect(() => {
const chip = chipRef.current
if (!chip) return
const previous = lastStateRef.current
lastStateRef.current = state
if (!previous || previous === state) return

const context = gsap.context(() => {
const media = gsap.matchMedia()
media.add('(prefers-reduced-motion: reduce)', () => {
gsap.set(chip, { scale: 1, autoAlpha: 1 })
})
media.add('(prefers-reduced-motion: no-preference)', () => {
gsap.fromTo(
chip,
{ scale: 0.94, autoAlpha: 0.65 },
{ scale: 1, autoAlpha: 1, duration: 0.2, ease: 'power2.out', overwrite: true },
)
})
}, chip)

return () => context.revert()
}, [state])

useLayoutEffect(() => {
const panel = authRef.current
if (!panel || !authPanelOpen) return

const context = gsap.context(() => {
const media = gsap.matchMedia()
media.add('(prefers-reduced-motion: reduce)', () => {
gsap.set(panel, { autoAlpha: 1, y: 0 })
})
media.add('(prefers-reduced-motion: no-preference)', () => {
gsap.fromTo(
panel,
{ autoAlpha: 0, y: -6 },
{ autoAlpha: 1, y: 0, duration: 0.2, ease: 'power3.out', overwrite: true },
)
})
}, panel)

return () => context.revert()
}, [authPanelOpen])

return (
<article
ref={cardRef}
data-gsap-reveal
data-state={state}
className="account-card app-panel-flat flex min-w-0 flex-col overflow-hidden rounded-lg"
onMouseEnter={() => cardRef.current && hoverLift(cardRef.current, true, 1)}
onMouseLeave={() => cardRef.current && hoverLift(cardRef.current, false, 1)}
>
<header className="flex items-start justify-between gap-3 px-3 pt-3 pb-2">
<div className="flex min-w-0 items-center gap-2.5">
<ProviderMark provider={account.provider} size={22} />
<div className="min-w-0">
<h2 className="truncate text-[13px] font-semibold leading-5 tracking-[-0.01em]">{account.name || account.id}</h2>
<div className="mono truncate text-[10px] leading-4 text-[var(--app-faint)]" title={`${account.id}${account.remote_uid ? ` · UID ${account.remote_uid}` : ''}`}>
{meta}
</div>
</div>
</div>
<span ref={chipRef} className="shrink-0">
<Chip className={ACCOUNT_CHIP_CLASS} size="sm" variant="soft" color={stateColor}>{stateCopy}</Chip>
</span>
</header>

<div className="flex flex-1 flex-col gap-2.5 px-3 pb-2.5">
<RuntimeMeter state={state} label={t('runtimeState')} stateCopy={stateCopy} />
{account.quota ? (
<QuotaMeter
quota={account.quota}
label={t('quota')}
remainingLabel={t('quotaRemaining')}
addOnLabel={t('quotaAddOn')}
exceededLabel={t('quotaExceeded')}
/>
) : null}

<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--app-faint)]">
<span>
<span className="mono font-medium text-[var(--app-ink)]">{inFlight}/{account.max_inflight ?? 0}</span>
{' '}{t('inFlight')}
</span>
<span>
{t('priority')} <span className="mono font-medium text-[var(--app-ink)]">{account.priority ?? 0}</span>
</span>
<span>
{t('restarts')} <span className="mono font-medium text-[var(--app-ink)]">{account.restarts ?? 0}</span>
</span>
</div>

{account.provider === 'workbuddy' ? (
<div className="flex items-center justify-between gap-3 text-[11px]">
<Tooltip>
<Tooltip.Trigger>
<span className="font-medium">{t('dropSystemPrompt')}</span>
</Tooltip.Trigger>
<Tooltip.Content>{t('dropSystemPromptHint')}</Tooltip.Content>
</Tooltip>
<CompactSwitch
isSelected={Boolean(account.drop_system_prompt)}
isDisabled={busyKind === 'toggle'}
ariaLabel={t('dropSystemPrompt')}
onChange={onToggleDropSystem}
/>
</div>
) : null}

{lastError ? (
<div className="flex gap-2 border-l-2 border-[var(--app-danger)] pl-2.5 text-xs leading-5 text-[var(--app-danger)]">
<WarningCircle size={14} className="mt-0.5 shrink-0" />
<div className="min-w-0">
{errorKind ? <div className="mono mb-0.5 text-[10px] uppercase opacity-75">{errorKind}</div> : null}
<p className="break-words">{lastError}</p>
</div>
</div>
) : null}
</div>

{authPanelOpen && account.enabled ? (
<section ref={authRef} className="grid gap-3 border-t border-[var(--app-line)] bg-[var(--app-surface-muted)]/55 px-3 py-3">
<div>
<div className="text-[10px] font-semibold tracking-[0.1em] text-[var(--app-faint)] uppercase">{t('oauthDeviceFlow')}</div>
<p className="mt-1.5 text-xs leading-5 text-[var(--app-muted)]">{t('qoderLoginHint')}</p>
<div className="mt-2.5 flex flex-wrap gap-2">
<Button className={ACCOUNT_BUTTON_CLASS} size="sm" isPending={busyKind === 'device'} onPress={onDeviceLogin}><ShieldCheck size={14} />{t('startBrowserLogin')}</Button>
{authUrl ? <Button className={ACCOUNT_BUTTON_CLASS} size="sm" variant="ghost" onPress={() => window.open(authUrl, '_blank', 'noopener,noreferrer')}><ArrowSquareOut size={14} />{t('open')}</Button> : null}
</div>
</div>
<div>
<div className="text-[10px] font-semibold tracking-[0.1em] text-[var(--app-faint)] uppercase">{t('patFallback')}</div>
<div className="mt-2.5 flex flex-col gap-2 sm:flex-row">
<Input className={ACCOUNT_INPUT_CLASS} type="password" value={pat} onChange={(event) => onPatChange(event.target.value)} placeholder={t('pasteToken')} aria-label={t('pat')} />
<Button className={ACCOUNT_BUTTON_CLASS} size="sm" variant="secondary" isPending={busyKind === 'pat'} onPress={onPatLogin}><Key size={14} />{t('usePat')}</Button>
</div>
</div>
{authUrl || note ? (
<div className="text-xs">
{authUrl ? <code className="mono block break-all text-[var(--app-faint)]">{authUrl}</code> : null}
{note ? <p className="mt-1 text-[var(--app-muted)]">{note}</p> : null}
</div>
) : null}
</section>
) : null}

<footer className="flex flex-wrap items-center gap-1.5 border-t border-[var(--app-line)] px-3 py-2">
<Button
className={ACCOUNT_BUTTON_CLASS}
size="sm"
variant={state === 'login' ? 'primary' : 'secondary'}
isDisabled={!account.enabled}
onPress={onToggleAuthPanel}
>
<Key size={14} />{t('authentication')}
</Button>
<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')}>
<ArrowClockwise size={14} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{t('rewarm')}</Tooltip.Content>
</Tooltip>
{account.auth_type !== 'none' ? (
<Tooltip>
<Tooltip.Trigger>
<Button className={ACCOUNT_ICON_BUTTON_CLASS} isIconOnly size="sm" variant="secondary" isPending={busyKind === 'export'} onPress={onExport} aria-label={t('export')}>
<Copy size={14} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{t('export')}</Tooltip.Content>
</Tooltip>
) : null}
<Tooltip>
<Tooltip.Trigger>
<Button className={ACCOUNT_ICON_BUTTON_CLASS} isIconOnly size="sm" variant="danger-soft" isPending={busyKind === 'delete'} onPress={onDelete} aria-label={t('delete')}>
<TrashSimple size={14} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{t('delete')}</Tooltip.Content>
</Tooltip>
<div className="ml-auto flex items-center gap-2">
<span className="text-[11px] font-medium text-[var(--app-faint)]">{account.enabled ? t('enabledState') : t('disabled')}</span>
<CompactSwitch
isSelected={Boolean(account.enabled)}
isDisabled={busyKind === 'toggle'}
ariaLabel={account.enabled ? t('disable') : t('enable')}
onChange={onToggle}
/>
</div>
</footer>
</article>
)
}
Loading
Loading