@linxin666/dsh-usage 0.4.10 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/lib/client.js +1773 -366
- package/lib/client.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +255 -0
- package/lib/types/client/RangeBubble.d.ts +46 -0
- package/lib/types/client/SegmentGroup.d.ts +28 -0
- package/lib/types/client/UsagePage.d.ts +19 -0
- package/lib/types/client/UsageSectionCard.d.ts +11 -18
- package/lib/types/client/locales.d.ts +13 -2
- package/lib/types/client/section-integration.d.ts +47 -0
- package/lib/types/client/settings-form.d.ts +235 -0
- package/lib/types/client/usage-settings-card.d.ts +48 -0
- package/lib/types/client/web-extension-page.d.ts +50 -0
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +838 -0
- package/src/client/RangeBubble.tsx +164 -0
- package/src/client/SegmentGroup.tsx +93 -0
- package/src/client/UsagePage.tsx +108 -0
- package/src/client/UsageSectionCard.tsx +173 -270
- package/src/client/index.ts +19 -30
- package/src/client/locales.ts +46 -24
- package/src/client/section-integration.ts +88 -0
- package/src/client/settings-card.module.css +821 -0
- package/src/client/settings-form.ts +510 -0
- package/src/client/usage-settings-card.ts +72 -0
- package/src/client/usage.module.css +74 -82
- package/src/client/web-extension-page.ts +151 -0
|
@@ -1,34 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The usage statistics settings
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* The usage statistics settings page: one family settings card whose header
|
|
3
|
+
* carries the plugin switch, whose body holds three tabs (用量: today's
|
|
4
|
+
* usage, balances, trend; 个人套餐: per-provider plan quota windows; Token
|
|
5
|
+
* 银行: the whale-yuan voucher minted from every provider's ledger usage)
|
|
6
|
+
* and, under them, the display rows. Data comes from the host's
|
|
7
|
+
* loopback-fenced /api/dsh-usage/overview document; polling runs only while
|
|
8
|
+
* the section is mounted and the tab is visible.
|
|
8
9
|
* @module @linxin666/dsh-usage/client/UsageSectionCard
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
|
|
12
|
-
import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
13
13
|
import type { UsageStoreInstance } from './usage-store.ts'
|
|
14
14
|
import { t } from './locales.ts'
|
|
15
15
|
import styles from './usage.module.css'
|
|
16
|
+
import cardCss from './settings-card.module.css'
|
|
16
17
|
import { PeakBadge } from './PeakBadge.tsx'
|
|
18
|
+
import { SegmentGroup } from './SegmentGroup.tsx'
|
|
19
|
+
import { UsagePage } from './UsagePage.tsx'
|
|
20
|
+
import type { UsageSettingsCardFace } from './usage-settings-card.ts'
|
|
17
21
|
import { isDeepSeekProviderRoute } from '../core/adapters.ts'
|
|
18
22
|
import { deepseekPeriodAt } from '../core/pricing.ts'
|
|
19
23
|
import { voucherData, drawVoucher, faceValue, formatDay, formatDenomination, loadVoucherArt } from './voucher.ts'
|
|
20
24
|
import type { ObservedSpendView, ProviderSnapshotView, UsageDayView, UsageOverviewView, UsageProviderSummary, UsageTokenTotals, UsageWindowSummary } from '../core/types.ts'
|
|
21
25
|
import { formatSpend, type SpendDisplay } from '../core/spend.ts'
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
export interface UsageSettings {
|
|
25
|
-
enabled?: boolean
|
|
26
|
-
pollIntervalSec?: number
|
|
27
|
-
/** Currency the spend figures are displayed in; the ledger stays CNY. */
|
|
28
|
-
spendCurrency?: 'CNY' | 'USD'
|
|
29
|
-
/** CNY per USD, consulted only while the display currency is USD. */
|
|
30
|
-
cnyPerUsd?: number
|
|
31
|
-
}
|
|
27
|
+
export type { UsageSettings } from './usage-settings-card.ts'
|
|
32
28
|
|
|
33
29
|
/** The registration-side face the section's slot entry injects. */
|
|
34
30
|
export interface UsageSectionFace {
|
|
@@ -44,8 +40,8 @@ export interface UsageSectionFace {
|
|
|
44
40
|
* is one small on-demand request.
|
|
45
41
|
*/
|
|
46
42
|
loadDay: (date: string) => Promise<UsageDayView>
|
|
47
|
-
/** The
|
|
48
|
-
|
|
43
|
+
/** The settings card over the usage namespace: the plugin switch and the display rows. */
|
|
44
|
+
settingsCard: UsageSettingsCardFace
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
export interface UsageSectionProps extends UsageSectionFace {
|
|
@@ -98,28 +94,28 @@ function isConfigured(provider: ProviderSnapshotView): boolean {
|
|
|
98
94
|
function TotalsRow(props: { totals: UsageTokenTotals }): ReactNode {
|
|
99
95
|
const { totals } = props
|
|
100
96
|
return (
|
|
101
|
-
<div className={styles.statRow}>
|
|
102
|
-
<div className={styles.stat}>
|
|
97
|
+
<div className={styles.statRow} data-dsh-layout="usage-totals">
|
|
98
|
+
<div className={styles.stat} data-dsh-stat="total">
|
|
103
99
|
<span className={styles.statValue}>{formatTokens(totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens)}</span>
|
|
104
100
|
<span className={styles.statLabel}>{t('usage.tokens.total')}</span>
|
|
105
101
|
</div>
|
|
106
|
-
<div className={styles.stat}>
|
|
102
|
+
<div className={styles.stat} data-dsh-stat="input">
|
|
107
103
|
<span className={styles.statValue}>{formatTokens(totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens)}</span>
|
|
108
104
|
<span className={styles.statLabel}>{t('usage.tokens.input')}</span>
|
|
109
105
|
</div>
|
|
110
|
-
<div className={styles.stat}>
|
|
106
|
+
<div className={styles.stat} data-dsh-stat="output">
|
|
111
107
|
<span className={styles.statValue}>{formatTokens(totals.outputTokens)}</span>
|
|
112
108
|
<span className={styles.statLabel}>{t('usage.tokens.output')}</span>
|
|
113
109
|
</div>
|
|
114
|
-
<div className={styles.stat}>
|
|
110
|
+
<div className={styles.stat} data-dsh-stat="cache-read">
|
|
115
111
|
<span className={styles.statValue}>{formatTokens(totals.cacheReadTokens)}</span>
|
|
116
112
|
<span className={styles.statLabel}>{t('usage.tokens.cacheRead')}</span>
|
|
117
113
|
</div>
|
|
118
|
-
<div className={styles.stat}>
|
|
114
|
+
<div className={styles.stat} data-dsh-stat="cache-write">
|
|
119
115
|
<span className={styles.statValue}>{formatTokens(totals.cacheWriteTokens)}</span>
|
|
120
116
|
<span className={styles.statLabel}>{t('usage.tokens.cacheWrite')}</span>
|
|
121
117
|
</div>
|
|
122
|
-
<div className={styles.stat}>
|
|
118
|
+
<div className={styles.stat} data-dsh-stat="calls">
|
|
123
119
|
<span className={styles.statValue}>{formatTokens(totals.calls)}</span>
|
|
124
120
|
<span className={styles.statLabel}>{t('usage.calls', { n: totals.calls })}</span>
|
|
125
121
|
</div>
|
|
@@ -225,31 +221,26 @@ function DayPicker(props: {
|
|
|
225
221
|
|
|
226
222
|
/** The section component; the slot merges the face into these props. */
|
|
227
223
|
export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
228
|
-
const { store, poll, refresh, loadDay,
|
|
224
|
+
const { store, poll, refresh, loadDay, settingsCard } = props
|
|
229
225
|
const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
226
|
+
// The card store republishes the Host's accepted value, so the poll below
|
|
227
|
+
// starts and stops with the switch without waiting for an unrelated render.
|
|
228
|
+
const card = useSyncExternalStore(settingsCard.store.subscribe, settingsCard.store.getSnapshot)
|
|
229
|
+
const [tab, setTab] = useState<UsageTab>('usage')
|
|
233
230
|
// Undefined means "the default window of this tab": today on the usage tab,
|
|
234
231
|
// the whole retained ledger in the Token bank. A date switches that tab to
|
|
235
232
|
// one retained day instead.
|
|
236
233
|
const [usageDay, setUsageDay] = useState<string | undefined>(undefined)
|
|
237
234
|
const [bankDay, setBankDay] = useState<string | undefined>(undefined)
|
|
238
235
|
const [refreshing, setRefreshing] = useState(false)
|
|
239
|
-
|
|
240
|
-
// keeps the flag below live: the form republishes the Host's accepted value,
|
|
241
|
-
// and the poll starts and stops with it instead of waiting for an unrelated
|
|
242
|
-
// render.
|
|
243
|
-
const [, bumpSettings] = useState(0)
|
|
244
|
-
useEffect(() => settings.subscribe(() => bumpSettings((count) => count + 1)), [settings])
|
|
245
|
-
const enabled = settingsValue.enabled ?? true
|
|
236
|
+
const enabled = card.enabled.text !== 'false'
|
|
246
237
|
|
|
247
238
|
// Poll while mounted, enabled and visible; the overview is cheap (no probes —
|
|
248
239
|
// the host's own cycle owns those) so 10 s keeps balances fresh-ish between
|
|
249
240
|
// manual refreshes.
|
|
250
241
|
useEffect(() => {
|
|
251
242
|
// A disabled plugin deregisters its host routes, so the poll must stop with
|
|
252
|
-
// it; the section re-enables by writing the flag back through the
|
|
243
|
+
// it; the section re-enables by writing the flag back through the switch.
|
|
253
244
|
if (!enabled) return undefined
|
|
254
245
|
poll()
|
|
255
246
|
let timer: number | undefined
|
|
@@ -310,21 +301,20 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
310
301
|
}
|
|
311
302
|
}
|
|
312
303
|
|
|
313
|
-
// Disabled, failed and still-loading states keep the
|
|
314
|
-
//
|
|
315
|
-
//
|
|
304
|
+
// Disabled, failed and still-loading states keep the card mounted: its
|
|
305
|
+
// header owns the switch, so replacing the whole page would leave the user
|
|
306
|
+
// no way back from the UI.
|
|
316
307
|
if (!enabled || ui.status === 'error' || snapshot === null) {
|
|
317
308
|
return (
|
|
318
|
-
<
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
</div>
|
|
309
|
+
<UsagePage card={card} face={settingsCard} enabled={enabled}>
|
|
310
|
+
{enabled
|
|
311
|
+
? (
|
|
312
|
+
<span className={styles.muted} data-dsh-part="status-line">
|
|
313
|
+
{ui.status === 'error' ? t('usage.error', { error: ui.error ?? '' }) : t('usage.loading')}
|
|
314
|
+
</span>
|
|
315
|
+
)
|
|
316
|
+
: null}
|
|
317
|
+
</UsagePage>
|
|
328
318
|
)
|
|
329
319
|
}
|
|
330
320
|
|
|
@@ -343,135 +333,146 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
343
333
|
const planProviders = snapshot.providers.filter((provider) => isConfigured(provider) && (provider.planSupported === true || (provider.planSupported === undefined && provider.plan !== undefined)))
|
|
344
334
|
|
|
345
335
|
return (
|
|
346
|
-
<
|
|
347
|
-
<div className={styles.
|
|
348
|
-
<
|
|
349
|
-
{
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
<
|
|
356
|
-
{
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
336
|
+
<UsagePage card={card} face={settingsCard} enabled={enabled}>
|
|
337
|
+
<div className={styles.section}>
|
|
338
|
+
<div className={styles.header} data-dsh-part="header">
|
|
339
|
+
{/* The cards below already say when there is no usage; without a current route the slot stays empty. */}
|
|
340
|
+
<span className={styles.currentProvider}>
|
|
341
|
+
{currentProvider !== undefined
|
|
342
|
+
? `${currentProvider.displayName}${current.model !== undefined && current.model !== '' ? ' · ' + current.model : ''}`
|
|
343
|
+
: null}
|
|
344
|
+
</span>
|
|
345
|
+
<span className={styles.headerActions}>
|
|
346
|
+
<span className={styles.muted}>{t('usage.updated', { time: formatTime(snapshot.updatedAt) })}</span>
|
|
347
|
+
<button type="button" className={styles.refreshBtn} onClick={onRefresh} disabled={refreshing}>
|
|
348
|
+
{refreshing ? t('usage.refreshing') : t('usage.refresh')}
|
|
349
|
+
</button>
|
|
350
|
+
</span>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<SegmentGroup className={styles.tabs} role="tablist" aria-label={t('usage.title')} data-dsh-part="tabs">
|
|
354
|
+
{USAGE_TABS.map((entry) => (
|
|
355
|
+
<button
|
|
356
|
+
key={entry}
|
|
357
|
+
type="button"
|
|
358
|
+
role="tab"
|
|
359
|
+
aria-selected={tab === entry}
|
|
360
|
+
className={`${cardCss.segment} ${styles.tab}`}
|
|
361
|
+
data-active={tab === entry ? '' : undefined}
|
|
362
|
+
onClick={() => setTab(entry)}
|
|
363
|
+
>
|
|
364
|
+
{t(`usage.tab.${entry}`)}
|
|
365
|
+
</button>
|
|
366
|
+
))}
|
|
367
|
+
</SegmentGroup>
|
|
368
|
+
|
|
369
|
+
{tab === 'usage' && (
|
|
370
|
+
<>
|
|
371
|
+
<div className={styles.usageSummaryGrid} data-dsh-layout="summary-grid">
|
|
372
|
+
<div className={styles.card} data-dsh-part="today-card">
|
|
373
|
+
<span className={styles.cardTitle}>{isToday ? t('usage.today') : t('usage.day.title', { date: selectedDay ?? '' })}</span>
|
|
374
|
+
<DayPicker
|
|
375
|
+
part="usage"
|
|
376
|
+
value={selectedDay ?? ''}
|
|
377
|
+
options={dayOptions}
|
|
378
|
+
today={todayDate ?? ''}
|
|
379
|
+
onSelect={(date) => { setUsageDay(date === todayDate ? undefined : date) }}
|
|
380
|
+
/>
|
|
381
|
+
{deepseekVisible && (
|
|
382
|
+
<PeakBadge peak={deepseekPeriod.peak} boundaryMs={deepseekPeriod.boundaryMs} variant="full" />
|
|
383
|
+
)}
|
|
384
|
+
{usageDayLoad?.status === 'error'
|
|
385
|
+
? <span className={styles.errorLine}>{t('usage.day.error', { error: usageDayLoad.error ?? '' })}</span>
|
|
386
|
+
: day === undefined
|
|
387
|
+
? <span className={styles.muted}>{t('usage.day.loading')}</span>
|
|
388
|
+
: day.totals.calls === 0
|
|
389
|
+
? <span className={styles.muted}>{isToday ? t('usage.noData') : t('usage.day.empty')}</span>
|
|
390
|
+
: <TotalsRow totals={day.totals} />}
|
|
391
|
+
{(day?.totals.cost ?? 0) > 0 && (
|
|
392
|
+
<div className={styles.providerRow} data-dsh-part="today-cost">
|
|
393
|
+
<span className={styles.providerName}>{isToday ? t('usage.today.cost') : t('usage.day.cost')}</span>
|
|
394
|
+
<span className={styles.providerTokens}>{formatSpend(day?.totals.cost ?? 0, snapshot.display)}</span>
|
|
395
|
+
</div>
|
|
396
|
+
)}
|
|
397
|
+
{(day?.providers.length ?? 0) > 0 && (
|
|
398
|
+
<div data-dsh-part="provider-list">
|
|
399
|
+
{(day?.providers ?? []).map((row) => (
|
|
400
|
+
<div key={row.provider} className={styles.providerRow}>
|
|
401
|
+
<span className={styles.providerName}>
|
|
402
|
+
{snapshot.providers.find((provider) => provider.provider === row.provider)?.displayName ?? row.provider}
|
|
403
|
+
{current.provider === row.provider && <span className={styles.currentBadge}>{t('usage.current')}</span>}
|
|
404
|
+
</span>
|
|
405
|
+
<span className={styles.providerTokens}>{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)}` : ''}</span>
|
|
406
|
+
</div>
|
|
407
|
+
))}
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
</div>
|
|
360
411
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
412
|
+
<div className={styles.card} data-dsh-part="balance-card">
|
|
413
|
+
<span className={styles.cardTitle}>{t('usage.balance')}</span>
|
|
414
|
+
{(() => {
|
|
415
|
+
const configured = snapshot.providers.filter(isConfigured)
|
|
416
|
+
// Include rows whose balance is deliberately not applicable
|
|
417
|
+
// (another origin's endpoint): the row itself explains why, which
|
|
418
|
+
// a single generic line cannot (#1688).
|
|
419
|
+
const rows = configured.filter((provider) => provider.balanceSupported === true || provider.balanceSupported === false || (provider.balanceSupported === undefined && provider.supported))
|
|
420
|
+
if (rows.length === 0) {
|
|
421
|
+
return <span className={styles.muted}>{configured.length === 0 ? t('usage.balance.noneConfigured') : t('usage.balance.unsupported')}</span>
|
|
422
|
+
}
|
|
423
|
+
return rows.map((provider) => (
|
|
424
|
+
<ProviderRow key={provider.provider} provider={provider} current={current.provider} />
|
|
425
|
+
))
|
|
426
|
+
})()}
|
|
427
|
+
{snapshot.providers.some((provider) => isConfigured(provider) && provider.error !== undefined) && (
|
|
428
|
+
<span className={styles.errorLine}>
|
|
429
|
+
{snapshot.providers.filter((provider) => isConfigured(provider) && provider.error !== undefined).map((provider) => `${provider.displayName}: ${t('usage.provider.error', { error: provider.error ?? '' })}`).join(t('usage.errorListSeparator'))}
|
|
430
|
+
</span>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<RangeCard range={snapshot.usage.range} providers={snapshot.providers} currentProvider={current.provider} />
|
|
436
|
+
</>
|
|
437
|
+
)}
|
|
438
|
+
|
|
439
|
+
{tab === 'plans' && (
|
|
440
|
+
planProviders.length === 0
|
|
441
|
+
? <div className={styles.card}><span className={styles.muted}>{t('usage.plan.noneConfigured')}</span></div>
|
|
442
|
+
: planProviders.map((provider) => <PlanCard key={provider.provider} provider={provider} current={current.provider} />)
|
|
443
|
+
)}
|
|
372
444
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
<div className={styles.card} data-dsh-part="today-card">
|
|
376
|
-
<span className={styles.cardTitle}>{isToday ? t('usage.today') : t('usage.day.title', { date: selectedDay ?? '' })}</span>
|
|
445
|
+
{tab === 'bank' && (
|
|
446
|
+
<>
|
|
377
447
|
<DayPicker
|
|
378
|
-
part="
|
|
379
|
-
value={
|
|
448
|
+
part="bank"
|
|
449
|
+
value={bankSelection ?? BANK_ALL_DAYS}
|
|
380
450
|
options={dayOptions}
|
|
381
451
|
today={todayDate ?? ''}
|
|
382
|
-
|
|
452
|
+
leading={{ value: BANK_ALL_DAYS, label: t('usage.bank.windowAll') }}
|
|
453
|
+
onSelect={(date) => { setBankDay(date === BANK_ALL_DAYS ? undefined : date) }}
|
|
383
454
|
/>
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
</div>
|
|
399
|
-
)}
|
|
400
|
-
{(day?.providers.length ?? 0) > 0 && (
|
|
401
|
-
<div data-dsh-part="provider-list">
|
|
402
|
-
{(day?.providers ?? []).map((row) => (
|
|
403
|
-
<div key={row.provider} className={styles.providerRow}>
|
|
404
|
-
<span className={styles.providerName}>
|
|
405
|
-
{snapshot.providers.find((provider) => provider.provider === row.provider)?.displayName ?? row.provider}
|
|
406
|
-
{current.provider === row.provider && <span className={styles.currentBadge}>{t('usage.current')}</span>}
|
|
407
|
-
</span>
|
|
408
|
-
<span className={styles.providerTokens}>{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)}` : ''}</span>
|
|
409
|
-
</div>
|
|
410
|
-
))}
|
|
411
|
-
</div>
|
|
412
|
-
)}
|
|
413
|
-
</div>
|
|
414
|
-
|
|
415
|
-
<div className={styles.card} data-dsh-part="balance-card">
|
|
416
|
-
<span className={styles.cardTitle}>{t('usage.balance')}</span>
|
|
417
|
-
{(() => {
|
|
418
|
-
const configured = snapshot.providers.filter(isConfigured)
|
|
419
|
-
// Include rows whose balance is deliberately not applicable
|
|
420
|
-
// (another origin's endpoint): the row itself explains why, which
|
|
421
|
-
// a single generic line cannot (#1688).
|
|
422
|
-
const rows = configured.filter((provider) => provider.balanceSupported === true || provider.balanceSupported === false || (provider.balanceSupported === undefined && provider.supported))
|
|
423
|
-
if (rows.length === 0) {
|
|
424
|
-
return <span className={styles.muted}>{configured.length === 0 ? t('usage.balance.noneConfigured') : t('usage.balance.unsupported')}</span>
|
|
425
|
-
}
|
|
426
|
-
return rows.map((provider) => (
|
|
427
|
-
<ProviderRow key={provider.provider} provider={provider} current={current.provider} />
|
|
428
|
-
))
|
|
429
|
-
})()}
|
|
430
|
-
{snapshot.providers.some((provider) => isConfigured(provider) && provider.error !== undefined) && (
|
|
431
|
-
<span className={styles.errorLine}>
|
|
432
|
-
{snapshot.providers.filter((provider) => isConfigured(provider) && provider.error !== undefined).map((provider) => `${provider.displayName}: ${t('usage.provider.error', { error: provider.error ?? '' })}`).join(t('usage.errorListSeparator'))}
|
|
433
|
-
</span>
|
|
434
|
-
)}
|
|
435
|
-
</div>
|
|
436
|
-
|
|
437
|
-
<RangeCard range={snapshot.usage.range} providers={snapshot.providers} currentProvider={current.provider} />
|
|
438
|
-
|
|
439
|
-
<SettingsRow settings={settings} snapshot={settingsSnapshot.status === 'ready' ? settingsSnapshot : undefined} value={settingsValue} />
|
|
440
|
-
</>
|
|
441
|
-
)}
|
|
442
|
-
|
|
443
|
-
{tab === 'plans' && (
|
|
444
|
-
planProviders.length === 0
|
|
445
|
-
? <div className={styles.card}><span className={styles.muted}>{t('usage.plan.noneConfigured')}</span></div>
|
|
446
|
-
: planProviders.map((provider) => <PlanCard key={provider.provider} provider={provider} current={current.provider} />)
|
|
447
|
-
)}
|
|
448
|
-
|
|
449
|
-
{tab === 'bank' && (
|
|
450
|
-
<>
|
|
451
|
-
<DayPicker
|
|
452
|
-
part="bank"
|
|
453
|
-
value={bankSelection ?? BANK_ALL_DAYS}
|
|
454
|
-
options={dayOptions}
|
|
455
|
-
today={todayDate ?? ''}
|
|
456
|
-
leading={{ value: BANK_ALL_DAYS, label: t('usage.bank.windowAll') }}
|
|
457
|
-
onSelect={(date) => { setBankDay(date === BANK_ALL_DAYS ? undefined : date) }}
|
|
458
|
-
/>
|
|
459
|
-
<VoucherCard
|
|
460
|
-
window={bankWindow}
|
|
461
|
-
// The observed-spend watch accrues from its own first balance
|
|
462
|
-
// reading, so it describes the whole window only: a single day's
|
|
463
|
-
// voucher falls back to that day's fold-time estimate.
|
|
464
|
-
observedSpend={bankSelection === undefined ? snapshot.usage.observedSpend : undefined}
|
|
465
|
-
loading={bankSelection !== undefined && bankDayLoad?.status === 'loading'}
|
|
466
|
-
display={snapshot.display}
|
|
467
|
-
{...(bankDayLoad?.status === 'error' ? { error: bankDayLoad.error ?? '' } : {})}
|
|
468
|
-
/>
|
|
469
|
-
</>
|
|
470
|
-
)}
|
|
471
|
-
</div>
|
|
455
|
+
<VoucherCard
|
|
456
|
+
window={bankWindow}
|
|
457
|
+
// The observed-spend watch accrues from its own first balance
|
|
458
|
+
// reading, so it describes the whole window only: a single day's
|
|
459
|
+
// voucher falls back to that day's fold-time estimate.
|
|
460
|
+
observedSpend={bankSelection === undefined ? snapshot.usage.observedSpend : undefined}
|
|
461
|
+
loading={bankSelection !== undefined && bankDayLoad?.status === 'loading'}
|
|
462
|
+
display={snapshot.display}
|
|
463
|
+
{...(bankDayLoad?.status === 'error' ? { error: bankDayLoad.error ?? '' } : {})}
|
|
464
|
+
/>
|
|
465
|
+
</>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
</UsagePage>
|
|
472
469
|
)
|
|
473
470
|
}
|
|
474
471
|
|
|
472
|
+
/** The statistics tabs, in their order on the page. */
|
|
473
|
+
const USAGE_TABS = ['usage', 'plans', 'bank'] as const
|
|
474
|
+
type UsageTab = (typeof USAGE_TABS)[number]
|
|
475
|
+
|
|
475
476
|
/** How many model sub-bars render under one provider bar. */
|
|
476
477
|
const CHART_MODEL_CAP = 3
|
|
477
478
|
|
|
@@ -492,7 +493,7 @@ function RangeCard(props: { range?: UsageOverviewView['usage']['range']; provide
|
|
|
492
493
|
<span className={styles.cardTitle}>{t('usage.trend')}</span>
|
|
493
494
|
{range.providers.length === 0 || grandTotal === 0
|
|
494
495
|
? <span className={styles.muted}>{t('usage.noData')}</span>
|
|
495
|
-
: <div className={styles.chart} data-dsh-part="usage-chart">
|
|
496
|
+
: <div className={styles.chart} data-dsh-part="usage-chart" data-dsh-layout="trend-grid">
|
|
496
497
|
{range.providers.map((row) => (
|
|
497
498
|
<ChartProviderRow key={row.provider} row={row} name={nameOf(row.provider)} max={maxProvider} current={currentProvider === row.provider} />
|
|
498
499
|
))}
|
|
@@ -510,7 +511,7 @@ function ChartProviderRow(props: { row: UsageProviderSummary; name: string; max:
|
|
|
510
511
|
const total = totalOf(row.totals)
|
|
511
512
|
const maxModel = Math.max(1, ...row.models.slice(0, CHART_MODEL_CAP).map((model) => totalOf(model.totals)))
|
|
512
513
|
return (
|
|
513
|
-
<div className={styles.chartProvider}>
|
|
514
|
+
<div className={styles.chartProvider} data-dsh-layout="trend-provider">
|
|
514
515
|
<span className={styles.chartHead}>
|
|
515
516
|
<span className={styles.providerName}>
|
|
516
517
|
{name}
|
|
@@ -680,101 +681,3 @@ function PlanCard(props: { provider: ProviderSnapshotView; current?: string }):
|
|
|
680
681
|
</div>
|
|
681
682
|
)
|
|
682
683
|
}
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* The compact settings row. Both controls write through the shared form the
|
|
686
|
-
* moment the user changes them, and the Host answers each write with a
|
|
687
|
-
* boolean: a refused (or transport-failed) write is surfaced as a failed save,
|
|
688
|
-
* because a value that did not land must never read as applied.
|
|
689
|
-
*/
|
|
690
|
-
function SettingsRow(props: {
|
|
691
|
-
settings: UsageSectionProps['settings']
|
|
692
|
-
snapshot?: ConfigFormSnapshot<UsageSettings>
|
|
693
|
-
value: UsageSettings
|
|
694
|
-
}): ReactNode {
|
|
695
|
-
const { settings, snapshot, value } = props
|
|
696
|
-
const disabled = snapshot === undefined || !snapshot.writable
|
|
697
|
-
const [failure, setFailure] = useState<string | undefined>(undefined)
|
|
698
|
-
|
|
699
|
-
const write = (field: 'enabled' | 'pollIntervalSec' | 'spendCurrency' | 'cnyPerUsd', next: boolean | number | 'CNY' | 'USD'): void => {
|
|
700
|
-
setFailure(undefined)
|
|
701
|
-
let answer: Promise<boolean>
|
|
702
|
-
try {
|
|
703
|
-
answer = settings.set(field, next)
|
|
704
|
-
} catch (error) {
|
|
705
|
-
setFailure(error instanceof Error ? error.message : String(error))
|
|
706
|
-
return
|
|
707
|
-
}
|
|
708
|
-
// false is the contract's refusal/skip answer (the Host rejected the value,
|
|
709
|
-
// the entry is not writable, or the write was dropped); a rejecting
|
|
710
|
-
// transport reports through the same failed-save surface.
|
|
711
|
-
Promise.resolve(answer).then(
|
|
712
|
-
(accepted) => { if (!accepted) setFailure('') },
|
|
713
|
-
(error: unknown) => { setFailure(error instanceof Error ? error.message : String(error)) },
|
|
714
|
-
)
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
return (
|
|
718
|
-
<div className={styles.card} data-dsh-part="settings-row">
|
|
719
|
-
<span className={styles.cardTitle}>{t('usage.config.title')}</span>
|
|
720
|
-
<div className={styles.settingsGrid}>
|
|
721
|
-
<label className={styles.settingItem}>
|
|
722
|
-
<input
|
|
723
|
-
type="checkbox"
|
|
724
|
-
checked={value.enabled ?? true}
|
|
725
|
-
disabled={disabled}
|
|
726
|
-
onChange={(event) => { write('enabled', event.target.checked) }}
|
|
727
|
-
/>
|
|
728
|
-
{t('usage.config.enabled')}
|
|
729
|
-
</label>
|
|
730
|
-
<label className={styles.settingItem}>
|
|
731
|
-
{t('usage.config.pollIntervalSec')}
|
|
732
|
-
<input
|
|
733
|
-
type="number"
|
|
734
|
-
min={30}
|
|
735
|
-
max={3600}
|
|
736
|
-
value={typeof value.pollIntervalSec === 'number' ? value.pollIntervalSec : 60}
|
|
737
|
-
disabled={disabled}
|
|
738
|
-
onChange={(event) => {
|
|
739
|
-
const parsed = Number(event.target.value)
|
|
740
|
-
if (Number.isFinite(parsed) && parsed >= 30 && parsed <= 3600) write('pollIntervalSec', Math.round(parsed))
|
|
741
|
-
}}
|
|
742
|
-
/>
|
|
743
|
-
</label>
|
|
744
|
-
<label className={styles.settingItem}>
|
|
745
|
-
{t('usage.config.spendCurrency')}
|
|
746
|
-
<select
|
|
747
|
-
value={value.spendCurrency === 'USD' ? 'USD' : 'CNY'}
|
|
748
|
-
disabled={disabled}
|
|
749
|
-
onChange={(event) => { write('spendCurrency', event.target.value === 'USD' ? 'USD' : 'CNY') }}
|
|
750
|
-
>
|
|
751
|
-
<option value="CNY">CNY</option>
|
|
752
|
-
<option value="USD">USD</option>
|
|
753
|
-
</select>
|
|
754
|
-
</label>
|
|
755
|
-
<label className={styles.settingItem}>
|
|
756
|
-
{t('usage.config.cnyPerUsd')}
|
|
757
|
-
<input
|
|
758
|
-
type="number"
|
|
759
|
-
min={0.01}
|
|
760
|
-
max={1000}
|
|
761
|
-
step={0.01}
|
|
762
|
-
value={typeof value.cnyPerUsd === 'number' ? value.cnyPerUsd : 7.2}
|
|
763
|
-
disabled={disabled}
|
|
764
|
-
onChange={(event) => {
|
|
765
|
-
const parsed = Number(event.target.value)
|
|
766
|
-
// An unusable rate is simply not written: the stored value stays,
|
|
767
|
-
// and rendering falls back to the documented default rate.
|
|
768
|
-
if (Number.isFinite(parsed) && parsed >= 0.01 && parsed <= 1000) write('cnyPerUsd', Math.round(parsed * 100) / 100)
|
|
769
|
-
}}
|
|
770
|
-
/>
|
|
771
|
-
</label>
|
|
772
|
-
</div>
|
|
773
|
-
{failure !== undefined && (
|
|
774
|
-
<span className={styles.errorLine} role="status">
|
|
775
|
-
{t('usage.config.saveFailed')}{failure === '' ? '' : ' - ' + failure}
|
|
776
|
-
</span>
|
|
777
|
-
)}
|
|
778
|
-
</div>
|
|
779
|
-
)
|
|
780
|
-
}
|
package/src/client/index.ts
CHANGED
|
@@ -19,9 +19,11 @@ import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
19
19
|
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
20
20
|
import { createUsageStore, type UsageStoreInstance } from './usage-store.ts'
|
|
21
21
|
import { UsageSectionCard, type UsageSectionFace, type UsageSettings } from './UsageSectionCard.tsx'
|
|
22
|
-
import { mountUsageFootCard
|
|
23
|
-
import { NS, en, zh
|
|
22
|
+
import { mountUsageFootCard } from './foot-card-mount.tsx'
|
|
23
|
+
import { NS, en, zh } from './locales.ts'
|
|
24
|
+
import { installUsageSection, openIntegratedUsageSettings } from './section-integration.ts'
|
|
24
25
|
import { createServedEntryForm } from './settings-entry-form.ts'
|
|
26
|
+
import { UsageSettingsCardController } from './usage-settings-card.ts'
|
|
25
27
|
import type { UsageDayResponse, UsageDayView, UsageOverviewView } from '../core/types.ts'
|
|
26
28
|
|
|
27
29
|
/** The host usage API as the browser sees it (same-origin JSON endpoints). */
|
|
@@ -68,9 +70,6 @@ const USAGE_ENTRY_IDS: readonly string[] = [AGGREGATE_ENTRY_ID, 'usage', USAGE_S
|
|
|
68
70
|
/** First-level nav position: directly below the Workshop section (order 150). */
|
|
69
71
|
const SECTION_ORDER = 151
|
|
70
72
|
|
|
71
|
-
/** The section's nav row id — what the shell's store selects when the card opens. */
|
|
72
|
-
const USAGE_SECTION_ID = 'dsh-usage'
|
|
73
|
-
|
|
74
73
|
/** Required services. */
|
|
75
74
|
export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote']
|
|
76
75
|
|
|
@@ -167,7 +166,13 @@ export function apply(ctx: ClientContext): void {
|
|
|
167
166
|
// small local request the 10 s poll has no reason to repeat.
|
|
168
167
|
const loadDay = (date: string): Promise<UsageDayView> => usageApi.day(date)
|
|
169
168
|
|
|
170
|
-
|
|
169
|
+
// The card form lives as long as the plugin, like the store: the page reads
|
|
170
|
+
// its last state on reopen, and a write in flight settles after the page closes.
|
|
171
|
+
const settingsCard = new UsageSettingsCardController(settingsForm)
|
|
172
|
+
ctx.effect(() => () => { settingsCard.dispose() }, 'dsh-usage: settings card')
|
|
173
|
+
const settingsCardFace = settingsCard.inject()
|
|
174
|
+
|
|
175
|
+
const face = (): UsageSectionFace => ({ store, poll, refresh, loadDay, settingsCard: settingsCardFace })
|
|
171
176
|
|
|
172
177
|
// Sidebar foot card: the compact usage glance seated below the shell's
|
|
173
178
|
// Settings row. It shares the section's store and poll path (the sequence
|
|
@@ -177,13 +182,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
177
182
|
store,
|
|
178
183
|
poll,
|
|
179
184
|
settings: settingsForm,
|
|
180
|
-
onOpen: () => {
|
|
181
|
-
openUsageSettings({
|
|
182
|
-
id: USAGE_SECTION_ID,
|
|
183
|
-
label: () => t('usage.title'),
|
|
184
|
-
store: settingsShellStore(ctx.slots),
|
|
185
|
-
})
|
|
186
|
-
},
|
|
185
|
+
onOpen: () => { openIntegratedUsageSettings(ctx) },
|
|
187
186
|
locale: ctx.locale,
|
|
188
187
|
})
|
|
189
188
|
ctx.effect(() => () => {
|
|
@@ -194,21 +193,11 @@ export function apply(ctx: ClientContext): void {
|
|
|
194
193
|
}
|
|
195
194
|
}, 'dsh-usage: sidebar foot card')
|
|
196
195
|
|
|
197
|
-
ctx.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
locale: NS,
|
|
205
|
-
inject: face,
|
|
206
|
-
}, UsageSectionCard)
|
|
207
|
-
return () => {
|
|
208
|
-
unregister()
|
|
209
|
-
}
|
|
210
|
-
} catch {
|
|
211
|
-
return () => {}
|
|
212
|
-
}
|
|
213
|
-
})
|
|
196
|
+
ctx.effect(() => installUsageSection(ctx, {
|
|
197
|
+
order: SECTION_ORDER,
|
|
198
|
+
label: () => ctx.locale.bind(NS)('usage.title'),
|
|
199
|
+
locale: NS,
|
|
200
|
+
inject: face,
|
|
201
|
+
component: UsageSectionCard,
|
|
202
|
+
}), 'dsh-usage: settings page')
|
|
214
203
|
}
|