@linxin666/dsh-usage 0.3.22 → 0.3.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +6 -8
  3. package/README.zh.md +6 -8
  4. package/lib/client.js +749 -78
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +171 -201
  7. package/lib/types/client/UsageSectionCard.d.ts +0 -1
  8. package/lib/types/client/UsageSidebarPanel.d.ts +34 -0
  9. package/lib/types/client/body-mutations.d.ts +45 -0
  10. package/lib/types/client/locales.d.ts +9 -4
  11. package/lib/types/client/sidebar-entry-core.d.ts +87 -0
  12. package/lib/types/client/sidebar-entry.d.ts +35 -0
  13. package/lib/types/client/sidebar-panel-mount.d.ts +40 -0
  14. package/lib/types/client/voucher.d.ts +2 -2
  15. package/lib/types/core/provider-routes.d.ts +26 -0
  16. package/lib/types/core/types.d.ts +14 -1
  17. package/lib/types/host/access.d.ts +17 -0
  18. package/lib/types/host/pair-access.d.ts +34 -0
  19. package/lib/types/host/routes.d.ts +16 -7
  20. package/lib/types/host/usage-service.d.ts +28 -73
  21. package/lib/types/index.d.ts +0 -2
  22. package/package.json +4 -3
  23. package/src/client/UsageSectionCard.tsx +1 -15
  24. package/src/client/UsageSidebarPanel.tsx +196 -0
  25. package/src/client/body-mutations.ts +133 -0
  26. package/src/client/index.ts +22 -1
  27. package/src/client/locales.ts +20 -10
  28. package/src/client/sidebar-entry-core.ts +321 -0
  29. package/src/client/sidebar-entry.ts +101 -0
  30. package/src/client/sidebar-panel-mount.tsx +113 -0
  31. package/src/client/usage.module.css +252 -0
  32. package/src/client/voucher.ts +2 -2
  33. package/src/core/adapters.ts +40 -6
  34. package/src/core/provider-routes.ts +37 -0
  35. package/src/core/types.ts +14 -1
  36. package/src/host/access.ts +21 -0
  37. package/src/host/pair-access.ts +49 -0
  38. package/src/host/routes.ts +19 -10
  39. package/src/host/usage-service.ts +65 -214
  40. package/src/index.ts +1 -6
package/lib/index.js CHANGED
@@ -162,6 +162,10 @@ function toNum(value) {
162
162
  function str(value) {
163
163
  return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
164
164
  }
165
+ /** Clamp a provider percentage into the renderable 0-100 band. */
166
+ function clampPercent(value) {
167
+ return Math.max(0, Math.min(100, value));
168
+ }
165
169
  /** Format a number to a fixed 2-decimal display string. */
166
170
  function money(value) {
167
171
  return value.toFixed(2);
@@ -294,7 +298,28 @@ const KIMI_CODING = {
294
298
  }
295
299
  }
296
300
  };
