@maci0/dsh-quota-check 0.12.2

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/lib/client.js ADDED
@@ -0,0 +1,471 @@
1
+ /**
2
+ * dsh-quota-check browser half: the quota chip in the composer statusbar,
3
+ * and the Quota check card on the Plugins page.
4
+ *
5
+ * It reads the session's model-selection projection for the provider in use,
6
+ * asks the host half's `/quota-check` route for that provider's figure, and
7
+ * renders one compact chip in `conversation.composer.dock` (the statusbar row
8
+ * under the composer card), flush right so it sits directly beneath the model
9
+ * selector. Nothing renders while a session has no selection or while the
10
+ * provider publishes no balance or quota route: the statusbar never grows a
11
+ * "no data" row. A lookup that **failed** is the exception: a rejected key or
12
+ * an unreachable endpoint is a broken setup, not absent data, so it renders as
13
+ * a dimmed chip whose tooltip says what went wrong.
14
+ *
15
+ * The provider key never reaches the browser. The host resolves the
16
+ * credential, calls the provider, and returns the formatted text and its
17
+ * tooltip lines; this half only draws them. A click forces a fresh read (the
18
+ * host serves cached readings for a minute otherwise).
19
+ *
20
+ * This file is plain JavaScript on purpose. The client module system serves a
21
+ * package's `exports["./client"]` artifact as a lazy-CJS factory registered on
22
+ * `window.__ModuleLoader__`, and that is the whole format: an out-of-tree
23
+ * plugin can author it directly instead of reproducing the repository's tsdown
24
+ * client preset. `react` is provided by the module system; nothing else is
25
+ * required here.
26
+ */
27
+
28
+ window.__ModuleLoader__.load({
29
+ id: '@maci0/dsh-quota-check',
30
+
31
+ factory: (require) => {
32
+ const module = { exports: {} }
33
+ const exports = module.exports
34
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
35
+
36
+ const React = require('react')
37
+
38
+ /** The host half's one route; same origin, so the session cookie rides along. */
39
+ const ROUTE = '/quota-check'
40
+
41
+ /** Re-read cadence used until the host reports the configured one. */
42
+ const FALLBACK_REFRESH_MS = 5 * 60 * 1000
43
+
44
+ /** Every class is `qc-`-prefixed: the sheet lands in the page's own document. */
45
+ const CSS = [
46
+ // The dock spans the whole composer column while the card is capped and
47
+ // centered inside it, so the chip is pushed right and then pulled back by
48
+ // the card's own inset: `max(0, (100% - cardMaxWidth) / 2)` is the gap
49
+ // between the dock edge and the card edge (`0` when the card fills the
50
+ // column, which the `100%` branch of the token already accounts for).
51
+ // `--qc-control-inset` then clears the send control (the 34px primary
52
+ // button plus the trailing row's 12px gap), so the figure lands under the
53
+ // model selector instead of under the send button.
54
+ '.qc-chip{--qc-control-inset:46px;order:1;margin-left:auto;margin-right:calc(max(0px,(100% - var(--dsh-composer-card-max-width))/2) + var(--qc-control-inset));appearance:none;display:inline-flex;align-items:center;font:inherit;font-size:11px;line-height:1.5;padding:0 6px;border:0;border-radius:999px;corner-shape:round;color:var(--dsw-alias-label-tertiary);background:none;cursor:pointer;white-space:nowrap}',
55
+ '.qc-chip:hover{color:var(--dsw-alias-label-secondary)}',
56
+ // Countdown color: green when plenty is left, red when it is nearly gone.
57
+ '.qc-chip[data-level="ok"]{color:#3fb950}',
58
+ '.qc-chip[data-level="warn"]{color:#d29922}',
59
+ '.qc-chip[data-level="low"]{color:#db6d28}',
60
+ '.qc-chip[data-level="crit"]{color:#f85149}',
61
+ '.qc-chip[data-level="error"]{color:var(--dsw-alias-label-tertiary)}',
62
+ // Plugins-page card. Classes stay `qc-`-prefixed: one sheet, one owner.
63
+ '.qc-page{display:flex;flex-direction:column;gap:12px}',
64
+ '.qc-field{display:flex;flex-direction:column;gap:4px}',
65
+ '.qc-label{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}',
66
+ '.qc-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
67
+ '.qc-input{font:inherit;font-size:13px;line-height:1.5;padding:5px 12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;width:100%;box-sizing:border-box}',
68
+ '.qc-input:disabled{cursor:default;opacity:.5}',
69
+ '.qc-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}',
70
+ '.qc-button{appearance:none;font:inherit;font-size:13px;line-height:1.5;padding:5px 14px;cursor:pointer;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
71
+ '.qc-button:disabled{cursor:default;opacity:.5}',
72
+ '.qc-button-quiet{padding:3px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
73
+ '.qc-status{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
74
+ '.qc-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}',
75
+ ].join('')
76
+
77
+ // Appended while the factory materializes: the module system claims the tag
78
+ // for this package and disposes it on unload. Guarded because the unit
79
+ // tests evaluate this file without a DOM.
80
+ if (typeof document !== 'undefined') {
81
+ const style = document.createElement('style')
82
+ style.textContent = CSS
83
+ document.head.append(style)
84
+ }
85
+
86
+ /**
87
+ * The provider the session is currently using, from the durable selection
88
+ * projection. `next` is the pending pick and wins over the last request's.
89
+ * @param useProjection - session-scope projection hook from the slot kit.
90
+ * @returns the provider route id, or undefined while nothing is selected.
91
+ */
92
+ function providerOf(useProjection) {
93
+ const selection = useProjection('modelSelection')
94
+ const current = selection === undefined || selection === null
95
+ ? undefined
96
+ : selection.next ?? selection.lastUsed
97
+ return current === undefined || current === null ? undefined : current.provider
98
+ }
99
+
100
+ /**
101
+ * Build the statusbar chip.
102
+ * @returns the component the composer dock renders.
103
+ */
104
+ function createChip() {
105
+ return function QuotaChip(props) {
106
+ const provider = providerOf(props.useProjection)
107
+ // The reading and the provider it was read for: after a switch the old
108
+ // figure must not be drawn as the new provider's while its read runs.
109
+ const [reading, setReading] = React.useState(null)
110
+ // A click bumps this, which re-runs the effect with a forced refresh.
111
+ const [nonce, setNonce] = React.useState(0)
112
+
113
+ React.useEffect(() => {
114
+ if (provider === undefined) {
115
+ setReading(null)
116
+ return undefined
117
+ }
118
+ const controller = new AbortController()
119
+ let timer
120
+ // The host owns the cadence: it reports the configured interval, so a
121
+ // change from the Plugins card or the profile patch reaches the
122
+ // browser on its next read.
123
+ const schedule = (next) => {
124
+ const wait = next !== null && Number.isFinite(next.refreshMs)
125
+ ? Math.max(10_000, next.refreshMs)
126
+ : FALLBACK_REFRESH_MS
127
+ timer = setTimeout(() => { load(false) }, wait)
128
+ }
129
+ const load = (force) => {
130
+ const url = `${ROUTE}?provider=${encodeURIComponent(provider)}${force ? '&refresh=1' : ''}`
131
+ fetch(url, { headers: { accept: 'application/json' }, signal: controller.signal })
132
+ .then(response => response.ok ? response.json() : null)
133
+ .then(next => {
134
+ if (controller.signal.aborted) return
135
+ setReading(next === null ? null : { provider, report: next })
136
+ schedule(next)
137
+ })
138
+ .catch(() => {
139
+ if (controller.signal.aborted) return
140
+ setReading(null)
141
+ schedule(null)
142
+ })
143
+ }
144
+ load(nonce > 0)
145
+ return () => {
146
+ controller.abort()
147
+ clearTimeout(timer)
148
+ }
149
+ }, [provider, nonce])
150
+
151
+ if (reading === null || reading.provider !== provider) return null
152
+ const report = reading.report
153
+ if (report.status === 'error') {
154
+ // The one failure the statusbar has to admit to: silence here hides a
155
+ // rejected key or an unreachable endpoint behind "nothing to show".
156
+ const failure = [report.displayName, report.message].filter(Boolean).join('\n')
157
+ return React.createElement(
158
+ 'button',
159
+ {
160
+ type: 'button',
161
+ className: 'qc-chip',
162
+ title: failure,
163
+ 'aria-label': failure,
164
+ 'data-level': 'error',
165
+ onClick: () => { setNonce(current => current + 1) },
166
+ },
167
+ 'quota ?',
168
+ )
169
+ }
170
+ if (report.status !== 'ok') return null
171
+ const title = [report.displayName, ...(report.lines ?? [])].join('\n')
172
+ // Green ≥50% left, yellow ≥25%, orange ≥10%, red below. A reading
173
+ // without a percentage (a money balance) keeps the default color.
174
+ const remaining = typeof report.remaining === 'number' ? report.remaining : undefined
175
+ const level = remaining === undefined ? undefined
176
+ : remaining >= 50 ? 'ok'
177
+ : remaining >= 25 ? 'warn'
178
+ : remaining >= 10 ? 'low'
179
+ : 'crit'
180
+ return React.createElement(
181
+ 'button',
182
+ {
183
+ type: 'button',
184
+ className: 'qc-chip',
185
+ title,
186
+ 'aria-label': title,
187
+ ...(level === undefined ? {} : { 'data-level': level }),
188
+ onClick: () => { setNonce(current => current + 1) },
189
+ },
190
+ report.text,
191
+ )
192
+ }
193
+ }
194
+
195
+
196
+ /** Settings namespace shared with the host half; also this card's slot key. */
197
+ const NAMESPACE = 'quota-check'
198
+
199
+ /** Locale namespace for this plugin's copy. */
200
+ const LOCALE_NS = 'quota-check'
201
+
202
+ /** Plugin version, shown in the card footer. Kept in lockstep with package.json. */
203
+ const VERSION = '0.12.2'
204
+
205
+ /**
206
+ * Every editable row field, in display order, with the bounds the row schema
207
+ * enforces. The card checks them before the write so a typo is answered
208
+ * where it was typed instead of by a refused save.
209
+ */
210
+ const FIELDS = [
211
+ { key: 'cacheSeconds', label: 'labelCacheSeconds', hint: 'hintCacheSeconds', min: 0, max: 3600 },
212
+ { key: 'timeoutMs', label: 'labelTimeoutMs', hint: 'hintTimeoutMs', min: 1, max: 60_000 },
213
+ { key: 'refreshSeconds', label: 'labelRefreshSeconds', hint: 'hintRefreshSeconds', min: 10, max: 3600 },
214
+ ]
215
+
216
+ const en = {
217
+ title: 'Quota check',
218
+ summary: 'Quota chip in the composer statusbar, refreshed every {refreshSeconds}s.',
219
+ summaryEmpty: 'Quota chip in the composer statusbar.',
220
+ labelCacheSeconds: 'Cache seconds',
221
+ labelTimeoutMs: 'Request deadline (ms)',
222
+ labelRefreshSeconds: 'Browser refresh (seconds)',
223
+ hintCacheSeconds: 'How long a reading is served before the provider is asked again. 0 asks every time.',
224
+ hintTimeoutMs: 'Per-request deadline for one provider call.',
225
+ hintRefreshSeconds: 'How often the composer chip re-reads the figure.',
226
+ overridden: 'overridden',
227
+ save: 'Save',
228
+ saving: 'Saving…',
229
+ saved: 'Saved. The next reading uses these values.',
230
+ reset: 'Reset to defaults',
231
+ persists: 'Stored in your profile; the route reads the row on every request.',
232
+ readOnly: 'Read-only: this deployment does not persist settings.',
233
+ numberInvalid: 'Must be a whole number within the allowed range.',
234
+ rejected: 'The Host refused the write; the previous values are still in effect.',
235
+ failed: 'Could not save: {message}',
236
+ version: 'v{version}',
237
+ }
238
+
239
+ const zh = {
240
+ title: '配额检查',
241
+ summary: '编辑器状态栏的配额标签,每 {refreshSeconds} 秒刷新。',
242
+ summaryEmpty: '编辑器状态栏的配额标签。',
243
+ labelCacheSeconds: '缓存秒数',
244
+ labelTimeoutMs: '请求超时(毫秒)',
245
+ labelRefreshSeconds: '浏览器刷新(秒)',
246
+ hintCacheSeconds: '一次读取在再次询问提供方之前可被复用的时长。0 表示每次都询问。',
247
+ hintTimeoutMs: '单次提供方调用的超时时间。',
248
+ hintRefreshSeconds: '状态栏标签重新读取该数值的频率。',
249
+ overridden: '已覆盖',
250
+ save: '保存',
251
+ saving: '保存中…',
252
+ saved: '已保存。下一次读取将使用这些值。',
253
+ reset: '恢复默认',
254
+ persists: '保存在你的配置中;路由每次请求都会读取该行。',
255
+ readOnly: '只读:此部署不持久化设置。',
256
+ numberInvalid: '必须是允许范围内的整数。',
257
+ rejected: 'Host 拒绝了写入;原先的值仍然有效。',
258
+ failed: '保存失败:{message}',
259
+ version: 'v{version}',
260
+ }
261
+
262
+ /**
263
+ * Bind one settings scope to a React subscription.
264
+ * @param scope - the scope bound to the quota-check settings namespace.
265
+ * @returns a hook reading that scope's current snapshot.
266
+ */
267
+ function useScope(scope) {
268
+ const subscribe = (listener) => scope.subscribe(listener)
269
+ const getSnapshot = () => scope.getSnapshot()
270
+ return () => React.useSyncExternalStore(subscribe, getSnapshot)
271
+ }
272
+
273
+ /**
274
+ * Read a snapshot's resolved row. A namespace this deployment does not
275
+ * serve reports no row, which the card renders as nothing at all.
276
+ * @param snapshot - the settings scope snapshot.
277
+ * @returns the resolved row, or `undefined` when unreadable.
278
+ */
279
+ function rowOf(snapshot) {
280
+ if (snapshot.status !== 'ready') return undefined
281
+ return snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : {}
282
+ }
283
+
284
+ /**
285
+ * Build the Plugins-page card over one bound settings scope.
286
+ * @param scope - the scope bound to the quota-check settings namespace.
287
+ * @param t - translate function bound to this plugin's locale namespace.
288
+ * @returns the component the slot renders.
289
+ */
290
+ function createCard(scope, t) {
291
+ const useQuotaCheck = useScope(scope)
292
+
293
+ return function QuotaCheckCard(props) {
294
+ const snapshot = useQuotaCheck()
295
+ const [draft, setDraft] = React.useState(null)
296
+ const [error, setError] = React.useState(null)
297
+ const [status, setStatus] = React.useState(null)
298
+ // True while a write is in flight: Save and Reset stay disabled, so a
299
+ // double click sends one write.
300
+ const [pending, setPending] = React.useState(false)
301
+
302
+ const row = rowOf(snapshot)
303
+ if (row === undefined) return null
304
+
305
+ if (props != null && props.view === 'summary') {
306
+ return row.refreshSeconds === undefined
307
+ ? t('summaryEmpty')
308
+ : t('summary', { refreshSeconds: String(row.refreshSeconds) })
309
+ }
310
+
311
+ const disabled = !snapshot.writable
312
+ const busy = disabled || pending
313
+ const shown = draft ?? row
314
+ const user = snapshot.user !== null && typeof snapshot.user === 'object' ? snapshot.user : {}
315
+
316
+ /** Validate and collect the staged edits into one ordered mutation. */
317
+ const collect = () => {
318
+ const ops = []
319
+ for (const field of FIELDS) {
320
+ const value = shown[field.key]
321
+ if (String(value) === String(row[field.key])) continue
322
+ // An emptied box is not zero: `Number('')` is 0, which would pass
323
+ // a min of 0 and write a value nobody typed.
324
+ const text = String(value).trim()
325
+ const parsed = Number(text)
326
+ if (text === '' || !Number.isInteger(parsed) || parsed < field.min || parsed > field.max) {
327
+ return { error: 'numberInvalid' }
328
+ }
329
+ ops.push({ op: 'set', path: [field.key], value: parsed })
330
+ }
331
+ return { ops }
332
+ }
333
+
334
+ /** Run one settings write; a synchronous throw becomes a rejection. */
335
+ const write = (ops) => new Promise((resolve) => { resolve(scope.mutate(ops)) })
336
+
337
+ const save = () => {
338
+ if (pending) return
339
+ setError(null)
340
+ const result = collect()
341
+ if (result.error !== undefined) {
342
+ setError(t(result.error))
343
+ return
344
+ }
345
+ if (result.ops.length === 0) {
346
+ setStatus(null)
347
+ return
348
+ }
349
+ setStatus(t('saving'))
350
+ setPending(true)
351
+ write(result.ops)
352
+ .then((accepted) => {
353
+ if (accepted === false) {
354
+ setError(t('rejected'))
355
+ setStatus(null)
356
+ return
357
+ }
358
+ setDraft(null)
359
+ setStatus(t('saved'))
360
+ })
361
+ .catch((cause) => {
362
+ setStatus(null)
363
+ setError(t('failed', { message: cause instanceof Error ? cause.message : String(cause) }))
364
+ })
365
+ .finally(() => { setPending(false) })
366
+ }
367
+
368
+ const reset = () => {
369
+ if (pending) return
370
+ setDraft(null)
371
+ setStatus(null)
372
+ setError(null)
373
+ const ops = Object.keys(user)
374
+ .filter((key) => FIELDS.some((field) => field.key === key))
375
+ .map((key) => ({ op: 'unset', path: [key] }))
376
+ if (ops.length === 0) return
377
+ setPending(true)
378
+ write(ops).then((accepted) => {
379
+ // The Host resolves false when it refuses the write, so a swallowed
380
+ // refusal would leave the override in place with nothing said.
381
+ if (accepted === false) setError(t('rejected'))
382
+ }).catch((cause) => {
383
+ setError(t('failed', { message: cause instanceof Error ? cause.message : String(cause) }))
384
+ }).finally(() => { setPending(false) })
385
+ }
386
+
387
+ const overridden = Object.keys(user).some((key) => FIELDS.some((field) => field.key === key))
388
+
389
+ return React.createElement(
390
+ 'div',
391
+ { className: 'qc-page' },
392
+ ...FIELDS.map((field) => React.createElement(
393
+ 'div',
394
+ { className: 'qc-field', key: field.key },
395
+ React.createElement(
396
+ 'div',
397
+ { className: 'qc-row' },
398
+ React.createElement('span', { className: 'qc-label' }, t(field.label)),
399
+ Object.hasOwn(user, field.key)
400
+ ? React.createElement('span', { className: 'qc-hint' }, `(${t('overridden')})`)
401
+ : null,
402
+ ),
403
+ React.createElement('input', {
404
+ className: 'qc-input',
405
+ type: 'number',
406
+ value: String(shown[field.key] ?? ''),
407
+ disabled: busy,
408
+ 'aria-label': t(field.label),
409
+ onChange: (event) => {
410
+ setStatus(null)
411
+ setDraft({ ...shown, [field.key]: event.target.value })
412
+ },
413
+ }),
414
+ React.createElement('span', { className: 'qc-hint' }, t(field.hint)),
415
+ )),
416
+ React.createElement(
417
+ 'div',
418
+ { className: 'qc-row' },
419
+ React.createElement('button', { type: 'button', className: 'qc-button', disabled: busy, onClick: save }, t('save')),
420
+ overridden
421
+ ? React.createElement('button', {
422
+ type: 'button', className: 'qc-button qc-button-quiet', disabled: busy, onClick: reset,
423
+ }, t('reset'))
424
+ : null,
425
+ ),
426
+ React.createElement(
427
+ 'div',
428
+ { className: 'qc-status' },
429
+ status ?? (snapshot.writable ? t('persists') : t('readOnly')),
430
+ ' ',
431
+ t('version', { version: VERSION }),
432
+ ),
433
+ error === null ? null : React.createElement('div', { className: 'qc-error' }, error),
434
+ )
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Mount both surfaces: the statusbar chip and the Plugins-page card.
440
+ * @param ctx - the browser plugin context.
441
+ */
442
+ function apply(ctx) {
443
+ const t = ctx.locale.bind(LOCALE_NS)
444
+ ctx.effect(
445
+ () => ctx.locale.register(LOCALE_NS, { en, zh }),
446
+ 'dsh-quota-check: locale dictionary',
447
+ )
448
+
449
+ // The dock's owner declares the slot; injecting waits for it to exist, so
450
+ // this registration does not depend on plugin load order.
451
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
452
+ name: 'conversation.composer.dock',
453
+ id: 'quota-check',
454
+ order: 20,
455
+ }, createChip()))
456
+
457
+ // The card takes no injected props (it closes over its own bound scope),
458
+ // so the entry declares the documented locale namespace and no inject.
459
+ const scope = ctx.configForms.get(NAMESPACE)
460
+ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
461
+ name: 'plugins.row.config',
462
+ key: '@maci0/dsh-quota-check#quota-check',
463
+ locale: LOCALE_NS,
464
+ }, createCard(scope, t)))
465
+ }
466
+
467
+ exports.apply = apply
468
+ exports.inject = ['slots', 'configForms', 'locale']
469
+ return module.exports
470
+ },
471
+ })
package/lib/host.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The slice of the DeepSeek Harness host surface this plugin uses, declared
3
+ * structurally.
4
+ *
5
+ * The plugin installs from outside the harness checkout and has no runtime
6
+ * dependency on harness packages: the services it reaches are typed here, and
7
+ * a composition that mounts none of them simply omits that capability. The
8
+ * same shape also lets the unit tests drive `apply` with a plain fake context.
9
+ *
10
+ * @module dsh-quota-check/host
11
+ */
12
+ export {};