@linxin666/dsh-usage 0.4.8 → 0.4.10
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 +3 -3
- package/README.zh.md +3 -3
- package/lib/client.js +256 -110
- package/lib/client.js.map +1 -1
- package/lib/index.js +60 -6
- 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 +2 -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 +21 -0
- 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/UsageFootCard.tsx +3 -2
- package/src/client/UsageSectionCard.tsx +50 -21
- package/src/client/foot-card-mount.tsx +161 -37
- package/src/client/index.ts +12 -3
- package/src/client/locales.ts +12 -8
- 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 +32 -0
- package/src/host/pair-access.ts +26 -3
- package/src/host/usage-service.ts +9 -0
- package/src/index.ts +17 -0
|
@@ -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.
|
|
@@ -18,11 +18,16 @@ import { isDeepSeekProviderRoute } from '../core/adapters.ts'
|
|
|
18
18
|
import { deepseekPeriodAt } from '../core/pricing.ts'
|
|
19
19
|
import { voucherData, drawVoucher, faceValue, formatDay, formatDenomination, loadVoucherArt } from './voucher.ts'
|
|
20
20
|
import type { ObservedSpendView, ProviderSnapshotView, UsageDayView, UsageOverviewView, UsageProviderSummary, UsageTokenTotals, UsageWindowSummary } from '../core/types.ts'
|
|
21
|
+
import { formatSpend, type SpendDisplay } from '../core/spend.ts'
|
|
21
22
|
|
|
22
23
|
/** The settings fields this section edits (immediate-apply semantics). */
|
|
23
24
|
export interface UsageSettings {
|
|
24
25
|
enabled?: boolean
|
|
25
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
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
/** The registration-side face the section's slot entry injects. */
|
|
@@ -78,11 +83,6 @@ function formatTime(ms: number): string {
|
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
/** Formatted CNY spend estimate (the only priced currency today). */
|
|
82
|
-
function formatCost(cost: number): string {
|
|
83
|
-
return '¥' + cost.toFixed(2)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
86
|
function toneClass(percent: number): string {
|
|
87
87
|
if (percent >= 90) return styles.barLow
|
|
88
88
|
if (percent >= 70) return styles.barWarn
|
|
@@ -394,7 +394,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
394
394
|
{(day?.totals.cost ?? 0) > 0 && (
|
|
395
395
|
<div className={styles.providerRow} data-dsh-part="today-cost">
|
|
396
396
|
<span className={styles.providerName}>{isToday ? t('usage.today.cost') : t('usage.day.cost')}</span>
|
|
397
|
-
<span className={styles.providerTokens}>{
|
|
397
|
+
<span className={styles.providerTokens}>{formatSpend(day?.totals.cost ?? 0, snapshot.display)}</span>
|
|
398
398
|
</div>
|
|
399
399
|
)}
|
|
400
400
|
{(day?.providers.length ?? 0) > 0 && (
|
|
@@ -405,7 +405,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
405
405
|
{snapshot.providers.find((provider) => provider.provider === row.provider)?.displayName ?? row.provider}
|
|
406
406
|
{current.provider === row.provider && <span className={styles.currentBadge}>{t('usage.current')}</span>}
|
|
407
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 ? ` · ${
|
|
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
409
|
</div>
|
|
410
410
|
))}
|
|
411
411
|
</div>
|
|
@@ -463,6 +463,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
463
463
|
// voucher falls back to that day's fold-time estimate.
|
|
464
464
|
observedSpend={bankSelection === undefined ? snapshot.usage.observedSpend : undefined}
|
|
465
465
|
loading={bankSelection !== undefined && bankDayLoad?.status === 'loading'}
|
|
466
|
+
display={snapshot.display}
|
|
466
467
|
{...(bankDayLoad?.status === 'error' ? { error: bankDayLoad.error ?? '' } : {})}
|
|
467
468
|
/>
|
|
468
469
|
</>
|
|
@@ -541,17 +542,17 @@ function totalOf(totals: UsageTokenTotals): number {
|
|
|
541
542
|
}
|
|
542
543
|
|
|
543
544
|
/**
|
|
544
|
-
* The Token 银行 card:
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
* host
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
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.
|
|
552
553
|
*/
|
|
553
|
-
function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: ObservedSpendView; loading?: boolean; error?: string }): ReactNode {
|
|
554
|
-
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
|
|
555
556
|
const data = voucherData(ledger)
|
|
556
557
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
557
558
|
const [drawError, setDrawError] = useState<string | undefined>(undefined)
|
|
@@ -631,8 +632,8 @@ function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: Obser
|
|
|
631
632
|
</div>
|
|
632
633
|
<span className={styles.muted}>
|
|
633
634
|
{observedSpend !== undefined
|
|
634
|
-
? t('usage.bank.spend.observed', { cost: observedSpend.cny
|
|
635
|
-
: 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) })}
|
|
636
637
|
</span>
|
|
637
638
|
<span className={styles.muted}>{t('usage.bank.window', { from: data.from, to: data.to })}</span>
|
|
638
639
|
<div className={styles.buttonRow}>
|
|
@@ -695,7 +696,7 @@ function SettingsRow(props: {
|
|
|
695
696
|
const disabled = snapshot === undefined || !snapshot.writable
|
|
696
697
|
const [failure, setFailure] = useState<string | undefined>(undefined)
|
|
697
698
|
|
|
698
|
-
const write = (field: 'enabled' | 'pollIntervalSec', next: boolean | number): void => {
|
|
699
|
+
const write = (field: 'enabled' | 'pollIntervalSec' | 'spendCurrency' | 'cnyPerUsd', next: boolean | number | 'CNY' | 'USD'): void => {
|
|
699
700
|
setFailure(undefined)
|
|
700
701
|
let answer: Promise<boolean>
|
|
701
702
|
try {
|
|
@@ -740,6 +741,34 @@ function SettingsRow(props: {
|
|
|
740
741
|
}}
|
|
741
742
|
/>
|
|
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>
|
|
743
772
|
</div>
|
|
744
773
|
{failure !== undefined && (
|
|
745
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
|
@@ -59,13 +59,15 @@ export const zh = {
|
|
|
59
59
|
'usage.config.title': '设置',
|
|
60
60
|
'usage.config.enabled': '启用插件',
|
|
61
61
|
'usage.config.pollIntervalSec': '轮询间隔(秒)',
|
|
62
|
+
'usage.config.spendCurrency': '消费显示货币',
|
|
63
|
+
'usage.config.cnyPerUsd': '汇率(1 美元兑人民币)',
|
|
62
64
|
'usage.config.saveFailed': '保存失败:设置未被接受。',
|
|
63
65
|
'usage.bank.title': '鲸元券',
|
|
64
|
-
'usage.bank.hint': '
|
|
65
|
-
'usage.bank.noUsage': '
|
|
66
|
+
'usage.bank.hint': '所有模型每消耗 100 万 tokens 铸造 1 鲸元;保存或分享这张票券。',
|
|
67
|
+
'usage.bank.noUsage': '暂无用量数据',
|
|
66
68
|
'usage.bank.minted': '累计铸造 {minted} 鲸元({tokens} tokens)',
|
|
67
|
-
'usage.bank.spend.observed': '官方余额实测花费
|
|
68
|
-
'usage.bank.spend.estimated': '消费估算:约
|
|
69
|
+
'usage.bank.spend.observed': '官方余额实测花费 {cost}(自 {since} 起)',
|
|
70
|
+
'usage.bank.spend.estimated': '消费估算:约 {cost}',
|
|
69
71
|
'usage.bank.window': '统计窗口 {from} ~ {to}',
|
|
70
72
|
'usage.bank.windowAll': '全部保留期',
|
|
71
73
|
'usage.bank.save': '保存图片',
|
|
@@ -130,13 +132,15 @@ export const en: Record<UsageKey, string> = {
|
|
|
130
132
|
'usage.config.title': 'Settings',
|
|
131
133
|
'usage.config.enabled': 'Enable plugin',
|
|
132
134
|
'usage.config.pollIntervalSec': 'Poll interval (seconds)',
|
|
135
|
+
'usage.config.spendCurrency': 'Spend display currency',
|
|
136
|
+
'usage.config.cnyPerUsd': 'Exchange rate (CNY per USD)',
|
|
133
137
|
'usage.config.saveFailed': 'Save failed: the deployment did not accept the setting.',
|
|
134
138
|
'usage.bank.title': 'Whale-yuan voucher',
|
|
135
|
-
'usage.bank.hint': 'Every 1,000,000 tokens spent
|
|
136
|
-
'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',
|
|
137
141
|
'usage.bank.minted': 'Minted {minted} whale yuan ({tokens} tokens)',
|
|
138
|
-
'usage.bank.spend.observed': 'Spent
|
|
139
|
-
'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}',
|
|
140
144
|
'usage.bank.window': 'Window {from} - {to}',
|
|
141
145
|
'usage.bank.windowAll': 'All retained days',
|
|
142
146
|
'usage.bank.save': 'Save image',
|
package/src/client/voucher.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Whale-yuan voucher (鲸元券): the
|
|
2
|
+
* The Whale-yuan voucher (鲸元券): the profile's retained ledger totals
|
|
3
3
|
* stamped onto the banknote artwork as a denomination, ready
|
|
4
4
|
* to save or share. The canvas copy is deliberately locale-neutral (digits,
|
|
5
5
|
* latin captions, ISO dates) so the exported image needs no dictionary;
|
|
@@ -16,11 +16,11 @@ import { VOUCHER_ART_DATA_URL } from './voucher-art.generated.ts'
|
|
|
16
16
|
|
|
17
17
|
/** The facts stamped onto one voucher. */
|
|
18
18
|
export interface VoucherData {
|
|
19
|
-
/**
|
|
19
|
+
/** Every provider's total tokens over the retained window. */
|
|
20
20
|
tokens: number
|
|
21
|
-
/** Provider calls that reported usage. */
|
|
21
|
+
/** Provider calls that reported usage, summed across every provider. */
|
|
22
22
|
calls: number
|
|
23
|
-
/** Fold-time spend estimate in CNY; only the priced family contributes. */
|
|
23
|
+
/** Fold-time spend estimate in CNY; only the priced (official DeepSeek) family contributes. */
|
|
24
24
|
cost: number
|
|
25
25
|
/** Retained window bounds (local date keys, inclusive). */
|
|
26
26
|
from: string
|
|
@@ -44,32 +44,23 @@ export function formatDay(ms: number): string {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
export function mintsWhaleYuan(provider: string): boolean {
|
|
60
|
-
return isDeepSeekProviderRoute(provider) || EXTRA_MINT_ROUTE_IDS.includes(provider)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Sum the mint family's rows out of a usage window: the whole DeepSeek
|
|
65
|
-
* official family (the `deepseek` catalog alias, the live `deepseek-official`
|
|
66
|
-
* route and the signed-in `deepseek-account` route all fold into one voucher)
|
|
67
|
-
* plus {@link EXTRA_MINT_ROUTE_IDS}. Undefined when the window is absent
|
|
68
|
-
* (older host) or the family has no usage — a zero-token note mints nothing.
|
|
47
|
+
* Sum every provider's rows out of a usage window: the note is a
|
|
48
|
+
* proof-of-spend for what the profile actually used, so the tokens and calls
|
|
49
|
+
* of every route in the ledger mint — the DeepSeek official family (the
|
|
50
|
+
* `deepseek` catalog alias, the live `deepseek-official` route and the
|
|
51
|
+
* signed-in `deepseek-account` route) beside every subscription and relay
|
|
52
|
+
* route. Undefined when the window is absent (older host) or nothing was used
|
|
53
|
+
* — a zero-token note mints nothing.
|
|
54
|
+
*
|
|
55
|
+
* Minting is an accounting decision over ledger rows, so it reads no adapter:
|
|
56
|
+
* widening it must never widen the credential or balance-probe paths, which
|
|
57
|
+
* keep reading `adapterFor()` (the split `isDeepSeekProviderRoute()` also
|
|
58
|
+
* keeps, issue #1772).
|
|
69
59
|
*
|
|
70
|
-
* Only the priced family contributes `cost`:
|
|
71
|
-
*
|
|
72
|
-
* family's fold-time estimate (and the observed-balance
|
|
60
|
+
* Only the priced family contributes `cost`: subscription and relay routes
|
|
61
|
+
* ship no price book, so they mint tokens and calls while the spend line keeps
|
|
62
|
+
* reporting the official family's fold-time estimate (and the observed-balance
|
|
63
|
+
* watch above it).
|
|
73
64
|
*/
|
|
74
65
|
export function voucherData(window: UsageWindowSummary | undefined): VoucherData | undefined {
|
|
75
66
|
if (window === undefined) return undefined
|
|
@@ -77,7 +68,6 @@ export function voucherData(window: UsageWindowSummary | undefined): VoucherData
|
|
|
77
68
|
let calls = 0
|
|
78
69
|
let cost = 0
|
|
79
70
|
for (const row of window.providers) {
|
|
80
|
-
if (!mintsWhaleYuan(row.provider)) continue
|
|
81
71
|
tokens += totalTokens(row.totals)
|
|
82
72
|
calls += row.totals.calls
|
|
83
73
|
if (isDeepSeekProviderRoute(row.provider)) cost += row.totals.cost
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend display: the ledger keeps every cost estimate in the priced provider's
|
|
3
|
+
* billing currency (CNY), and that stored value never moves. This module turns
|
|
4
|
+
* one CNY figure into the currency the user chose to READ it in, so switching
|
|
5
|
+
* the display is a rendering decision, not a data migration (issue #1937).
|
|
6
|
+
* @module @linxin666/dsh-usage/core/spend
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A currency the spend display can render. */
|
|
10
|
+
export type SpendCurrency = 'CNY' | 'USD'
|
|
11
|
+
|
|
12
|
+
/** How to read a stored CNY cost. */
|
|
13
|
+
export interface SpendDisplay {
|
|
14
|
+
/** The currency to render in. */
|
|
15
|
+
currency: SpendCurrency
|
|
16
|
+
/** CNY per USD; consulted only when the display currency is USD. */
|
|
17
|
+
cnyPerUsd: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The rate used when the row configures none: a plain default, so an
|
|
22
|
+
* unconfigured display still has a defined美元 figure instead of a blank card.
|
|
23
|
+
* The plugin never fetches a live rate (its host routes are loopback-fenced and
|
|
24
|
+
* its browser half has no network), so there is no fetch that could fail and
|
|
25
|
+
* leave the card empty.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_CNY_PER_USD = 7.2
|
|
28
|
+
|
|
29
|
+
/** The display used when the row carries no setting: the historical CNY rendering. */
|
|
30
|
+
export const DEFAULT_SPEND_DISPLAY: SpendDisplay = { currency: 'CNY', cnyPerUsd: DEFAULT_CNY_PER_USD }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalize a configured display.
|
|
34
|
+
* An unknown currency reads as CNY, and a rate that is not a positive finite
|
|
35
|
+
* number falls back to the default rate — the display must never be able to
|
|
36
|
+
* blank the card.
|
|
37
|
+
* @param input - the configured currency and rate, if any.
|
|
38
|
+
* @returns a display that can always render.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveSpendDisplay(input?: { currency?: unknown; cnyPerUsd?: unknown }): SpendDisplay {
|
|
41
|
+
const currency: SpendCurrency = input?.currency === 'USD' ? 'USD' : 'CNY'
|
|
42
|
+
const rate = typeof input?.cnyPerUsd === 'number' && Number.isFinite(input.cnyPerUsd) && input.cnyPerUsd > 0
|
|
43
|
+
? input.cnyPerUsd
|
|
44
|
+
: DEFAULT_CNY_PER_USD
|
|
45
|
+
return { currency, cnyPerUsd: rate }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Render one stored CNY cost in the display currency.
|
|
50
|
+
* A non-finite or negative cost renders as zero rather than as `NaN`/`¥-0.10`.
|
|
51
|
+
* @param costCny - the stored cost in CNY.
|
|
52
|
+
* @param display - the display to render in; defaults to CNY.
|
|
53
|
+
* @returns the formatted amount with its currency symbol.
|
|
54
|
+
*/
|
|
55
|
+
export function formatSpend(costCny: number, display: SpendDisplay = DEFAULT_SPEND_DISPLAY): string {
|
|
56
|
+
const safe = Number.isFinite(costCny) && costCny > 0 ? costCny : 0
|
|
57
|
+
if (display.currency === 'USD') {
|
|
58
|
+
const rate = Number.isFinite(display.cnyPerUsd) && display.cnyPerUsd > 0 ? display.cnyPerUsd : DEFAULT_CNY_PER_USD
|
|
59
|
+
return '$' + (safe / rate).toFixed(2)
|
|
60
|
+
}
|
|
61
|
+
return '¥' + safe.toFixed(2)
|
|
62
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* @module @linxin666/dsh-usage/core/types
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { SpendDisplay } from './spend.ts'
|
|
10
|
+
|
|
9
11
|
/** One disjoint token bucket total, aggregated over calls (billed input = input + cacheRead + cacheWrite). */
|
|
10
12
|
export interface UsageTokenTotals {
|
|
11
13
|
/** Uncached input tokens. */
|
|
@@ -159,6 +161,12 @@ export interface ProviderSnapshotView {
|
|
|
159
161
|
/** The overview the browser section renders. */
|
|
160
162
|
export interface UsageOverviewView {
|
|
161
163
|
updatedAt: number
|
|
164
|
+
/**
|
|
165
|
+
* How to read every CNY cost in this document. The host stamps the row's
|
|
166
|
+
* configured display here so both browser halves format spend the same way;
|
|
167
|
+
* absent on an older host document, which renders as CNY (issue #1937).
|
|
168
|
+
*/
|
|
169
|
+
display?: SpendDisplay
|
|
162
170
|
providers: ProviderSnapshotView[]
|
|
163
171
|
/** The provider the collapsed sidebar strip and the header highlight; `source` says how it was picked. */
|
|
164
172
|
current: {
|