@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/LICENSE +202 -0
- package/README.i18n.yaml +5 -0
- package/README.md +50 -0
- package/README.zh.md +50 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +755 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1443 -0
- package/lib/types/client/UsageSectionCard.d.ts +36 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +62 -0
- package/lib/types/client/usage-store.d.ts +27 -0
- package/lib/types/core/adapters.d.ts +58 -0
- package/lib/types/core/ledger.d.ts +31 -0
- package/lib/types/core/types.d.ts +112 -0
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host/http.d.ts +42 -0
- package/lib/types/host/loopback.d.ts +24 -0
- package/lib/types/host/routes.d.ts +14 -0
- package/lib/types/host/usage-service.d.ts +82 -0
- package/lib/types/index.d.ts +21 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/package.json +92 -0
- package/src/client/UsageSectionCard.tsx +368 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.ts +131 -0
- package/src/client/locales.ts +116 -0
- package/src/client/usage-store.ts +52 -0
- package/src/client/usage.module.css +311 -0
- package/src/core/adapters.ts +370 -0
- package/src/core/ledger.ts +108 -0
- package/src/core/types.ts +127 -0
- package/src/dsh-home.ts +42 -0
- package/src/host/http.ts +105 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/routes.ts +54 -0
- package/src/host/usage-service.ts +530 -0
- package/src/index.ts +90 -0
- package/src/mount-once.ts +48 -0
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,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
|
+
}
|