dsh-simple-usage-info 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +269 -0
- package/cordis.patch.yml +35 -0
- package/lib/client.d.ts +85 -0
- package/lib/client.js +620 -0
- package/lib/holiday-source.d.ts +54 -0
- package/lib/holiday-source.js +154 -0
- package/lib/holidays.d.ts +19 -0
- package/lib/holidays.js +109 -0
- package/lib/index.d.ts +46 -0
- package/lib/index.js +263 -0
- package/lib/pricing.d.ts +105 -0
- package/lib/pricing.js +225 -0
- package/package.json +88 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-simple-usage-info — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Registered into the `conversation.composer.dock` slot: the ambient row the
|
|
5
|
+
* composer renders *below* the message input. The bar shows the DeepSeek account
|
|
6
|
+
* balance plus a peak / off-peak pill, and clicking it opens a popover with the
|
|
7
|
+
* full breakdown, laid out as label/value rows the way the shipped context meter
|
|
8
|
+
* does.
|
|
9
|
+
*
|
|
10
|
+
* The billing window is evaluated HERE, in the browser, from the serializable
|
|
11
|
+
* schedule the host sends:
|
|
12
|
+
*
|
|
13
|
+
* - the instant is skew-corrected against the host's clock, so a wrong browser
|
|
14
|
+
* clock cannot mis-report the price;
|
|
15
|
+
* - the rule is re-evaluated on a timer, so the badge flips on time instead of
|
|
16
|
+
* waiting for the next poll;
|
|
17
|
+
* - the windows are rendered in the user's own timezone, which only the
|
|
18
|
+
* browser knows.
|
|
19
|
+
*
|
|
20
|
+
* `windowAt` and `nextWindowChange` mirror lib/pricing.js. A preflight sweeps
|
|
21
|
+
* both implementations against each other so they cannot drift.
|
|
22
|
+
*
|
|
23
|
+
* This file is authored directly in the module-loader bundle format the DSH web
|
|
24
|
+
* client serves from a package's `./client` export, so the plugin needs no
|
|
25
|
+
* bundler:
|
|
26
|
+
*
|
|
27
|
+
* window.__ModuleLoader__.load({ id, factory: (require) => exports })
|
|
28
|
+
*
|
|
29
|
+
* `react`, `react-dom`, and `@deepseek-ai/dsh-client-ui-primitives` all come from
|
|
30
|
+
* the client's frozen platform module seed, so the plugin declares no
|
|
31
|
+
* `dsh.client.external` request.
|
|
32
|
+
*/
|
|
33
|
+
window.__ModuleLoader__.load({
|
|
34
|
+
id: 'dsh-simple-usage-info',
|
|
35
|
+
factory: (require) => {
|
|
36
|
+
var module = { exports: {} }
|
|
37
|
+
var exports = module.exports
|
|
38
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
39
|
+
|
|
40
|
+
const React = require('react')
|
|
41
|
+
const ReactDOM = require('react-dom')
|
|
42
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
43
|
+
|
|
44
|
+
/** The host half's route; keep in sync with `BALANCE_PATH` in lib/index.js. */
|
|
45
|
+
const PATH = '/usage/balance'
|
|
46
|
+
/** The dock cell key this plugin owns. */
|
|
47
|
+
const ID = 'usage-info'
|
|
48
|
+
/** Re-read cadence. The host caches for a minute, so this is cheap. */
|
|
49
|
+
const POLL_MS = 60000
|
|
50
|
+
/** How often the clock-derived badge re-evaluates between polls. */
|
|
51
|
+
const TICK_MS = 20000
|
|
52
|
+
|
|
53
|
+
const PEAK = 'peak'
|
|
54
|
+
const OFF_PEAK = 'off-peak'
|
|
55
|
+
|
|
56
|
+
// ------------------------------------------------------------------ styles
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Dock entries render in a centered flex row, so the bar is one inline item.
|
|
60
|
+
*
|
|
61
|
+
* `order: 1` is deliberate. ui-conversation renders the dock as
|
|
62
|
+
* `[conversation.composer.dock entries..., ContextMeter]` — the context
|
|
63
|
+
* percentage is hardcoded AFTER this slot, so no order value inside the slot
|
|
64
|
+
* can place a bar past it. Flex `order` sorts across the whole row instead:
|
|
65
|
+
* the ContextMeter root (`.JObwrW_root`) declares no `order`, so it keeps the
|
|
66
|
+
* default 0 and this item lands at the very end of the row.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Every rule lives in a stylesheet, never inline.
|
|
70
|
+
*
|
|
71
|
+
* `useAnchoredPosition` returns ONLY `{left, top}`, and that object replaces
|
|
72
|
+
* the panel's whole inline `style` — so a design carried inline disappears the
|
|
73
|
+
* instant the panel is positioned. The shipped context meter avoids this the
|
|
74
|
+
* same way: the panel wears a class, and the inline style is just coordinates.
|
|
75
|
+
*/
|
|
76
|
+
const PANEL_CSS = `
|
|
77
|
+
.dsu-panel{position:fixed;z-index:1100;box-sizing:border-box;width:min(320px,100vw - 24px);padding:12px;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-secondary);cursor:default;border:0;font-size:12px;line-height:20px}
|
|
78
|
+
.dsu-header{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
|
|
79
|
+
.dsu-title{display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary);font-weight:600}
|
|
80
|
+
.dsu-headline{color:var(--dsw-alias-label-primary);font-weight:600;font-variant-numeric:tabular-nums}
|
|
81
|
+
.dsu-divider{height:1px;background:var(--dsw-alias-border-l1);margin:8px 0}
|
|
82
|
+
.dsu-group-title{color:var(--dsw-alias-label-caption);font-size:11px;margin-top:2px}
|
|
83
|
+
.dsu-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
|
|
84
|
+
.dsu-label{color:var(--dsw-alias-label-tertiary);flex:none}
|
|
85
|
+
.dsu-value{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;text-align:right;min-width:0;overflow-wrap:anywhere}
|
|
86
|
+
.dsu-value-error{color:var(--dsw-alias-state-error-primary)}
|
|
87
|
+
.dsu-footer{display:flex;justify-content:space-between;align-items:center;gap:12px;color:var(--dsw-alias-label-tertiary)}
|
|
88
|
+
.dsu-refresh{display:inline-flex;align-items:center;justify-content:center;background:0 0;border:none;padding:2px;border-radius:var(--dsw-radius-sm);cursor:pointer;color:var(--dsw-alias-label-tertiary)}
|
|
89
|
+
.dsu-refresh:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
|
|
90
|
+
.dsu-refresh svg{display:block;transition:transform .15s ease}
|
|
91
|
+
.dsu-refresh:active svg{transform:rotate(-90deg)}
|
|
92
|
+
.dsu-bar{order:1;display:inline-flex;align-items:center;gap:6px;padding:1px 8px;border-radius:var(--dsw-radius-sm);font-family:inherit;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;cursor:pointer;user-select:none;background:0 0;border:none}
|
|
93
|
+
.dsu-bar:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
|
|
94
|
+
.dsu-dot{width:6px;height:6px;border-radius:50%;flex:none}
|
|
95
|
+
.dsu-dot-ok{background:var(--dsw-alias-state-success-primary)}
|
|
96
|
+
.dsu-dot-low{background:var(--dsw-alias-state-error-primary)}
|
|
97
|
+
.dsu-dot-unknown{background:var(--dsw-alias-label-quaternary)}
|
|
98
|
+
.dsu-pill{padding:0 6px;border-radius:999px;border:1px solid currentColor;font-size:11px;line-height:15px;font-weight:500;flex:none}
|
|
99
|
+
.dsu-pill-off{color:var(--dsw-alias-state-success-primary)}
|
|
100
|
+
.dsu-pill-peak{color:var(--dsw-alias-state-error-primary)}
|
|
101
|
+
`
|
|
102
|
+
|
|
103
|
+
const STYLE_TAG = 'dsh-simple-usage-info/panel.css'
|
|
104
|
+
|
|
105
|
+
/** Install the stylesheet once, tagged so the module system can withdraw it. */
|
|
106
|
+
function installStyles() {
|
|
107
|
+
if (typeof document === 'undefined') return
|
|
108
|
+
if (document.querySelector(`style[data-plugin-css="${STYLE_TAG}"]`) !== null) return
|
|
109
|
+
const tag = document.createElement('style')
|
|
110
|
+
tag.dataset.plugin = 'dsh-simple-usage-info'
|
|
111
|
+
tag.dataset.pluginCss = STYLE_TAG
|
|
112
|
+
tag.textContent = PANEL_CSS
|
|
113
|
+
document.head.appendChild(tag)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
installStyles()
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------- schedule
|
|
119
|
+
// Mirrors lib/pricing.js. Keep the two in step; the preflight sweeps them.
|
|
120
|
+
|
|
121
|
+
/** `YYYY-MM-DD` for one instant's UTC date. */
|
|
122
|
+
function utcDayKey(instant) {
|
|
123
|
+
const date = new Date(instant)
|
|
124
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
125
|
+
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Which window one instant falls in, per the published policy: peak only in
|
|
130
|
+
* the listed UTC windows on a listed weekday, and never on a Chinese public
|
|
131
|
+
* holiday.
|
|
132
|
+
* @param schedule - the serialized schedule from the host.
|
|
133
|
+
* @param instant - epoch milliseconds.
|
|
134
|
+
* @returns the window and why it applies.
|
|
135
|
+
*/
|
|
136
|
+
function windowAt(schedule, instant) {
|
|
137
|
+
const date = new Date(instant)
|
|
138
|
+
const key = utcDayKey(instant)
|
|
139
|
+
if (schedule.holidays.indexOf(key) >= 0) return { window: OFF_PEAK, reason: 'holiday' }
|
|
140
|
+
const makesUp = schedule.makeupWorkdaysArePeak === true && schedule.makeupWorkdays.indexOf(key) >= 0
|
|
141
|
+
if (!makesUp && schedule.peakDays.indexOf(date.getUTCDay()) < 0) return { window: OFF_PEAK, reason: 'non-peak-day' }
|
|
142
|
+
const minutes = date.getUTCHours() * 60 + date.getUTCMinutes()
|
|
143
|
+
for (const window of schedule.peakWindows) {
|
|
144
|
+
if (minutes >= window.start && minutes < window.end) return { window: PEAK, reason: 'peak-hours' }
|
|
145
|
+
}
|
|
146
|
+
return { window: OFF_PEAK, reason: 'outside-peak-hours' }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The first instant after `instant` whose window differs. Same boundary scan
|
|
151
|
+
* as the host: UTC midnights plus every window edge, eight days out.
|
|
152
|
+
* @param schedule - the serialized schedule.
|
|
153
|
+
* @param instant - epoch milliseconds.
|
|
154
|
+
* @returns the next boundary, or `undefined`.
|
|
155
|
+
*/
|
|
156
|
+
function nextWindowChange(schedule, instant) {
|
|
157
|
+
const base = new Date(instant)
|
|
158
|
+
const year = base.getUTCFullYear()
|
|
159
|
+
const month = base.getUTCMonth()
|
|
160
|
+
const day = base.getUTCDate()
|
|
161
|
+
const current = windowAt(schedule, instant).window
|
|
162
|
+
const candidates = []
|
|
163
|
+
for (let offset = 0; offset <= 8; offset += 1) {
|
|
164
|
+
const midnight = Date.UTC(year, month, day + offset, 0, 0, 0, 0)
|
|
165
|
+
candidates.push(midnight)
|
|
166
|
+
for (const window of schedule.peakWindows) {
|
|
167
|
+
candidates.push(midnight + window.start * 60000)
|
|
168
|
+
candidates.push(midnight + window.end * 60000)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const candidate of candidates.filter((value) => value > instant).sort((left, right) => left - right)) {
|
|
172
|
+
const state = windowAt(schedule, candidate)
|
|
173
|
+
if (state.window !== current) return { at: candidate, window: state.window }
|
|
174
|
+
}
|
|
175
|
+
return { at: undefined, window: undefined }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The complete billing reading for one instant.
|
|
180
|
+
* @param schedule - the serialized schedule.
|
|
181
|
+
* @param instant - epoch milliseconds.
|
|
182
|
+
* @returns window, reason, discount, and the next transition.
|
|
183
|
+
*/
|
|
184
|
+
function readWindow(schedule, instant) {
|
|
185
|
+
const state = windowAt(schedule, instant)
|
|
186
|
+
const next = nextWindowChange(schedule, instant)
|
|
187
|
+
return {
|
|
188
|
+
window: state.window,
|
|
189
|
+
reason: state.reason,
|
|
190
|
+
discountPercent: state.window === OFF_PEAK ? schedule.offPeakDiscountPercent : 0,
|
|
191
|
+
nextChangeAt: next.at,
|
|
192
|
+
nextWindow: next.window
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// -------------------------------------------------------------- formatting
|
|
197
|
+
|
|
198
|
+
/** The viewer's own timezone, which only the browser knows. */
|
|
199
|
+
const ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
200
|
+
|
|
201
|
+
const TIME_FORMAT = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
|
202
|
+
const STAMP_FORMAT = new Intl.DateTimeFormat(undefined, {
|
|
203
|
+
weekday: 'short',
|
|
204
|
+
hour: '2-digit',
|
|
205
|
+
minute: '2-digit',
|
|
206
|
+
hourCycle: 'h23'
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
/** Why the window applies, in words. */
|
|
210
|
+
const REASONS = {
|
|
211
|
+
holiday: 'Chinese public holiday',
|
|
212
|
+
'non-peak-day': 'not a peak weekday',
|
|
213
|
+
'peak-hours': 'inside a peak window',
|
|
214
|
+
'outside-peak-hours': 'outside the peak windows'
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A rough, always-rounding-up countdown, so it never reads `0m` while pending. */
|
|
218
|
+
function countdown(target, now) {
|
|
219
|
+
if (typeof target !== 'number') return undefined
|
|
220
|
+
const delta = target - (typeof now === 'number' ? now : Date.now())
|
|
221
|
+
if (delta <= 0) return undefined
|
|
222
|
+
const minutes = Math.ceil(delta / 60000)
|
|
223
|
+
const hours = Math.floor(minutes / 60)
|
|
224
|
+
if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`
|
|
225
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`
|
|
226
|
+
return `${minutes}m`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The schedule's peak windows as wall-clock times in a timezone. Offsets are
|
|
231
|
+
* read for the day of `instant`, so a DST shift shows correctly for today.
|
|
232
|
+
* @param schedule - the serialized schedule.
|
|
233
|
+
* @param instant - the reference instant.
|
|
234
|
+
* @param zone - an IANA zone; defaults to the viewer's own.
|
|
235
|
+
* @returns `09:00–12:00, 14:00–18:00`.
|
|
236
|
+
*/
|
|
237
|
+
function localWindows(schedule, instant, zone) {
|
|
238
|
+
const format =
|
|
239
|
+
zone === undefined
|
|
240
|
+
? TIME_FORMAT
|
|
241
|
+
: new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: zone })
|
|
242
|
+
const date = new Date(instant)
|
|
243
|
+
const midnight = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
|
244
|
+
return schedule.peakWindows
|
|
245
|
+
.map(
|
|
246
|
+
(window) =>
|
|
247
|
+
`${format.format(new Date(midnight + window.start * 60000))}–${format.format(new Date(midnight + window.end * 60000))}`
|
|
248
|
+
)
|
|
249
|
+
.join(', ')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The next transition as a wall-clock stamp in the viewer's timezone. */
|
|
253
|
+
function localStamp(instant) {
|
|
254
|
+
return typeof instant === 'number' ? STAMP_FORMAT.format(new Date(instant)) : undefined
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Render one wallet as `¥110.00` / `$110.00` / `110.00 CHF`. */
|
|
258
|
+
function formatWallet(info) {
|
|
259
|
+
const amount = String(info?.total_balance ?? '')
|
|
260
|
+
const currency = String(info?.currency ?? '')
|
|
261
|
+
if (currency === 'CNY') return `¥${amount}`
|
|
262
|
+
if (currency === 'USD') return `$${amount}`
|
|
263
|
+
return currency === '' ? amount : `${amount} ${currency}`
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A 6px status dot, green while the account can make API calls. */
|
|
267
|
+
function dot(available, key) {
|
|
268
|
+
const tone =
|
|
269
|
+
available === true ? 'dsu-dot-ok' : available === false ? 'dsu-dot-low' : 'dsu-dot-unknown'
|
|
270
|
+
return React.createElement('span', { key: key ?? 'dot', 'aria-hidden': true, className: `dsu-dot ${tone}` })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The dock pill. Deliberately just the window name — the discount, the
|
|
275
|
+
* reason, and every number live in the popover.
|
|
276
|
+
*/
|
|
277
|
+
function priceBadge(pricing) {
|
|
278
|
+
if (pricing === undefined || pricing === null || pricing.window === undefined) return null
|
|
279
|
+
const offPeak = pricing.window === OFF_PEAK
|
|
280
|
+
return React.createElement(
|
|
281
|
+
'span',
|
|
282
|
+
{ key: 'window', className: `dsu-pill ${offPeak ? 'dsu-pill-off' : 'dsu-pill-peak'}` },
|
|
283
|
+
offPeak ? 'Off-peak' : 'Peak'
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* One line of text for the dock. The API reports every configured wallet,
|
|
289
|
+
* usually including a permanently zero one, so the bar shows the first
|
|
290
|
+
* funded wallet; the popover carries the full breakdown either way.
|
|
291
|
+
*/
|
|
292
|
+
function summarize(state) {
|
|
293
|
+
if (state.status === 'loading') return 'DeepSeek …'
|
|
294
|
+
if (state.status === 'error') return 'DeepSeek unavailable'
|
|
295
|
+
const infos = state.payload?.balance?.balance_infos ?? []
|
|
296
|
+
if (infos.length === 0) return 'DeepSeek no balance'
|
|
297
|
+
const funded = infos.find((info) => Number(info?.total_balance ?? 0) > 0)
|
|
298
|
+
return `DeepSeek ${formatWallet(funded ?? infos[0])}`
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The popover's contents as plain data, so they can be asserted without a DOM.
|
|
303
|
+
* @param payload - the host's balance branch, or undefined.
|
|
304
|
+
* @param pricing - the live billing reading.
|
|
305
|
+
* @param now - skew-corrected epoch milliseconds.
|
|
306
|
+
* @returns the headline, the label/value groups, and the footer.
|
|
307
|
+
*/
|
|
308
|
+
function panelModel(payload, pricing, now) {
|
|
309
|
+
const infos = payload?.ok === true ? (payload.balance?.balance_infos ?? []) : []
|
|
310
|
+
const funded = infos.find((info) => Number(info?.total_balance ?? 0) > 0)
|
|
311
|
+
// The API lists a permanently zero wallet first (USD beside a funded CNY),
|
|
312
|
+
// so the funded wallet leads the panel. `sort` is stable, so the rest keep
|
|
313
|
+
// the API's order.
|
|
314
|
+
const ordered = [...infos].sort(
|
|
315
|
+
(left, right) => (Number(left?.total_balance ?? 0) > 0 ? 0 : 1) - (Number(right?.total_balance ?? 0) > 0 ? 0 : 1)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
const groups = []
|
|
319
|
+
if (pricing !== undefined && pricing !== null && pricing.window !== undefined) {
|
|
320
|
+
const offPeak = pricing.window === OFF_PEAK
|
|
321
|
+
const rows = [
|
|
322
|
+
{ label: 'Billing', value: offPeak ? 'Off-peak' : 'Peak' },
|
|
323
|
+
{ label: 'Applies because', value: REASONS[pricing.reason] ?? String(pricing.reason) }
|
|
324
|
+
]
|
|
325
|
+
const schedule = pricing.windows
|
|
326
|
+
if (schedule !== undefined) {
|
|
327
|
+
rows.push({ label: `Peak hours today (${ZONE})`, value: localWindows(schedule, now, undefined) })
|
|
328
|
+
}
|
|
329
|
+
const remaining = countdown(pricing.nextChangeAt, now)
|
|
330
|
+
rows.push({
|
|
331
|
+
label: 'Next switch',
|
|
332
|
+
value:
|
|
333
|
+
remaining === undefined
|
|
334
|
+
? '—'
|
|
335
|
+
: `${pricing.nextWindow === PEAK ? 'peak' : 'off-peak'} in ${remaining} (${localStamp(pricing.nextChangeAt)})`
|
|
336
|
+
})
|
|
337
|
+
if (pricing.holidayCovered === false) {
|
|
338
|
+
rows.push({ label: 'Holidays', value: `not bundled for ${pricing.holidayYear}` })
|
|
339
|
+
}
|
|
340
|
+
groups.push({ title: 'Billing window', rows })
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const info of ordered) {
|
|
344
|
+
groups.push({
|
|
345
|
+
title: String(info.currency ?? 'balance'),
|
|
346
|
+
rows: [
|
|
347
|
+
{ label: 'Total', value: formatWallet(info) },
|
|
348
|
+
{ label: 'Granted', value: formatWallet({ currency: info.currency, total_balance: info.granted_balance }) }
|
|
349
|
+
]
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const error =
|
|
354
|
+
payload?.ok === false ? String(payload.error?.message ?? payload.error?.code ?? 'balance unavailable') : undefined
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
headline: funded !== undefined ? formatWallet(funded) : '—',
|
|
358
|
+
available: payload?.ok === true ? payload.balance?.is_available : undefined,
|
|
359
|
+
groups,
|
|
360
|
+
error,
|
|
361
|
+
footer:
|
|
362
|
+
typeof payload?.fetchedAt === 'number'
|
|
363
|
+
? `Read at ${TIME_FORMAT.format(new Date(payload.fetchedAt))}`
|
|
364
|
+
: 'Not read yet'
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** One label/value row. */
|
|
369
|
+
function panelRow(row, index) {
|
|
370
|
+
return React.createElement('div', { key: `row-${index}`, className: 'dsu-row' }, [
|
|
371
|
+
React.createElement('span', { key: 'label', className: 'dsu-label' }, row.label),
|
|
372
|
+
React.createElement('span', { key: 'value', className: 'dsu-value' }, row.value)
|
|
373
|
+
])
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The refresh glyph: a stroked circular arrow, sized to the text line. */
|
|
377
|
+
function refreshIcon() {
|
|
378
|
+
return React.createElement(
|
|
379
|
+
'svg',
|
|
380
|
+
{
|
|
381
|
+
key: 'glyph',
|
|
382
|
+
viewBox: '0 0 24 24',
|
|
383
|
+
width: 14,
|
|
384
|
+
height: 14,
|
|
385
|
+
fill: 'none',
|
|
386
|
+
stroke: 'currentColor',
|
|
387
|
+
strokeWidth: 2,
|
|
388
|
+
strokeLinecap: 'round',
|
|
389
|
+
strokeLinejoin: 'round',
|
|
390
|
+
'aria-hidden': true,
|
|
391
|
+
focusable: false
|
|
392
|
+
},
|
|
393
|
+
[
|
|
394
|
+
React.createElement('polyline', { key: 'head', points: '23 4 23 10 17 10' }),
|
|
395
|
+
React.createElement('path', { key: 'arc', d: 'M20.49 15a9 9 0 1 1-2.12-9.36L23 10' })
|
|
396
|
+
]
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The popover card, portaled to the body and positioned from its anchor. */
|
|
401
|
+
function panelElement(model, position, panelRef, onRefresh) {
|
|
402
|
+
const children = [
|
|
403
|
+
React.createElement('div', { key: 'header', className: 'dsu-header' }, [
|
|
404
|
+
React.createElement('span', { key: 'title', className: 'dsu-title' }, [
|
|
405
|
+
dot(model.available, 'dot'),
|
|
406
|
+
React.createElement('span', { key: 'text' }, 'DeepSeek account')
|
|
407
|
+
]),
|
|
408
|
+
React.createElement('span', { key: 'headline', className: 'dsu-headline' }, model.headline)
|
|
409
|
+
])
|
|
410
|
+
]
|
|
411
|
+
model.groups.forEach((group, groupIndex) => {
|
|
412
|
+
children.push(React.createElement('div', { key: `divider-${groupIndex}`, className: 'dsu-divider' }))
|
|
413
|
+
children.push(
|
|
414
|
+
React.createElement('div', { key: `group-${groupIndex}` }, [
|
|
415
|
+
React.createElement('div', { key: 'title', className: 'dsu-group-title' }, group.title),
|
|
416
|
+
...group.rows.map(panelRow)
|
|
417
|
+
])
|
|
418
|
+
)
|
|
419
|
+
})
|
|
420
|
+
if (model.error !== undefined) {
|
|
421
|
+
children.push(
|
|
422
|
+
React.createElement('div', { key: 'error', className: 'dsu-row' }, [
|
|
423
|
+
React.createElement('span', { key: 'label', className: 'dsu-label' }, 'Balance'),
|
|
424
|
+
React.createElement('span', { key: 'value', className: 'dsu-value dsu-value-error' }, model.error)
|
|
425
|
+
])
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
children.push(React.createElement('div', { key: 'footer-divider', className: 'dsu-divider' }))
|
|
429
|
+
children.push(
|
|
430
|
+
React.createElement('div', { key: 'footer', className: 'dsu-footer' }, [
|
|
431
|
+
React.createElement('span', { key: 'read' }, model.footer),
|
|
432
|
+
React.createElement(
|
|
433
|
+
'button',
|
|
434
|
+
{
|
|
435
|
+
key: 'refresh',
|
|
436
|
+
type: 'button',
|
|
437
|
+
className: 'dsu-refresh',
|
|
438
|
+
onClick: onRefresh,
|
|
439
|
+
title: 'Refresh balance',
|
|
440
|
+
'aria-label': 'Refresh balance'
|
|
441
|
+
},
|
|
442
|
+
refreshIcon()
|
|
443
|
+
)
|
|
444
|
+
])
|
|
445
|
+
)
|
|
446
|
+
return React.createElement(
|
|
447
|
+
'div',
|
|
448
|
+
{
|
|
449
|
+
ref: panelRef,
|
|
450
|
+
className: 'dsu-panel',
|
|
451
|
+
// The anchor hook returns ONLY coordinates, and this object replaces the
|
|
452
|
+
// whole inline style — so it must never be the carrier of the design.
|
|
453
|
+
style: position ?? { visibility: 'hidden', left: 0, top: 0 },
|
|
454
|
+
role: 'dialog',
|
|
455
|
+
'aria-label': 'DeepSeek account and billing window'
|
|
456
|
+
},
|
|
457
|
+
children
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The dock entry. Reads the host route on mount and once a minute after,
|
|
463
|
+
* re-evaluates the billing window every 20 s in between, and opens the
|
|
464
|
+
* popover on click.
|
|
465
|
+
* @returns the bar plus, while open, the portaled panel.
|
|
466
|
+
*/
|
|
467
|
+
function UsageBar() {
|
|
468
|
+
const [state, setState] = React.useState({ status: 'loading' })
|
|
469
|
+
const [open, setOpen] = React.useState(false)
|
|
470
|
+
const [, setTick] = React.useState(0)
|
|
471
|
+
const alive = React.useRef(true)
|
|
472
|
+
const rootRef = React.useRef(null)
|
|
473
|
+
const panelRef = React.useRef(null)
|
|
474
|
+
|
|
475
|
+
const load = React.useCallback(async (force) => {
|
|
476
|
+
try {
|
|
477
|
+
const response = await fetch(force === true ? `${PATH}?refresh=1` : PATH, {
|
|
478
|
+
headers: { accept: 'application/json' },
|
|
479
|
+
credentials: 'same-origin'
|
|
480
|
+
})
|
|
481
|
+
const body = await response.json().catch(() => undefined)
|
|
482
|
+
if (alive.current !== true) return
|
|
483
|
+
// The host sends `pricing` in both branches, so the billing window
|
|
484
|
+
// stays on screen even when the balance read failed.
|
|
485
|
+
const pricing = body?.pricing
|
|
486
|
+
const skewMs = typeof pricing?.now === 'number' ? pricing.now - Date.now() : 0
|
|
487
|
+
if (body !== undefined && body.ok === true) setState({ status: 'ready', payload: body, pricing, skewMs })
|
|
488
|
+
else if (body !== undefined && body.ok === false)
|
|
489
|
+
setState({ status: 'error', message: String(body.error?.message ?? 'unknown error'), pricing, skewMs })
|
|
490
|
+
else setState({ status: 'error', message: `HTTP ${response.status}` })
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (alive.current === true)
|
|
493
|
+
setState((previous) => ({
|
|
494
|
+
status: 'error',
|
|
495
|
+
message: String(error?.message ?? error),
|
|
496
|
+
pricing: previous.pricing,
|
|
497
|
+
skewMs: previous.skewMs
|
|
498
|
+
}))
|
|
499
|
+
}
|
|
500
|
+
}, [])
|
|
501
|
+
|
|
502
|
+
React.useEffect(() => {
|
|
503
|
+
alive.current = true
|
|
504
|
+
load(false)
|
|
505
|
+
const poll = setInterval(() => load(false), POLL_MS)
|
|
506
|
+
const tick = setInterval(() => setTick((count) => count + 1), TICK_MS)
|
|
507
|
+
return () => {
|
|
508
|
+
alive.current = false
|
|
509
|
+
clearInterval(poll)
|
|
510
|
+
clearInterval(tick)
|
|
511
|
+
}
|
|
512
|
+
}, [load])
|
|
513
|
+
|
|
514
|
+
React.useEffect(() => {
|
|
515
|
+
if (open !== true) return undefined
|
|
516
|
+
const onKeyDown = (event) => {
|
|
517
|
+
if (event.key === 'Escape') setOpen(false)
|
|
518
|
+
}
|
|
519
|
+
document.addEventListener('keydown', onKeyDown)
|
|
520
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
521
|
+
}, [open])
|
|
522
|
+
|
|
523
|
+
const position = primitives.useAnchoredPosition({
|
|
524
|
+
open,
|
|
525
|
+
anchorRef: rootRef,
|
|
526
|
+
panelRef,
|
|
527
|
+
side: 'top',
|
|
528
|
+
gap: 8,
|
|
529
|
+
margin: 12
|
|
530
|
+
})
|
|
531
|
+
primitives.useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef)
|
|
532
|
+
|
|
533
|
+
// Billing time is the host's clock, corrected for this browser's skew, so a
|
|
534
|
+
// wrong local clock cannot mis-report the price.
|
|
535
|
+
const now = Date.now() + (state.skewMs ?? 0)
|
|
536
|
+
const host = state.pricing
|
|
537
|
+
const schedule = host?.windows
|
|
538
|
+
const pricing =
|
|
539
|
+
schedule !== undefined
|
|
540
|
+
? {
|
|
541
|
+
...readWindow(schedule, now),
|
|
542
|
+
schedule: host.schedule,
|
|
543
|
+
holidayYear: host.holidayYear,
|
|
544
|
+
holidayCovered: host.holidayCovered,
|
|
545
|
+
windows: schedule
|
|
546
|
+
}
|
|
547
|
+
: host
|
|
548
|
+
|
|
549
|
+
const available = state.status === 'ready' ? state.payload?.balance?.is_available : undefined
|
|
550
|
+
const bar = React.createElement(
|
|
551
|
+
'span',
|
|
552
|
+
{
|
|
553
|
+
key: 'bar',
|
|
554
|
+
ref: rootRef,
|
|
555
|
+
role: 'button',
|
|
556
|
+
tabIndex: 0,
|
|
557
|
+
'aria-haspopup': 'dialog',
|
|
558
|
+
'aria-expanded': open,
|
|
559
|
+
title: 'DeepSeek balance and billing window',
|
|
560
|
+
onClick: () => setOpen((value) => value !== true),
|
|
561
|
+
onKeyDown: (event) => {
|
|
562
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
563
|
+
event.preventDefault()
|
|
564
|
+
setOpen((value) => value !== true)
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
className: 'dsu-bar'
|
|
568
|
+
},
|
|
569
|
+
[dot(available), React.createElement('span', { key: 'text' }, summarize(state)), priceBadge(pricing)]
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
return React.createElement(React.Fragment, null, [
|
|
573
|
+
bar,
|
|
574
|
+
open === true
|
|
575
|
+
? ReactDOM.createPortal(
|
|
576
|
+
panelElement(panelModel(state.payload, pricing, now), position, panelRef, () => load(true)),
|
|
577
|
+
document.body
|
|
578
|
+
)
|
|
579
|
+
: null
|
|
580
|
+
])
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Cordis services this browser plugin needs. */
|
|
584
|
+
const inject = ['slots']
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Client plugin body: one entry in the composer dock.
|
|
588
|
+
* @param ctx - client root context.
|
|
589
|
+
*/
|
|
590
|
+
function apply(ctx) {
|
|
591
|
+
ctx.slots.inject('conversation.composer.dock', () =>
|
|
592
|
+
ctx.slots.register(
|
|
593
|
+
{
|
|
594
|
+
name: 'conversation.composer.dock',
|
|
595
|
+
id: ID,
|
|
596
|
+
order: 50
|
|
597
|
+
},
|
|
598
|
+
UsageBar
|
|
599
|
+
)
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
exports.UsageBar = UsageBar
|
|
604
|
+
exports.apply = apply
|
|
605
|
+
exports.inject = inject
|
|
606
|
+
// Exported for the offline preflight; not part of the plugin contract.
|
|
607
|
+
exports.formatWallet = formatWallet
|
|
608
|
+
exports.summarize = summarize
|
|
609
|
+
exports.priceBadge = priceBadge
|
|
610
|
+
exports.panelModel = panelModel
|
|
611
|
+
exports.countdown = countdown
|
|
612
|
+
exports.windowAt = windowAt
|
|
613
|
+
exports.readWindow = readWindow
|
|
614
|
+
exports.localWindows = localWindows
|
|
615
|
+
exports.zone = ZONE
|
|
616
|
+
exports.panelElement = panelElement
|
|
617
|
+
exports.PANEL_CSS = PANEL_CSS
|
|
618
|
+
return module.exports
|
|
619
|
+
}
|
|
620
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-simple-usage-info — maintained holiday source types.
|
|
3
|
+
* @module dsh-simple-usage-info/holiday-source
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Where the maintained data lives; `{year}` is substituted. */
|
|
7
|
+
export declare const DEFAULT_HOLIDAY_URL: string
|
|
8
|
+
/** How long one successful snapshot stays fresh. */
|
|
9
|
+
export declare const DEFAULT_TTL_MS: number
|
|
10
|
+
/** Remote request timeout. */
|
|
11
|
+
export declare const DEFAULT_TIMEOUT_MS: number
|
|
12
|
+
|
|
13
|
+
/** One merged, fetched holiday snapshot. */
|
|
14
|
+
export interface HolidaySnapshot {
|
|
15
|
+
/** Every statutory day off across the fetched years, sorted. */
|
|
16
|
+
readonly holidays: readonly string[]
|
|
17
|
+
/** Every 调休 makeup workday across the fetched years, sorted. */
|
|
18
|
+
readonly makeupWorkdays: readonly string[]
|
|
19
|
+
/** The years the snapshot actually covers. */
|
|
20
|
+
readonly years: readonly number[]
|
|
21
|
+
/** The State Council papers the data was scraped from. */
|
|
22
|
+
readonly papers: readonly string[]
|
|
23
|
+
/** When the snapshot was published. */
|
|
24
|
+
readonly fetchedAt: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Injectable seams and configuration for {@link createHolidaySource}. */
|
|
28
|
+
export interface HolidaySourceOptions {
|
|
29
|
+
/** URL template with a `{year}` placeholder; empty disables fetching. */
|
|
30
|
+
readonly urlTemplate?: string
|
|
31
|
+
/** How long a snapshot stays fresh. */
|
|
32
|
+
readonly ttlMs?: number
|
|
33
|
+
/** Per-request timeout. */
|
|
34
|
+
readonly timeoutMs?: number
|
|
35
|
+
/** The fetch implementation; injectable for tests. */
|
|
36
|
+
readonly fetchImpl?: typeof fetch
|
|
37
|
+
/** The clock; injectable for tests. */
|
|
38
|
+
readonly now?: () => number
|
|
39
|
+
/** Where refresh failures are reported. */
|
|
40
|
+
readonly log?: (message: string) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A lazily-refreshed holiday source. */
|
|
44
|
+
export interface HolidaySource {
|
|
45
|
+
/** The current snapshot, or undefined before the first success. */
|
|
46
|
+
peek(): HolidaySnapshot | undefined
|
|
47
|
+
/** Whether a refresh is worth starting for these years. */
|
|
48
|
+
stale(years: readonly number[]): boolean
|
|
49
|
+
/** Fetch every requested year; never rejects. */
|
|
50
|
+
refresh(years: readonly number[]): Promise<HolidaySnapshot | undefined>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build a lazily-refreshed holiday source. */
|
|
54
|
+
export declare function createHolidaySource(options?: HolidaySourceOptions): HolidaySource
|