@linxin666/dsh-usage 0.3.23 → 0.4.0
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 +7 -9
- package/README.zh.md +7 -9
- package/icon.svg +3 -0
- package/lib/client.js +621 -65
- package/lib/client.js.map +1 -1
- package/lib/index.js +258 -271
- package/lib/types/client/UsageFootCard.d.ts +55 -0
- package/lib/types/client/UsageSectionCard.d.ts +3 -4
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/foot-card-mount.d.ts +34 -0
- package/lib/types/client/index.d.ts +21 -8
- package/lib/types/client/locales.d.ts +6 -4
- package/lib/types/core/provider-routes.d.ts +26 -0
- package/lib/types/core/types.d.ts +14 -1
- package/lib/types/host/access.d.ts +17 -0
- package/lib/types/host/http.d.ts +18 -1
- package/lib/types/host/pair-access.d.ts +34 -0
- package/lib/types/host/routes.d.ts +16 -7
- package/lib/types/host/usage-service.d.ts +32 -76
- package/lib/types/index.d.ts +14 -2
- package/package.json +20 -17
- package/src/client/UsageFootCard.tsx +300 -0
- package/src/client/UsageSectionCard.tsx +41 -23
- package/src/client/body-mutations.ts +133 -0
- package/src/client/foot-card-mount.tsx +112 -0
- package/src/client/index.ts +57 -16
- package/src/client/locales.ts +12 -8
- package/src/client/usage.module.css +188 -0
- package/src/core/adapters.ts +40 -6
- package/src/core/provider-routes.ts +37 -0
- package/src/core/types.ts +14 -1
- package/src/host/access.ts +21 -0
- package/src/host/http.ts +23 -1
- package/src/host/pair-access.ts +49 -0
- package/src/host/routes.ts +19 -10
- package/src/host/usage-service.ts +74 -218
- package/src/index.ts +59 -60
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-usage",
|
|
3
|
-
"description": "Usage statistics plugin for the dsh web GUI: per-provider balance and coding-plan quota detection plus a live token usage ledger, with a
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "Usage statistics plugin for the dsh web GUI: per-provider balance and coding-plan quota detection plus a live token usage ledger, with a compact price glance card above the sidebar's Settings row",
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"icon": "icon.svg",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"engines": {
|
|
7
8
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -26,12 +27,13 @@
|
|
|
26
27
|
"lib/**/*.d.ts",
|
|
27
28
|
"lib/**/*.d.ts.map",
|
|
28
29
|
"src",
|
|
29
|
-
"cordis.patch.yml"
|
|
30
|
+
"cordis.patch.yml",
|
|
31
|
+
"icon.svg"
|
|
30
32
|
],
|
|
31
33
|
"license": "Apache-2.0",
|
|
32
34
|
"dsh": {
|
|
33
35
|
"engines": {
|
|
34
|
-
"dsh": ">=0.1.
|
|
36
|
+
"dsh": ">=0.1.7-rc.1"
|
|
35
37
|
},
|
|
36
38
|
"bundle": {
|
|
37
39
|
"patch": "./cordis.patch.yml"
|
|
@@ -51,26 +53,27 @@
|
|
|
51
53
|
"schemastery": "^3.18.0"
|
|
52
54
|
},
|
|
53
55
|
"peerDependencies": {
|
|
56
|
+
"@deepseek-ai/dsh": ">=0.1.7-rc.1",
|
|
54
57
|
"react": "^18.2.0"
|
|
55
58
|
},
|
|
56
59
|
"devDependencies": {
|
|
57
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
58
|
-
"@deepseek-ai/dsh-credentials": "^0.1.
|
|
59
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.
|
|
60
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.
|
|
61
|
-
"@deepseek-ai/dsh-client-store": "^0.1.
|
|
62
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.
|
|
63
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.
|
|
64
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
65
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.
|
|
66
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
67
|
-
"@deepseek-ai/dsh-session": "^0.1.
|
|
68
|
-
"@deepseek-ai/dsh-settings": "^0.1.
|
|
69
|
-
"@linxin666/dsh-pet": "0.3.23",
|
|
60
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
61
|
+
"@deepseek-ai/dsh-credentials": "^0.1.7-rc.1",
|
|
62
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.7-rc.1",
|
|
63
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
|
|
64
|
+
"@deepseek-ai/dsh-client-store": "^0.1.7-rc.1",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.1",
|
|
67
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
|
|
68
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
|
|
69
|
+
"@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
|
|
70
|
+
"@deepseek-ai/dsh-session": "^0.1.7-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
|
|
70
72
|
"@testing-library/dom": "^10.4.1",
|
|
71
73
|
"@testing-library/react": "^16.3.2",
|
|
72
74
|
"@types/node": "^22.20.0",
|
|
73
75
|
"@types/react": "~18.3.1",
|
|
76
|
+
"@types/react-dom": "^18.3.5",
|
|
74
77
|
"jsdom": "29.1.1",
|
|
75
78
|
"react": "^18.2.0",
|
|
76
79
|
"react-dom": "^18.2.0",
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar foot card: the compact usage glance seated below the shell sidebar's
|
|
3
|
+
* Settings row. It is deliberately a GLANCE, not the panel the 2026-09-18
|
|
4
|
+
* removal took out. Two states, persisted across reloads:
|
|
5
|
+
*
|
|
6
|
+
* - expanded (default): a price-first headline (today's estimated spend,
|
|
7
|
+
* falling back to today's tokens when nothing priced was recorded), a
|
|
8
|
+
* tokens/calls line, up to two configured-provider balances, and an
|
|
9
|
+
* updated-at footer;
|
|
10
|
+
* - collapsed: a one-line strip with the gauge glyph, the spending
|
|
11
|
+
* provider's name, the label and the headline value.
|
|
12
|
+
*
|
|
13
|
+
* The card body is one button that opens the settings panel on the usage
|
|
14
|
+
* section, so detail lives in exactly one place; the corner chevron toggles
|
|
15
|
+
* the collapse state (sibling buttons — buttons inside a button are invalid
|
|
16
|
+
* HTML).
|
|
17
|
+
*
|
|
18
|
+
* Data comes from the same shared store the settings section reads; the card
|
|
19
|
+
* runs its own relaxed poll loop (30 s, visible-tab only) because the sidebar
|
|
20
|
+
* foot is permanently mounted. The card disappears while the plugin is
|
|
21
|
+
* disabled (settings flag) or while the host serves no usage routes (a 404
|
|
22
|
+
* means the host half is off), matching the section's own gating.
|
|
23
|
+
* @module @linxin666/dsh-usage/client/UsageFootCard
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useEffect, useState, useSyncExternalStore, type ReactNode } from 'react'
|
|
27
|
+
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
28
|
+
import { t } from './locales.ts'
|
|
29
|
+
import styles from './usage.module.css'
|
|
30
|
+
import { formatTokens, type UsageSettings } from './UsageSectionCard.tsx'
|
|
31
|
+
import { totalTokens } from '../core/ledger.ts'
|
|
32
|
+
import type { BalanceView, ProviderSnapshotView, UsageOverviewView } from '../core/types.ts'
|
|
33
|
+
import type { UsageStoreInstance } from './usage-store.ts'
|
|
34
|
+
|
|
35
|
+
/** Props the mount forwards into the React tree. */
|
|
36
|
+
export interface UsageFootCardProps {
|
|
37
|
+
/** The shared overview store (section and card read the same snapshot). */
|
|
38
|
+
store: UsageStoreInstance
|
|
39
|
+
/** Fetch one overview now. */
|
|
40
|
+
poll: () => void
|
|
41
|
+
/** Card-body click: open the settings panel on the usage section. */
|
|
42
|
+
onOpen: () => void
|
|
43
|
+
/** The shared settings form; the card hides while the plugin is disabled. */
|
|
44
|
+
settings: ConfigForm<UsageSettings>
|
|
45
|
+
/** Locale-change source; the plain copy re-renders on a language switch. */
|
|
46
|
+
locale?: { subscribe(listener: () => void): () => void }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Poll cadence of the permanently seated card; the section's 10 s loop stays its own. */
|
|
50
|
+
export const FOOT_CARD_POLL_MS = 30_000
|
|
51
|
+
|
|
52
|
+
/** localStorage key holding the collapsed flag ('1' = collapsed strip). */
|
|
53
|
+
export const FOOT_CARD_COLLAPSED_KEY = 'dsh-usage.foot-card.collapsed'
|
|
54
|
+
|
|
55
|
+
/** At most this many provider balances render before a +N overflow marker. */
|
|
56
|
+
const BALANCE_CAP = 2
|
|
57
|
+
|
|
58
|
+
/** Read the persisted collapsed flag (absent = expanded). */
|
|
59
|
+
export function readFootCardCollapsed(): boolean {
|
|
60
|
+
try {
|
|
61
|
+
return window.localStorage.getItem(FOOT_CARD_COLLAPSED_KEY) === '1'
|
|
62
|
+
} catch {
|
|
63
|
+
// Storage can be unavailable (privacy modes); the card stays expanded.
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Persist the collapsed flag; storage failures keep the session state. */
|
|
69
|
+
function writeFootCardCollapsed(collapsed: boolean): void {
|
|
70
|
+
try {
|
|
71
|
+
window.localStorage.setItem(FOOT_CARD_COLLAPSED_KEY, collapsed ? '1' : '0')
|
|
72
|
+
} catch {
|
|
73
|
+
// Storage unavailable: the state still flips for this session.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Inline gauge glyph, 14px beside the card title. */
|
|
78
|
+
function GaugeIcon(): ReactNode {
|
|
79
|
+
return (
|
|
80
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
81
|
+
<path d="M2.5 12.5a7 7 0 0 1 11 -4.3" />
|
|
82
|
+
<path d="M8 12.5V8.2l2.9-2.1" />
|
|
83
|
+
<circle cx="8" cy="12.5" r="1" />
|
|
84
|
+
</svg>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Disclosure chevrons: down collapses the expanded card, up expands the strip. */
|
|
89
|
+
function ChevronIcon(props: { collapsed: boolean }): ReactNode {
|
|
90
|
+
return props.collapsed
|
|
91
|
+
? (
|
|
92
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
93
|
+
<path d="m4 10 4-4 4 4" />
|
|
94
|
+
</svg>
|
|
95
|
+
)
|
|
96
|
+
: (
|
|
97
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
98
|
+
<path d="m4 6 4 4 4-4" />
|
|
99
|
+
</svg>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A provider row worth showing a balance for: configured, with a probed balance. */
|
|
104
|
+
function balanceRow(provider: ProviderSnapshotView): boolean {
|
|
105
|
+
const supported = provider.balanceSupported === true || (provider.balanceSupported === undefined && provider.supported)
|
|
106
|
+
return provider.credential !== 'none' && supported && provider.balance !== undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Currency-symbol prefix for the priced balances (CNY/USD), else the ISO code. */
|
|
110
|
+
function formatBalance(balance: BalanceView): string {
|
|
111
|
+
const currency = balance.currency.toUpperCase()
|
|
112
|
+
if (currency === 'CNY') return '¥' + balance.totalBalance
|
|
113
|
+
if (currency === 'USD') return '$' + balance.totalBalance
|
|
114
|
+
return currency + ' ' + balance.totalBalance
|
|
115
|
+
}
|
|
116
|
+
|
|
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
|
+
/**
|
|
126
|
+
* Display name of the provider owning today's priced spend: the day's highest
|
|
127
|
+
* cost row, else the current session route, resolved through the provider
|
|
128
|
+
* snapshot (an older host without the day rows falls back to the current
|
|
129
|
+
* route). Undefined when there is no route to name.
|
|
130
|
+
*/
|
|
131
|
+
function spendProvider(snapshot: UsageOverviewView): string | undefined {
|
|
132
|
+
let top: UsageOverviewView['usage']['today']['providers'][number] | undefined
|
|
133
|
+
for (const row of snapshot.usage.today.providers) {
|
|
134
|
+
if (row.totals.cost <= 0) continue
|
|
135
|
+
if (top === undefined || row.totals.cost > top.totals.cost) top = row
|
|
136
|
+
}
|
|
137
|
+
const route = top?.provider ?? snapshot.current.provider
|
|
138
|
+
if (route === undefined) return undefined
|
|
139
|
+
return snapshot.providers.find((entry) => entry.provider === route)?.displayName
|
|
140
|
+
?? (route === snapshot.current.provider ? snapshot.current.displayName : undefined)
|
|
141
|
+
?? route
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The headline triple: today's priced spend, else today's tokens, else zero.
|
|
146
|
+
* tokenFallback tells the sub-line to keep only the call count instead of
|
|
147
|
+
* repeating the total the headline already shows; provider names the spender
|
|
148
|
+
* beside the collapsed cost headline.
|
|
149
|
+
*/
|
|
150
|
+
function headline(snapshot: UsageOverviewView): { label: string; value: string; tokenFallback: boolean; provider?: string } {
|
|
151
|
+
const totals = snapshot.usage.today.totals
|
|
152
|
+
if (totals.cost > 0) return { label: t('usage.foot.cost'), value: '¥' + totals.cost.toFixed(2), tokenFallback: false, provider: spendProvider(snapshot) }
|
|
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: '¥0.00', tokenFallback: false }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The tokens/calls sub-line under the headline; a quiet no-data line on an empty day. */
|
|
158
|
+
function usageLine(snapshot: UsageOverviewView, tokenFallback: boolean): string {
|
|
159
|
+
const totals = snapshot.usage.today.totals
|
|
160
|
+
if (totals.calls === 0) return t('usage.foot.noData')
|
|
161
|
+
const calls = t('usage.calls', { n: totals.calls })
|
|
162
|
+
return tokenFallback ? calls : formatTokens(totalTokens(totals)) + ' tokens · ' + calls
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The balance line: up to BALANCE_CAP providers plus a +N overflow marker. */
|
|
166
|
+
function balanceLine(snapshot: UsageOverviewView): string | undefined {
|
|
167
|
+
const rows = snapshot.providers.filter(balanceRow)
|
|
168
|
+
if (rows.length === 0) return undefined
|
|
169
|
+
const shown = rows.slice(0, BALANCE_CAP).map((provider) => provider.displayName + ' ' + formatBalance(provider.balance!))
|
|
170
|
+
return shown.join(' · ') + (rows.length > BALANCE_CAP ? ' +' + String(rows.length - BALANCE_CAP) : '')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Card chrome classes for the current collapse state. */
|
|
174
|
+
function cardClass(collapsed: boolean): string {
|
|
175
|
+
return collapsed ? styles.footCard + ' ' + styles.footCardCollapsed : styles.footCard
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Render the foot card content for the current store snapshot.
|
|
180
|
+
* @param props - store, poller, settings form and open callback from the mount.
|
|
181
|
+
* @returns the card, or null while disabled / host-off.
|
|
182
|
+
*/
|
|
183
|
+
export function UsageFootCard(props: UsageFootCardProps): ReactNode {
|
|
184
|
+
const { store, poll, onOpen, settings, locale } = props
|
|
185
|
+
const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
|
|
186
|
+
// Settings writes and locale switches both re-render the card's copy/visibility.
|
|
187
|
+
const [, bump] = useState(0)
|
|
188
|
+
useEffect(() => settings.subscribe(() => bump((count) => count + 1)), [settings])
|
|
189
|
+
useEffect(() => locale?.subscribe(() => bump((count) => count + 1)), [locale])
|
|
190
|
+
const enabled = settings.getSnapshot().value?.enabled ?? true
|
|
191
|
+
|
|
192
|
+
// The collapse choice is one localStorage flag, read at mount and persisted
|
|
193
|
+
// on every flip (the old sidebar strip used the same contract shape).
|
|
194
|
+
const [collapsed, setCollapsed] = useState(readFootCardCollapsed)
|
|
195
|
+
const toggleCollapsed = (): void => {
|
|
196
|
+
setCollapsed((current) => {
|
|
197
|
+
writeFootCardCollapsed(!current)
|
|
198
|
+
return !current
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The permanently seated card pays a relaxed cadence; a hidden tab pauses.
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (!enabled) return undefined
|
|
205
|
+
poll()
|
|
206
|
+
let timer: number | undefined
|
|
207
|
+
const start = (): void => {
|
|
208
|
+
if (timer === undefined && document.visibilityState === 'visible') timer = window.setInterval(poll, FOOT_CARD_POLL_MS)
|
|
209
|
+
}
|
|
210
|
+
const onVisibility = (): void => {
|
|
211
|
+
if (document.visibilityState === 'visible') {
|
|
212
|
+
poll()
|
|
213
|
+
start()
|
|
214
|
+
} else if (timer !== undefined) {
|
|
215
|
+
window.clearInterval(timer)
|
|
216
|
+
timer = undefined
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
start()
|
|
220
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
221
|
+
return () => {
|
|
222
|
+
if (timer !== undefined) window.clearInterval(timer)
|
|
223
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
224
|
+
}
|
|
225
|
+
}, [poll, enabled])
|
|
226
|
+
|
|
227
|
+
if (!enabled) return null
|
|
228
|
+
const snapshot = ui.snapshot
|
|
229
|
+
// A 404 means the host half serves no usage routes (plugin off): the card
|
|
230
|
+
// bows out instead of pinning a permanent error to the sidebar foot.
|
|
231
|
+
if (snapshot === null && ui.status === 'error' && /failed: 404/.test(ui.error ?? '')) return null
|
|
232
|
+
|
|
233
|
+
const toggle = (
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
className={styles.footToggle}
|
|
237
|
+
data-dsh-part="foot-card-toggle"
|
|
238
|
+
aria-label={t(collapsed ? 'usage.foot.expand' : 'usage.foot.collapse')}
|
|
239
|
+
title={t(collapsed ? 'usage.foot.expand' : 'usage.foot.collapse')}
|
|
240
|
+
onClick={toggleCollapsed}
|
|
241
|
+
>
|
|
242
|
+
<ChevronIcon collapsed={collapsed} />
|
|
243
|
+
</button>
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if (snapshot === null) {
|
|
247
|
+
const quiet = ui.status === 'error' ? t('usage.error', { error: ui.error ?? '' }) : t('usage.loading')
|
|
248
|
+
return (
|
|
249
|
+
<div className={cardClass(collapsed)} data-dsh-plugin="usage" data-dsh-part="foot-card">
|
|
250
|
+
<button type="button" className={styles.footMain} data-dsh-part="foot-card-main" aria-label={t('usage.foot.open')} title={t('usage.foot.open')} onClick={onOpen}>
|
|
251
|
+
{collapsed
|
|
252
|
+
? <span className={styles.footStrip} data-dsh-part="foot-card-strip"><GaugeIcon /><span className={styles.footStripValue}>—</span></span>
|
|
253
|
+
: (
|
|
254
|
+
<>
|
|
255
|
+
<span className={styles.footHead}>
|
|
256
|
+
<span className={styles.footTitle}><GaugeIcon />{t('usage.title')}</span>
|
|
257
|
+
</span>
|
|
258
|
+
<span className={styles.footLine}>{quiet}</span>
|
|
259
|
+
</>
|
|
260
|
+
)}
|
|
261
|
+
</button>
|
|
262
|
+
{toggle}
|
|
263
|
+
</div>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const head = headline(snapshot)
|
|
268
|
+
const balances = balanceLine(snapshot)
|
|
269
|
+
|
|
270
|
+
if (collapsed) {
|
|
271
|
+
return (
|
|
272
|
+
<div className={cardClass(true)} data-dsh-plugin="usage" data-dsh-part="foot-card">
|
|
273
|
+
<button type="button" className={styles.footMain} data-dsh-part="foot-card-main" aria-label={t('usage.foot.open')} title={t('usage.foot.open')} onClick={onOpen}>
|
|
274
|
+
<span className={styles.footStrip} data-dsh-part="foot-card-strip">
|
|
275
|
+
<GaugeIcon />
|
|
276
|
+
{head.provider !== undefined && <span className={styles.footStripProvider}>{head.provider}</span>}
|
|
277
|
+
<span className={styles.footStripLabel}>{head.label}</span>
|
|
278
|
+
<span className={styles.footStripValue}>{head.value}</span>
|
|
279
|
+
</span>
|
|
280
|
+
</button>
|
|
281
|
+
{toggle}
|
|
282
|
+
</div>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<div className={cardClass(false)} data-dsh-plugin="usage" data-dsh-part="foot-card">
|
|
288
|
+
<button type="button" className={styles.footMain} data-dsh-part="foot-card-main" aria-label={t('usage.foot.open')} title={t('usage.foot.open')} onClick={onOpen}>
|
|
289
|
+
<span className={styles.footHead}>
|
|
290
|
+
<span className={styles.footTitle}><GaugeIcon />{head.label}</span>
|
|
291
|
+
<span className={styles.footValue}>{head.value}</span>
|
|
292
|
+
</span>
|
|
293
|
+
<span className={styles.footLine} data-dsh-part="foot-card-usage">{usageLine(snapshot, head.tokenFallback)}</span>
|
|
294
|
+
{balances !== undefined && <span className={styles.footLine} data-dsh-part="foot-card-balances">{balances}</span>}
|
|
295
|
+
<span className={styles.footMeta}>{t('usage.updated', { time: formatClock(snapshot.updatedAt) })}</span>
|
|
296
|
+
</button>
|
|
297
|
+
{toggle}
|
|
298
|
+
</div>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
|
|
12
|
-
import type {
|
|
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'
|
|
@@ -22,7 +22,6 @@ import type { ObservedSpendView, ProviderSnapshotView, UsageOverviewView, UsageP
|
|
|
22
22
|
export interface UsageSettings {
|
|
23
23
|
enabled?: boolean
|
|
24
24
|
pollIntervalSec?: number
|
|
25
|
-
bubbleMode?: string
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
/** The registration-side face the section's slot entry injects. */
|
|
@@ -33,8 +32,8 @@ export interface UsageSectionFace {
|
|
|
33
32
|
poll: () => void
|
|
34
33
|
/** Force a host probe cycle now (resolves with the fresh overview). */
|
|
35
34
|
refresh: () => void
|
|
36
|
-
/**
|
|
37
|
-
settings:
|
|
35
|
+
/** The shared configuration form this section's settings row reads and writes. */
|
|
36
|
+
settings: ConfigForm<UsageSettings>
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
export interface UsageSectionProps extends UsageSectionFace {
|
|
@@ -154,9 +153,10 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
154
153
|
const settingsValue = settingsSnapshot.value ?? {}
|
|
155
154
|
const [tab, setTab] = useState<'usage' | 'plans' | 'bank'>('usage')
|
|
156
155
|
const [refreshing, setRefreshing] = useState(false)
|
|
157
|
-
// The enable checkbox writes through the
|
|
158
|
-
// keeps the flag below live: the
|
|
159
|
-
// waiting for an unrelated
|
|
156
|
+
// The enable checkbox writes through the shared form, so subscribing here
|
|
157
|
+
// keeps the flag below live: the form republishes the Host's accepted value,
|
|
158
|
+
// and the poll starts and stops with it instead of waiting for an unrelated
|
|
159
|
+
// render.
|
|
160
160
|
const [, bumpSettings] = useState(0)
|
|
161
161
|
useEffect(() => settings.subscribe(() => bumpSettings((count) => count + 1)), [settings])
|
|
162
162
|
const enabled = settingsValue.enabled ?? true
|
|
@@ -534,14 +534,39 @@ function PlanCard(props: { provider: ProviderSnapshotView; current?: string }):
|
|
|
534
534
|
)
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
+
/**
|
|
538
|
+
* The compact settings row. Both controls write through the shared form the
|
|
539
|
+
* moment the user changes them, and the Host answers each write with a
|
|
540
|
+
* boolean: a refused (or transport-failed) write is surfaced as a failed save,
|
|
541
|
+
* because a value that did not land must never read as applied.
|
|
542
|
+
*/
|
|
537
543
|
function SettingsRow(props: {
|
|
538
544
|
settings: UsageSectionProps['settings']
|
|
539
|
-
snapshot?:
|
|
545
|
+
snapshot?: ConfigFormSnapshot<UsageSettings>
|
|
540
546
|
value: UsageSettings
|
|
541
547
|
}): ReactNode {
|
|
542
548
|
const { settings, snapshot, value } = props
|
|
543
549
|
const disabled = snapshot === undefined || !snapshot.writable
|
|
544
|
-
const
|
|
550
|
+
const [failure, setFailure] = useState<string | undefined>(undefined)
|
|
551
|
+
|
|
552
|
+
const write = (field: 'enabled' | 'pollIntervalSec', next: boolean | number): void => {
|
|
553
|
+
setFailure(undefined)
|
|
554
|
+
let answer: Promise<boolean>
|
|
555
|
+
try {
|
|
556
|
+
answer = settings.set(field, next)
|
|
557
|
+
} catch (error) {
|
|
558
|
+
setFailure(error instanceof Error ? error.message : String(error))
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
// false is the contract's refusal/skip answer (the Host rejected the value,
|
|
562
|
+
// the entry is not writable, or the write was dropped); a rejecting
|
|
563
|
+
// transport reports through the same failed-save surface.
|
|
564
|
+
Promise.resolve(answer).then(
|
|
565
|
+
(accepted) => { if (!accepted) setFailure('') },
|
|
566
|
+
(error: unknown) => { setFailure(error instanceof Error ? error.message : String(error)) },
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
|
|
545
570
|
return (
|
|
546
571
|
<div className={styles.card} data-dsh-part="settings-row">
|
|
547
572
|
<span className={styles.cardTitle}>{t('usage.config.title')}</span>
|
|
@@ -551,7 +576,7 @@ function SettingsRow(props: {
|
|
|
551
576
|
type="checkbox"
|
|
552
577
|
checked={value.enabled ?? true}
|
|
553
578
|
disabled={disabled}
|
|
554
|
-
onChange={(event) => {
|
|
579
|
+
onChange={(event) => { write('enabled', event.target.checked) }}
|
|
555
580
|
/>
|
|
556
581
|
{t('usage.config.enabled')}
|
|
557
582
|
</label>
|
|
@@ -565,23 +590,16 @@ function SettingsRow(props: {
|
|
|
565
590
|
disabled={disabled}
|
|
566
591
|
onChange={(event) => {
|
|
567
592
|
const parsed = Number(event.target.value)
|
|
568
|
-
if (Number.isFinite(parsed) && parsed >= 30 && parsed <= 3600)
|
|
593
|
+
if (Number.isFinite(parsed) && parsed >= 30 && parsed <= 3600) write('pollIntervalSec', Math.round(parsed))
|
|
569
594
|
}}
|
|
570
595
|
/>
|
|
571
596
|
</label>
|
|
572
|
-
<label className={styles.settingItem}>
|
|
573
|
-
{t('usage.config.bubbleMode')}
|
|
574
|
-
<select
|
|
575
|
-
value={bubbleMode}
|
|
576
|
-
disabled={disabled}
|
|
577
|
-
onChange={(event) => { void settings.set('bubbleMode', event.target.value) }}
|
|
578
|
-
>
|
|
579
|
-
<option value="always">{t('usage.config.bubbleMode.always')}</option>
|
|
580
|
-
<option value="change">{t('usage.config.bubbleMode.change')}</option>
|
|
581
|
-
<option value="off">{t('usage.config.bubbleMode.off')}</option>
|
|
582
|
-
</select>
|
|
583
|
-
</label>
|
|
584
597
|
</div>
|
|
598
|
+
{failure !== undefined && (
|
|
599
|
+
<span className={styles.errorLine} role="status">
|
|
600
|
+
{t('usage.config.saveFailed')}{failure === '' ? '' : ' - ' + failure}
|
|
601
|
+
</span>
|
|
602
|
+
)}
|
|
585
603
|
</div>
|
|
586
604
|
)
|
|
587
605
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/body-mutations.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* One `document.body` MutationObserver per page, shared by every family
|
|
4
|
+
* plugin bundle.
|
|
5
|
+
*
|
|
6
|
+
* Family plugins that inject at the DOM level must notice when the shell
|
|
7
|
+
* re-renders around their injected node: the sidebar entry rows
|
|
8
|
+
* (sidebar-entry-core), the center-column panel containers
|
|
9
|
+
* (panel-mount-core), and the aggregate shell's column shims. Each consumer
|
|
10
|
+
* used to install its OWN `MutationObserver` on `document.body` with
|
|
11
|
+
* `{ childList: true, subtree: true }`, so a page carrying N family plugins
|
|
12
|
+
* paid N native observers and N callback invocations for EVERY mutation batch
|
|
13
|
+
* the app produced — chat token streaming alone produces many per second, and
|
|
14
|
+
* the count grows with the number of installed plugins.
|
|
15
|
+
*
|
|
16
|
+
* This module keeps exactly ONE body observer per page. The registry lives on
|
|
17
|
+
* `globalThis` under a `Symbol.for` key, so the per-package generated copies
|
|
18
|
+
* of this file (scripts/sync-shared.mjs) and separately bundled plugins all
|
|
19
|
+
* reach the same hub at runtime instead of one hub per module instance.
|
|
20
|
+
* Record subscribers receive the mutations accumulated since the last flush.
|
|
21
|
+
* Consumers that only re-check their DOM use subscribeBodyInvalidations:
|
|
22
|
+
* when all subscribers use that path, the hub retains no mutation records
|
|
23
|
+
* (or detached subtrees) while a background page's animation frames pause.
|
|
24
|
+
* Both paths run at most once per frame. The last subscriber disconnects the
|
|
25
|
+
* observer, cancels the pending frame and releases its records.
|
|
26
|
+
*
|
|
27
|
+
* Failure policy: without a DOM or `MutationObserver` the subscription is a
|
|
28
|
+
* no-op disposer (the same silence the per-consumer observers had), and when
|
|
29
|
+
* `requestAnimationFrame` is unavailable the flush runs synchronously so a
|
|
30
|
+
* subscriber is never silently dropped. A throwing subscriber cannot stop the
|
|
31
|
+
* others.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The page-wide hub: one observer plus its subscribers and pending records. */
|
|
35
|
+
interface BodyMutationHub {
|
|
36
|
+
observer: MutationObserver
|
|
37
|
+
subscribers: Set<(records: MutationRecord[]) => void>
|
|
38
|
+
pending: MutationRecord[]
|
|
39
|
+
scheduled: boolean
|
|
40
|
+
frame?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Cross-bundle registry key; `Symbol.for` so every module copy agrees. */
|
|
44
|
+
const HUB_KEY = Symbol.for('dsh-web.body-mutation-hub')
|
|
45
|
+
const INVALIDATION_ONLY = Symbol.for('dsh-web.body-mutation-invalidation')
|
|
46
|
+
type Subscriber = ((records: MutationRecord[]) => void) & { [INVALIDATION_ONLY]?: true }
|
|
47
|
+
|
|
48
|
+
function needsRecords(subscribers: Set<(records: MutationRecord[]) => void>): boolean {
|
|
49
|
+
for (const listener of subscribers) {
|
|
50
|
+
if (!(listener as Subscriber)[INVALIDATION_ONLY]) return true
|
|
51
|
+
}
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Subscribe to a coalesced DOM re-check without retaining mutation records.
|
|
57
|
+
* The marked wrapper also works with an older hub, which delivers records
|
|
58
|
+
* that it simply ignores until a page reload picks up the updated hub.
|
|
59
|
+
*/
|
|
60
|
+
export function subscribeBodyInvalidations(subscriber: () => void): () => void {
|
|
61
|
+
const listener: Subscriber = () => { subscriber() }
|
|
62
|
+
listener[INVALIDATION_ONLY] = true
|
|
63
|
+
return subscribeBodyMutations(listener)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Subscribe to body-level childList mutations.
|
|
68
|
+
* @param subscriber - called at most once per animation frame with the records
|
|
69
|
+
* collected since the previous flush; must be safe to run repeatedly.
|
|
70
|
+
* @returns the disposer removing this subscriber (and the observer when it was
|
|
71
|
+
* the last one).
|
|
72
|
+
*/
|
|
73
|
+
export function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void {
|
|
74
|
+
if (typeof globalThis === 'undefined' || typeof document === 'undefined') return () => {}
|
|
75
|
+
if (typeof MutationObserver !== 'function') return () => {}
|
|
76
|
+
const registry = globalThis as unknown as Record<symbol, unknown>
|
|
77
|
+
let hub = registry[HUB_KEY] as BodyMutationHub | undefined
|
|
78
|
+
if (hub === undefined) {
|
|
79
|
+
const subscribers = new Set<(records: MutationRecord[]) => void>()
|
|
80
|
+
const created: BodyMutationHub = {
|
|
81
|
+
observer: undefined as unknown as MutationObserver,
|
|
82
|
+
subscribers,
|
|
83
|
+
pending: [],
|
|
84
|
+
scheduled: false,
|
|
85
|
+
}
|
|
86
|
+
const flush = (): void => {
|
|
87
|
+
created.frame = undefined
|
|
88
|
+
created.scheduled = false
|
|
89
|
+
const batch = created.pending
|
|
90
|
+
created.pending = []
|
|
91
|
+
for (const listener of [...subscribers]) {
|
|
92
|
+
if (!subscribers.has(listener)) continue
|
|
93
|
+
try {
|
|
94
|
+
listener(batch)
|
|
95
|
+
} catch {
|
|
96
|
+
// One subscriber must never break the others; the next flush retries it.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const schedule = (): void => {
|
|
101
|
+
if (created.scheduled) return
|
|
102
|
+
created.scheduled = true
|
|
103
|
+
if (typeof requestAnimationFrame === 'function') created.frame = requestAnimationFrame(flush)
|
|
104
|
+
else flush()
|
|
105
|
+
}
|
|
106
|
+
created.observer = new MutationObserver((records) => {
|
|
107
|
+
if (needsRecords(subscribers)) {
|
|
108
|
+
for (const record of records) created.pending.push(record)
|
|
109
|
+
}
|
|
110
|
+
schedule()
|
|
111
|
+
})
|
|
112
|
+
created.observer.observe(document.body ?? document.documentElement, { childList: true, subtree: true })
|
|
113
|
+
registry[HUB_KEY] = created
|
|
114
|
+
hub = created
|
|
115
|
+
}
|
|
116
|
+
const active = hub
|
|
117
|
+
active.subscribers.add(subscriber)
|
|
118
|
+
let subscribed = true
|
|
119
|
+
return () => {
|
|
120
|
+
if (!subscribed) return
|
|
121
|
+
subscribed = false
|
|
122
|
+
active.subscribers.delete(subscriber)
|
|
123
|
+
if (!needsRecords(active.subscribers)) active.pending = []
|
|
124
|
+
if (active.subscribers.size === 0 && registry[HUB_KEY] === active) {
|
|
125
|
+
active.observer.disconnect()
|
|
126
|
+
if (active.frame !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(active.frame)
|
|
127
|
+
active.frame = undefined
|
|
128
|
+
active.pending = []
|
|
129
|
+
active.scheduled = false
|
|
130
|
+
delete registry[HUB_KEY]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|