@linxin666/dsh-usage 0.3.22 → 0.3.24

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.
Files changed (40) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +6 -8
  3. package/README.zh.md +6 -8
  4. package/lib/client.js +749 -78
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +171 -201
  7. package/lib/types/client/UsageSectionCard.d.ts +0 -1
  8. package/lib/types/client/UsageSidebarPanel.d.ts +34 -0
  9. package/lib/types/client/body-mutations.d.ts +45 -0
  10. package/lib/types/client/locales.d.ts +9 -4
  11. package/lib/types/client/sidebar-entry-core.d.ts +87 -0
  12. package/lib/types/client/sidebar-entry.d.ts +35 -0
  13. package/lib/types/client/sidebar-panel-mount.d.ts +40 -0
  14. package/lib/types/client/voucher.d.ts +2 -2
  15. package/lib/types/core/provider-routes.d.ts +26 -0
  16. package/lib/types/core/types.d.ts +14 -1
  17. package/lib/types/host/access.d.ts +17 -0
  18. package/lib/types/host/pair-access.d.ts +34 -0
  19. package/lib/types/host/routes.d.ts +16 -7
  20. package/lib/types/host/usage-service.d.ts +28 -73
  21. package/lib/types/index.d.ts +0 -2
  22. package/package.json +4 -3
  23. package/src/client/UsageSectionCard.tsx +1 -15
  24. package/src/client/UsageSidebarPanel.tsx +196 -0
  25. package/src/client/body-mutations.ts +133 -0
  26. package/src/client/index.ts +22 -1
  27. package/src/client/locales.ts +20 -10
  28. package/src/client/sidebar-entry-core.ts +321 -0
  29. package/src/client/sidebar-entry.ts +101 -0
  30. package/src/client/sidebar-panel-mount.tsx +113 -0
  31. package/src/client/usage.module.css +252 -0
  32. package/src/client/voucher.ts +2 -2
  33. package/src/core/adapters.ts +40 -6
  34. package/src/core/provider-routes.ts +37 -0
  35. package/src/core/types.ts +14 -1
  36. package/src/host/access.ts +21 -0
  37. package/src/host/pair-access.ts +49 -0
  38. package/src/host/routes.ts +19 -10
  39. package/src/host/usage-service.ts +65 -214
  40. package/src/index.ts +1 -6
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Sidebar usage panel (issue #1592): the usage body seated directly under the
3
+ * sidebar entry row. The row carries the panel's controls (refresh, collapse
4
+ * chevron), so this component renders only the content: expanded, plan
5
+ * providers show their quota windows and balance-only providers their
6
+ * remaining amount; collapsed, a one-line strip shows the current session
7
+ * provider's today usage (tokens, calls, and cost when priced) from the same
8
+ * /api/dsh-usage/overview document the settings section renders. The open
9
+ * state lives in the mount (persisted to localStorage) and arrives through
10
+ * the open face; polling runs at 10s expanded, 30s collapsed, and pauses
11
+ * while the page is hidden.
12
+ * @module @linxin666/dsh-usage/client/UsageSidebarPanel
13
+ */
14
+
15
+ import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
16
+ import { t } from './locales.ts'
17
+ import styles from './usage.module.css'
18
+ import { formatTokens } from './UsageSectionCard.tsx'
19
+ import type { UsageStoreInstance } from './usage-store.ts'
20
+ import { adapterFor } from '../core/adapters.ts'
21
+ import { totalTokens } from '../core/ledger.ts'
22
+ import type { ProviderSnapshotView, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
23
+ import { addTotals, emptyTotals } from '../core/types.ts'
24
+
25
+ /** Expanded-state face the owning mount supplies (subscribe + current value). */
26
+ export interface OpenStateSource {
27
+ subscribe(listener: () => void): () => void
28
+ isOpen(): boolean
29
+ }
30
+
31
+ /** Props of the sidebar panel (the apply body supplies the store and poller). */
32
+ export interface UsageSidebarPanelProps {
33
+ store: UsageStoreInstance
34
+ /** Fetch one overview now. */
35
+ poll: () => void
36
+ /** Expanded state owned by the mount; false renders nothing and pauses polling. */
37
+ open: OpenStateSource
38
+ }
39
+
40
+ /** Poll cadence while the panel is expanded and the page visible. */
41
+ const PANEL_POLL_MS = 10_000
42
+
43
+ /**
44
+ * Poll cadence of the collapsed strip: the current session's usage should
45
+ * stay visibly live, but a collapsed surface pays a relaxed cadence instead
46
+ * of the expanded one.
47
+ */
48
+ const COLLAPSED_POLL_MS = 30_000
49
+
50
+ /** Bar tone, matching the settings section's thresholds. */
51
+ function tone(percent: number): string {
52
+ if (percent >= 90) return styles.barLow
53
+ if (percent >= 70) return styles.barWarn
54
+ return styles.sidebarBarFill
55
+ }
56
+
57
+ /**
58
+ * The collapsed-strip facts for the current provider: display name plus
59
+ * today's adapter-family totals (tokens, calls, cost). The host serves them
60
+ * strip-ready inside `current`; an older host document is derived against
61
+ * here. Undefined on a day without usage — a strip about nothing is noise.
62
+ */
63
+ function collapsedSummary(snapshot: UsageOverviewView): { name: string; totals: UsageTokenTotals } | undefined {
64
+ const provider = snapshot.current.provider
65
+ if (provider === undefined) return undefined
66
+ const name = snapshot.current.displayName
67
+ ?? snapshot.providers.find((entry) => entry.provider === provider)?.displayName
68
+ ?? provider
69
+ if (snapshot.current.today !== undefined) {
70
+ const totals = snapshot.current.today
71
+ return totals.calls > 0 && totalTokens(totals) > 0 ? { name, totals } : undefined
72
+ }
73
+ const family = adapterFor(provider)
74
+ let merged = emptyTotals()
75
+ for (const row of snapshot.usage.today.providers) {
76
+ const same = family === undefined ? row.provider === provider : adapterFor(row.provider) === family
77
+ if (same) merged = addTotals(merged, row.totals)
78
+ }
79
+ return merged.calls > 0 && totalTokens(merged) > 0 ? { name, totals: merged } : undefined
80
+ }
81
+
82
+ /** A provider is worth listing when it is configured and carries a fact. */
83
+ function listable(provider: ProviderSnapshotView): boolean {
84
+ return provider.credential !== 'none' && (provider.plan !== undefined || provider.balance !== undefined)
85
+ }
86
+
87
+ /** One provider row: plan windows when present, otherwise the balance line. */
88
+ function ProviderBlock(props: { provider: ProviderSnapshotView }): ReactNode {
89
+ const { provider } = props
90
+ const windows = provider.plan?.windows ?? []
91
+ if (windows.length > 0) {
92
+ return (
93
+ <div className={styles.sidebarProvider} data-dsh-part="sidebar-provider">
94
+ <div className={styles.sidebarProviderHead}>
95
+ <span className={styles.sidebarProviderName}>{provider.displayName}</span>
96
+ </div>
97
+ {windows.map((window) => (
98
+ <div key={window.key} className={styles.sidebarWindowRow} data-dsh-part="sidebar-window">
99
+ <span>{window.name ?? t(`usage.plan.windows.${window.key}`)}</span>
100
+ {window.percent !== undefined && (
101
+ <>
102
+ <span className={styles.sidebarBar}>
103
+ <span className={tone(window.percent)} style={{ width: `${Math.min(100, Math.max(0, window.percent))}%` }} />
104
+ </span>
105
+ <span>{window.percent >= 10 ? Math.round(window.percent) : window.percent.toFixed(1)}%</span>
106
+ </>
107
+ )}
108
+ </div>
109
+ ))}
110
+ </div>
111
+ )
112
+ }
113
+ if (provider.balance !== undefined) {
114
+ return (
115
+ <div className={styles.sidebarProvider} data-dsh-part="sidebar-provider">
116
+ <div className={styles.sidebarProviderHead}>
117
+ <span className={styles.sidebarProviderName}>{provider.displayName}</span>
118
+ <span className={styles.sidebarBalance}>
119
+ {t('usage.sidebar.balanceLeft', { balance: `${provider.balance.currency} ${provider.balance.totalBalance}` })}
120
+ </span>
121
+ </div>
122
+ </div>
123
+ )
124
+ }
125
+ return null
126
+ }
127
+
128
+ /**
129
+ * Render the sidebar usage panel body.
130
+ * @param props - store, the poll callback, and the mount's open-state face.
131
+ * @returns the panel content, or null while collapsed.
132
+ */
133
+ export function UsageSidebarPanel(props: UsageSidebarPanelProps): ReactNode {
134
+ const { store, poll, open: openState } = props
135
+ const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
136
+ const open = useSyncExternalStore(openState.subscribe, openState.isOpen)
137
+
138
+ // The strip stays live at a relaxed cadence while collapsed; the
139
+ // expanded panel keeps the section's own 10s cadence. A hidden tab always
140
+ // pauses, matching the settings section's gating.
141
+ useEffect(() => {
142
+ poll()
143
+ const cadence = open ? PANEL_POLL_MS : COLLAPSED_POLL_MS
144
+ let timer: number | undefined
145
+ const start = (): void => {
146
+ if (timer === undefined && document.visibilityState === 'visible') timer = window.setInterval(poll, cadence)
147
+ }
148
+ const onVisibility = (): void => {
149
+ if (document.visibilityState === 'visible') {
150
+ poll()
151
+ start()
152
+ } else if (timer !== undefined) {
153
+ window.clearInterval(timer)
154
+ timer = undefined
155
+ }
156
+ }
157
+ start()
158
+ document.addEventListener('visibilitychange', onVisibility)
159
+ return () => {
160
+ if (timer !== undefined) window.clearInterval(timer)
161
+ document.removeEventListener('visibilitychange', onVisibility)
162
+ }
163
+ }, [poll, open])
164
+
165
+ const snapshot: UsageOverviewView | null = ui.snapshot
166
+
167
+ if (!open) {
168
+ const summary = snapshot === null ? undefined : collapsedSummary(snapshot)
169
+ if (summary === undefined) return null
170
+ return (
171
+ <div className={styles.sidebarSummary} data-dsh-plugin="usage" data-dsh-part="sidebar-summary">
172
+ <span className={styles.sidebarSummaryName}>{summary.name}</span>
173
+ <span className={styles.sidebarSummaryFacts}>
174
+ {t('usage.sidebar.todayUsage', { tokens: formatTokens(totalTokens(summary.totals)) })}
175
+ {' · '}
176
+ {t('usage.calls', { n: summary.totals.calls })}
177
+ {summary.totals.cost > 0 ? ` · ¥${summary.totals.cost.toFixed(2)}` : ''}
178
+ </span>
179
+ </div>
180
+ )
181
+ }
182
+
183
+ const providers = snapshot === null ? [] : snapshot.providers.filter(listable)
184
+
185
+ return (
186
+ <div className={styles.sidebarPanel} data-dsh-plugin="usage" data-dsh-part="sidebar-panel">
187
+ {ui.status === 'error'
188
+ ? <span className={styles.sidebarMuted}>{t('usage.sidebar.error', { error: ui.error ?? '' })}</span>
189
+ : snapshot === null
190
+ ? <span className={styles.sidebarMuted}>{t('usage.sidebar.loading')}</span>
191
+ : providers.length === 0
192
+ ? <span className={styles.sidebarMuted}>{t('usage.sidebar.empty')}</span>
193
+ : providers.map((provider) => <ProviderBlock key={provider.provider} provider={provider} />)}
194
+ </div>
195
+ )
196
+ }
@@ -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
+ }
@@ -15,8 +15,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
15
15
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
16
16
  // Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