297
- /** GLM Coding Plan quota; auth is the RAW key without a Bearer prefix. */
301
+ /**
302
+ * GLM Coding Plan quota windows, keyed by the provider's own `unit` code
303
+ * rather than by row position so an added or reordered limit cannot shift a
304
+ * window's meaning: 3 = the rolling 5-hour window, 6 = the weekly window,
305
+ * 5 = the monthly one. Two row kinds carry quota — `TOKENS_LIMIT` (token
306
+ * plans) and `CREDIT_LIMIT` (credit plans, whose exact ratio is
307
+ * `currentValue` / `usage`) — while `TIME_LIMIT` is the MCP request cap and
308
+ * is not a plan window at all. Auth is the RAW key without a Bearer prefix.
309
+ */
310
+ const GLM_UNIT_KEYS = {
311
+ 3: "5h",
312
+ 5: "month",
313
+ 6: "week"
314
+ };
315
+ /** Percent from a credit row's exact ratio, else the provider's own percentage. */
316
+ function glmCreditPercent(row) {
317
+ const used = toNum(row.currentValue);
318
+ const limit = toNum(row.usage);
319
+ if (used !== void 0 && limit !== void 0 && limit > 0) return clampPercent(used / limit * 100);
320
+ const percentage = toNum(row.percentage);
321
+ return percentage === void 0 ? void 0 : clampPercent(percentage);
322
+ }
298
323
  function glmPlan(host, ids) {
299
324
  return {
300
325
  ids,
@@ -319,11 +344,17 @@ function glmPlan(host, ids) {
319
344
  for (const entry of limits) {
320
345
  if (typeof entry !== "object" || entry === null) continue;
321
346
  const row = entry;
322
- const unit = toNum(row.unit);
323
- const percent = toNum(row.percentage);
347
+ const kind = str(row.type);
348
+ if (kind === "TIME_LIMIT") continue;
349
+ const key = GLM_UNIT_KEYS[toNum(row.unit) ?? NaN];
350
+ if (key === void 0) continue;
351
+ if (kind !== "TOKENS_LIMIT" && kind !== "CREDIT_LIMIT") continue;
324
352
  windows.push({
325
- key: unit === 3 ? "5h" : unit === 6 ? "week" : unit !== void 0 ? `unit-${unit}` : "window",
326
- percent: percent === void 0 ? void 0 : Math.max(0, Math.min(100, percent)),
353
+ key,
354
+ percent: kind === "CREDIT_LIMIT" ? glmCreditPercent(row) : (() => {
355
+ const percentage = toNum(row.percentage);
356
+ return percentage === void 0 ? void 0 : clampPercent(percentage);
357
+ })(),
327
358
  resetsAt: toIso(row.nextResetTime)
328
359
  });
329
360
  }
@@ -537,7 +568,7 @@ const PROVIDER_ADAPTERS = [
537
568
  moonshotBalance("api.moonshot.ai", "USD", ["moonshotai"]),
538
569
  KIMI_CODING,
539
570
  glmPlan("open.bigmodel.cn", ["zai-coding-cn"]),
540
- glmPlan("api.z.ai", ["zai-coding"]),
571
+ glmPlan("api.z.ai", ["zai", "zai-coding"]),
541
572
  OPENCODE_GO,
542
573
  minimaxPlan("api.minimaxi.com", ["minimax-cn"]),
543
574
  minimaxPlan("api.minimax.io", ["minimax"]),
@@ -574,6 +605,34 @@ function providerErrorMessage(status, body) {
574
605
  return `HTTP ${status}${message === void 0 ? "" : `: ${message.slice(0, 120)}`}`;
575
606
  }
576
607
  //#endregion
608
+ //#region src/core/provider-routes.ts
609
+ /**
610
+ * Alias folding for the enumerated provider routes. Kept in core because the
611
+ * rule is pure — the host enumerates routes and this decides which of two
612
+ * routes serving one adapter family survives.
613
+ * @module @linxin666/dsh-usage/core/provider-routes
614
+ */
615
+ /**
616
+ * Drop dormant catalog aliases that shadow a live route of the same adapter
617
+ * family. The pi-ai catalog ships route keys the runtime serves nothing on
618
+ * until a profile declares them: for DeepSeek the catalog entry `deepseek`
619
+ * sits beside the llm-deepseek runtime route `deepseek-official`, and both
620
+ * resolve the same credential (the family's `apiKeyEnv` fallback), so probing
621
+ * and rendering both turns one account into two identical balance rows.
622
+ *
623
+ * A family keeps every live route it has — two configured profiles are two
624
+ * real accounts, not aliases. A route with no adapter, or one whose family
625
+ * has no live route at all, is returned untouched.
626
+ */
627
+ function foldAliasRoutes(routes) {
628
+ return routes.filter((route) => {
629
+ if (route.live) return true;
630
+ const family = adapterFor(route.id);
631
+ if (family === void 0) return true;
632
+ return !routes.some((other) => other.live && adapterFor(other.id) === family);
633
+ });
634
+ }
635
+ //#endregion
577
636
  //#region src/core/pricing.ts
578
637
  /** The published peak windows: weekday 09:00-12:00 and 14:00-18:00. */
579
638
  const PEAK_WINDOWS = [{
@@ -856,112 +915,15 @@ function deserializeLedger(value) {
856
915
  /**
857
916
  * The dsh-usage host service: folds live session usage into the persistent
858
917
  * ledger, probes each configured provider's balance/coding-plan endpoint on
859
- * a poll cycle, and announces the current provider's status to the pet
860
- * bubble. Secrets stay in the host process; the browser only ever sees the
861
- * overview document.
918
+ * a poll cycle, and serves the current provider's strip-ready view inside
919
+ * the overview document. Secrets stay in the host process; the browser only
920
+ * ever sees the overview document.
862
921
  * @module @linxin666/dsh-usage/host/usage-service
863
922
  */
864
- /** Source tag the plugin stamps onto pet announcements. */
865
- const USAGE_ANNOUNCE_SOURCE = "dsh-usage";
866
923
  /** Probe timeout per HTTP call. */
867
924
  const PROBE_TIMEOUT_MS = 1e4;
868
925
  /** Ledger flush debounce. */
869
926
  const FLUSH_DEBOUNCE_MS = 3e3;
870
- /** Currency symbols the bubble and section render inline; other codes render as `12.00 EUR`. */
871
- const CURRENCY_SYMBOLS = {
872
- CNY: "¥",
873
- USD: "$",
874
- EUR: "€",
875
- GBP: "£"
876
- };
877
- /** Format a balance for display: symbol prefix when known, code suffix otherwise. */
878
- function formatMoney(currency, totalBalance) {
879
- const symbol = CURRENCY_SYMBOLS[currency.toUpperCase()];
880
- if (symbol !== void 0) return symbol + totalBalance;
881
- return `${totalBalance} ${currency.toUpperCase()}`;
882
- }
883
- /** Map a used percent to the announcement tone. */
884
- function planTone(percent) {
885
- if (percent >= 90) return "low";
886
- if (percent >= 70) return "warn";
887
- return "ok";
888
- }
889
- /**
890
- * Build the raw pet announce payload for one provider snapshot, or
891
- * undefined when nothing worth announcing exists. Pure: every payload this
892
- * returns satisfies the pet's `parseAnnouncement` contract — plan
893
- * announcements require a numeric percent, so percent-less windows never
894
- * announce (the pet validator would silently drop them). A priced family
895
- * (DeepSeek) with spend today announces a cost bubble first; balance-only
896
- * families announce the balance; plan families announce their tightest
897
- * percent window.
898
- */
899
- function buildAnnouncement(snapshot, context) {
900
- const todayCost = context?.todayCost ?? 0;
901
- if (todayCost > 0) {
902
- const peak = context?.peak ?? false;
903
- const noteParts = [peak ? "高峰时段 计价×2" : "空闲时段 计价减半", ...snapshot.balance !== void 0 ? [`余额 ${formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance)}`] : []];
904
- return {
905
- kind: "cost",
906
- title: snapshot.displayName,
907
- amount: `今日 ${formatMoney("CNY", todayCost.toFixed(2))}`,
908
- ...noteParts.length > 0 ? { note: noteParts.join(" · ") } : {},
909
- tone: peak ? "warn" : "ok"
910
- };
911
- }
912
- if (snapshot.balance !== void 0) return {
913
- kind: "balance",
914
- title: snapshot.displayName,
915
- amount: formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance),
916
- tone: "ok"
917
- };
918
- if (snapshot.plan === void 0) return void 0;
919
- const window = snapshot.plan.windows.filter((entry) => typeof entry.percent === "number").sort((a, b) => (b.percent ?? 0) - (a.percent ?? 0))[0];
920
- if (window === void 0 || window.percent === void 0) return void 0;
921
- return {
922
- kind: "plan",
923
- title: snapshot.displayName,
924
- percent: window.percent,
925
- ...window.resetsAt !== void 0 ? { resetAt: window.resetsAt } : {},
926
- ...snapshot.plan.planName !== void 0 ? { note: snapshot.plan.planName } : {},
927
- tone: planTone(window.percent)
928
- };
929
- }
930
- /**
931
- * Compact token-count display for the usage fallback bubble: `9805`,
932
- * `8.7万`, `1.2亿`. The host-authored bubble copy is zh (like every other
933
- * line this service speaks), so the magnitudes follow the zh convention.
934
- */
935
- function formatTokens(count) {
936
- const compact = (value) => {
937
- const rounded = Math.round(value * 10) / 10;
938
- return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
939
- };
940
- if (count >= 1e8) return compact(count / 1e8) + "亿";
941
- if (count >= 1e4) return compact(count / 1e4) + "万";
942
- return String(count);
943
- }
944
- /**
945
- * The usage fallback for the session's provider: today's token consumption
946
- * and call count, the one fact the ledger owns for every provider. Providers
947
- * without a readable balance/plan endpoint (relay stations, local runtimes,
948
- * token-plan vendors — and probeable providers whose probes are failing)
949
- * would otherwise leave the pet bubble permanently silent even while their
950
- * sessions run. Undefined when the provider has no usage today: a bubble
951
- * about nothing is noise, not information. Pure; the payload satisfies the
952
- * pet's `parseAnnouncement` contract.
953
- */
954
- function buildLedgerAnnouncement(input) {
955
- const total = totalTokens(input.totals);
956
- if (input.totals.calls <= 0 || total <= 0) return void 0;
957
- return {
958
- kind: "cost",
959
- title: input.displayName,
960
- amount: `今日 ${formatTokens(total)} tokens`,
961
- note: `${input.totals.calls} 次调用`,
962
- tone: "ok"
963
- };
964
- }
965
927
  /** Best-effort typed service read: absent services resolve to undefined at runtime. */
966
928
  function service(ctx, name) {
967
929
  try {
@@ -1018,7 +980,7 @@ var UsageService = class {
1018
980
  spendWatch;
1019
981
  /** Per-live-session route attribution (WeakMap: disposed sessions age out). */
1020
982
  sessionRoutes = /* @__PURE__ */ new WeakMap();
1021
- /** The most recent route seen this boot; the pet bubble follows it. */
983
+ /** The most recent route seen this boot; the sidebar strip and the header highlight follow it. */
1022
984
  current = { source: "default" };
1023
985
  sessionListenerDisposer;
1024
986
  pollTimer;
@@ -1031,7 +993,6 @@ var UsageService = class {
1031
993
  /** True once loadPersisted finished; nothing may overwrite the files before that. */
1032
994
  loaded = false;
1033
995
  disposed = false;
1034
- lastSignature;
1035
996
  /** Last prune guard: once per local day, and whenever retention shrinks. */
1036
997
  lastPrune;
1037
998
  constructor(ctx, options) {
@@ -1098,7 +1059,7 @@ var UsageService = class {
1098
1059
  return {
1099
1060
  updatedAt: Date.now(),
1100
1061
  providers,
1101
- current: { ...this.current },
1062
+ current: this.currentView(),
1102
1063
  usage: {
1103
1064
  today: this.daySummary(todayKey),
1104
1065
  days: days.map((date) => {
@@ -1126,6 +1087,33 @@ var UsageService = class {
1126
1087
  }
1127
1088
  };
1128
1089
  }
1090
+ /**
1091
+ * The strip-ready current-provider view: the resolved display name plus
1092
+ * today's ledger totals for the provider's adapter family (the route
1093
+ * itself when adapter-less). `today` is absent on a day without usage —
1094
+ * a strip about nothing is noise, not information.
1095
+ */
1096
+ currentView() {
1097
+ const provider = this.current.provider;
1098
+ if (provider === void 0) return { ...this.current };
1099
+ let snapshot = this.snapshots.get(provider);
1100
+ if (snapshot === void 0) {
1101
+ const family = adapterFor(provider);
1102
+ if (family !== void 0) {
1103
+ for (const [id, candidate] of this.snapshots) if (adapterFor(id) === family) {
1104
+ snapshot = candidate;
1105
+ break;
1106
+ }
1107
+ }
1108
+ }
1109
+ const displayName = snapshot?.displayName ?? this.routeDisplayName(provider);
1110
+ const today = this.familyUsageToday(provider);
1111
+ return {
1112
+ ...this.current,
1113
+ displayName,
1114
+ ...today.calls > 0 && totalTokens(today) > 0 ? { today } : {}
1115
+ };
1116
+ }
1129
1117
  /** One local day aggregated per provider. */
1130
1118
  daySummary(dateKey) {
1131
1119
  const { totals, providers } = summarizeDays([this.ledger.days[dateKey] ?? {}]);
@@ -1135,17 +1123,17 @@ var UsageService = class {
1135
1123
  providers
1136
1124
  };
1137
1125
  }
1138
- /** Today's ledger spend for one provider's adapter family (0 when unpriced). */
1139
- familyCostToday(provider) {
1126
+ /**
1127
+ * Today's ledger totals for one provider's adapter family — the exact
1128
+ * route when adapter-less, the merged family otherwise, so aliased routes
1129
+ * of one account (the deepseek catalog id and its runtime alias) fold
1130
+ * together.
1131
+ */
1132
+ familyUsageToday(provider) {
1140
1133
  const family = adapterFor(provider);
1141
- if (family === void 0) return 0;
1142
- let cost = 0;
1143
- for (const row of this.daySummary(localDateKey(Date.now())).providers) if (adapterFor(row.provider) === family) cost += row.totals.cost;
1144
- return cost;
1145
- }
1146
- /** Today's ledger totals for one provider route (the exact id, not the family). */
1147
- providerUsageToday(provider) {
1148
- return this.daySummary(localDateKey(Date.now())).providers.find((entry) => entry.provider === provider)?.totals ?? emptyTotals();
1134
+ let merged = emptyTotals();
1135
+ for (const row of this.daySummary(localDateKey(Date.now())).providers) if (family === void 0 ? row.provider === provider : adapterFor(row.provider) === family) merged = addTotals(merged, row.totals);
1136
+ return merged;
1149
1137
  }
1150
1138
  /**
1151
1139
  * Accrue the official DeepSeek family's real spend from observed CNY
@@ -1177,7 +1165,7 @@ var UsageService = class {
1177
1165
  /**
1178
1166
  * The display name for a route key: the LLM runtime's first, then the
1179
1167
  * adapter's, then the id itself — the snapshot may not exist for
1180
- * adapter-less routes, yet the bubble still needs a legible title.
1168
+ * adapter-less routes, yet the sidebar strip still needs a legible title.
1181
1169
  */
1182
1170
  routeDisplayName(provider) {
1183
1171
  return this.listProviderRoutes().find((entry) => entry.id === provider)?.displayName || adapterFor(provider)?.displayName || provider;
@@ -1326,7 +1314,7 @@ var UsageService = class {
1326
1314
  }, delayMs);
1327
1315
  }
1328
1316
  /**
1329
- * One poll cycle: enumerate routes, resolve credentials, probe, announce.
1317
+ * One poll cycle: enumerate routes, resolve credentials, probe.
1330
1318
  * A call while a cycle is already running joins that cycle instead of
1331
1319
  * returning immediately, so a manual refresh always waits for real probes.
1332
1320
  */
@@ -1350,7 +1338,6 @@ var UsageService = class {
1350
1338
  if (this.disposed) return;
1351
1339
  this.accrueDeepSeekSpend();
1352
1340
  await this.persistSnapshots();
1353
- this.announceCurrent();
1354
1341
  } finally {
1355
1342
  this.pollInFlight = false;
1356
1343
  this.pollPromise = void 0;
@@ -1359,17 +1346,28 @@ var UsageService = class {
1359
1346
  this.pollPromise = cycle;
1360
1347
  return cycle;
1361
1348
  }
1349
+ /**
1350
+ * Every route the service probes and renders: the runtime's live providers
1351
+ * plus the configurable directory's catalog entries, then alias-folded so a
1352
+ * dormant catalog entry cannot shadow the live route of the same adapter
1353
+ * family (see foldAliasRoutes).
1354
+ */
1362
1355
  listProviderRoutes() {
1363
1356
  const routes = /* @__PURE__ */ new Map();
1364
1357
  const runtime = service(this.ctx, "llm");
1365
1358
  if (runtime !== void 0) try {
1366
- for (const provider of runtime.listProviders()) if (provider.id !== "") routes.set(provider.id, provider.name);
1367
- for (const provider of runtime.listConfigurableProviders()) if (!routes.has(provider.provider)) routes.set(provider.provider, provider.displayName);
1359
+ for (const provider of runtime.listProviders()) if (provider.id !== "") routes.set(provider.id, {
1360
+ id: provider.id,
1361
+ displayName: provider.name,
1362
+ live: true
1363
+ });
1364
+ for (const provider of runtime.listConfigurableProviders()) if (!routes.has(provider.provider)) routes.set(provider.provider, {
1365
+ id: provider.provider,
1366
+ displayName: provider.displayName,
1367
+ live: false
1368
+ });
1368
1369
  } catch {}
1369
- return [...routes].map(([id, displayName]) => ({
1370
- id,
1371
- displayName
1372
- }));
1370
+ return foldAliasRoutes([...routes.values()]);
1373
1371
  }
1374
1372
  async probeRoute(route, adapter) {
1375
1373
  const credential = await this.resolveCredential(route.id);
@@ -1486,66 +1484,6 @@ var UsageService = class {
1486
1484
  deepseekApiKeyEnv() {
1487
1485
  return readNamespace(this.ctx, "llm-deepseek")?.apiKeyEnv ?? "DEEPSEEK_API_KEY";
1488
1486
  }
1489
- /**
1490
- * Announce the current provider's spend, balance, or plan usage to the pet.
1491
- * In `change` mode only meaningful value changes re-announce; `off` skips.
1492
- * The TTL rides the poll interval (bubble_mode `always` re-announces every
1493
- * cycle, so a TTL of two cycles + margin keeps the bubble continuous
1494
- * across polls; the pet contract caps the ceiling). A route id the
1495
- * catalogs spell differently than the snapshot keys falls back to its
1496
- * adapter family's snapshot. When the provider has no announceable probe
1497
- * fact (no adapter, failing probes, percent-less windows) the bubble falls
1498
- * back to the provider's today ledger usage, and with neither fact nor
1499
- * usage it stays silent. Fully guarded: a malformed snapshot or a failing
1500
- * pet service must never break the poll loop, and a disposed service never
1501
- * announces.
1502
- */
1503
- announceCurrent() {
1504
- if (this.disposed || this.options.bubbleMode === "off") return;
1505
- try {
1506
- let provider = this.current.provider;
1507
- if (provider === void 0) {
1508
- const fallback = readNamespace(this.ctx, "agent-default-model");
1509
- if (fallback?.provider !== void 0) {
1510
- provider = fallback.provider;
1511
- this.current = {
1512
- provider: fallback.provider,
1513
- model: fallback.model,
1514
- source: "default"
1515
- };
1516
- } else return;
1517
- }
1518
- let snapshot = this.snapshots.get(provider);
1519
- if (snapshot === void 0) {
1520
- const family = adapterFor(provider);
1521
- if (family !== void 0) {
1522
- for (const [id, candidate] of this.snapshots) if (adapterFor(id) === family) {
1523
- snapshot = candidate;
1524
- break;
1525
- }
1526
- }
1527
- }
1528
- const displayName = snapshot?.displayName ?? this.routeDisplayName(provider);
1529
- let announcement = snapshot !== void 0 ? buildAnnouncement(snapshot, {
1530
- todayCost: this.familyCostToday(provider),
1531
- peak: deepseekPeriodAt(Date.now()).peak
1532
- }) : void 0;
1533
- if (announcement === void 0) announcement = buildLedgerAnnouncement({
1534
- displayName,
1535
- totals: this.providerUsageToday(provider)
1536
- });
1537
- if (announcement === void 0) return;
1538
- const signature = JSON.stringify(announcement);
1539
- if (this.options.bubbleMode === "change" && signature === this.lastSignature) return;
1540
- this.lastSignature = signature;
1541
- const ttlMs = Math.min(72e5, this.options.pollIntervalSec * 2e3 + 3e4);
1542
- service(this.ctx, "pet")?.announce({
1543
- source: USAGE_ANNOUNCE_SOURCE,
1544
- ttlMs,
1545
- ...announcement
1546
- });
1547
- } catch {}
1548
- }
1549
1487
  };
1550
1488
  //#endregion
1551
1489
  //#region src/host/loopback.ts
@@ -1593,6 +1531,33 @@ function isLoopbackRequest(request) {
1593
1531
  }
1594
1532
  }
1595
1533
  //#endregion
1534
+ //#region src/host/pair-access.ts
1535
+ /**
1536
+ * Whether this request may enter the plugin's host routes.
1537
+ * @param ctx - host context; may expose remoteWebUiPairing.
1538
+ * @param request - the incoming HTTP request.
1539
+ * @returns true for loopback, or a live paired-device cookie.
1540
+ */
1541
+ function isPairedOrLoopbackAllowed(ctx, request) {
1542
+ if (isLoopbackRequest(request)) return true;
1543
+ const fromGet = typeof ctx.get === "function" ? ctx.get("remoteWebUiPairing", false) : void 0;
1544
+ return (isPairingAccess(fromGet) ? fromGet : ctx.remoteWebUiPairing)?.isPairedDevice(request) === true;
1545
+ }
1546
+ function isPairingAccess(value) {
1547
+ return value !== void 0 && value !== null && typeof value.isPairedDevice === "function";
1548
+ }
1549
+ //#endregion
1550
+ //#region src/host/access.ts
1551
+ /**
1552
+ * Whether this request may read the usage overview or trigger a refresh.
1553
+ * @param ctx - host context; may expose remoteWebUiPairing.
1554
+ * @param request - the incoming HTTP request.
1555
+ * @returns true for loopback, or a live paired-device cookie.
1556
+ */
1557
+ function isUsageAllowed(ctx, request) {
1558
+ return isPairedOrLoopbackAllowed(ctx, request);
1559
+ }
1560
+ //#endregion
1596
1561
  //#region src/host/http.ts
1597
1562
  /** Family-default JSON response headers; callers may append or override. */
1598
1563
  const JSON_HEADERS = {
@@ -1615,16 +1580,21 @@ function writeJson(res, status, body, headers = {}) {
1615
1580
  //#endregion
1616
1581
  //#region src/host/routes.ts
1617
1582
  /**
1618
- * Loopback-fenced overview route: provider balances, plan quotas, and token
1619
- * usage totals. Personal account data, so the loopback fence mirrors
1620
- * dsh-perf's stats surface; the browser runs on the same machine.
1583
+ * Overview route: provider balances, plan quotas, and token usage totals.
1584
+ * Personal account data behind the family trust fence — loopback always
1585
+ * passes, and a live paired-device cookie passes too when remote-web-ui is
1586
+ * loaded (issue #1592: the sidebar usage panel reads this from a paired LAN
1587
+ * browser); every unpaired LAN request keeps its 403.
1588
+ * @param ctx - host context; may expose remoteWebUiPairing.
1589
+ * @param service - the usage service.
1590
+ * @returns the route.
1621
1591
  */
1622
- function makeUsageOverviewRoute(service) {
1592
+ function makeUsageOverviewRoute(ctx, service) {
1623
1593
  return {
1624
1594
  kind: "exact",
1625
1595
  path: "/api/dsh-usage/overview",
1626
1596
  handler: async (req, res) => {
1627
- if (!isLoopbackRequest(req)) {
1597
+ if (!isUsageAllowed(ctx, req)) {
1628
1598
  writeJson(res, 403, {
1629
1599
  ok: false,
1630
1600
  error: "forbidden: loopback-only"
@@ -1636,15 +1606,18 @@ function makeUsageOverviewRoute(service) {
1636
1606
  };
1637
1607
  }
1638
1608
  /**
1639
- * Loopback-fenced manual refresh: forces one probe cycle now and answers
1640
- * with the fresh overview.
1609
+ * Manual refresh: forces one probe cycle now and answers with the fresh
1610
+ * overview. Same trust fence as the overview route.
1611
+ * @param ctx - host context; may expose remoteWebUiPairing.
1612
+ * @param service - the usage service.
1613
+ * @returns the route.
1641
1614
  */
1642
- function makeUsageRefreshRoute(service) {
1615
+ function makeUsageRefreshRoute(ctx, service) {
1643
1616
  return {
1644
1617
  kind: "exact",
1645
1618
  path: "/api/dsh-usage/refresh",
1646
1619
  handler: async (req, res) => {
1647
- if (!isLoopbackRequest(req)) {
1620
+ if (!isUsageAllowed(ctx, req)) {
1648
1621
  writeJson(res, 403, {
1649
1622
  ok: false,
1650
1623
  error: "forbidden: loopback-only"
@@ -1679,15 +1652,12 @@ const USAGE_SETTINGS_NAMESPACE = "dsh-usage";
1679
1652
  const Config = z.object({
1680
1653
  enabled: z.boolean().default(true),
1681
1654
  pollIntervalSec: z.number().min(30).max(3600).default(60),
1682
- bubbleMode: z.string().default("always"),
1683
1655
  retainDays: z.number().min(7).max(730).default(180)
1684
1656
  });
1685
1657
  function resolveConfig(config) {
1686
- const bubbleMode = config?.bubbleMode === "change" || config?.bubbleMode === "off" ? config.bubbleMode : "always";
1687
1658
  return {
1688
1659
  enabled: config?.enabled ?? true,
1689
1660
  pollIntervalSec: typeof config?.pollIntervalSec === "number" ? config.pollIntervalSec : 60,
1690
- bubbleMode,
1691
1661
  retainDays: typeof config?.retainDays === "number" ? config.retainDays : 180
1692
1662
  };
1693
1663
  }
@@ -1711,7 +1681,7 @@ const apply = mountOnce("@linxin666/dsh-usage", (ctx, config) => {
1711
1681
  const begin = () => {
1712
1682
  if (service !== next) return;
1713
1683
  next.start();
1714
- const disposers = [makeUsageOverviewRoute(next), makeUsageRefreshRoute(next)].map((route) => ctx.webServer.register(route));
1684
+ const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)].map((route) => ctx.webServer.register(route));
1715
1685
  disposeRoutes = () => {
1716
1686
  for (const dispose of disposers) try {
1717
1687
  dispose();
@@ -14,7 +14,6 @@ import type { UsageStoreInstance } from './usage-store.ts';
14
14
  export interface UsageSettings {
15
15
  enabled?: boolean;
16
16
  pollIntervalSec?: number;
17
- bubbleMode?: string;
18
17
  }
19
18
  /** The registration-side face the section's slot entry injects. */
20
19
  export interface UsageSectionFace {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Sidebar usage panel (issue #1592): the usage body seated directly under the
3
+ * sidebar entry row. The row carries the panel's controls (refresh, collapse
4
+ * chevron), so this component renders only the content: expanded, plan
5
+ * providers show their quota windows and balance-only providers their
6
+ * remaining amount; collapsed, a one-line strip shows the current session
7
+ * provider's today usage (tokens, calls, and cost when priced) from the same
8
+ * /api/dsh-usage/overview document the settings section renders. The open
9
+ * state lives in the mount (persisted to localStorage) and arrives through
10
+ * the open face; polling runs at 10s expanded, 30s collapsed, and pauses
11
+ * while the page is hidden.
12
+ * @module @linxin666/dsh-usage/client/UsageSidebarPanel
13
+ */
14
+ import { type ReactNode } from 'react';
15
+ import type { UsageStoreInstance } from './usage-store.ts';
16
+ /** Expanded-state face the owning mount supplies (subscribe + current value). */
17
+ export interface OpenStateSource {
18
+ subscribe(listener: () => void): () => void;
19
+ isOpen(): boolean;
20
+ }
21
+ /** Props of the sidebar panel (the apply body supplies the store and poller). */
22
+ export interface UsageSidebarPanelProps {
23
+ store: UsageStoreInstance;
24
+ /** Fetch one overview now. */
25
+ poll: () => void;
26
+ /** Expanded state owned by the mount; false renders nothing and pauses polling. */
27
+ open: OpenStateSource;
28
+ }
29
+ /**
30
+ * Render the sidebar usage panel body.
31
+ * @param props - store, the poll callback, and the mount's open-state face.
32
+ * @returns the panel content, or null while collapsed.
33
+ */
34
+ export declare function UsageSidebarPanel(props: UsageSidebarPanelProps): ReactNode;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One `document.body` MutationObserver per page, shared by every family
3
+ * plugin bundle.
4
+ *
5
+ * Family plugins that inject at the DOM level must notice when the shell
6
+ * re-renders around their injected node: the sidebar entry rows
7
+ * (sidebar-entry-core), the center-column panel containers
8
+ * (panel-mount-core), and the aggregate shell's column shims. Each consumer
9
+ * used to install its OWN `MutationObserver` on `document.body` with
10
+ * `{ childList: true, subtree: true }`, so a page carrying N family plugins
11
+ * paid N native observers and N callback invocations for EVERY mutation batch
12
+ * the app produced — chat token streaming alone produces many per second, and
13
+ * the count grows with the number of installed plugins.
14
+ *
15
+ * This module keeps exactly ONE body observer per page. The registry lives on
16
+ * `globalThis` under a `Symbol.for` key, so the per-package generated copies
17
+ * of this file (scripts/sync-shared.mjs) and separately bundled plugins all
18
+ * reach the same hub at runtime instead of one hub per module instance.
19
+ * Record subscribers receive the mutations accumulated since the last flush.
20
+ * Consumers that only re-check their DOM use subscribeBodyInvalidations:
21
+ * when all subscribers use that path, the hub retains no mutation records
22
+ * (or detached subtrees) while a background page's animation frames pause.
23
+ * Both paths run at most once per frame. The last subscriber disconnects the
24
+ * observer, cancels the pending frame and releases its records.
25
+ *
26
+ * Failure policy: without a DOM or `MutationObserver` the subscription is a
27
+ * no-op disposer (the same silence the per-consumer observers had), and when
28
+ * `requestAnimationFrame` is unavailable the flush runs synchronously so a
29
+ * subscriber is never silently dropped. A throwing subscriber cannot stop the
30
+ * others.
31
+ */
32
+ /**
33
+ * Subscribe to a coalesced DOM re-check without retaining mutation records.
34
+ * The marked wrapper also works with an older hub, which delivers records
35
+ * that it simply ignores until a page reload picks up the updated hub.
36
+ */
37
+ export declare function subscribeBodyInvalidations(subscriber: () => void): () => void;
38
+ /**
39
+ * Subscribe to body-level childList mutations.
40
+ * @param subscriber - called at most once per animation frame with the records
41
+ * collected since the previous flush; must be safe to run repeatedly.
42
+ * @returns the disposer removing this subscriber (and the observer when it was
43
+ * the last one).
44
+ */
45
+ export declare function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void;
@@ -46,10 +46,6 @@ export declare const zh: {
46
46
  'usage.config.title': string;
47
47
  'usage.config.enabled': string;
48
48
  'usage.config.pollIntervalSec': string;
49
- 'usage.config.bubbleMode': string;
50
- 'usage.config.bubbleMode.always': string;
51
- 'usage.config.bubbleMode.change': string;
52
- 'usage.config.bubbleMode.off': string;
53
49
  'usage.bank.title': string;
54
50
  'usage.bank.hint': string;
55
51
  'usage.bank.noUsage': string;
@@ -60,6 +56,15 @@ export declare const zh: {
60
56
  'usage.bank.save': string;
61
57
  'usage.bank.share': string;
62
58
  'usage.bank.drawError': string;
59
+ 'usage.sidebar.entry.label': string;
60
+ 'usage.sidebar.entry.tooltip': string;
61
+ 'usage.sidebar.toggle.collapse': string;
62
+ 'usage.sidebar.toggle.expand': string;
63
+ 'usage.sidebar.empty': string;
64
+ 'usage.sidebar.error': string;
65
+ 'usage.sidebar.loading': string;
66
+ 'usage.sidebar.balanceLeft': string;
67
+ 'usage.sidebar.todayUsage': string;
63
68
  };
64
69
  /** English mirror; every zh key present. */
65
70
  export declare const en: Record<UsageKey, string>;