/** * The usage statistics settings section: three tabs (用量: today's usage, * balances, trend; 个人套餐: per-provider plan quota windows; Token 银行: * the whale-yuan voucher minted from every provider's ledger usage) * plus a compact settings row. Data comes from the host's loopback-fenced * /api/dsh-usage/overview document; polling runs only while the section is * mounted and the tab is visible. * @module @linxin666/dsh-usage/client/UsageSectionCard */ import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react' import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import type { UsageStoreInstance } from './usage-store.ts' import { t } from './locales.ts' import styles from './usage.module.css' import { PeakBadge } from './PeakBadge.tsx' import { isDeepSeekProviderRoute } from '../core/adapters.ts' import { deepseekPeriodAt } from '../core/pricing.ts' import { voucherData, drawVoucher, faceValue, formatDay, formatDenomination, loadVoucherArt } from './voucher.ts' import type { ObservedSpendView, ProviderSnapshotView, UsageDayView, UsageOverviewView, UsageProviderSummary, UsageTokenTotals, UsageWindowSummary } from '../core/types.ts' import { formatSpend, type SpendDisplay } from '../core/spend.ts' /** The settings fields this section edits (immediate-apply semantics). */ export interface UsageSettings { enabled?: boolean pollIntervalSec?: number /** Currency the spend figures are displayed in; the ledger stays CNY. */ spendCurrency?: 'CNY' | 'USD' /** CNY per USD, consulted only while the display currency is USD. */ cnyPerUsd?: number } /** The registration-side face the section's slot entry injects. */ export interface UsageSectionFace { /** The section-local store (overview snapshot + lifecycle). */ store: UsageStoreInstance /** Fetch one overview now. */ poll: () => void /** Force a host probe cycle now (resolves with the fresh overview). */ refresh: () => void /** * Fetch one retained local day (YYYY-MM-DD) from the host. The overview * only carries today plus the list of recorded days, so a day the user picks * is one small on-demand request. */ loadDay: (date: string) => Promise /** The shared configuration form this section's settings row reads and writes. */ settings: ConfigForm } export interface UsageSectionProps extends UsageSectionFace { /** Close the settings panel (the shell owns the open state). */ close: () => void } /** Poll cadence while the section is open. */ const SECTION_POLL_MS = 10_000 /** * The Token bank's default window: the whole retained ledger. It is a select * value rather than a date, so it can never collide with a day key. */ const BANK_ALL_DAYS = 'all' /** Compact token count: 12345 -> 12.3k, 1234567 -> 1.23M. */ export function formatTokens(value: number): string { if (!Number.isFinite(value) || value <= 0) return '0' if (value < 1000) return String(value) if (value < 1_000_000) return trim(value / 1000) + 'k' if (value < 1_000_000_000) return trim(value / 1_000_000) + 'M' return trim(value / 1_000_000_000) + 'B' } function trim(value: number): string { return value >= 100 ? String(Math.round(value)) : value.toFixed(value >= 10 ? 1 : 2).replace(/\.?0+$/, '') } function formatTime(ms: number): string { try { return new Date(ms).toLocaleTimeString() } catch { return '' } } function toneClass(percent: number): string { if (percent >= 90) return styles.barLow if (percent >= 70) return styles.barWarn return styles.barFill } /** A provider row backed by a configured credential (api key, env key, or OAuth grant). */ function isConfigured(provider: ProviderSnapshotView): boolean { // An older wire document without the credential field renders as before. return provider.credential !== 'none' } function TotalsRow(props: { totals: UsageTokenTotals }): ReactNode { const { totals } = props return (
{formatTokens(totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens)} {t('usage.tokens.total')}
{formatTokens(totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens)} {t('usage.tokens.input')}
{formatTokens(totals.outputTokens)} {t('usage.tokens.output')}
{formatTokens(totals.cacheReadTokens)} {t('usage.tokens.cacheRead')}
{formatTokens(totals.cacheWriteTokens)} {t('usage.tokens.cacheWrite')}
{formatTokens(totals.calls)} {t('usage.calls', { n: totals.calls })}
) } function balanceLine(provider: ProviderSnapshotView): ReactNode { if (provider.balance !== undefined) { return {provider.balance.currency.toUpperCase() === 'CNY' ? '¥' : provider.balance.currency.toUpperCase() === 'USD' ? '$' : ''}{provider.balance.totalBalance}{provider.balance.currency.toUpperCase() !== 'CNY' && provider.balance.currency.toUpperCase() !== 'USD' ? ' ' + provider.balance.currency.toUpperCase() : ''} } if (provider.credential === 'oauth') return {t('usage.oauth')} if (provider.credential === 'none') return {t('usage.balance.noCredential')} // balanceSupported === false is the origin gate: the adapter has a balance // endpoint but it belongs to another provider's account (issue #1688), so // the row says so instead of silently vanishing from the card. if (provider.balanceSupported === false) return {t('usage.balance.unsupported')} if (!provider.supported) return {t('usage.balance.unsupported')} return null } function ProviderRow(props: { provider: ProviderSnapshotView; current?: string }): ReactNode { const { provider, current } = props return (
{provider.displayName} {current === provider.provider && {t('usage.current')}} {balanceLine(provider)}
) } /** One day the pickers selected, as its fetch progresses. */ interface DayLoad { status: 'loading' | 'ready' | 'error' /** The served day, once the host answered. */ view?: UsageDayView /** Transport error message, on a failed load. */ error?: string } /** * Fetch the day a picker selected, or stay idle for the default window. Today * never fetches: the overview already carries it live, so switching back to * today is instant and keeps counting with the poll. A superseded selection * never lands (the effect cancels), and a failed load reports its own message * instead of leaving the card on a stale day. */ function useDayView(loadDay: (date: string) => Promise, date: string | undefined, today: string | undefined): DayLoad | undefined { const [load, setLoad] = useState(undefined) useEffect(() => { if (date === undefined || date === today) { setLoad((previous) => (previous === undefined ? previous : undefined)) return undefined } let cancelled = false setLoad({ status: 'loading' }) loadDay(date).then( (view) => { if (!cancelled) setLoad({ status: 'ready', view }) }, (error: unknown) => { if (!cancelled) setLoad({ status: 'error', error: error instanceof Error ? error.message : String(error) }) }, ) return () => { cancelled = true } }, [date, today, loadDay]) return load } /** * The day picker: today plus every retained day that recorded usage, newest * first (the host serves the list ascending). Without that list (an older host * document) no picker renders and the card stays on its default window. */ function DayPicker(props: { value: string options: string[] today: string /** An extra leading option; the Token bank passes its whole-ledger window. */ leading?: { value: string; label: string } onSelect: (date: string) => void /** Namespaces the element id, so the two tabs keep one control each. */ part: string }): ReactNode { const { value, options, today, leading, onSelect, part } = props if (options.length === 0) return null return (
) } /** The section component; the slot merges the face into these props. */ export function UsageSectionCard(props: UsageSectionProps): ReactNode { const { store, poll, refresh, loadDay, settings } = props const ui = useSyncExternalStore(store.subscribe, store.getSnapshot) const settingsSnapshot = settings.getSnapshot() const settingsValue = settingsSnapshot.value ?? {} const [tab, setTab] = useState<'usage' | 'plans' | 'bank'>('usage') // Undefined means "the default window of this tab": today on the usage tab, // the whole retained ledger in the Token bank. A date switches that tab to // one retained day instead. const [usageDay, setUsageDay] = useState(undefined) const [bankDay, setBankDay] = useState(undefined) const [refreshing, setRefreshing] = useState(false) // The enable checkbox writes through the shared form, so subscribing here // keeps the flag below live: the form republishes the Host's accepted value, // and the poll starts and stops with it instead of waiting for an unrelated // render. const [, bumpSettings] = useState(0) useEffect(() => settings.subscribe(() => bumpSettings((count) => count + 1)), [settings]) const enabled = settingsValue.enabled ?? true // Poll while mounted, enabled and visible; the overview is cheap (no probes — // the host's own cycle owns those) so 10 s keeps balances fresh-ish between // manual refreshes. useEffect(() => { // A disabled plugin deregisters its host routes, so the poll must stop with // it; the section re-enables by writing the flag back through the checkbox. if (!enabled) return undefined poll() let timer: number | undefined const start = (): void => { if (timer === undefined && document.visibilityState === 'visible') timer = window.setInterval(poll, SECTION_POLL_MS) } const onVisibility = (): void => { if (document.visibilityState === 'visible') { poll() start() } else if (timer !== undefined) { window.clearInterval(timer) timer = undefined } } start() document.addEventListener('visibilitychange', onVisibility) return () => { if (timer !== undefined) window.clearInterval(timer) document.removeEventListener('visibilitychange', onVisibility) } }, [poll, enabled]) const snapshot = ui.snapshot const todayDate = snapshot?.usage.today.date // Every retained day that recorded usage, newest first, plus today so the // default window is always one of the options even before it has usage. const dayOptions = snapshot === null ? [] : [...new Set([todayDate, ...(snapshot.usage.availableDays ?? [])] .filter((date): date is string => date !== undefined))].sort().reverse() // A selection retention no longer holds falls back to the tab's default // window, so a picker can never keep displaying a day the host dropped. const usageSelection = usageDay !== undefined && dayOptions.includes(usageDay) ? usageDay : undefined const bankSelection = bankDay !== undefined && dayOptions.includes(bankDay) ? bankDay : undefined const selectedDay = usageSelection ?? todayDate const isToday = selectedDay === todayDate const usageDayLoad = useDayView(loadDay, usageSelection, todayDate) const bankDayLoad = useDayView(loadDay, bankSelection, todayDate) // A selected day rides its own fetch; the default windows ride the overview. const day = isToday ? snapshot?.usage.today : usageDayLoad?.view const bankDayView = bankSelection === undefined ? undefined : bankSelection === todayDate ? snapshot?.usage.today : bankDayLoad?.view const bankWindow = bankSelection === undefined ? snapshot?.usage.all ?? snapshot?.usage.range : bankDayView === undefined ? undefined : { from: bankDayView.date, to: bankDayView.date, totals: bankDayView.totals, providers: bankDayView.providers } const onRefresh = (): void => { setRefreshing(true) try { refresh() } finally { // The POST resolves through the next poll tick; unlock shortly either way. window.setTimeout(() => setRefreshing(false), 3000) } } // Disabled, failed and still-loading states keep the settings row mounted: // it owns the enable checkbox, so replacing the whole panel would leave the // user no way back from the UI. if (!enabled || ui.status === 'error' || snapshot === null) { return (
{!enabled ? t('usage.disabled') : ui.status === 'error' ? t('usage.error', { error: ui.error ?? '' }) : t('usage.loading')}
) } const current = snapshot.current const currentProvider = snapshot.providers.find((provider) => provider.provider === current.provider) // Peak-period line: only when the official DeepSeek family is in play this // session (the current route, or spend recorded under one today). It // describes the pricing window now, so a past day's card drops it. const deepseekPeriod = deepseekPeriodAt(Date.now()) const deepseekVisible = isToday && ((current.provider !== undefined && isDeepSeekProviderRoute(current.provider)) || snapshot.usage.today.providers.some((row) => isDeepSeekProviderRoute(row.provider))) // Plans tab: only configured routes with a real coding-plan/subscription // adapter (planSupported; an older host without the flag falls back to "has // a plan fact"). Balance-only providers (DeepSeek, ZenMux, ...) and // unconfigured routes (credential 'none') never appear here. const planProviders = snapshot.providers.filter((provider) => isConfigured(provider) && (provider.planSupported === true || (provider.planSupported === undefined && provider.plan !== undefined))) return (
{currentProvider !== undefined ? `${currentProvider.displayName}${current.model !== undefined && current.model !== '' ? ' · ' + current.model : ''}` : t('usage.noData')} {t('usage.updated', { time: formatTime(snapshot.updatedAt) })}
{tab === 'usage' && ( <>
{isToday ? t('usage.today') : t('usage.day.title', { date: selectedDay ?? '' })} { setUsageDay(date === todayDate ? undefined : date) }} /> {deepseekVisible && ( )} {usageDayLoad?.status === 'error' ? {t('usage.day.error', { error: usageDayLoad.error ?? '' })} : day === undefined ? {t('usage.day.loading')} : day.totals.calls === 0 ? {isToday ? t('usage.noData') : t('usage.day.empty')} : } {(day?.totals.cost ?? 0) > 0 && (
{isToday ? t('usage.today.cost') : t('usage.day.cost')} {formatSpend(day?.totals.cost ?? 0, snapshot.display)}
)} {(day?.providers.length ?? 0) > 0 && (
{(day?.providers ?? []).map((row) => (
{snapshot.providers.find((provider) => provider.provider === row.provider)?.displayName ?? row.provider} {current.provider === row.provider && {t('usage.current')}} {formatTokens(row.totals.inputTokens + row.totals.cacheReadTokens + row.totals.cacheWriteTokens + row.totals.outputTokens)} · {t('usage.calls', { n: row.totals.calls })}{row.totals.cost > 0 ? ` · ${formatSpend(row.totals.cost, snapshot.display)}` : ''}
))}
)}
{t('usage.balance')} {(() => { const configured = snapshot.providers.filter(isConfigured) // Include rows whose balance is deliberately not applicable // (another origin's endpoint): the row itself explains why, which // a single generic line cannot (#1688). const rows = configured.filter((provider) => provider.balanceSupported === true || provider.balanceSupported === false || (provider.balanceSupported === undefined && provider.supported)) if (rows.length === 0) { return {configured.length === 0 ? t('usage.balance.noneConfigured') : t('usage.balance.unsupported')} } return rows.map((provider) => ( )) })()} {snapshot.providers.some((provider) => isConfigured(provider) && provider.error !== undefined) && ( {snapshot.providers.filter((provider) => isConfigured(provider) && provider.error !== undefined).map((provider) => `${provider.displayName}: ${t('usage.provider.error', { error: provider.error ?? '' })}`).join(t('usage.errorListSeparator'))} )}
)} {tab === 'plans' && ( planProviders.length === 0 ?
{t('usage.plan.noneConfigured')}
: planProviders.map((provider) => ) )} {tab === 'bank' && ( <> { setBankDay(date === BANK_ALL_DAYS ? undefined : date) }} /> )}
) } /** How many model sub-bars render under one provider bar. */ const CHART_MODEL_CAP = 3 /** * The 近 30 天 card: horizontal bars per provider over the trend window, * each with its heaviest models as nested sub-bars. Window totals come from * the host's aggregated `usage.range` (an older host without it renders no * card instead of a wrong one). */ function RangeCard(props: { range?: UsageOverviewView['usage']['range']; providers: ProviderSnapshotView[]; currentProvider?: string }): ReactNode { const { range, providers, currentProvider } = props if (range === undefined) return null const grandTotal = range.totals.inputTokens + range.totals.outputTokens + range.totals.cacheReadTokens + range.totals.cacheWriteTokens const maxProvider = Math.max(1, ...range.providers.map((row) => totalOf(row.totals))) const nameOf = (id: string): string => providers.find((provider) => provider.provider === id)?.displayName ?? id return (
{t('usage.trend')} {range.providers.length === 0 || grandTotal === 0 ? {t('usage.noData')} :
{range.providers.map((row) => ( ))} {range.from.slice(5)} {range.to.slice(5)}
}
) } function ChartProviderRow(props: { row: UsageProviderSummary; name: string; max: number; current: boolean }): ReactNode { const { row, name, max, current } = props const total = totalOf(row.totals) const maxModel = Math.max(1, ...row.models.slice(0, CHART_MODEL_CAP).map((model) => totalOf(model.totals))) return (
{name} {current && {t('usage.current')}} {formatTokens(total)} · {t('usage.calls', { n: row.totals.calls })} {row.models.slice(0, CHART_MODEL_CAP).map((model) => { const modelTotal = totalOf(model.totals) return ( {model.model} {formatTokens(modelTotal)} ) })}
) } function totalOf(totals: UsageTokenTotals): number { return totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens } /** * The Token 银行 card: every provider's retained-ledger usage minted onto the * whale-yuan note at 1,000,000 tokens per whale yuan. The window is the whole * retained ledger by default (the host's aggregate, falling back to the 30-day * trend when an older host serves no `all`) or the single day the user picked; * the spend line prefers the official balance watch (whole window only) and * falls back to the fold-time estimate, which only the priced family feeds; * the artwork draw failure degrades to an error line and never takes the * section down. */ function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: ObservedSpendView; loading?: boolean; error?: string; display?: SpendDisplay }): ReactNode { const { window: ledger, observedSpend, loading, error, display } = props const data = voucherData(ledger) const canvasRef = useRef(null) const [drawError, setDrawError] = useState(undefined) const dataKey = data === undefined ? '' : `${data.from}|${data.to}|${data.tokens}|${data.calls}|${data.cost}` useEffect(() => { if (data === undefined) return const voucher = data let cancelled = false loadVoucherArt().then((art) => { if (cancelled) return const canvas = canvasRef.current if (canvas !== null) { try { drawVoucher(canvas, art, voucher) } catch (error) { if (!cancelled) setDrawError(error instanceof Error ? error.message : String(error)) } } }, (error) => { if (!cancelled) setDrawError(error instanceof Error ? error.message : String(error)) }) return () => { cancelled = true } // dataKey covers every field the draw and the buttons read. }, [dataKey]) const onSave = (): void => { const canvas = canvasRef.current if (canvas === null || data === undefined) return canvas.toBlob((blob) => { if (blob === null) return const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = `dsh-whale-voucher-${data.to}.png` anchor.click() window.setTimeout(() => URL.revokeObjectURL(url), 10_000) }, 'image/png') } const shareSupported = typeof navigator !== 'undefined' && typeof navigator.canShare === 'function' const onShare = (): void => { const canvas = canvasRef.current if (canvas === null || data === undefined || !shareSupported) return canvas.toBlob(async (blob) => { if (blob === null) return const file = new File([blob], `dsh-whale-voucher-${data.to}.png`, { type: 'image/png' }) if (!navigator.canShare({ files: [file] })) return try { await navigator.share({ files: [file], title: t('usage.bank.title') }) } catch { // A user-cancelled share sheet rejects; nothing to report. } }, 'image/png') } return (
{t('usage.bank.title')} {error !== undefined ? {t('usage.day.error', { error })} : loading === true ? {t('usage.day.loading')} : data === undefined ? {t('usage.bank.noUsage')} : <> {t('usage.bank.hint')}
{drawError !== undefined && {t('usage.bank.drawError', { error: drawError })}}
{t('usage.bank.minted', { minted: formatDenomination(faceValue(data.tokens)), tokens: formatTokens(data.tokens) })} {t('usage.calls', { n: data.calls })}
{observedSpend !== undefined ? t('usage.bank.spend.observed', { cost: formatSpend(observedSpend.cny, display), since: formatDay(observedSpend.since) }) : t('usage.bank.spend.estimated', { cost: formatSpend(data.cost, display) })} {t('usage.bank.window', { from: data.from, to: data.to })}
{shareSupported && }
}
) } function PlanCard(props: { provider: ProviderSnapshotView; current?: string }): ReactNode { const { provider, current } = props return (
{provider.displayName} {current === provider.provider && {t('usage.current')}} {provider.plan?.planName !== undefined ? ` · ${provider.plan.planName}` : ''}
{provider.error !== undefined && {t('usage.provider.error', { error: provider.error })}} {provider.credential === 'none' && provider.plan === undefined ? {t('usage.balance.noCredential')} : provider.plan === undefined || provider.plan.windows.length === 0 ? {t('usage.plan.noPlan')} : provider.plan.windows.map((window) => (
{window.name ?? t(`usage.plan.windows.${window.key}`)} {window.percent !== undefined ? `${window.percent >= 10 ? Math.round(window.percent) : window.percent.toFixed(1)}%` : ''} {window.percent !== undefined && ( )} {window.resetsAt !== undefined && ( {t('usage.plan.reset', { date: new Date(window.resetsAt).toLocaleString() })} )}
))}
) } /** * The compact settings row. Both controls write through the shared form the * moment the user changes them, and the Host answers each write with a * boolean: a refused (or transport-failed) write is surfaced as a failed save, * because a value that did not land must never read as applied. */ function SettingsRow(props: { settings: UsageSectionProps['settings'] snapshot?: ConfigFormSnapshot value: UsageSettings }): ReactNode { const { settings, snapshot, value } = props const disabled = snapshot === undefined || !snapshot.writable const [failure, setFailure] = useState(undefined) const write = (field: 'enabled' | 'pollIntervalSec' | 'spendCurrency' | 'cnyPerUsd', next: boolean | number | 'CNY' | 'USD'): void => { setFailure(undefined) let answer: Promise try { answer = settings.set(field, next) } catch (error) { setFailure(error instanceof Error ? error.message : String(error)) return } // false is the contract's refusal/skip answer (the Host rejected the value, // the entry is not writable, or the write was dropped); a rejecting // transport reports through the same failed-save surface. Promise.resolve(answer).then( (accepted) => { if (!accepted) setFailure('') }, (error: unknown) => { setFailure(error instanceof Error ? error.message : String(error)) }, ) } return (
{t('usage.config.title')}
{failure !== undefined && ( {t('usage.config.saveFailed')}{failure === '' ? '' : ' - ' + failure} )}
) }