@linxin666/dsh-usage 0.3.7

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/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
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 dedicated pet bubble for the current provider",
4
+ "version": "0.3.7",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": "^22.19.0 || >=24.0.0"
8
+ },
9
+ "main": "lib/index.js",
10
+ "types": "lib/types/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./lib/types/index.d.ts",
14
+ "default": "./lib/index.js"
15
+ },
16
+ "./client": {
17
+ "types": "./lib/types/client/index.d.ts",
18
+ "default": "./lib/client.js"
19
+ },
20
+ "./src/*": "./src/*",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "lib/**/*.js",
25
+ "lib/**/*.js.map",
26
+ "lib/**/*.d.ts",
27
+ "lib/**/*.d.ts.map",
28
+ "src",
29
+ "cordis.patch.yml"
30
+ ],
31
+ "license": "Apache-2.0",
32
+ "dsh": {
33
+ "engines": {
34
+ "dsh": ">=0.1.2-alpha.1"
35
+ },
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ },
39
+ "client": {
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-connection",
42
+ "@deepseek-ai/dsh-client-locale",
43
+ "@deepseek-ai/dsh-client-ui-settings",
44
+ "@deepseek-ai/dsh-client-ui-slots",
45
+ "@deepseek-ai/dsh-client-ui-renderer"
46
+ ],
47
+ "platform": "web"
48
+ }
49
+ },
50
+ "dependencies": {
51
+ "schemastery": "^3.18.0"
52
+ },
53
+ "peerDependencies": {
54
+ "react": "^18.2.0"
55
+ },
56
+ "devDependencies": {
57
+ "@deepseek-ai/cordis": "^4.0.1",
58
+ "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.1",
59
+ "@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.1",
60
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.1",
61
+ "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.1",
62
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.1",
63
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.1",
64
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.1",
65
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.1",
66
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.1",
67
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.1",
68
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.1",
69
+ "@linxin666/dsh-pet": "0.3.7",
70
+ "@testing-library/dom": "^10.4.1",
71
+ "@testing-library/react": "^16.3.2",
72
+ "@types/node": "^22.20.0",
73
+ "@types/react": "~18.3.1",
74
+ "jsdom": "29.1.1",
75
+ "react": "^18.2.0",
76
+ "react-dom": "^18.2.0",
77
+ "tsdown": "^0.22.2",
78
+ "typescript": "^6.0.3",
79
+ "vite-tsconfig-paths": "^6.1.1",
80
+ "vitest": "^4.1.8"
81
+ },
82
+ "repository": {
83
+ "type": "git",
84
+ "url": "https://github.com/zhu1090093659/dsh-web.git"
85
+ },
86
+ "scripts": {
87
+ "build": "tsc -p tsconfig.build.json && tsdown",
88
+ "watch": "tsdown --watch",
89
+ "test": "vitest run",
90
+ "typecheck": "tsc --noEmit"
91
+ }
92
+ }
@@ -0,0 +1,368 @@
1
+ /**
2
+ * The usage statistics settings section: two tabs (用量: today's usage,
3
+ * balances, trend; 个人套餐: per-provider plan quota windows) plus a compact
4
+ * settings row. Data comes from the host's loopback-fenced
5
+ * /api/dsh-usage/overview document; polling runs only while the section is
6
+ * mounted and the tab is visible.
7
+ * @module @linxin666/dsh-usage/client/UsageSectionCard
8
+ */
9
+
10
+ import { useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react'
11
+ import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
12
+ import type { UsageStoreInstance } from './usage-store.ts'
13
+ import { t } from './locales.ts'
14
+ import styles from './usage.module.css'
15
+ import type { ProviderSnapshotView, UsageTokenTotals } from '../core/types.ts'
16
+
17
+ /** The settings fields this section edits (immediate-apply semantics). */
18
+ export interface UsageSettings {
19
+ enabled?: boolean
20
+ pollIntervalSec?: number
21
+ bubbleMode?: string
22
+ }
23
+
24
+ /** The registration-side face the section's slot entry injects. */
25
+ export interface UsageSectionFace {
26
+ /** The section-local store (overview snapshot + lifecycle). */
27
+ store: UsageStoreInstance
28
+ /** Fetch one overview now. */
29
+ poll: () => void
30
+ /** Force a host probe cycle now (resolves with the fresh overview). */
31
+ refresh: () => void
32
+ /** Whether a forced refresh is in flight (component-local state mirrors it). */
33
+ settings: SettingsScope<UsageSettings>
34
+ }
35
+
36
+ export interface UsageSectionProps extends UsageSectionFace {
37
+ /** Close the settings panel (the shell owns the open state). */
38
+ close: () => void
39
+ }
40
+
41
+ /** Poll cadence while the section is open. */
42
+ const SECTION_POLL_MS = 10_000
43
+
44
+ /** Compact token count: 12345 -> 12.3k, 1234567 -> 1.23M. */
45
+ export function formatTokens(value: number): string {
46
+ if (!Number.isFinite(value) || value <= 0) return '0'
47
+ if (value < 1000) return String(value)
48
+ if (value < 1_000_000) return trim(value / 1000) + 'k'
49
+ if (value < 1_000_000_000) return trim(value / 1_000_000) + 'M'
50
+ return trim(value / 1_000_000_000) + 'B'
51
+ }
52
+
53
+ function trim(value: number): string {
54
+ return value >= 100 ? String(Math.round(value)) : value.toFixed(value >= 10 ? 1 : 2).replace(/\.?0+$/, '')
55
+ }
56
+
57
+ function formatTime(ms: number): string {
58
+ try {
59
+ return new Date(ms).toLocaleTimeString()
60
+ } catch {
61
+ return ''
62
+ }
63
+ }
64
+
65
+ function toneClass(percent: number): string {
66
+ if (percent >= 90) return styles.barLow
67
+ if (percent >= 70) return styles.barWarn
68
+ return styles.barFill
69
+ }
70
+
71
+ function TotalsRow(props: { totals: UsageTokenTotals }): ReactNode {
72
+ const { totals } = props
73
+ return (
74
+ <div className={styles.statRow}>
75
+ <div className={styles.stat}>
76
+ <span className={styles.statValue}>{formatTokens(totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens)}</span>
77
+ <span className={styles.statLabel}>{t('usage.tokens.total')}</span>
78
+ </div>
79
+ <div className={styles.stat}>
80
+ <span className={styles.statValue}>{formatTokens(totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens)}</span>
81
+ <span className={styles.statLabel}>{t('usage.tokens.input')}</span>
82
+ </div>
83
+ <div className={styles.stat}>
84
+ <span className={styles.statValue}>{formatTokens(totals.outputTokens)}</span>
85
+ <span className={styles.statLabel}>{t('usage.tokens.output')}</span>
86
+ </div>
87
+ <div className={styles.stat}>
88
+ <span className={styles.statValue}>{formatTokens(totals.cacheReadTokens)}</span>
89
+ <span className={styles.statLabel}>{t('usage.tokens.cacheRead')}</span>
90
+ </div>
91
+ <div className={styles.stat}>
92
+ <span className={styles.statValue}>{formatTokens(totals.cacheWriteTokens)}</span>
93
+ <span className={styles.statLabel}>{t('usage.tokens.cacheWrite')}</span>
94
+ </div>
95
+ <div className={styles.stat}>
96
+ <span className={styles.statValue}>{formatTokens(totals.calls)}</span>
97
+ <span className={styles.statLabel}>{t('usage.calls', { n: totals.calls })}</span>
98
+ </div>
99
+ </div>
100
+ )
101
+ }
102
+
103
+ function balanceLine(provider: ProviderSnapshotView): ReactNode {
104
+ if (provider.balance !== undefined) {
105
+ return <span className={styles.providerBalance}>{provider.balance.currency.toUpperCase() === 'CNY' ? '¥' : provider.balance.currency.toUpperCase() === 'USD' ? '$' : ''}{provider.balance.totalBalance}{provider.balance.currency.toUpperCase() !== 'CNY' && provider.balance.currency.toUpperCase() !== 'USD' ? ' ' + provider.balance.currency.toUpperCase() : ''}</span>
106
+ }
107
+ if (provider.credential === 'oauth') return <span className={styles.muted}>{t('usage.oauth')}</span>
108
+ if (provider.credential === 'none') return <span className={styles.muted}>{t('usage.balance.noCredential')}</span>
109
+ if (!provider.supported) return <span className={styles.muted}>{t('usage.balance.unsupported')}</span>
110
+ return null
111
+ }
112
+
113
+ function ProviderRow(props: { provider: ProviderSnapshotView; current?: string }): ReactNode {
114
+ const { provider, current } = props
115
+ return (
116
+ <div className={styles.providerRow} data-dsh-part="provider-row">
117
+ <span className={styles.providerName}>
118
+ {provider.displayName}
119
+ {current === provider.provider && <span className={styles.currentBadge}>{t('usage.current')}</span>}
120
+ </span>
121
+ <span className={styles.providerTokens}>{balanceLine(provider)}</span>
122
+ </div>
123
+ )
124
+ }
125
+
126
+ /** The section component; the slot merges the face into these props. */
127
+ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
128
+ const { store, poll, refresh, settings } = props
129
+ const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
130
+ const settingsSnapshot = settings.getSnapshot()
131
+ const settingsValue = settingsSnapshot.value ?? {}
132
+ const [tab, setTab] = useState<'usage' | 'plans'>('usage')
133
+ const [refreshing, setRefreshing] = useState(false)
134
+
135
+ // Poll while mounted and visible; the overview is cheap (no probes — the
136
+ // host's own cycle owns those) so 10 s keeps balances fresh-ish between
137
+ // manual refreshes.
138
+ useEffect(() => {
139
+ poll()
140
+ let timer: number | undefined
141
+ const start = (): void => {
142
+ if (timer === undefined && document.visibilityState === 'visible') timer = window.setInterval(poll, SECTION_POLL_MS)
143
+ }
144
+ const onVisibility = (): void => {
145
+ if (document.visibilityState === 'visible') {
146
+ poll()
147
+ start()
148
+ } else if (timer !== undefined) {
149
+ window.clearInterval(timer)
150
+ timer = undefined
151
+ }
152
+ }
153
+ start()
154
+ document.addEventListener('visibilitychange', onVisibility)
155
+ return () => {
156
+ if (timer !== undefined) window.clearInterval(timer)
157
+ document.removeEventListener('visibilitychange', onVisibility)
158
+ }
159
+ }, [poll])
160
+
161
+ const snapshot = ui.snapshot
162
+ const trendMax = useMemo(() => {
163
+ if (snapshot === null) return 0
164
+ return Math.max(1, ...snapshot.usage.days.map((day) => day.totals.inputTokens + day.totals.outputTokens + day.totals.cacheReadTokens + day.totals.cacheWriteTokens))
165
+ }, [snapshot])
166
+
167
+ const onRefresh = (): void => {
168
+ setRefreshing(true)
169
+ try {
170
+ refresh()
171
+ } finally {
172
+ // The POST resolves through the next poll tick; unlock shortly either way.
173
+ window.setTimeout(() => setRefreshing(false), 3000)
174
+ }
175
+ }
176
+
177
+ if (ui.status === 'error') {
178
+ return <div className={styles.section} data-dsh-plugin="usage">{t('usage.error', { error: ui.error ?? '' })}</div>
179
+ }
180
+ if (snapshot === null) {
181
+ return <div className={styles.section} data-dsh-plugin="usage">{t('usage.loading')}</div>
182
+ }
183
+
184
+ const current = snapshot.current
185
+ const currentProvider = snapshot.providers.find((provider) => provider.provider === current.provider)
186
+ const planProviders = snapshot.providers.filter((provider) => provider.plan !== undefined || (provider.supported && provider.credential !== 'none' && provider.credential !== 'oauth'))
187
+
188
+ return (
189
+ <div className={styles.section} data-dsh-plugin="usage">
190
+ <div className={styles.header} data-dsh-part="header">
191
+ <span className={styles.currentProvider}>
192
+ {currentProvider !== undefined
193
+ ? `${currentProvider.displayName}${current.model !== undefined && current.model !== '' ? ' · ' + current.model : ''}`
194
+ : t('usage.noData')}
195
+ </span>
196
+ <span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
197
+ <span className={styles.muted}>{t('usage.updated', { time: formatTime(snapshot.updatedAt) })}</span>
198
+ <button type="button" className={styles.refreshBtn} onClick={onRefresh} disabled={refreshing}>
199
+ {refreshing ? t('usage.refreshing') : t('usage.refresh')}
200
+ </button>
201
+ </span>
202
+ </div>
203
+
204
+ <div className={styles.tabs} role="tablist" data-dsh-part="tabs">
205
+ <button type="button" role="tab" aria-selected={tab === 'usage'} className={tab === 'usage' ? `${styles.tab} ${styles.tabActive}` : styles.tab} onClick={() => setTab('usage')}>
206
+ {t('usage.tab.usage')}
207
+ </button>
208
+ <button type="button" role="tab" aria-selected={tab === 'plans'} className={tab === 'plans' ? `${styles.tab} ${styles.tabActive}` : styles.tab} onClick={() => setTab('plans')}>
209
+ {t('usage.tab.plans')}
210
+ </button>
211
+ </div>
212
+
213
+ {tab === 'usage' && (
214
+ <>
215
+ <div className={styles.card} data-dsh-part="today-card">
216
+ <span className={styles.cardTitle}>{t('usage.today')}</span>
217
+ {snapshot.usage.today.totals.calls === 0
218
+ ? <span className={styles.muted}>{t('usage.noData')}</span>
219
+ : <TotalsRow totals={snapshot.usage.today.totals} />}
220
+ {snapshot.usage.today.providers.length > 0 && (
221
+ <div data-dsh-part="provider-list">
222
+ {snapshot.usage.today.providers.map((row) => (
223
+ <div key={row.provider} className={styles.providerRow}>
224
+ <span className={styles.providerName}>
225
+ {snapshot.providers.find((provider) => provider.provider === row.provider)?.displayName ?? row.provider}
226
+ {current.provider === row.provider && <span className={styles.currentBadge}>{t('usage.current')}</span>}
227
+ </span>
228
+ <span className={styles.providerTokens}>{formatTokens(row.totals.inputTokens + row.totals.cacheReadTokens + row.totals.cacheWriteTokens + row.totals.outputTokens)} · {t('usage.calls', { n: row.totals.calls })}</span>
229
+ </div>
230
+ ))}
231
+ </div>
232
+ )}
233
+ </div>
234
+
235
+ <div className={styles.card} data-dsh-part="balance-card">
236
+ <span className={styles.cardTitle}>{t('usage.balance')}</span>
237
+ {snapshot.providers.filter((provider) => provider.supported).length === 0
238
+ ? <span className={styles.muted}>{t('usage.balance.unsupported')}</span>
239
+ : snapshot.providers.filter((provider) => provider.supported).map((provider) => (
240
+ <ProviderRow key={provider.provider} provider={provider} current={current.provider} />
241
+ ))}
242
+ {snapshot.providers.some((provider) => provider.error !== undefined) && (
243
+ <span className={styles.errorLine}>
244
+ {snapshot.providers.filter((provider) => provider.error !== undefined).map((provider) => `${provider.displayName}: ${t('usage.provider.error', { error: provider.error ?? '' })}`).join(';')}
245
+ </span>
246
+ )}
247
+ </div>
248
+
249
+ {snapshot.usage.days.length > 0 && (
250
+ <div className={styles.card} data-dsh-part="trend-card">
251
+ <span className={styles.cardTitle}>{t('usage.trend')}</span>
252
+ <div className={styles.trend}>
253
+ {snapshot.usage.days.map((day, index) => {
254
+ const total = day.totals.inputTokens + day.totals.outputTokens + day.totals.cacheReadTokens + day.totals.cacheWriteTokens
255
+ return (
256
+ <div
257
+ key={day.date}
258
+ className={index === snapshot.usage.days.length - 1 ? `${styles.trendBar} ${styles.trendBarToday}` : styles.trendBar}
259
+ style={{ height: `${Math.max(3, Math.round((total / trendMax) * 100))}%` }}
260
+ title={`${day.date}: ${formatTokens(total)}`}
261
+ />
262
+ )
263
+ })}
264
+ </div>
265
+ <div className={styles.trendAxis}>
266
+ <span>{snapshot.usage.days[0]?.date.slice(5)}</span>
267
+ <span>{snapshot.usage.days[snapshot.usage.days.length - 1]?.date.slice(5)}</span>
268
+ </div>
269
+ </div>
270
+ )}
271
+
272
+ <SettingsRow settings={settings} snapshot={settingsSnapshot.status === 'ready' ? settingsSnapshot : undefined} value={settingsValue} />
273
+ </>
274
+ )}
275
+
276
+ {tab === 'plans' && (
277
+ planProviders.length === 0
278
+ ? <div className={styles.card}><span className={styles.muted}>{t('usage.plan.noPlan')}</span></div>
279
+ : planProviders.map((provider) => <PlanCard key={provider.provider} provider={provider} />)
280
+ )}
281
+ </div>
282
+ )
283
+ }
284
+
285
+ function PlanCard(props: { provider: ProviderSnapshotView }): ReactNode {
286
+ const { provider } = props
287
+ return (
288
+ <div className={`${styles.card} ${styles.planCard}`} data-dsh-part="plan-card">
289
+ <div className={styles.planHead}>
290
+ <span className={styles.planName}>
291
+ {provider.displayName}
292
+ {provider.plan?.planName !== undefined ? ` · ${provider.plan.planName}` : ''}
293
+ </span>
294
+ </div>
295
+ {provider.error !== undefined && <span className={styles.errorLine}>{t('usage.provider.error', { error: provider.error })}</span>}
296
+ {provider.plan === undefined || provider.plan.windows.length === 0
297
+ ? <span className={styles.muted}>{t('usage.plan.noPlan')}</span>
298
+ : provider.plan.windows.map((window) => (
299
+ <div key={window.key} className={styles.windowRow} data-dsh-part="plan-window">
300
+ <span className={styles.windowLabel}>
301
+ <span>{window.name ?? t(`usage.plan.windows.${window.key}`)}</span>
302
+ <span>{window.percent !== undefined ? `${window.percent >= 10 ? Math.round(window.percent) : window.percent.toFixed(1)}%` : ''}</span>
303
+ </span>
304
+ {window.percent !== undefined && (
305
+ <span className={styles.bar}>
306
+ <span className={toneClass(window.percent)} style={{ width: `${Math.min(100, Math.max(0, window.percent))}%`, display: 'block' }} />
307
+ </span>
308
+ )}
309
+ {window.resetsAt !== undefined && (
310
+ <span className={styles.resetLine}>{t('usage.plan.reset', { date: new Date(window.resetsAt).toLocaleString() })}</span>
311
+ )}
312
+ </div>
313
+ ))}
314
+ </div>
315
+ )
316
+ }
317
+
318
+ function SettingsRow(props: {
319
+ settings: UsageSectionProps['settings']
320
+ snapshot?: { writable: boolean }
321
+ value: UsageSettings
322
+ }): ReactNode {
323
+ const { settings, snapshot, value } = props
324
+ const disabled = snapshot === undefined || !snapshot.writable
325
+ const bubbleMode = typeof value.bubbleMode === 'string' && ['always', 'change', 'off'].includes(value.bubbleMode) ? value.bubbleMode : 'always'
326
+ return (
327
+ <div className={styles.card} data-dsh-part="settings-row">
328
+ <span className={styles.cardTitle}>{t('usage.config.title')}</span>
329
+ <div className={styles.settingsGrid}>
330
+ <label className={styles.settingItem}>
331
+ <input
332
+ type="checkbox"
333
+ checked={value.enabled ?? true}
334
+ disabled={disabled}
335
+ onChange={(event) => { void settings.set('enabled', event.target.checked) }}
336
+ />
337
+ {t('usage.config.enabled')}
338
+ </label>
339
+ <label className={styles.settingItem}>
340
+ {t('usage.config.pollIntervalSec')}
341
+ <input
342
+ type="number"
343
+ min={30}
344
+ max={3600}
345
+ value={typeof value.pollIntervalSec === 'number' ? value.pollIntervalSec : 60}
346
+ disabled={disabled}
347
+ onChange={(event) => {
348
+ const parsed = Number(event.target.value)
349
+ if (Number.isFinite(parsed) && parsed >= 30 && parsed <= 3600) void settings.set('pollIntervalSec', Math.round(parsed))
350
+ }}
351
+ />
352
+ </label>
353
+ <label className={styles.settingItem}>
354
+ {t('usage.config.bubbleMode')}
355
+ <select
356
+ value={bubbleMode}
357
+ disabled={disabled}
358
+ onChange={(event) => { void settings.set('bubbleMode', event.target.value) }}
359
+ >
360
+ <option value="always">{t('usage.config.bubbleMode.always')}</option>
361
+ <option value="change">{t('usage.config.bubbleMode.change')}</option>
362
+ <option value="off">{t('usage.config.bubbleMode.off')}</option>
363
+ </select>
364
+ </label>
365
+ </div>
366
+ </div>
367
+ )
368
+ }
@@ -0,0 +1,4 @@
1
+ declare module '*.module.css' {
2
+ const classes: Record<string, string>
3
+ export default classes
4
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * dsh-usage browser half — seats the first-level 使用统计 settings section
3
+ * (below the Workshop entry) and polls the host overview only while the
4
+ * section is open. All provider probing and credential handling happens in
5
+ * the host half; this bundle only renders the overview document.
6
+ * @module @linxin666/dsh-usage/client
7
+ */
8
+
9
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
10
+ import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-ui-settings/client'
11
+ // Type-only: pulls the locale plugin's Context merge (ctx.locale).
12
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
13
+ // Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
14
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
15
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
16
+ // Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
17
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
18
+ import { createElement } from 'react'
19
+ import { createUsageStore, type UsageStoreInstance } from './usage-store.ts'
20
+ import { UsageSectionCard, type UsageSectionFace, type UsageSettings } from './UsageSectionCard.tsx'
21
+ import { NS, en, zh } from './locales.ts'
22
+ import type { UsageOverviewView } from '../core/types.ts'
23
+
24
+ /** The host usage API as the browser sees it (same-origin JSON endpoints). */
25
+ interface UsageHttpApi {
26
+ overview(): Promise<UsageOverviewView>
27
+ refresh(): Promise<UsageOverviewView>
28
+ }
29
+
30
+ async function usageFetch<T>(path: string, method: 'GET' | 'POST'): Promise<T> {
31
+ const response = await fetch(path, method === 'POST' ? { method: 'POST' } : {})
32
+ if (!response.ok) throw new Error('usage ' + path + ' failed: ' + response.status)
33
+ return (await response.json()) as T
34
+ }
35
+
36
+ const usageApi: UsageHttpApi = {
37
+ overview: () => usageFetch('/api/dsh-usage/overview', 'GET'),
38
+ refresh: () => usageFetch('/api/dsh-usage/refresh', 'POST'),
39
+ }
40
+
41
+ /** Settings namespace the section edits (the host plugin registers it). */
42
+ const USAGE_SETTINGS_NS = 'dsh-usage'
43
+
44
+ /** First-level nav position: directly below the Workshop section (order 150). */
45
+ const SECTION_ORDER = 151
46
+
47
+ /** Required services. */
48
+ export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote']
49
+
50
+ export type { UsageSectionProps, UsageSectionFace } from './UsageSectionCard.tsx'
51
+ export type { UsageUiState } from './usage-store.ts'
52
+ export type { UsageSettings }
53
+
54
+ declare module '@deepseek-ai/cordis' {
55
+ interface Context {
56
+ /**
57
+ * Optional rc.6 compatibility binder provided by dsh-web-settings;
58
+ * absent when that group plugin is not installed, so callers fall back to
59
+ * the official settings scope.
60
+ */
61
+ webUiSettings?: { bind<S>(spec: SettingsScopeSpec<S>): SettingsScope<S> }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Client plugin body: register dictionaries and seat the settings section.
67
+ * The overview poll loop and the store live with the section component's
68
+ * mount cycle, so no background traffic exists while the page is closed.
69
+ * @param ctx - client root context.
70
+ */
71
+ export function apply(ctx: ClientContext): void {
72
+ ctx.effect(() => {
73
+ try {
74
+ return ctx.locale.register(NS, { zh, en })
75
+ } catch {
76
+ return () => {}
77
+ }
78
+ }, 'dsh-usage: dictionaries')
79
+
80
+ const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
81
+ const settingsScope = binder.bind<UsageSettings>({ namespace: USAGE_SETTINGS_NS })
82
+
83
+ // One store instance per apply body; the section mounts and unmounts with
84
+ // the settings page, and the store survives between visits so the last
85
+ // overview renders instantly on reopen.
86
+ const store: UsageStoreInstance = createUsageStore().create()
87
+
88
+ let pollSeq = 0
89
+ const poll = (): void => {
90
+ const seq = pollSeq + 1
91
+ pollSeq = seq
92
+ usageApi.overview().then((snapshot) => {
93
+ if (seq !== pollSeq) return
94
+ store.actions.setSnapshot(snapshot)
95
+ }, () => {
96
+ if (seq !== pollSeq) return
97
+ store.actions.setState('error', 'usage.overview transport error')
98
+ })
99
+ }
100
+ const refresh = (): void => {
101
+ const seq = pollSeq + 1
102
+ pollSeq = seq
103
+ usageApi.refresh().then((snapshot) => {
104
+ if (seq !== pollSeq) return
105
+ store.actions.setSnapshot(snapshot)
106
+ }, () => {
107
+ if (seq !== pollSeq) return
108
+ store.actions.setState('error', 'usage.refresh transport error')
109
+ })
110
+ }
111
+
112
+ const face = (): UsageSectionFace => ({ store, poll, refresh, settings: settingsScope })
113
+
114
+ ctx.slots.inject('settings.section', () => {
115
+ try {
116
+ const unregister = ctx.slots.register({
117
+ name: 'settings.section',
118
+ id: 'dsh-usage',
119
+ order: SECTION_ORDER,
120
+ label: () => ctx.locale.bind(NS)('usage.title'),
121
+ locale: NS,
122
+ inject: face,
123
+ }, UsageSectionCard)
124
+ return () => {
125
+ unregister()
126
+ }
127
+ } catch {
128
+ return () => {}
129
+ }
130
+ })
131
+ }