@linxin666/dsh-usage 0.4.7 → 0.4.9
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 +5 -5
- package/README.zh.md +5 -5
- package/lib/client.js +329 -141
- package/lib/client.js.map +1 -1
- package/lib/index.js +75 -7
- package/lib/types/client/PeakBadge.d.ts +22 -0
- package/lib/types/client/UsageSectionCard.d.ts +5 -1
- package/lib/types/client/foot-card-mount.d.ts +52 -8
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/client/voucher.d.ts +20 -25
- package/lib/types/core/spend.d.ts +46 -0
- package/lib/types/core/types.d.ts +7 -0
- package/lib/types/host/loopback.d.ts +41 -1
- package/lib/types/host/usage-service.d.ts +7 -0
- package/lib/types/index.d.ts +11 -0
- package/package.json +14 -14
- package/src/client/PeakBadge.tsx +48 -0
- package/src/client/UsageFootCard.tsx +30 -16
- package/src/client/UsageSectionCard.tsx +52 -32
- package/src/client/foot-card-mount.tsx +161 -37
- package/src/client/index.ts +12 -3
- package/src/client/locales.ts +20 -8
- package/src/client/usage.module.css +59 -1
- package/src/client/voucher.ts +20 -30
- package/src/core/spend.ts +62 -0
- package/src/core/types.ts +8 -0
- package/src/host/loopback.ts +57 -1
- package/src/host/pair-access.ts +26 -3
- package/src/host/usage-service.ts +9 -0
- package/src/index.ts +17 -0
|
@@ -27,8 +27,12 @@ import { useEffect, useState, useSyncExternalStore, type ReactNode } from 'react
|
|
|
27
27
|
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
28
28
|
import { t } from './locales.ts'
|
|
29
29
|
import styles from './usage.module.css'
|
|
30
|
+
import { formatClock, PeakBadge } from './PeakBadge.tsx'
|
|
30
31
|
import { formatTokens, type UsageSettings } from './UsageSectionCard.tsx'
|
|
32
|
+
import { deepseekPeriodAt } from '../core/pricing.ts'
|
|
33
|
+
import { isDeepSeekProviderRoute } from '../core/adapters.ts'
|
|
31
34
|
import { totalTokens } from '../core/ledger.ts'
|
|
35
|
+
import { formatSpend } from '../core/spend.ts'
|
|
32
36
|
import type { BalanceView, ProviderSnapshotView, UsageOverviewView } from '../core/types.ts'
|
|
33
37
|
import type { UsageStoreInstance } from './usage-store.ts'
|
|
34
38
|
|
|
@@ -114,27 +118,23 @@ function formatBalance(balance: BalanceView): string {
|
|
|
114
118
|
return currency + ' ' + balance.totalBalance
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
function formatClock(ms: number): string {
|
|
118
|
-
try {
|
|
119
|
-
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
120
|
-
} catch {
|
|
121
|
-
return ''
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
121
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* route). Undefined when there is no route to name.
|
|
122
|
+
* Route key owning today's priced spend: the day's highest cost row, else the
|
|
123
|
+
* current session route (an older host without the day rows falls back to the
|
|
124
|
+
* current route). Undefined when neither names one.
|
|
130
125
|
*/
|
|
131
|
-
function
|
|
126
|
+
function spendRoute(snapshot: UsageOverviewView): string | undefined {
|
|
132
127
|
let top: UsageOverviewView['usage']['today']['providers'][number] | undefined
|
|
133
128
|
for (const row of snapshot.usage.today.providers) {
|
|
134
129
|
if (row.totals.cost <= 0) continue
|
|
135
130
|
if (top === undefined || row.totals.cost > top.totals.cost) top = row
|
|
136
131
|
}
|
|
137
|
-
|
|
132
|
+
return top?.provider ?? snapshot.current.provider
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Display name of the route spendRoute() names, resolved through the snapshot. */
|
|
136
|
+
function spendProvider(snapshot: UsageOverviewView): string | undefined {
|
|
137
|
+
const route = spendRoute(snapshot)
|
|
138
138
|
if (route === undefined) return undefined
|
|
139
139
|
return snapshot.providers.find((entry) => entry.provider === route)?.displayName
|
|
140
140
|
?? (route === snapshot.current.provider ? snapshot.current.displayName : undefined)
|
|
@@ -149,9 +149,9 @@ function spendProvider(snapshot: UsageOverviewView): string | undefined {
|
|
|
149
149
|
*/
|
|
150
150
|
function headline(snapshot: UsageOverviewView): { label: string; value: string; tokenFallback: boolean; provider?: string } {
|
|
151
151
|
const totals = snapshot.usage.today.totals
|
|
152
|
-
if (totals.cost > 0) return { label: t('usage.foot.cost'), value:
|
|
152
|
+
if (totals.cost > 0) return { label: t('usage.foot.cost'), value: formatSpend(totals.cost, snapshot.display), tokenFallback: false, provider: spendProvider(snapshot) }
|
|
153
153
|
if (totals.calls > 0) return { label: t('usage.today'), value: formatTokens(totalTokens(totals)) + ' tokens', tokenFallback: true }
|
|
154
|
-
return { label: t('usage.foot.cost'), value:
|
|
154
|
+
return { label: t('usage.foot.cost'), value: formatSpend(0, snapshot.display), tokenFallback: false }
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/** The tokens/calls sub-line under the headline; a quiet no-data line on an empty day. */
|
|
@@ -266,6 +266,11 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
|
|
|
266
266
|
|
|
267
267
|
const head = headline(snapshot)
|
|
268
268
|
const balances = balanceLine(snapshot)
|
|
269
|
+
// The published peak/off-peak clock prices the official DeepSeek family only,
|
|
270
|
+
// so the marker follows the route that owns today's spend (else the session
|
|
271
|
+
// route) and stays away from every other provider's card.
|
|
272
|
+
const route = spendRoute(snapshot)
|
|
273
|
+
const period = route !== undefined && isDeepSeekProviderRoute(route) ? deepseekPeriodAt(Date.now()) : undefined
|
|
269
274
|
|
|
270
275
|
if (collapsed) {
|
|
271
276
|
return (
|
|
@@ -274,6 +279,7 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
|
|
|
274
279
|
<span className={styles.footStrip} data-dsh-part="foot-card-strip">
|
|
275
280
|
<GaugeIcon />
|
|
276
281
|
{head.provider !== undefined && <span className={styles.footStripProvider}>{head.provider}</span>}
|
|
282
|
+
{period !== undefined && <PeakBadge peak={period.peak} boundaryMs={period.boundaryMs} variant="compact" />}
|
|
277
283
|
<span className={styles.footStripLabel}>{head.label}</span>
|
|
278
284
|
<span className={styles.footStripValue}>{head.value}</span>
|
|
279
285
|
</span>
|
|
@@ -292,6 +298,14 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
|
|
|
292
298
|
</span>
|
|
293
299
|
<span className={styles.footLine} data-dsh-part="foot-card-usage">{usageLine(snapshot, head.tokenFallback)}</span>
|
|
294
300
|
{balances !== undefined && <span className={styles.footLine} data-dsh-part="foot-card-balances">{balances}</span>}
|
|
301
|
+
{period !== undefined && (
|
|
302
|
+
<span className={styles.footPeak}>
|
|
303
|
+
<PeakBadge peak={period.peak} boundaryMs={period.boundaryMs} variant="compact" />
|
|
304
|
+
<span className={styles.footPeakClock}>
|
|
305
|
+
{t(period.peak ? 'usage.peak.ends' : 'usage.peak.starts', { time: formatClock(period.boundaryMs) })}
|
|
306
|
+
</span>
|
|
307
|
+
</span>
|
|
308
|
+
)}
|
|
295
309
|
<span className={styles.footMeta}>{t('usage.updated', { time: formatClock(snapshot.updatedAt) })}</span>
|
|
296
310
|
</button>
|
|
297
311
|
{toggle}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The usage statistics settings section: three tabs (用量: today's usage,
|
|
3
3
|
* balances, trend; 个人套餐: per-provider plan quota windows; Token 银行:
|
|
4
|
-
* the whale-yuan voucher minted from
|
|
4
|
+
* the whale-yuan voucher minted from every provider's ledger usage)
|
|
5
5
|
* plus a compact settings row. Data comes from the host's loopback-fenced
|
|
6
6
|
* /api/dsh-usage/overview document; polling runs only while the section is
|
|
7
7
|
* mounted and the tab is visible.
|
|
@@ -13,15 +13,21 @@ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-
|
|
|
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 { PeakBadge } from './PeakBadge.tsx'
|
|
16
17
|
import { isDeepSeekProviderRoute } from '../core/adapters.ts'
|
|
17
18
|
import { deepseekPeriodAt } from '../core/pricing.ts'
|
|
18
19
|
import { voucherData, drawVoucher, faceValue, formatDay, formatDenomination, loadVoucherArt } from './voucher.ts'
|
|
19
20
|
import type { ObservedSpendView, ProviderSnapshotView, UsageDayView, UsageOverviewView, UsageProviderSummary, UsageTokenTotals, UsageWindowSummary } from '../core/types.ts'
|
|
21
|
+
import { formatSpend, type SpendDisplay } from '../core/spend.ts'
|
|
20
22
|
|
|
21
23
|
/** The settings fields this section edits (immediate-apply semantics). */
|
|
22
24
|
export interface UsageSettings {
|
|
23
25
|
enabled?: boolean
|
|
24
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
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/** The registration-side face the section's slot entry injects. */
|
|
@@ -77,19 +83,6 @@ function formatTime(ms: number): string {
|
|
|
77
83
|
}
|
|
78
84
|
}
|
|
79
85
|
|
|
80
|
-
function formatClock(ms: number): string {
|
|
81
|
-
try {
|
|
82
|
-
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
83
|
-
} catch {
|
|
84
|
-
return ''
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Formatted CNY spend estimate (the only priced currency today). */
|
|
89
|
-
function formatCost(cost: number): string {
|
|
90
|
-
return '¥' + cost.toFixed(2)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
86
|
function toneClass(percent: number): string {
|
|
94
87
|
if (percent >= 90) return styles.barLow
|
|
95
88
|
if (percent >= 70) return styles.barWarn
|
|
@@ -389,9 +382,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
389
382
|
onSelect={(date) => { setUsageDay(date === todayDate ? undefined : date) }}
|
|
390
383
|
/>
|
|
391
384
|
{deepseekVisible && (
|
|
392
|
-
<
|
|
393
|
-
{t(deepseekPeriod.peak ? 'usage.peak.on' : 'usage.peak.off', { time: formatClock(deepseekPeriod.boundaryMs) })}
|
|
394
|
-
</span>
|
|
385
|
+
<PeakBadge peak={deepseekPeriod.peak} boundaryMs={deepseekPeriod.boundaryMs} variant="full" />
|
|
395
386
|
)}
|
|
396
387
|
{usageDayLoad?.status === 'error'
|
|
397
388
|
? <span className={styles.errorLine}>{t('usage.day.error', { error: usageDayLoad.error ?? '' })}</span>
|
|
@@ -403,7 +394,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
403
394
|
{(day?.totals.cost ?? 0) > 0 && (
|
|
404
395
|
<div className={styles.providerRow} data-dsh-part="today-cost">
|
|
405
396
|
<span className={styles.providerName}>{isToday ? t('usage.today.cost') : t('usage.day.cost')}</span>
|
|
406
|
-
<span className={styles.providerTokens}>{
|
|
397
|
+
<span className={styles.providerTokens}>{formatSpend(day?.totals.cost ?? 0, snapshot.display)}</span>
|
|
407
398
|
</div>
|
|
408
399
|
)}
|
|
409
400
|
{(day?.providers.length ?? 0) > 0 && (
|
|
@@ -414,7 +405,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
414
405
|
{snapshot.providers.find((provider) => provider.provider === row.provider)?.displayName ?? row.provider}
|
|
415
406
|
{current.provider === row.provider && <span className={styles.currentBadge}>{t('usage.current')}</span>}
|
|
416
407
|
</span>
|
|
417
|
-
<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 ? ` · ${
|
|
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>
|
|
418
409
|
</div>
|
|
419
410
|
))}
|
|
420
411
|
</div>
|
|
@@ -472,6 +463,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
472
463
|
// voucher falls back to that day's fold-time estimate.
|
|
473
464
|
observedSpend={bankSelection === undefined ? snapshot.usage.observedSpend : undefined}
|
|
474
465
|
loading={bankSelection !== undefined && bankDayLoad?.status === 'loading'}
|
|
466
|
+
display={snapshot.display}
|
|
475
467
|
{...(bankDayLoad?.status === 'error' ? { error: bankDayLoad.error ?? '' } : {})}
|
|
476
468
|
/>
|
|
477
469
|
</>
|
|
@@ -550,17 +542,17 @@ function totalOf(totals: UsageTokenTotals): number {
|
|
|
550
542
|
}
|
|
551
543
|
|
|
552
544
|
/**
|
|
553
|
-
* The Token 银行 card:
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
* host
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
545
|
+
* The Token 银行 card: every provider's retained-ledger usage minted onto the
|
|
546
|
+
* whale-yuan note at 1,000,000 tokens per whale yuan. The window is the whole
|
|
547
|
+
* retained ledger by default (the host's aggregate, falling back to the 30-day
|
|
548
|
+
* trend when an older host serves no `all`) or the single day the user picked;
|
|
549
|
+
* the spend line prefers the official balance watch (whole window only) and
|
|
550
|
+
* falls back to the fold-time estimate, which only the priced family feeds;
|
|
551
|
+
* the artwork draw failure degrades to an error line and never takes the
|
|
552
|
+
* section down.
|
|
561
553
|
*/
|
|
562
|
-
function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: ObservedSpendView; loading?: boolean; error?: string }): ReactNode {
|
|
563
|
-
const { window: ledger, observedSpend, loading, error } = props
|
|
554
|
+
function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: ObservedSpendView; loading?: boolean; error?: string; display?: SpendDisplay }): ReactNode {
|
|
555
|
+
const { window: ledger, observedSpend, loading, error, display } = props
|
|
564
556
|
const data = voucherData(ledger)
|
|
565
557
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
566
558
|
const [drawError, setDrawError] = useState<string | undefined>(undefined)
|
|
@@ -640,8 +632,8 @@ function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: Obser
|
|
|
640
632
|
</div>
|
|
641
633
|
<span className={styles.muted}>
|
|
642
634
|
{observedSpend !== undefined
|
|
643
|
-
? t('usage.bank.spend.observed', { cost: observedSpend.cny
|
|
644
|
-
: t('usage.bank.spend.estimated', { cost: data.cost
|
|
635
|
+
? t('usage.bank.spend.observed', { cost: formatSpend(observedSpend.cny, display), since: formatDay(observedSpend.since) })
|
|
636
|
+
: t('usage.bank.spend.estimated', { cost: formatSpend(data.cost, display) })}
|
|
645
637
|
</span>
|
|
646
638
|
<span className={styles.muted}>{t('usage.bank.window', { from: data.from, to: data.to })}</span>
|
|
647
639
|
<div className={styles.buttonRow}>
|
|
@@ -704,7 +696,7 @@ function SettingsRow(props: {
|
|
|
704
696
|
const disabled = snapshot === undefined || !snapshot.writable
|
|
705
697
|
const [failure, setFailure] = useState<string | undefined>(undefined)
|
|
706
698
|
|
|
707
|
-
const write = (field: 'enabled' | 'pollIntervalSec', next: boolean | number): void => {
|
|
699
|
+
const write = (field: 'enabled' | 'pollIntervalSec' | 'spendCurrency' | 'cnyPerUsd', next: boolean | number | 'CNY' | 'USD'): void => {
|
|
708
700
|
setFailure(undefined)
|
|
709
701
|
let answer: Promise<boolean>
|
|
710
702
|
try {
|
|
@@ -749,6 +741,34 @@ function SettingsRow(props: {
|
|
|
749
741
|
}}
|
|
750
742
|
/>
|
|
751
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>
|
|
752
772
|
</div>
|
|
753
773
|
{failure !== undefined && (
|
|
754
774
|
<span className={styles.errorLine} role="status">
|
|
@@ -22,55 +22,179 @@ import { UsageFootCard, type UsageFootCardProps } from './UsageFootCard.tsx'
|
|
|
22
22
|
/** Stable data attribute identifying the injected card container. */
|
|
23
23
|
export const FOOT_CARD_SELECTOR = '[data-dsh-usage-foot-card]'
|
|
24
24
|
|
|
25
|
+
/** The shell slot whose occupant is the settings shell (and carries its store). */
|
|
26
|
+
const SETTINGS_SEAT_SLOT = 'sidebar.settings'
|
|
27
|
+
|
|
28
|
+
/** The shell sidebar column, which also scopes the settings seat. */
|
|
29
|
+
const SIDEBAR_SELECTOR = '[data-pane="sidebar"], [class*="sidebarCol"]'
|
|
30
|
+
|
|
31
|
+
/** The shell's settings row: the launcher seat plus the Settings trigger fallback. */
|
|
32
|
+
const SETTINGS_AREA_SELECTOR = '[class*="settingsArea"]'
|
|
33
|
+
|
|
34
|
+
/** A launcher that opens the settings dialog by itself (the shell's own gear). */
|
|
35
|
+
const DIALOG_TRIGGER_SELECTOR = 'button[aria-haspopup="dialog"]'
|
|
36
|
+
|
|
37
|
+
/** A launcher that opens a menu whose settings item opens the dialog (the account row). */
|
|
38
|
+
const MENU_ITEM_SELECTOR = '[role="menu"] button[role="menuitem"]'
|
|
39
|
+
|
|
40
|
+
/** One shell frame of the replay poll: menus and the panel mount a frame later. */
|
|
41
|
+
const REPLAY_STEP_MS = 50
|
|
42
|
+
|
|
43
|
+
/** Replay poll budget in steps (about one second of shell frames). */
|
|
44
|
+
const REPLAY_STEPS = 20
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The settings shell's store handle, narrowed to the one write this module
|
|
48
|
+
* performs. A slot registration stores its seat type-erased (`StoreDecl`), so
|
|
49
|
+
* the shape the seat's occupant guarantees is restated here.
|
|
50
|
+
*/
|
|
51
|
+
export interface SettingsShellStore {
|
|
52
|
+
create(): { actions: { openSection(id: string): void } }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The registry read the seat lookup needs (structurally `ctx.slots`). */
|
|
56
|
+
export interface SlotSeatReader {
|
|
57
|
+
entries(key: string): readonly { store?: unknown }[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What the card knows about the settings section it opens. */
|
|
61
|
+
export interface UsageSettingsTarget {
|
|
62
|
+
/** Slot registration id of the usage section (the nav row the panel selects). */
|
|
63
|
+
id: string
|
|
64
|
+
/** The section's nav label in the active locale (the DOM replay's row pick). */
|
|
65
|
+
label: () => string
|
|
66
|
+
/** The settings shell's store handle, when the seat exposes one. */
|
|
67
|
+
store?: SettingsShellStore | undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The shell sidebar column, when the shell has mounted one. */
|
|
71
|
+
function sidebarColumn(): HTMLElement | undefined {
|
|
72
|
+
return document.querySelector<HTMLElement>(SIDEBAR_SELECTOR) ?? undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
25
75
|
/** The shell sidebar's foot area (footer actions + the Settings row), when mounted. */
|
|
26
76
|
function footArea(): HTMLElement | undefined {
|
|
27
|
-
|
|
28
|
-
|
|
77
|
+
return sidebarColumn()?.querySelector<HTMLElement>('[class*="footArea"]') ?? undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the settings shell's store handle from the `sidebar.settings` seat.
|
|
82
|
+
*
|
|
83
|
+
* The seat is a single-occupant slot and the shell's own registration carries
|
|
84
|
+
* the store that owns the panel's open state and active section, so reading it
|
|
85
|
+
* is the one step that opens the panel without touching the DOM. A shell that
|
|
86
|
+
* exposes no seat, a seat without a store, or a registry answering mid-teardown
|
|
87
|
+
* all yield undefined, and the caller replays the DOM path instead.
|
|
88
|
+
* @param slots - the renderer-owned slot registry.
|
|
89
|
+
* @returns the store handle, or undefined when the seat carries none.
|
|
90
|
+
*/
|
|
91
|
+
export function settingsShellStore(slots: SlotSeatReader | undefined): SettingsShellStore | undefined {
|
|
92
|
+
let seat: unknown
|
|
93
|
+
try {
|
|
94
|
+
seat = slots?.entries(SETTINGS_SEAT_SLOT)[0]?.store
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
if (seat === undefined || seat === null) return undefined
|
|
99
|
+
return typeof (seat as { create?: unknown }).create === 'function'
|
|
100
|
+
? seat as SettingsShellStore
|
|
101
|
+
: undefined
|
|
29
102
|
}
|
|
30
103
|
|
|
31
104
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* with the panel already open only the nav pick runs, and a missing row
|
|
37
|
-
* leaves the panel on its default section.
|
|
38
|
-
* @param label - the usage section's nav label in the active locale.
|
|
105
|
+
* Drive the shell's settings store onto the usage section.
|
|
106
|
+
* @param store - the seat's store handle.
|
|
107
|
+
* @param id - the section's slot registration id.
|
|
108
|
+
* @returns true when the store accepted the call, false when it threw.
|
|
39
109
|
*/
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
110
|
+
function openThroughStore(store: SettingsShellStore, id: string): boolean {
|
|
111
|
+
try {
|
|
112
|
+
store.create().actions.openSection(id)
|
|
113
|
+
return true
|
|
114
|
+
} catch {
|
|
115
|
+
// A foreign shell exposing an unrelated seat: the DOM replay still runs.
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Click the nav row carrying the section's label inside the open panel.
|
|
122
|
+
* @param panel - the open settings dialog.
|
|
123
|
+
* @param label - the section's nav label in the active locale.
|
|
124
|
+
*/
|
|
125
|
+
function pickSectionRow(panel: Element, label: () => string): void {
|
|
126
|
+
const wanted = label()
|
|
127
|
+
for (const row of panel.querySelectorAll<HTMLButtonElement>('nav button')) {
|
|
128
|
+
if (row.textContent !== null && row.textContent.includes(wanted)) {
|
|
129
|
+
row.click()
|
|
130
|
+
return
|
|
49
131
|
}
|
|
50
|
-
// Panel open but the usage row is gone (plugin toggled off): stay put.
|
|
51
132
|
}
|
|
52
|
-
//
|
|
133
|
+
// Panel open but the usage row is gone (plugin toggled off): stay put.
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Poll the page over the shell's next frames until a lookup yields a value.
|
|
138
|
+
* @param find - the lookup, run once per step.
|
|
139
|
+
* @param steps - remaining frame budget.
|
|
140
|
+
* @returns the first value found, or undefined once the budget is spent.
|
|
141
|
+
*/
|
|
142
|
+
function until<T>(find: () => T | null | undefined, steps = REPLAY_STEPS): Promise<T | undefined> {
|
|
143
|
+
const found = find()
|
|
144
|
+
if (found !== undefined && found !== null) return Promise.resolve(found)
|
|
145
|
+
if (steps <= 0) return Promise.resolve(undefined)
|
|
146
|
+
return new Promise<void>((resolve) => { window.setTimeout(resolve, REPLAY_STEP_MS) })
|
|
147
|
+
.then(() => until(find, steps - 1))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Replay the user's own settings path, one shell frame at a time.
|
|
152
|
+
* @param launcher - the settings seat's trigger.
|
|
153
|
+
* @param label - the section's nav label in the active locale.
|
|
154
|
+
*/
|
|
155
|
+
async function replay(launcher: HTMLButtonElement, label: () => string): Promise<void> {
|
|
156
|
+
launcher.click()
|
|
157
|
+
// A launcher that opens a menu (an account row occupying settings.launcher)
|
|
158
|
+
// reaches Settings through its first item; the shell's own trigger opens the
|
|
159
|
+
// panel directly.
|
|
160
|
+
if (launcher.getAttribute('aria-haspopup') === 'menu') {
|
|
161
|
+
const item = await until(() => document.querySelector<HTMLButtonElement>(MENU_ITEM_SELECTOR))
|
|
162
|
+
if (item === undefined) return
|
|
163
|
+
item.click()
|
|
164
|
+
}
|
|
165
|
+
const panel = await until(() => document.querySelector('[role="dialog"]'))
|
|
166
|
+
if (panel !== undefined) pickSectionRow(panel, label)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Open the settings panel on the usage section.
|
|
171
|
+
*
|
|
172
|
+
* The seat's store is the primary path: `openSection` opens the dialog on the
|
|
173
|
+
* usage row in one call, with no dependence on which plugin occupies the
|
|
174
|
+
* `settings.launcher` seat — an account row there turns a trigger click into a
|
|
175
|
+
* menu, which is how this click used to land on the account menu instead of the
|
|
176
|
+
* panel. Without such a store the card replays the user's own path: activate
|
|
177
|
+
* the launcher (preferring the dialog trigger over a menu trigger), follow a
|
|
178
|
+
* menu launcher's first settings item, then pick the nav row carrying the
|
|
179
|
+
* section's label. Every step degrades silently — a missing surface leaves the
|
|
180
|
+
* page untouched, and with the panel already open only the nav pick runs.
|
|
181
|
+
* @param target - the section id, its localized label, and the seat's store.
|
|
182
|
+
*/
|
|
183
|
+
export function openUsageSettings(target: UsageSettingsTarget): void {
|
|
184
|
+
const { id, label, store } = target
|
|
185
|
+
if (store !== undefined && openThroughStore(store, id)) return
|
|
186
|
+
// A panel that is already open must never be toggled shut by the launcher;
|
|
53
187
|
// only the nav pick runs.
|
|
54
188
|
const open = document.querySelector('[role="dialog"]')
|
|
55
189
|
if (open !== null) {
|
|
56
|
-
|
|
190
|
+
pickSectionRow(open, label)
|
|
57
191
|
return
|
|
58
192
|
}
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const panel = document.querySelector('[role="dialog"]')
|
|
65
|
-
if (panel !== null) {
|
|
66
|
-
pick(panel)
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
tries += 1
|
|
70
|
-
// The panel mounts on the shell's next frames; wait briefly, then give up.
|
|
71
|
-
if (tries <= 20) window.setTimeout(attempt, 50)
|
|
72
|
-
}
|
|
73
|
-
window.setTimeout(attempt, 0)
|
|
193
|
+
const area = sidebarColumn()?.querySelector<HTMLElement>(SETTINGS_AREA_SELECTOR)
|
|
194
|
+
const launcher = area?.querySelector<HTMLButtonElement>(DIALOG_TRIGGER_SELECTOR)
|
|
195
|
+
?? area?.querySelector<HTMLButtonElement>('button')
|
|
196
|
+
if (launcher === undefined || launcher === null) return
|
|
197
|
+
void replay(launcher, label)
|
|
74
198
|
}
|
|
75
199
|
|
|
76
200
|
/**
|
|
@@ -93,7 +217,7 @@ export function mountUsageFootCard(props: UsageFootCardProps): () => void {
|
|
|
93
217
|
const place = (): void => {
|
|
94
218
|
const foot = footArea()
|
|
95
219
|
if (foot === undefined) return
|
|
96
|
-
const settings = foot.querySelector<HTMLElement>(
|
|
220
|
+
const settings = foot.querySelector<HTMLElement>(SETTINGS_AREA_SELECTOR)
|
|
97
221
|
if (settings !== null) {
|
|
98
222
|
if (container.nextElementSibling !== settings) foot.insertBefore(container, settings)
|
|
99
223
|
return
|
package/src/client/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ 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, openUsageSettings } from './foot-card-mount.tsx'
|
|
22
|
+
import { mountUsageFootCard, openUsageSettings, settingsShellStore } from './foot-card-mount.tsx'
|
|
23
23
|
import { NS, en, zh, t } from './locales.ts'
|
|
24
24
|
import { createServedEntryForm } from './settings-entry-form.ts'
|
|
25
25
|
import type { UsageDayResponse, UsageDayView, UsageOverviewView } from '../core/types.ts'
|
|
@@ -68,6 +68,9 @@ const USAGE_ENTRY_IDS: readonly string[] = [AGGREGATE_ENTRY_ID, 'usage', USAGE_S
|
|
|
68
68
|
/** First-level nav position: directly below the Workshop section (order 150). */
|
|
69
69
|
const SECTION_ORDER = 151
|
|
70
70
|
|
|
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
|
+
|
|
71
74
|
/** Required services. */
|
|
72
75
|
export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote']
|
|
73
76
|
|
|
@@ -174,7 +177,13 @@ export function apply(ctx: ClientContext): void {
|
|
|
174
177
|
store,
|
|
175
178
|
poll,
|
|
176
179
|
settings: settingsForm,
|
|
177
|
-
onOpen: () => {
|
|
180
|
+
onOpen: () => {
|
|
181
|
+
openUsageSettings({
|
|
182
|
+
id: USAGE_SECTION_ID,
|
|
183
|
+
label: () => t('usage.title'),
|
|
184
|
+
store: settingsShellStore(ctx.slots),
|
|
185
|
+
})
|
|
186
|
+
},
|
|
178
187
|
locale: ctx.locale,
|
|
179
188
|
})
|
|
180
189
|
ctx.effect(() => () => {
|
|
@@ -189,7 +198,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
189
198
|
try {
|
|
190
199
|
const unregister = ctx.slots.register({
|
|
191
200
|
name: 'settings.section',
|
|
192
|
-
id:
|
|
201
|
+
id: USAGE_SECTION_ID,
|
|
193
202
|
order: SECTION_ORDER,
|
|
194
203
|
label: () => ctx.locale.bind(NS)('usage.title'),
|
|
195
204
|
locale: NS,
|
package/src/client/locales.ts
CHANGED
|
@@ -31,6 +31,10 @@ export const zh = {
|
|
|
31
31
|
'usage.day.error': '该日用量加载失败:{error}',
|
|
32
32
|
'usage.peak.on': 'DeepSeek 高峰时段:计价 ×2,{time} 结束',
|
|
33
33
|
'usage.peak.off': 'DeepSeek 空闲时段:计价为高峰一半,{time} 进入高峰',
|
|
34
|
+
'usage.peak.tag.on': '高峰',
|
|
35
|
+
'usage.peak.tag.off': '空闲',
|
|
36
|
+
'usage.peak.ends': '{time} 结束',
|
|
37
|
+
'usage.peak.starts': '{time} 进入高峰',
|
|
34
38
|
'usage.calls': '{n} 次调用',
|
|
35
39
|
'usage.tokens.total': '总 tokens',
|
|
36
40
|
'usage.tokens.input': '输入',
|
|
@@ -55,13 +59,15 @@ export const zh = {
|
|
|
55
59
|
'usage.config.title': '设置',
|
|
56
60
|
'usage.config.enabled': '启用插件',
|
|
57
61
|
'usage.config.pollIntervalSec': '轮询间隔(秒)',
|
|
62
|
+
'usage.config.spendCurrency': '消费显示货币',
|
|
63
|
+
'usage.config.cnyPerUsd': '汇率(1 美元兑人民币)',
|
|
58
64
|
'usage.config.saveFailed': '保存失败:设置未被接受。',
|
|
59
65
|
'usage.bank.title': '鲸元券',
|
|
60
|
-
'usage.bank.hint': '
|
|
61
|
-
'usage.bank.noUsage': '
|
|
66
|
+
'usage.bank.hint': '所有模型每消耗 100 万 tokens 铸造 1 鲸元;保存或分享这张票券。',
|
|
67
|
+
'usage.bank.noUsage': '暂无用量数据',
|
|
62
68
|
'usage.bank.minted': '累计铸造 {minted} 鲸元({tokens} tokens)',
|
|
63
|
-
'usage.bank.spend.observed': '官方余额实测花费
|
|
64
|
-
'usage.bank.spend.estimated': '消费估算:约
|
|
69
|
+
'usage.bank.spend.observed': '官方余额实测花费 {cost}(自 {since} 起)',
|
|
70
|
+
'usage.bank.spend.estimated': '消费估算:约 {cost}',
|
|
65
71
|
'usage.bank.window': '统计窗口 {from} ~ {to}',
|
|
66
72
|
'usage.bank.windowAll': '全部保留期',
|
|
67
73
|
'usage.bank.save': '保存图片',
|
|
@@ -98,6 +104,10 @@ export const en: Record<UsageKey, string> = {
|
|
|
98
104
|
'usage.day.error': 'Failed to load that day: {error}',
|
|
99
105
|
'usage.peak.on': 'DeepSeek peak hours: 2x pricing, ends {time}',
|
|
100
106
|
'usage.peak.off': 'DeepSeek off-peak: half of peak pricing, peak returns {time}',
|
|
107
|
+
'usage.peak.tag.on': 'Peak',
|
|
108
|
+
'usage.peak.tag.off': 'Off-peak',
|
|
109
|
+
'usage.peak.ends': 'ends {time}',
|
|
110
|
+
'usage.peak.starts': 'peak at {time}',
|
|
101
111
|
'usage.calls': '{n} calls',
|
|
102
112
|
'usage.tokens.total': 'Total tokens',
|
|
103
113
|
'usage.tokens.input': 'Input',
|
|
@@ -122,13 +132,15 @@ export const en: Record<UsageKey, string> = {
|
|
|
122
132
|
'usage.config.title': 'Settings',
|
|
123
133
|
'usage.config.enabled': 'Enable plugin',
|
|
124
134
|
'usage.config.pollIntervalSec': 'Poll interval (seconds)',
|
|
135
|
+
'usage.config.spendCurrency': 'Spend display currency',
|
|
136
|
+
'usage.config.cnyPerUsd': 'Exchange rate (CNY per USD)',
|
|
125
137
|
'usage.config.saveFailed': 'Save failed: the deployment did not accept the setting.',
|
|
126
138
|
'usage.bank.title': 'Whale-yuan voucher',
|
|
127
|
-
'usage.bank.hint': 'Every 1,000,000 tokens spent
|
|
128
|
-
'usage.bank.noUsage': 'No
|
|
139
|
+
'usage.bank.hint': 'Every 1,000,000 tokens spent across all models mint one whale yuan; save or share the note.',
|
|
140
|
+
'usage.bank.noUsage': 'No usage data yet',
|
|
129
141
|
'usage.bank.minted': 'Minted {minted} whale yuan ({tokens} tokens)',
|
|
130
|
-
'usage.bank.spend.observed': 'Spent
|
|
131
|
-
'usage.bank.spend.estimated': 'Estimated spend: about
|
|
142
|
+
'usage.bank.spend.observed': 'Spent {cost} observed on the official balance (watching since {since})',
|
|
143
|
+
'usage.bank.spend.estimated': 'Estimated spend: about {cost}',
|
|
132
144
|
'usage.bank.window': 'Window {from} - {to}',
|
|
133
145
|
'usage.bank.windowAll': 'All retained days',
|
|
134
146
|
'usage.bank.save': 'Save image',
|