dsh-provider-usage 0.3.1

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,905 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-provider-usage",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let react_dom = require("react-dom");
9
+ let react_jsx_runtime = require("react/jsx-runtime");
10
+ //#region src/client/locales.ts
11
+ /** Simplified Chinese dictionary (the key-set source of truth). */
12
+ const zh = {
13
+ "action.label": "额度",
14
+ "action.aria": "Provider 额度",
15
+ "panel.title": "Provider 额度",
16
+ "panel.refresh": "立即刷新",
17
+ "panel.refreshing": "刷新中…",
18
+ "lang.switch": "Switch to English",
19
+ "panel.resetPos": "悬浮球归位",
20
+ "panel.interval": "刷新周期",
21
+ "panel.updated": "更新于 {time}",
22
+ "panel.never": "尚未获取",
23
+ "panel.empty": "未检测到已配置的 Provider",
24
+ "interval.15s": "15 秒",
25
+ "interval.30s": "30 秒",
26
+ "interval.60s": "1 分钟",
27
+ "interval.300s": "5 分钟",
28
+ "interval.900s": "15 分钟",
29
+ "interval.1800s": "30 分钟",
30
+ "balance.total": "总额",
31
+ "balance.granted": "赠送 {amount}",
32
+ "balance.toppedUp": "充值 {amount}",
33
+ "usage.weekly": "每周配额",
34
+ "usage.used": "已用 {used}",
35
+ "usage.remaining": "剩余 {remaining}",
36
+ "usage.unlimited": "不限量",
37
+ "usage.resetAt": "重置于 {time}",
38
+ "usage.resetIn": "{countdown} 后重置",
39
+ "status.unsupported": "该 Provider 暂不支持额度查询",
40
+ "status.missingCredential": "未配置密钥({ref})",
41
+ "status.error": "查询失败",
42
+ "status.tone.ok": "全部正常",
43
+ "status.tone.warn": "有用量接近上限",
44
+ "status.tone.danger": "有 Provider 异常或用量将尽",
45
+ "countdown.days": "{days}天{hours}小时",
46
+ "countdown.hours": "{hours}小时{minutes}分",
47
+ "countdown.minutes": "{minutes}分钟"
48
+ };
49
+ /** English dictionary, key-identical to the Chinese source of truth. */
50
+ const en = {
51
+ "action.label": "Usage",
52
+ "action.aria": "Provider usage",
53
+ "panel.title": "Provider Usage",
54
+ "panel.refresh": "Refresh now",
55
+ "panel.refreshing": "Refreshing…",
56
+ "lang.switch": "切换到中文",
57
+ "panel.resetPos": "Reset ball position",
58
+ "panel.interval": "Refresh interval",
59
+ "panel.updated": "Updated {time}",
60
+ "panel.never": "Not fetched yet",
61
+ "panel.empty": "No configured provider detected",
62
+ "interval.15s": "15s",
63
+ "interval.30s": "30s",
64
+ "interval.60s": "1 min",
65
+ "interval.300s": "5 min",
66
+ "interval.900s": "15 min",
67
+ "interval.1800s": "30 min",
68
+ "balance.total": "Total",
69
+ "balance.granted": "granted {amount}",
70
+ "balance.toppedUp": "topped up {amount}",
71
+ "usage.weekly": "Weekly quota",
72
+ "usage.used": "used {used}",
73
+ "usage.remaining": "{remaining} left",
74
+ "usage.unlimited": "Unlimited",
75
+ "usage.resetAt": "Resets at {time}",
76
+ "usage.resetIn": "Resets in {countdown}",
77
+ "status.unsupported": "Usage query is not supported for this provider",
78
+ "status.missingCredential": "No API key configured ({ref})",
79
+ "status.error": "Query failed",
80
+ "status.tone.ok": "All providers healthy",
81
+ "status.tone.warn": "Some usage nearing its limit",
82
+ "status.tone.danger": "Provider error or usage almost exhausted",
83
+ "countdown.days": "{days}d {hours}h",
84
+ "countdown.hours": "{hours}h {minutes}m",
85
+ "countdown.minutes": "{minutes}m"
86
+ };
87
+ //#endregion
88
+ //#region src/client/Panel.tsx
89
+ /**
90
+ * Floating-ball quota widget: a draggable ball portaled to document.body
91
+ * (the sidebar slot only mounts the component; the button itself no longer
92
+ * lives in the sidebar, avoiding slot layout contention with other plugins).
93
+ * The popover panel lists every configured provider's live balance/quota.
94
+ * Polling interval is user-selectable (persisted in localStorage) and falls
95
+ * back to the deployment-suggested value from the host plugin config.
96
+ */
97
+ const INTERVAL_OPTIONS = [
98
+ 15,
99
+ 30,
100
+ 60,
101
+ 300,
102
+ 900,
103
+ 1800
104
+ ];
105
+ const STORAGE_KEY = "dsh.provider-usage.refreshSeconds";
106
+ const LANG_KEY = "dsh.provider-usage.lang";
107
+ const POS_KEY = "dsh.provider-usage.floatPos";
108
+ const DEFAULT_INTERVAL = 60;
109
+ /** Floating ball diameter in px; drag math derives from it. */
110
+ const BALL_SIZE = 32;
111
+ /** Panel-level override stored in localStorage; null means follow the harness language. */
112
+ function readStoredLang() {
113
+ try {
114
+ const raw = localStorage.getItem(LANG_KEY);
115
+ return raw === "zh" || raw === "en" ? raw : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+ function storeLang(lang) {
121
+ try {
122
+ localStorage.setItem(LANG_KEY, lang);
123
+ } catch {}
124
+ }
125
+ /** Local translator over the bundled dictionaries, mirroring the shell's `{param}` interpolation. */
126
+ function makeT(dict) {
127
+ return (key, params) => {
128
+ const template = dict[key] ?? key;
129
+ if (params === void 0) return template;
130
+ return template.replace(/\{(\w+)\}/g, (raw, name) => name in params ? String(params[name]) : raw);
131
+ };
132
+ }
133
+ function readStoredInterval() {
134
+ try {
135
+ const raw = localStorage.getItem(STORAGE_KEY);
136
+ if (raw === null) return null;
137
+ const value = Number(raw);
138
+ return Number.isFinite(value) && value >= 5 ? value : null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+ function storeInterval(value) {
144
+ try {
145
+ localStorage.setItem(STORAGE_KEY, String(value));
146
+ } catch {}
147
+ }
148
+ function defaultFloatPos() {
149
+ let sidebarRight = 264;
150
+ let node = document.querySelector("[data-slot=\"sidebar.settings\"]")?.parentElement ?? null;
151
+ while (node) {
152
+ const rect = node.getBoundingClientRect();
153
+ if (rect.x <= 1 && rect.height >= window.innerHeight * .9) {
154
+ sidebarRight = rect.right;
155
+ break;
156
+ }
157
+ node = node.parentElement;
158
+ }
159
+ const send = document.querySelector("button[aria-label=\"发送消息\"], button[aria-label=\"Send message\"], button[aria-label=\"Send\"]")?.getBoundingClientRect();
160
+ const y = send && send.width > 0 ? Math.round(send.top + send.height / 2 - BALL_SIZE / 2) : window.innerHeight - 100 - BALL_SIZE;
161
+ return {
162
+ x: Math.round(sidebarRight + 16),
163
+ y
164
+ };
165
+ }
166
+ function readStoredFloatPos() {
167
+ try {
168
+ const raw = localStorage.getItem(POS_KEY);
169
+ if (raw === null) return null;
170
+ const parsed = JSON.parse(raw);
171
+ if (parsed === null || typeof parsed.x !== "number" || !Number.isFinite(parsed.x)) return null;
172
+ if (typeof parsed.y !== "number" || !Number.isFinite(parsed.y)) return null;
173
+ return {
174
+ x: parsed.x,
175
+ y: parsed.y
176
+ };
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+ function storeFloatPos(pos) {
182
+ try {
183
+ localStorage.setItem(POS_KEY, JSON.stringify(pos));
184
+ } catch {}
185
+ }
186
+ function formatCountdown(resetAt, now, t) {
187
+ const diff = new Date(resetAt).getTime() - now;
188
+ if (!Number.isFinite(diff) || diff <= 0) return null;
189
+ const minutes = Math.floor(diff / 6e4);
190
+ if (minutes >= 1440) return t("countdown.days", {
191
+ days: Math.floor(minutes / 1440),
192
+ hours: Math.floor(minutes % 1440 / 60)
193
+ });
194
+ if (minutes >= 60) return t("countdown.hours", {
195
+ hours: Math.floor(minutes / 60),
196
+ minutes: minutes % 60
197
+ });
198
+ return t("countdown.minutes", { minutes: Math.max(1, minutes) });
199
+ }
200
+ function barTone(percent) {
201
+ if (percent === null) return "ok";
202
+ if (percent >= 90) return "danger";
203
+ if (percent >= 70) return "warn";
204
+ return "ok";
205
+ }
206
+ /** Worst health across the fetch + every provider: drives the trigger's status dot. */
207
+ function healthTone(data, error) {
208
+ if (error !== null) return "danger";
209
+ let tone = "ok";
210
+ for (const provider of data?.providers ?? []) {
211
+ if (provider.status === "error" || provider.status === "missing-credential") return "danger";
212
+ for (const row of provider.usages ?? []) {
213
+ const rowTone = barTone(row.percent);
214
+ if (rowTone === "danger") return "danger";
215
+ if (rowTone === "warn") tone = "warn";
216
+ }
217
+ }
218
+ return tone;
219
+ }
220
+ function formatAmount(value) {
221
+ if (value === null) return "—";
222
+ return Number.isInteger(value) ? String(value) : value.toFixed(2);
223
+ }
224
+ function UsageRows({ provider, now, t }) {
225
+ const rows = provider.usages ?? [];
226
+ if (rows.length === 0) return null;
227
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: rows.map((row, index) => {
228
+ const percent = row.percent;
229
+ const reset = row.resetAt !== null ? formatCountdown(row.resetAt, now, t) : null;
230
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
231
+ className: "dsh-usage-row",
232
+ children: [
233
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
234
+ className: "dsh-usage-rowHead",
235
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
236
+ className: "dsh-usage-rowLabel",
237
+ children: row.label === "weekly" ? t("usage.weekly") : row.label
238
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
239
+ className: "dsh-usage-rowValue",
240
+ children: row.limit === null ? t("usage.unlimited") : percent !== null ? `${Math.round(percent)}%` : t("usage.used", { used: formatAmount(row.used) })
241
+ })]
242
+ }),
243
+ percent !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
244
+ className: "dsh-usage-barTrack",
245
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
246
+ className: `dsh-usage-barFill dsh-usage-barFill--${barTone(percent)}`,
247
+ style: { width: `${Math.min(100, Math.max(0, percent))}%` }
248
+ })
249
+ }) : null,
250
+ reset !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
251
+ className: "dsh-usage-rowReset",
252
+ children: t("usage.resetIn", { countdown: reset })
253
+ }) : null
254
+ ]
255
+ }, `${row.label}-${index}`);
256
+ }) });
257
+ }
258
+ function ProviderCard({ provider, now, t }) {
259
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
260
+ className: "dsh-usage-card",
261
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
262
+ className: "dsh-usage-cardHead",
263
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
264
+ className: "dsh-usage-providerName",
265
+ children: provider.displayName
266
+ })
267
+ }), provider.status === "unsupported" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
268
+ className: "dsh-usage-message",
269
+ children: t("status.unsupported")
270
+ }) : provider.status === "missing-credential" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
271
+ className: "dsh-usage-message",
272
+ children: t("status.missingCredential", { ref: provider.message })
273
+ }) : provider.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
274
+ className: "dsh-usage-message",
275
+ children: [
276
+ t("status.error"),
277
+ ": ",
278
+ provider.message
279
+ ]
280
+ }) : provider.kind === "balance" ? (provider.balances ?? []).map((row, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
281
+ className: "dsh-usage-balanceRow",
282
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
283
+ className: "dsh-usage-balanceTotal",
284
+ children: row.total
285
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
286
+ className: "dsh-usage-balanceCurrency",
287
+ children: row.currency
288
+ })]
289
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
290
+ className: "dsh-usage-balanceParts",
291
+ children: [row.granted !== "0" && row.granted !== "0.00" ? t("balance.granted", { amount: row.granted }) : null, row.toppedUp !== "0" && row.toppedUp !== "0.00" ? t("balance.toppedUp", { amount: row.toppedUp }) : null].filter(Boolean).join(" · ")
292
+ })] }, `${row.currency}-${index}`)) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageRows, {
293
+ provider,
294
+ now,
295
+ t
296
+ })]
297
+ });
298
+ }
299
+ /** Battery level: remaining charge reads as remaining quota. */
300
+ function BatteryIcon() {
301
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
302
+ width: "14",
303
+ height: "14",
304
+ viewBox: "0 0 24 24",
305
+ fill: "none",
306
+ stroke: "currentColor",
307
+ strokeWidth: "2",
308
+ strokeLinecap: "round",
309
+ strokeLinejoin: "round",
310
+ "aria-hidden": "true",
311
+ children: [
312
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
313
+ width: "16",
314
+ height: "10",
315
+ x: "2",
316
+ y: "7",
317
+ rx: "2",
318
+ ry: "2"
319
+ }),
320
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M22 11v2" }),
321
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M6 11v2" }),
322
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 11v2" })
323
+ ]
324
+ });
325
+ }
326
+ function RefreshIcon() {
327
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
328
+ width: "14",
329
+ height: "14",
330
+ viewBox: "0 0 24 24",
331
+ fill: "none",
332
+ stroke: "currentColor",
333
+ strokeWidth: "2",
334
+ strokeLinecap: "round",
335
+ strokeLinejoin: "round",
336
+ "aria-hidden": "true",
337
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M20 12a8 8 0 1 1-2.34-5.66" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M20 4v4h-4" })]
338
+ });
339
+ }
340
+ function GlobeIcon() {
341
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
342
+ width: "13",
343
+ height: "13",
344
+ viewBox: "0 0 24 24",
345
+ fill: "none",
346
+ stroke: "currentColor",
347
+ strokeWidth: "2",
348
+ strokeLinecap: "round",
349
+ strokeLinejoin: "round",
350
+ "aria-hidden": "true",
351
+ children: [
352
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
353
+ cx: "12",
354
+ cy: "12",
355
+ r: "9"
356
+ }),
357
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h18" }),
358
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 3c2.5 2.6 3.9 5.7 3.9 9s-1.4 6.4-3.9 9c-2.5-2.6-3.9-5.7-3.9-9S9.5 5.6 12 3z" })
359
+ ]
360
+ });
361
+ }
362
+ /** Crosshair for the "reset ball to its default spot" head button. */
363
+ function HomeIcon() {
364
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
365
+ width: "13",
366
+ height: "13",
367
+ viewBox: "0 0 24 24",
368
+ fill: "none",
369
+ stroke: "currentColor",
370
+ strokeWidth: "2",
371
+ strokeLinecap: "round",
372
+ strokeLinejoin: "round",
373
+ "aria-hidden": "true",
374
+ children: [
375
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
376
+ cx: "12",
377
+ cy: "12",
378
+ r: "7"
379
+ }),
380
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 2v3M12 19v3M2 12h3M19 12h3" }),
381
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
382
+ cx: "12",
383
+ cy: "12",
384
+ r: "1.5",
385
+ fill: "currentColor",
386
+ stroke: "none"
387
+ })
388
+ ]
389
+ });
390
+ }
391
+ function UsageAction({ t, fetchUsage }) {
392
+ const [open, setOpen] = (0, react.useState)(false);
393
+ const [data, setData] = (0, react.useState)(null);
394
+ const [error, setError] = (0, react.useState)(null);
395
+ const [loading, setLoading] = (0, react.useState)(false);
396
+ const [intervalSec, setIntervalSec] = (0, react.useState)(() => readStoredInterval() ?? DEFAULT_INTERVAL);
397
+ const [langOverride, setLangOverride] = (0, react.useState)(() => readStoredLang());
398
+ const [now, setNow] = (0, react.useState)(() => Date.now());
399
+ const [floatPos, setFloatPos] = (0, react.useState)(() => readStoredFloatPos() ?? defaultFloatPos());
400
+ /** True once the position comes from storage/drag/reset — the default
401
+ anchors to the composer send button, which mounts after this plugin,
402
+ so an unpinned ball re-anchors as soon as the composer appears. */
403
+ const posPinned = (0, react.useRef)(readStoredFloatPos() !== null);
404
+ /** Transient ball position while dragging (cursor-centered); null when docked. */
405
+ const [dragPos, setDragPos] = (0, react.useState)(null);
406
+ const harnessLang = t("lang.switch") === zh["lang.switch"] ? "zh" : t("lang.switch") === en["lang.switch"] ? "en" : null;
407
+ const lang = langOverride ?? harnessLang ?? "en";
408
+ const tt = (0, react.useMemo)(() => makeT(lang === "zh" ? zh : en), [lang]);
409
+ const toggleLang = (0, react.useCallback)(() => {
410
+ setLangOverride((current) => {
411
+ const next = (current ?? harnessLang ?? "en") === "zh" ? "en" : "zh";
412
+ storeLang(next);
413
+ return next;
414
+ });
415
+ }, [harnessLang]);
416
+ /** Panel-head home button: send the ball back to its default spot. */
417
+ const resetFloatPos = () => {
418
+ const next = defaultFloatPos();
419
+ storeFloatPos(next);
420
+ setFloatPos(next);
421
+ posPinned.current = true;
422
+ };
423
+ const [panelPos, setPanelPos] = (0, react.useState)(null);
424
+ const rootRef = (0, react.useRef)(null);
425
+ const ballRef = (0, react.useRef)(null);
426
+ const panelRef = (0, react.useRef)(null);
427
+ const inFlight = (0, react.useRef)(false);
428
+ /** Active ball-drag session; cleared on pointerup/pointercancel. */
429
+ const dragRef = (0, react.useRef)(null);
430
+ /** Set when a drag ends so the trailing click does not toggle the panel. */
431
+ const suppressClickRef = (0, react.useRef)(false);
432
+ const refresh = (0, react.useCallback)(async () => {
433
+ if (inFlight.current) return;
434
+ inFlight.current = true;
435
+ setLoading(true);
436
+ try {
437
+ const result = await fetchUsage();
438
+ setData(result);
439
+ setError(null);
440
+ } catch (reason) {
441
+ setError(reason instanceof Error ? reason.message : String(reason));
442
+ } finally {
443
+ inFlight.current = false;
444
+ setLoading(false);
445
+ }
446
+ }, [fetchUsage]);
447
+ (0, react.useEffect)(() => {
448
+ refresh();
449
+ }, [refresh]);
450
+ (0, react.useEffect)(() => {
451
+ const timer = setInterval(() => {
452
+ if (document.visibilityState === "visible") refresh();
453
+ }, intervalSec * 1e3);
454
+ return () => clearInterval(timer);
455
+ }, [intervalSec, refresh]);
456
+ (0, react.useEffect)(() => {
457
+ if (data !== null && readStoredInterval() === null && data.refreshSeconds !== intervalSec) setIntervalSec(data.refreshSeconds);
458
+ }, [data]);
459
+ (0, react.useEffect)(() => {
460
+ if (!open) return;
461
+ setNow(Date.now());
462
+ const timer = setInterval(() => setNow(Date.now()), 3e4);
463
+ return () => clearInterval(timer);
464
+ }, [open]);
465
+ (0, react.useEffect)(() => {
466
+ if (!open) return;
467
+ const closeOutside = (event) => {
468
+ if (!(event.target instanceof Node)) return;
469
+ if (rootRef.current?.contains(event.target)) return;
470
+ if (ballRef.current?.contains(event.target)) return;
471
+ if (panelRef.current?.contains(event.target)) return;
472
+ setOpen(false);
473
+ };
474
+ document.addEventListener("pointerdown", closeOutside);
475
+ return () => document.removeEventListener("pointerdown", closeOutside);
476
+ }, [open]);
477
+ (0, react.useEffect)(() => {
478
+ if (!open) return;
479
+ const update = () => {
480
+ const rect = ballRef.current?.getBoundingClientRect();
481
+ if (!rect) return;
482
+ const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - 336));
483
+ setPanelPos(rect.top > window.innerHeight / 2 ? {
484
+ left,
485
+ bottom: window.innerHeight - rect.top + 6
486
+ } : {
487
+ left,
488
+ top: rect.bottom + 6
489
+ });
490
+ };
491
+ update();
492
+ window.addEventListener("resize", update);
493
+ return () => window.removeEventListener("resize", update);
494
+ }, [open, floatPos]);
495
+ (0, react.useEffect)(() => {
496
+ const clampPos = () => {
497
+ setFloatPos((current) => {
498
+ const x = Math.min(Math.max(8, current.x), window.innerWidth - BALL_SIZE - 8);
499
+ const y = Math.min(Math.max(8, current.y), window.innerHeight - BALL_SIZE - 8);
500
+ if (x === current.x && y === current.y) return current;
501
+ const next = {
502
+ x,
503
+ y
504
+ };
505
+ storeFloatPos(next);
506
+ return next;
507
+ });
508
+ };
509
+ clampPos();
510
+ window.addEventListener("resize", clampPos);
511
+ return () => window.removeEventListener("resize", clampPos);
512
+ }, []);
513
+ (0, react.useEffect)(() => {
514
+ if (posPinned.current) return;
515
+ let tries = 0;
516
+ const timer = setInterval(() => {
517
+ tries += 1;
518
+ if (posPinned.current || tries > 20) {
519
+ clearInterval(timer);
520
+ return;
521
+ }
522
+ const next = defaultFloatPos();
523
+ setFloatPos((current) => current.x === next.x && current.y === next.y ? current : next);
524
+ }, 400);
525
+ return () => clearInterval(timer);
526
+ }, []);
527
+ const tone = healthTone(data, error);
528
+ const onKeyDown = (event) => {
529
+ if (event.key !== "Escape" || !open) return;
530
+ event.preventDefault();
531
+ setOpen(false);
532
+ };
533
+ const onBallPointerDown = (event) => {
534
+ event.currentTarget.setPointerCapture(event.pointerId);
535
+ dragRef.current = {
536
+ pointerId: event.pointerId,
537
+ startX: event.clientX,
538
+ startY: event.clientY,
539
+ dragging: false
540
+ };
541
+ };
542
+ const onBallPointerMove = (event) => {
543
+ const drag = dragRef.current;
544
+ if (drag === null || drag.pointerId !== event.pointerId) return;
545
+ if (!drag.dragging) {
546
+ if (Math.abs(event.clientX - drag.startX) < 5 && Math.abs(event.clientY - drag.startY) < 5) return;
547
+ drag.dragging = true;
548
+ if (open) setOpen(false);
549
+ }
550
+ const half = BALL_SIZE / 2;
551
+ setDragPos({
552
+ x: Math.min(Math.max(half, event.clientX), window.innerWidth - half),
553
+ y: Math.min(Math.max(half, event.clientY), window.innerHeight - half)
554
+ });
555
+ };
556
+ const endBallDrag = (event) => {
557
+ const drag = dragRef.current;
558
+ dragRef.current = null;
559
+ if (drag === null || drag.pointerId !== event.pointerId || !drag.dragging) return;
560
+ const half = BALL_SIZE / 2;
561
+ const x = Math.min(Math.max(half, event.clientX), window.innerWidth - half);
562
+ const y = Math.min(Math.max(half, event.clientY), window.innerHeight - half);
563
+ const next = {
564
+ x: x - half,
565
+ y: y - half
566
+ };
567
+ setFloatPos(next);
568
+ storeFloatPos(next);
569
+ setDragPos(null);
570
+ posPinned.current = true;
571
+ suppressClickRef.current = true;
572
+ };
573
+ const cancelBallDrag = (event) => {
574
+ if (dragRef.current?.pointerId !== event.pointerId) return;
575
+ dragRef.current = null;
576
+ setDragPos(null);
577
+ };
578
+ const onBallClick = () => {
579
+ if (suppressClickRef.current) {
580
+ suppressClickRef.current = false;
581
+ return;
582
+ }
583
+ setNow(Date.now());
584
+ setOpen((current) => !current);
585
+ if (!open) refresh();
586
+ };
587
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
588
+ ref: rootRef,
589
+ className: "dsh-usage-root",
590
+ onKeyDown,
591
+ children: [(0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
592
+ ref: ballRef,
593
+ type: "button",
594
+ className: `dsh-usage-ball dsh-usage-tone-${tone}`,
595
+ style: dragPos !== null ? {
596
+ left: dragPos.x - BALL_SIZE / 2,
597
+ top: dragPos.y - BALL_SIZE / 2
598
+ } : {
599
+ left: floatPos.x,
600
+ top: floatPos.y
601
+ },
602
+ "aria-expanded": open,
603
+ "aria-label": tt("action.aria"),
604
+ title: `${tt("action.aria")} · ${tt(`status.tone.${tone}`)}`,
605
+ onPointerDown: onBallPointerDown,
606
+ onPointerMove: onBallPointerMove,
607
+ onPointerUp: endBallDrag,
608
+ onPointerCancel: cancelBallDrag,
609
+ onClick: onBallClick,
610
+ onKeyDown,
611
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BatteryIcon, {})
612
+ }), document.body), open && panelPos !== null ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
613
+ ref: panelRef,
614
+ className: "dsh-usage-panel",
615
+ role: "dialog",
616
+ "aria-label": tt("panel.title"),
617
+ style: {
618
+ left: panelPos.left,
619
+ top: panelPos.top,
620
+ bottom: panelPos.bottom
621
+ },
622
+ onKeyDown,
623
+ children: [
624
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
625
+ className: "dsh-usage-head",
626
+ children: [
627
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
628
+ className: "dsh-usage-title",
629
+ children: tt("panel.title")
630
+ }),
631
+ data?.version ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
632
+ className: "dsh-usage-version",
633
+ children: ["v", data.version]
634
+ }) : null,
635
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
636
+ type: "button",
637
+ className: "dsh-usage-home",
638
+ onClick: resetFloatPos,
639
+ title: tt("panel.resetPos"),
640
+ "aria-label": tt("panel.resetPos"),
641
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HomeIcon, {})
642
+ }),
643
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
644
+ type: "button",
645
+ className: "dsh-usage-lang",
646
+ onClick: toggleLang,
647
+ title: tt("lang.switch"),
648
+ "aria-label": tt("lang.switch"),
649
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(GlobeIcon, {}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: lang === "zh" ? "中" : "EN" })]
650
+ }),
651
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
652
+ type: "button",
653
+ className: `dsh-usage-refresh${loading ? " dsh-usage-refresh--loading" : ""}`,
654
+ disabled: loading,
655
+ onClick: refresh,
656
+ title: tt("panel.refresh"),
657
+ "aria-label": tt("panel.refresh"),
658
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RefreshIcon, {})
659
+ })
660
+ ]
661
+ }),
662
+ data === null && error !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
663
+ className: "dsh-usage-message",
664
+ children: [
665
+ tt("status.error"),
666
+ ": ",
667
+ error
668
+ ]
669
+ }) : null,
670
+ data !== null && data.providers.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
671
+ className: "dsh-usage-message",
672
+ children: tt("panel.empty")
673
+ }) : null,
674
+ (data?.providers ?? []).map((provider) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProviderCard, {
675
+ provider,
676
+ now,
677
+ t: tt
678
+ }, provider.id)),
679
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
680
+ className: "dsh-usage-foot",
681
+ children: [
682
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
683
+ className: "dsh-usage-footLabel",
684
+ children: tt("panel.interval")
685
+ }),
686
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
687
+ className: "dsh-usage-select",
688
+ value: intervalSec,
689
+ onChange: (event) => {
690
+ const value = Number(event.target.value);
691
+ setIntervalSec(value);
692
+ storeInterval(value);
693
+ },
694
+ children: INTERVAL_OPTIONS.map((seconds) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
695
+ value: seconds,
696
+ children: tt(`interval.${seconds}s`)
697
+ }, seconds))
698
+ }),
699
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
700
+ className: "dsh-usage-updated",
701
+ children: data === null ? tt("panel.never") : tt("panel.updated", { time: new Date(data.fetchedAt).toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-US") })
702
+ })
703
+ ]
704
+ })
705
+ ]
706
+ }), document.body) : null]
707
+ });
708
+ }
709
+ //#endregion
710
+ //#region src/client/styles.ts
711
+ /**
712
+ * Panel stylesheet, injected once per page. Follows the shipped plugins'
713
+ * convention of a `<style data-plugin-css>` tag and design-token colors.
714
+ */
715
+ const CSS = `
716
+ .dsh-usage-root { position: relative; display: inline-flex; }
717
+ /* Tri-tone health halo: only the shadow carries the worst provider state
718
+ (green ok / amber remaining<30% / red error or usage>=90%) — the button
719
+ face itself stays neutral. A 1px tone ring plus a soft halo keeps the
720
+ state readable on both light and dark skins. */
721
+ .dsh-usage-tone-ok { --dsh-usage-tone: #22a06b; }
722
+ .dsh-usage-tone-warn { --dsh-usage-tone: #e2b93b; }
723
+ .dsh-usage-tone-danger { --dsh-usage-tone: #d94f4f; }
724
+ /* Floating ball trigger: a draggable fixed circle portaled to document.body,
725
+ one layer below the panel (1000) so the popover always covers it. Position
726
+ comes from inline style: docked it sits at its stored coordinates, while
727
+ dragging it follows the cursor. Keeps the shell's lv3 lift shadow; the
728
+ tone halo stacks on top. */
729
+ .dsh-usage-ball {
730
+ position: fixed; z-index: 999; width: 32px; height: 32px; border-radius: 50%;
731
+ cursor: pointer; touch-action: none;
732
+ display: inline-flex; align-items: center; justify-content: center;
733
+ border: 1px solid var(--dsw-alias-border-l2);
734
+ background-color: var(--dsw-specific-menu);
735
+ color: var(--dsw-alias-label-tertiary);
736
+ transition: box-shadow 0.15s;
737
+ }
738
+ .dsh-usage-ball:hover, .dsh-usage-ball:focus-visible { color: var(--dsw-alias-label-secondary); }
739
+ .dsh-usage-ball svg { width: 15px; height: 15px; }
740
+ .dsh-usage-ball.dsh-usage-tone-ok,
741
+ .dsh-usage-ball.dsh-usage-tone-warn,
742
+ .dsh-usage-ball.dsh-usage-tone-danger {
743
+ box-shadow:
744
+ var(--dsw-shadow-lv3),
745
+ 0 0 0 1px color-mix(in srgb, var(--dsh-usage-tone) 40%, transparent),
746
+ 0 0 14px color-mix(in srgb, var(--dsh-usage-tone) 55%, transparent);
747
+ }
748
+ .dsh-usage-ball.dsh-usage-tone-ok:hover,
749
+ .dsh-usage-ball.dsh-usage-tone-warn:hover,
750
+ .dsh-usage-ball.dsh-usage-tone-danger:hover,
751
+ .dsh-usage-ball.dsh-usage-tone-ok:focus-visible,
752
+ .dsh-usage-ball.dsh-usage-tone-warn:focus-visible,
753
+ .dsh-usage-ball.dsh-usage-tone-danger:focus-visible {
754
+ box-shadow:
755
+ var(--dsw-shadow-lv3),
756
+ 0 0 0 1px color-mix(in srgb, var(--dsh-usage-tone) 55%, transparent),
757
+ 0 0 18px color-mix(in srgb, var(--dsh-usage-tone) 70%, transparent);
758
+ }
759
+ .dsh-usage-panel {
760
+ z-index: 1000; box-sizing: border-box;
761
+ border: 1px solid var(--dsw-alias-border-l2);
762
+ background: var(--dsw-specific-menu);
763
+ box-shadow: var(--dsw-shadow-lv3);
764
+ border-radius: 12px; margin: 0; padding: 8px;
765
+ width: 320px; max-width: min(360px, 100vw - 16px);
766
+ max-height: min(460px, 100vh - 60px); overflow: auto;
767
+ display: flex; flex-direction: column; gap: 6px;
768
+ /* position: fixed with viewport coordinates computed inline from the
769
+ trigger rect — absolute positioning inside the sidebar gets clipped by
770
+ the column's overflow. */
771
+ position: fixed;
772
+ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
773
+ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
774
+ }
775
+ .dsh-usage-head {
776
+ display: flex; align-items: center; gap: 8px; padding: 2px 4px 6px;
777
+ border-bottom: 1px solid var(--dsw-alias-border-l2);
778
+ }
779
+ .dsh-usage-title { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 600; }
780
+ .dsh-usage-version {
781
+ color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px;
782
+ padding: 0 5px; border-radius: 4px; background: var(--dsw-alias-fill-l2);
783
+ font-variant-numeric: tabular-nums; user-select: none; margin-right: auto;
784
+ }
785
+ .dsh-usage-lang {
786
+ cursor: pointer; display: inline-flex; align-items: center; gap: 4px;
787
+ border: 0; border-radius: 6px; background: 0; padding: 3px 5px;
788
+ color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px;
789
+ }
790
+ .dsh-usage-lang:hover { color: var(--dsw-alias-label-secondary); }
791
+ .dsh-usage-refresh {
792
+ cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
793
+ border: 0; border-radius: 6px; background: 0; padding: 4px;
794
+ color: var(--dsw-alias-label-tertiary); line-height: 0;
795
+ }
796
+ .dsh-usage-refresh:hover { color: var(--dsw-alias-label-secondary); }
797
+ .dsh-usage-refresh:disabled { opacity: 0.5; cursor: default; }
798
+ /* Home button in the panel head: sends the ball back to its default spot. */
799
+ .dsh-usage-home {
800
+ cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
801
+ border: 0; border-radius: 6px; background: 0; padding: 4px;
802
+ color: var(--dsw-alias-label-tertiary); line-height: 0;
803
+ }
804
+ .dsh-usage-home:hover { color: var(--dsw-alias-label-secondary); }
805
+ @keyframes dsh-usage-spin { to { transform: rotate(360deg); } }
806
+ .dsh-usage-refresh--loading svg { animation: dsh-usage-spin 0.9s linear infinite; }
807
+ .dsh-usage-card {
808
+ border-radius: 8px; padding: 8px 10px; display: flex; flex-direction: column; gap: 6px;
809
+ background: var(--dsw-alias-fill-l2);
810
+ }
811
+ .dsh-usage-cardHead { display: flex; align-items: center; gap: 8px; }
812
+ .dsh-usage-providerName {
813
+ color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 600;
814
+ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
815
+ }
816
+ .dsh-usage-message { color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; word-break: break-all; }
817
+ .dsh-usage-balanceRow { display: flex; align-items: baseline; gap: 8px; }
818
+ .dsh-usage-balanceTotal {
819
+ color: var(--dsw-alias-label-primary); font-size: 18px; font-weight: 600;
820
+ font-variant-numeric: tabular-nums;
821
+ }
822
+ .dsh-usage-balanceCurrency { color: var(--dsw-alias-label-secondary); font-size: 12px; }
823
+ .dsh-usage-balanceParts { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; }
824
+ .dsh-usage-row { display: flex; flex-direction: column; gap: 3px; }
825
+ .dsh-usage-rowHead { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; }
826
+ .dsh-usage-rowLabel { color: var(--dsw-alias-label-secondary); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
827
+ .dsh-usage-rowValue { color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; flex: none; }
828
+ .dsh-usage-barTrack {
829
+ height: 4px; border-radius: 2px; overflow: hidden;
830
+ background: var(--dsw-alias-border-l2);
831
+ }
832
+ .dsh-usage-barFill { height: 100%; border-radius: 2px; transition: width 0.3s; }
833
+ .dsh-usage-barFill--ok { background: #22a06b; }
834
+ .dsh-usage-barFill--warn { background: #e2b93b; }
835
+ .dsh-usage-barFill--danger { background: #d94f4f; }
836
+ .dsh-usage-rowReset { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; }
837
+ .dsh-usage-foot {
838
+ display: flex; align-items: center; gap: 8px; padding: 6px 4px 2px;
839
+ border-top: 1px solid var(--dsw-alias-border-l2);
840
+ }
841
+ .dsh-usage-footLabel { color: var(--dsw-alias-label-tertiary); font-size: 11px; }
842
+ .dsh-usage-select {
843
+ border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: 0;
844
+ color: var(--dsw-alias-label-secondary); font-size: 11px; padding: 2px 4px; cursor: pointer;
845
+ }
846
+ .dsh-usage-updated { color: var(--dsw-alias-label-tertiary); font-size: 11px; margin-left: auto; }
847
+ `;
848
+ const TAG_ID = "dsh-provider-usage/panel.css";
849
+ /** Inject the panel stylesheet once; on HMR swap, refresh the existing tag's payload in place. */
850
+ function ensureStyles() {
851
+ if (typeof document === "undefined") return;
852
+ const existing = document.querySelector("style[data-plugin-css=" + JSON.stringify(TAG_ID) + "]");
853
+ if (existing !== null) {
854
+ if (existing.textContent !== CSS) existing.textContent = CSS;
855
+ return;
856
+ }
857
+ const tag = document.createElement("style");
858
+ tag.dataset.plugin = "dsh-provider-usage";
859
+ tag.dataset.pluginCss = TAG_ID;
860
+ tag.textContent = CSS;
861
+ document.head.appendChild(tag);
862
+ }
863
+ //#endregion
864
+ //#region src/client/index.ts
865
+ /**
866
+ * provider-usage — browser half.
867
+ *
868
+ * Registers the locale dictionaries and contributes the usage widget into the
869
+ * sidebar footer's additive action slot. Data comes from the host `usage`
870
+ * Typert Remote (SRC mode — no generated descriptors) through the raw RPC
871
+ * caller on the connection service.
872
+ */
873
+ /** Required client services: slot registry, locale seats, and the connection RPC caller. */
874
+ const inject = [
875
+ "slots",
876
+ "locale",
877
+ "connection"
878
+ ];
879
+ function apply(ctx) {
880
+ ensureStyles();
881
+ ctx.effect(() => ctx.locale.register("provider-usage", {
882
+ zh,
883
+ en
884
+ }), "provider-usage: dictionaries");
885
+ const fetchUsage = async () => {
886
+ const result = await ctx.connection.rpc.call("/api", "usage/list", { args: {} });
887
+ if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
888
+ return result.value;
889
+ };
890
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
891
+ name: "sidebar.footer.action",
892
+ id: "provider-usage",
893
+ order: 10,
894
+ locale: "provider-usage",
895
+ inject: () => ({ fetchUsage })
896
+ }, UsageAction));
897
+ }
898
+ //#endregion
899
+ exports.apply = apply;
900
+ exports.inject = inject;
901
+ return module.exports;
902
+ }
903
+ });
904
+
905
+ //# sourceMappingURL=client.js.map