@linxin666/dsh-usage 0.3.24 → 0.4.1
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 +4 -4
- package/README.zh.md +4 -4
- package/icon.svg +3 -0
- package/lib/client.js +540 -655
- package/lib/client.js.map +1 -1
- package/lib/index.js +92 -75
- package/lib/types/client/UsageFootCard.d.ts +55 -0
- package/lib/types/client/UsageSectionCard.d.ts +3 -3
- 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 -9
- package/lib/types/host/http.d.ts +18 -1
- package/lib/types/host/usage-service.d.ts +4 -3
- package/lib/types/index.d.ts +14 -0
- package/package.json +19 -17
- package/src/client/UsageFootCard.tsx +300 -0
- package/src/client/UsageSectionCard.tsx +41 -9
- package/src/client/foot-card-mount.tsx +112 -0
- package/src/client/index.ts +53 -33
- package/src/client/locales.ts +12 -18
- package/src/client/usage.module.css +101 -165
- package/src/host/http.ts +23 -1
- package/src/host/usage-service.ts +9 -4
- package/src/index.ts +59 -55
- package/lib/types/client/UsageSidebarPanel.d.ts +0 -34
- package/lib/types/client/sidebar-entry-core.d.ts +0 -87
- package/lib/types/client/sidebar-entry.d.ts +0 -35
- package/lib/types/client/sidebar-panel-mount.d.ts +0 -40
- package/src/client/UsageSidebarPanel.tsx +0 -196
- package/src/client/sidebar-entry-core.ts +0 -321
- package/src/client/sidebar-entry.ts +0 -101
- package/src/client/sidebar-panel-mount.tsx +0 -113
|
@@ -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'
|
|
@@ -32,8 +32,8 @@ export interface UsageSectionFace {
|
|
|
32
32
|
poll: () => void
|
|
33
33
|
/** Force a host probe cycle now (resolves with the fresh overview). */
|
|
34
34
|
refresh: () => void
|
|
35
|
-
/**
|
|
36
|
-
settings:
|
|
35
|
+
/** The shared configuration form this section's settings row reads and writes. */
|
|
36
|
+
settings: ConfigForm<UsageSettings>
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export interface UsageSectionProps extends UsageSectionFace {
|
|
@@ -153,9 +153,10 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
|
|
|
153
153
|
const settingsValue = settingsSnapshot.value ?? {}
|
|
154
154
|
const [tab, setTab] = useState<'usage' | 'plans' | 'bank'>('usage')
|
|
155
155
|
const [refreshing, setRefreshing] = useState(false)
|
|
156
|
-
// The enable checkbox writes through the
|
|
157
|
-
// keeps the flag below live: the
|
|
158
|
-
// 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.
|
|
159
160
|
const [, bumpSettings] = useState(0)
|
|
160
161
|
useEffect(() => settings.subscribe(() => bumpSettings((count) => count + 1)), [settings])
|
|
161
162
|
const enabled = settingsValue.enabled ?? true
|
|
@@ -533,13 +534,39 @@ function PlanCard(props: { provider: ProviderSnapshotView; current?: string }):
|
|
|
533
534
|
)
|
|
534
535
|
}
|
|
535
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
|
+
*/
|
|
536
543
|
function SettingsRow(props: {
|
|
537
544
|
settings: UsageSectionProps['settings']
|
|
538
|
-
snapshot?:
|
|
545
|
+
snapshot?: ConfigFormSnapshot<UsageSettings>
|
|
539
546
|
value: UsageSettings
|
|
540
547
|
}): ReactNode {
|
|
541
548
|
const { settings, snapshot, value } = props
|
|
542
549
|
const disabled = snapshot === undefined || !snapshot.writable
|
|
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
|
+
|
|
543
570
|
return (
|
|
544
571
|
<div className={styles.card} data-dsh-part="settings-row">
|
|
545
572
|
<span className={styles.cardTitle}>{t('usage.config.title')}</span>
|
|
@@ -549,7 +576,7 @@ function SettingsRow(props: {
|
|
|
549
576
|
type="checkbox"
|
|
550
577
|
checked={value.enabled ?? true}
|
|
551
578
|
disabled={disabled}
|
|
552
|
-
onChange={(event) => {
|
|
579
|
+
onChange={(event) => { write('enabled', event.target.checked) }}
|
|
553
580
|
/>
|
|
554
581
|
{t('usage.config.enabled')}
|
|
555
582
|
</label>
|
|
@@ -563,11 +590,16 @@ function SettingsRow(props: {
|
|
|
563
590
|
disabled={disabled}
|
|
564
591
|
onChange={(event) => {
|
|
565
592
|
const parsed = Number(event.target.value)
|
|
566
|
-
if (Number.isFinite(parsed) && parsed >= 30 && parsed <= 3600)
|
|
593
|
+
if (Number.isFinite(parsed) && parsed >= 30 && parsed <= 3600) write('pollIntervalSec', Math.round(parsed))
|
|
567
594
|
}}
|
|
568
595
|
/>
|
|
569
596
|
</label>
|
|
570
597
|
</div>
|
|
598
|
+
{failure !== undefined && (
|
|
599
|
+
<span className={styles.errorLine} role="status">
|
|
600
|
+
{t('usage.config.saveFailed')}{failure === '' ? '' : ' - ' + failure}
|
|
601
|
+
</span>
|
|
602
|
+
)}
|
|
571
603
|
</div>
|
|
572
604
|
)
|
|
573
605
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar foot card mounting.
|
|
3
|
+
*
|
|
4
|
+
* The foot's only slot (sidebar.footer.action) stacks ABOVE the Settings row
|
|
5
|
+
* and cannot host a block, so the card container is inserted into the shell's
|
|
6
|
+
* foot area directly above the Settings seat — keeping Settings the foot's
|
|
7
|
+
* terminal row instead of hanging a card off the sidebar floor. It self-heals
|
|
8
|
+
* with the page-wide body-mutation hub: a React re-render that displaces the
|
|
9
|
+
* container re-seats it on the next frame, and a whole-pane rebuild is noticed
|
|
10
|
+
* by the same body-level watcher the family entry rows use.
|
|
11
|
+
*
|
|
12
|
+
* The container is a plain div carrying its own React root, so it can never
|
|
13
|
+
* disturb the shell's reconciliation.
|
|
14
|
+
* @module @linxin666/dsh-usage/client/foot-card-mount
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createElement } from 'react'
|
|
18
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
19
|
+
import { subscribeBodyInvalidations } from './body-mutations.ts'
|
|
20
|
+
import { UsageFootCard, type UsageFootCardProps } from './UsageFootCard.tsx'
|
|
21
|
+
|
|
22
|
+
/** Stable data attribute identifying the injected card container. */
|
|
23
|
+
export const FOOT_CARD_SELECTOR = '[data-dsh-usage-foot-card]'
|
|
24
|
+
|
|
25
|
+
/** The shell sidebar's foot area (footer actions + the Settings row), when mounted. */
|
|
26
|
+
function footArea(): HTMLElement | undefined {
|
|
27
|
+
const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
|
|
28
|
+
return column?.querySelector<HTMLElement>('[class*="footArea"]') ?? undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Open the settings panel on the usage section. The shell owns the panel's
|
|
33
|
+
* open state and exposes no service for it, so the card replays the user's
|
|
34
|
+
* own path: activate the sidebar Settings trigger, then pick the nav row
|
|
35
|
+
* carrying the section's (localized) label. Every step degrades silently —
|
|
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.
|
|
39
|
+
*/
|
|
40
|
+
export function openUsageSettings(label: () => string): void {
|
|
41
|
+
/** Click the nav row carrying the usage label inside the open panel. */
|
|
42
|
+
const pick = (panel: Element): void => {
|
|
43
|
+
const wanted = label()
|
|
44
|
+
for (const row of panel.querySelectorAll<HTMLButtonElement>('nav button')) {
|
|
45
|
+
if (row.textContent !== null && row.textContent.includes(wanted)) {
|
|
46
|
+
row.click()
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Panel open but the usage row is gone (plugin toggled off): stay put.
|
|
51
|
+
}
|
|
52
|
+
// A panel that is already open must never be toggled shut by the trigger;
|
|
53
|
+
// only the nav pick runs.
|
|
54
|
+
const open = document.querySelector('[role="dialog"]')
|
|
55
|
+
if (open !== null) {
|
|
56
|
+
pick(open)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
|
|
60
|
+
const trigger = column?.querySelector<HTMLButtonElement>('[class*="settingsArea"] button')
|
|
61
|
+
trigger?.click()
|
|
62
|
+
let tries = 0
|
|
63
|
+
const attempt = (): void => {
|
|
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)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mount the sidebar foot card directly above the Settings row.
|
|
78
|
+
* @param props - the store/poll/settings/open inputs of the apply body.
|
|
79
|
+
* @returns disposer removing the container and its observers.
|
|
80
|
+
*/
|
|
81
|
+
export function mountUsageFootCard(props: UsageFootCardProps): () => void {
|
|
82
|
+
// DOM-level idempotency: whatever path mounted a card before this call (a
|
|
83
|
+
// duplicated apply, an HMR re-injection), never mount a second one.
|
|
84
|
+
if (typeof document !== 'undefined' && document.querySelector(FOOT_CARD_SELECTOR) !== null) {
|
|
85
|
+
return () => {}
|
|
86
|
+
}
|
|
87
|
+
const container = document.createElement('div')
|
|
88
|
+
container.setAttribute('data-dsh-usage-foot-card', '')
|
|
89
|
+
const root: Root = createRoot(container)
|
|
90
|
+
root.render(createElement(UsageFootCard, props))
|
|
91
|
+
|
|
92
|
+
/** Keep the container directly above the Settings seat inside the foot area. */
|
|
93
|
+
const place = (): void => {
|
|
94
|
+
const foot = footArea()
|
|
95
|
+
if (foot === undefined) return
|
|
96
|
+
const settings = foot.querySelector<HTMLElement>('[class*="settingsArea"]')
|
|
97
|
+
if (settings !== null) {
|
|
98
|
+
if (container.nextElementSibling !== settings) foot.insertBefore(container, settings)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
// No Settings seat to anchor against (shell change): fall back to the tail.
|
|
102
|
+
if (foot.lastElementChild !== container) foot.append(container)
|
|
103
|
+
}
|
|
104
|
+
place()
|
|
105
|
+
const unsubscribeBody = subscribeBodyInvalidations(place)
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
unsubscribeBody()
|
|
109
|
+
root.unmount()
|
|
110
|
+
container.remove()
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-usage browser half — seats the first-level 使用统计 settings section
|
|
3
|
-
* (below the Workshop entry)
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
3
|
+
* (below the Workshop entry) plus the compact usage glance card below the
|
|
4
|
+
* sidebar's Settings row. The section polls the host overview only while it
|
|
5
|
+
* is open; the foot card runs its own relaxed loop because the sidebar foot
|
|
6
|
+
* is permanently mounted. All provider probing and credential handling
|
|
7
|
+
* happens in the host half; this bundle only renders the overview document.
|
|
6
8
|
* @module @linxin666/dsh-usage/client
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
10
|
-
import type {
|
|
12
|
+
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
11
13
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
12
14
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
13
|
-
// Type-only: pulls the
|
|
15
|
+
// Type-only: pulls the shared-forms Context merge (ctx.configForms).
|
|
14
16
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
15
17
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
16
18
|
// Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
|
|
17
19
|
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
18
20
|
import { createUsageStore, type UsageStoreInstance } from './usage-store.ts'
|
|
19
|
-
import { mountSidebarEntry } from './sidebar-entry.ts'
|
|
20
|
-
import { mountUsagePanel } from './sidebar-panel-mount.tsx'
|
|
21
21
|
import { UsageSectionCard, type UsageSectionFace, type UsageSettings } from './UsageSectionCard.tsx'
|
|
22
|
-
import {
|
|
22
|
+
import { mountUsageFootCard, openUsageSettings } from './foot-card-mount.tsx'
|
|
23
|
+
import { NS, en, zh, t } from './locales.ts'
|
|
23
24
|
import type { UsageOverviewView } from '../core/types.ts'
|
|
24
25
|
|
|
25
26
|
/** The host usage API as the browser sees it (same-origin JSON endpoints). */
|
|
@@ -45,27 +46,39 @@ const usageApi: UsageHttpApi = {
|
|
|
45
46
|
refresh: () => usageFetch('/api/dsh-usage/refresh', 'POST'),
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
/** Settings namespace the section edits (
|
|
49
|
+
/** Settings namespace the section edits (dsh-web-settings maps it onto this row's profile entry id). */
|
|
49
50
|
const USAGE_SETTINGS_NS = 'dsh-usage'
|
|
50
51
|
|
|
51
52
|
/** First-level nav position: directly below the Workshop section (order 150). */
|
|
52
53
|
const SECTION_ORDER = 151
|
|
53
54
|
|
|
54
55
|
/** Required services. */
|
|
55
|
-
export const inject = ['slots', 'locale', 'connection', '
|
|
56
|
+
export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote']
|
|
56
57
|
|
|
57
58
|
export type { UsageSectionProps, UsageSectionFace } from './UsageSectionCard.tsx'
|
|
58
59
|
export type { UsageUiState } from './usage-store.ts'
|
|
59
60
|
export type { UsageSettings }
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* One settings namespace a family card binds. The 0.1.7 client exports no spec
|
|
64
|
+
* type (the form controller takes it privately), so the binder's input shape is
|
|
65
|
+
* restated here.
|
|
66
|
+
*/
|
|
67
|
+
export interface UsageSettingsBindSpec<T> {
|
|
68
|
+
/** Settings namespace registered by the owning host plugin. */
|
|
69
|
+
namespace: string
|
|
70
|
+
/** Narrow one wire section; undefined keeps the last accepted value. */
|
|
71
|
+
decode?: (section: unknown) => T | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
61
74
|
declare module '@deepseek-ai/cordis' {
|
|
62
75
|
interface Context {
|
|
63
76
|
/**
|
|
64
|
-
* Optional
|
|
65
|
-
*
|
|
66
|
-
*
|
|
77
|
+
* Optional family settings binder provided by dsh-web-settings; absent when
|
|
78
|
+
* that group plugin is not installed, so callers fall back to the native
|
|
79
|
+
* per-entry forms (`ctx.configForms`).
|
|
67
80
|
*/
|
|
68
|
-
webUiSettings?: { bind<S>(spec:
|
|
81
|
+
webUiSettings?: { bind<S>(spec: UsageSettingsBindSpec<S>): ConfigForm<S> }
|
|
69
82
|
}
|
|
70
83
|
}
|
|
71
84
|
|
|
@@ -84,8 +97,16 @@ export function apply(ctx: ClientContext): void {
|
|
|
84
97
|
}
|
|
85
98
|
}, 'dsh-usage: dictionaries')
|
|
86
99
|
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
// The family binder is what maps this namespace onto the row's profile entry
|
|
101
|
+
// id and hands back the native form. Without the group plugin the client can
|
|
102
|
+
// only address an entry id it already knows: the namespace itself is one when
|
|
103
|
+
// the profile keeps the family spelling. Any other entry id leaves the form
|
|
104
|
+
// unavailable, which renders the row's controls disabled — the Host's own
|
|
105
|
+
// generated page for the row still edits the same Config.
|
|
106
|
+
const binder = ctx.get('webUiSettings')
|
|
107
|
+
const settingsForm = binder !== undefined
|
|
108
|
+
? binder.bind<UsageSettings>({ namespace: USAGE_SETTINGS_NS })
|
|
109
|
+
: ctx.configForms.get<UsageSettings>(USAGE_SETTINGS_NS)
|
|
89
110
|
|
|
90
111
|
// One store instance per apply body; the section mounts and unmounts with
|
|
91
112
|
// the settings page, and the store survives between visits so the last
|
|
@@ -122,27 +143,26 @@ export function apply(ctx: ClientContext): void {
|
|
|
122
143
|
})
|
|
123
144
|
}
|
|
124
145
|
|
|
125
|
-
const face = (): UsageSectionFace => ({ store, poll, refresh, settings:
|
|
126
|
-
|
|
127
|
-
// Sidebar
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
146
|
+
const face = (): UsageSectionFace => ({ store, poll, refresh, settings: settingsForm })
|
|
147
|
+
|
|
148
|
+
// Sidebar foot card: the compact usage glance seated below the shell's
|
|
149
|
+
// Settings row. It shares the section's store and poll path (the sequence
|
|
150
|
+
// guard absorbs interleaved calls), runs its own relaxed poll loop, and
|
|
151
|
+
// opens the settings panel on the usage section when clicked.
|
|
152
|
+
const disposeFootCard = mountUsageFootCard({
|
|
153
|
+
store,
|
|
154
|
+
poll,
|
|
155
|
+
settings: settingsForm,
|
|
156
|
+
onOpen: () => { openUsageSettings(() => t('usage.title')) },
|
|
157
|
+
locale: ctx.locale,
|
|
158
|
+
})
|
|
138
159
|
ctx.effect(() => () => {
|
|
139
160
|
try {
|
|
140
|
-
|
|
161
|
+
disposeFootCard()
|
|
141
162
|
} catch {
|
|
142
|
-
//
|
|
163
|
+
// Card container already gone (teardown race).
|
|
143
164
|
}
|
|
144
|
-
|
|
145
|
-
}, 'dsh-usage: sidebar panel')
|
|
165
|
+
}, 'dsh-usage: sidebar foot card')
|
|
146
166
|
|
|
147
167
|
ctx.slots.inject('settings.section', () => {
|
|
148
168
|
try {
|