@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/LICENSE +21 -0
- package/README.md +220 -0
- package/cordis.patch.yml +13 -0
- package/icon.svg +5 -0
- package/lib/client.js +471 -0
- package/lib/host.js +12 -0
- package/lib/index.js +469 -0
- package/lib/local-usage.js +453 -0
- package/lib/probes.js +848 -0
- package/lib/types/host.d.ts +128 -0
- package/lib/types/index.d.ts +113 -0
- package/lib/types/local-usage.d.ts +45 -0
- package/lib/types/probes.d.ts +126 -0
- package/lib/types/util.d.ts +14 -0
- package/lib/util.js +28 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +104 -0
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 {};
|