17
17
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
18
- import { createElement } from 'react'
19
18
  import { createUsageStore, type UsageStoreInstance } from './usage-store.ts'
19
+ import { mountSidebarEntry } from './sidebar-entry.ts'
20
+ import { mountUsagePanel } from './sidebar-panel-mount.tsx'
20
21
  import { UsageSectionCard, type UsageSectionFace, type UsageSettings } from './UsageSectionCard.tsx'
21
22
  import { NS, en, zh } from './locales.ts'
22
23
  import type { UsageOverviewView } from '../core/types.ts'
@@ -123,6 +124,26 @@ export function apply(ctx: ClientContext): void {
123
124
 
124
125
  const face = (): UsageSectionFace => ({ store, poll, refresh, settings: settingsScope })
125
126
 
127
+ // Sidebar surface (issue #1592): the entry row seats the panel's controls
128
+ // (refresh, collapse chevron); the collapsible panel directly under it reads
129
+ // the same overview document. The mount owns the persisted open state; the
130
+ // entry row mirrors it as highlight and chevron direction.
131
+ const panel = mountUsagePanel({ store, poll })
132
+ const disposeEntry = mountSidebarEntry({
133
+ onToggle: () => { panel.toggle() },
134
+ onRefresh: () => { refresh() },
135
+ isOpen: () => panel.isOpen(),
136
+ subscribeOpen: (listener) => panel.subscribe(listener),
137
+ }, ctx.locale)
138
+ ctx.effect(() => () => {
139
+ try {
140
+ disposeEntry()
141
+ } catch {
142
+ // Entry row already gone (teardown race).
143
+ }
144
+ panel.dispose()
145
+ }, 'dsh-usage: sidebar panel')
146
+
126
147
  ctx.slots.inject('settings.section', () => {
127
148
  try {
128
149
  const unregister = ctx.slots.register({
@@ -48,12 +48,8 @@ export const zh = {
48
48
  'usage.config.title': '设置',
49
49
  'usage.config.enabled': '启用插件',
50
50
  'usage.config.pollIntervalSec': '轮询间隔(秒)',
51
- 'usage.config.bubbleMode': '宠物气泡',
52
- 'usage.config.bubbleMode.always': '常驻显示',
53
- 'usage.config.bubbleMode.change': '仅变化时',
54
- 'usage.config.bubbleMode.off': '关闭',
55
51
  'usage.bank.title': '鲸元券',
56
- 'usage.bank.hint': '官方 API 每消耗 1000 tokens 铸造 1 鲸元;保存或分享这张票券。',
52
+ 'usage.bank.hint': '官方 API 每消耗 100 万 tokens 铸造 1 鲸元;保存或分享这张票券。',
57
53
  'usage.bank.noUsage': '暂无 DeepSeek 官方用量数据(统计自插件启用起)',
58
54
  'usage.bank.minted': '累计铸造 {minted} 鲸元({tokens} tokens)',
59
55
  'usage.bank.spend.observed': '官方余额实测花费 ¥{cost}(自 {since} 起)',
@@ -62,6 +58,15 @@ export const zh = {
62
58
  'usage.bank.save': '保存图片',
63
59
  'usage.bank.share': '分享',
64
60
  'usage.bank.drawError': '票券生成失败:{error}',
61
+ 'usage.sidebar.entry.label': '用量',
62
+ 'usage.sidebar.entry.tooltip': '用量面板:套餐配额与余额',
63
+ 'usage.sidebar.toggle.collapse': '折叠',
64
+ 'usage.sidebar.toggle.expand': '展开',
65
+ 'usage.sidebar.empty': '没有已配置的套餐或余额数据。',
66
+ 'usage.sidebar.error': '加载失败:{error}',
67
+ 'usage.sidebar.loading': '正在加载用量数据…',
68
+ 'usage.sidebar.balanceLeft': '剩余 {balance}',
69
+ 'usage.sidebar.todayUsage': '今日 {tokens} tokens',
65
70
  }
66
71
 
67
72
  /** English mirror; every zh key present. */
@@ -105,12 +110,8 @@ export const en: Record<UsageKey, string> = {
105
110
  'usage.config.title': 'Settings',
106
111
  'usage.config.enabled': 'Enable plugin',
107
112
  'usage.config.pollIntervalSec': 'Poll interval (seconds)',
108
- 'usage.config.bubbleMode': 'Pet bubble',
109
- 'usage.config.bubbleMode.always': 'Always visible',
110
- 'usage.config.bubbleMode.change': 'On change',
111
- 'usage.config.bubbleMode.off': 'Off',
112
113
  'usage.bank.title': 'Whale-yuan voucher',
113
- 'usage.bank.hint': 'Every 1,000 tokens spent on the official API mint one whale yuan; save or share the note.',
114
+ 'usage.bank.hint': 'Every 1,000,000 tokens spent on the official API mint one whale yuan; save or share the note.',
114
115
  'usage.bank.noUsage': 'No official DeepSeek usage yet (counting starts when the plugin is enabled)',
115
116
  'usage.bank.minted': 'Minted {minted} whale yuan ({tokens} tokens)',
116
117
  'usage.bank.spend.observed': 'Spent ¥{cost} observed on the official balance (watching since {since})',
@@ -119,6 +120,15 @@ export const en: Record<UsageKey, string> = {
119
120
  'usage.bank.save': 'Save image',
120
121
  'usage.bank.share': 'Share',
121
122
  'usage.bank.drawError': 'Failed to render the voucher: {error}',
123
+ 'usage.sidebar.entry.label': 'Usage',
124
+ 'usage.sidebar.entry.tooltip': 'Usage panel: plan quotas and balances',
125
+ 'usage.sidebar.toggle.collapse': 'Collapse',
126
+ 'usage.sidebar.toggle.expand': 'Expand',
127
+ 'usage.sidebar.empty': 'No plan or balance data configured.',
128
+ 'usage.sidebar.error': 'Loading failed: {error}',
129
+ 'usage.sidebar.loading': 'Loading usage data…',
130
+ 'usage.sidebar.balanceLeft': '{balance} left',
131
+ 'usage.sidebar.todayUsage': '{tokens} tokens today',
122
132
  }
123
133
 
124
134
  export type UsageKey = keyof typeof zh