dsh-xdpool 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -116,6 +116,17 @@ window.__ModuleLoader__.load({
116
116
  /** Set or clear one account's reserved-credit floor. */
117
117
  const POOL_CREDIT_RESERVE_PATH = "/plugins/dsh-xdpool/accounts/credit-reserve";
118
118
  /**
119
+ * Estimated credit spend, per channel and per model.
120
+ *
121
+ * Derived from the pack's own token ledger, never from a credit counter — the pack
122
+ * records tokens, and the vendor's balance is only visible when it is read again. The
123
+ * arithmetic (tokens ÷ 1e6 × the model's published multiplier) and the honesty about
124
+ * its error live in `credits.ts`.
125
+ *
126
+ * `?days=N` limits the window; the ledger keeps 90 days.
127
+ */
128
+ const POOL_CREDITS_SPEND_PATH = "/plugins/dsh-xdpool/credits/spend";
129
+ /**
119
130
  * Name the account to try first, or hand the choice back to the strategy.
120
131
  *
121
132
  * A route rather than a settings write for the reason the ignore list is a file:
@@ -1204,7 +1215,13 @@ window.__ModuleLoader__.load({
1204
1215
  .dsm-xdpool-group svg{fill:currentColor;transition:transform .28s cubic-bezier(.34,1.56,.64,1)}
1205
1216
  /* The invite dialog. Positioned against the shell (which is the containing
1206
1217
  block) so it covers the card and nothing of the host dialog around it. */
1207
- .dsm-xdpool-groupmask{position:absolute;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;padding:16px;border-radius:16px;background:color-mix(in srgb, var(--dsw-alias-bg-layer-1,#191a1e) 72%, transparent)}
1218
+ /* The invite, expanded in the flow directly under the tab strip.
1219
+ No overlay and no positioning: it is a disclosure, so it pushes the panels below
1220
+ down by its own height and appears exactly where the 交流群 button sits. Two earlier
1221
+ attempts are worth remembering — absolute measured against the page column (so it
1222
+ landed half-way down the scroll), and fixed centred it as a modal (correct for a
1223
+ modal, but the ask was "show it HERE"). */
1224
+ .dsm-xdpool-groupinline{display:flex;justify-content:center}
1208
1225
  .dsm-xdpool-groupcard{display:flex;flex-direction:column;align-items:center;gap:10px;max-width:520px;padding:16px 18px 18px;border-radius:16px;border:1px solid color-mix(in srgb, var(--dsw-alias-border-l2,#ffffff1f) 86%, transparent);background:var(--dsw-alias-bg-layer-2,#1c1e22);box-shadow:0 18px 48px rgb(0 0 0 / 34%), inset 0 1px 0 rgb(255 255 255 / 7%)}
1209
1226
  /* The two codes sit side by side and WRAP to a column before either shrinks:
1210
1227
  a QR squeezed narrower than its module grid is a QR that stops scanning, so
@@ -1372,6 +1389,12 @@ window.__ModuleLoader__.load({
1372
1389
  .dsm-xdpool-gw-model input:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
1373
1390
  .dsm-xdpool-gw-model-name{color:var(--dsw-alias-label-primary,#e8e8ea);font-size:12px;line-height:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
1374
1391
  .dsm-xdpool-gw-model-id{font-family:var(--dsw-alias-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#8a90a0);white-space:nowrap}
1392
+ /* The model id is a copy button living inside a <label>. It has to look like the text it
1393
+ replaced, not like a second submit button — so the chrome is stripped and only the
1394
+ pointer and a hover tint say it is pressable. */
1395
+ .dsm-xdpool-gw-copyid{appearance:none;-webkit-appearance:none;background:none;border:0;padding:0 2px;margin:0;cursor:pointer;border-radius:4px;font-family:var(--dsw-alias-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#8a90a0);white-space:nowrap;text-align:left}
1396
+ .dsm-xdpool-gw-copyid:hover{background:color-mix(in srgb, var(--dsw-alias-label-primary,#e8eaf0) 10%, transparent);color:var(--dsw-alias-label-secondary,#b9bfcc)}
1397
+ .dsm-xdpool-gw-copyid[data-on='true']{color:#3ba55d}
1375
1398
  .dsm-xdpool-gw-btn:hover:not(:disabled){transform:translateY(-1px);border-color:color-mix(in srgb, var(--dsw-alias-brand-primary,#5686fe) 55%, var(--dsw-alias-border-l2,#36373b));color:var(--dsw-alias-label-primary,#e8e8ea)}
1376
1399
  .dsm-xdpool-gw-btn:active:not(:disabled){transform:translateY(0);box-shadow:inset 0 2px 4px rgb(0 0 0 / 22%)}
1377
1400
  .dsm-xdpool-gw-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:2px}
@@ -1793,6 +1816,92 @@ window.__ModuleLoader__.load({
1793
1816
  .dsm-xdpool-page > .dsm-xdpool-shell{margin:0 auto}
1794
1817
  @media (max-width:720px){.dsm-xdpool-page{padding:14px 16px 26px}}
1795
1818
  `.trim();
1819
+ /**
1820
+ * The composer credit dot and its panel.
1821
+ *
1822
+ * Its OWN export, not a member of a page block. The dot renders in the COMPOSER, which
1823
+ * no settings page's `<style>` ever reaches — CSS parked in `GATEWAY_CARD_CSS` or
1824
+ * `SHARED_CARD_CSS` is injected only inside the settings shell, so the panel came out as
1825
+ * bare text with no dot and no card.
1826
+ */
1827
+ const CREDIT_DOT_CSS = `
1828
+ /* ── Composer credit dot ────────────────────────────────────────────────────
1829
+ A 9px dot on the composer's own row, beside the model name it describes. Its COLOUR
1830
+ is the whole at-a-glance message — green while the serving channel has credits, grey
1831
+ once it does not — and the detail lives behind a click, because that row already
1832
+ holds a back button, a model name, a multiplier and a reasoning level. */
1833
+ .dsm-xdpool-dotwrap{position:relative;display:inline-flex;align-items:center;flex:none;margin:0 2px}
1834
+ .dsm-xdpool-dot{appearance:none;-webkit-appearance:none;display:grid;place-items:center;width:16px;height:16px;padding:0;margin:0;border:0;background:none;cursor:pointer;flex:none;border-radius:4px;transition:background-color .16s ease}
1835
+ .dsm-xdpool-dot:hover{background:color-mix(in srgb, var(--dsw-alias-label-primary,#e8eaf0) 12%, transparent)}
1836
+ .dsm-xdpool-dot-glyph{display:block;width:13px;height:13px;background-color:currentColor;-webkit-mask-image:var(--xdpool-dot-mask, none);mask-image:var(--xdpool-dot-mask, none);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center;-webkit-mask-size:13px 13px;mask-size:13px 13px;transition:color .18s ease}
1837
+ .dsm-xdpool-dot[data-state='alive']{color:#22c55e}
1838
+ .dsm-xdpool-dot[data-state='empty']{color:color-mix(in srgb, var(--dsw-alias-label-tertiary,#8a90a0) 85%, transparent)}
1839
+ .dsm-xdpool-dot:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:3px}
1840
+ /* Measured coordinates, not flow: the composer sits at the bottom of the viewport, so a
1841
+ panel laid out downward would be clipped. */
1842
+ .dsm-xdpool-dotpanel{position:fixed;z-index:80;width:max-content;min-width:172px;max-width:min(280px,calc(100vw - 24px));display:flex;flex-direction:column;gap:7px;padding:10px 12px 11px;border-radius:14px;border:1px solid color-mix(in srgb, var(--dsw-alias-border-l2,#ffffff1f) 92%, transparent);background:var(--dsw-alias-bg-layer-2,#1c1e22);box-shadow:0 18px 48px rgb(0 0 0 / 44%),0 3px 10px rgb(0 0 0 / 24%),inset 0 1px 0 rgb(255 255 255 / 7%);animation:dsm-xdpool-dotpanel-in .16s cubic-bezier(.16,1,.3,1)}
1843
+ @keyframes dsm-xdpool-dotpanel-in{from{opacity:0;transform:translateY(5px) scale(.985)}to{opacity:1;transform:none}}
1844
+ .dsm-xdpool-dotpanel-head{display:flex;align-items:center;gap:8px;min-width:0}
1845
+ .dsm-xdpool-dotpanel-channel{font-size:12.5px;font-weight:600;letter-spacing:.01em;color:var(--dsw-alias-label-primary,#e8eaf0);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
1846
+ .dsm-xdpool-dotpanel-chip{margin-left:auto;padding:1px 8px;border-radius:999px;font-size:10px;line-height:16px;font-weight:500}
1847
+ .dsm-xdpool-dotpanel-chip[data-state='alive']{color:#22c55e;background:rgb(34 197 94 / 15%)}
1848
+ .dsm-xdpool-dotpanel-chip[data-state='empty']{color:var(--dsw-alias-label-tertiary,#8a90a0);background:color-mix(in srgb, var(--dsw-alias-label-tertiary,#8a90a0) 15%, transparent)}
1849
+ .dsm-xdpool-dotpanel-sec{display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px;align-items:baseline;font-size:12px;line-height:18px}
1850
+ .dsm-xdpool-dotpanel-k{color:var(--dsw-alias-label-tertiary,#8a90a0);white-space:nowrap}
1851
+ .dsm-xdpool-dotpanel-v{text-align:right;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary,#e8eaf0);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1852
+ /* The spend bar. A fuller bar means MORE used, so it reads as a fuel gauge draining
1853
+ toward the right; it turns amber past 90% because that is the point at which a long
1854
+ task becomes a gamble. */
1855
+ .dsm-xdpool-dotbar{height:4px;border-radius:999px;overflow:hidden;background:color-mix(in srgb, var(--dsw-alias-label-tertiary,#8a90a0) 22%, transparent);margin:1px 0 2px}
1856
+ .dsm-xdpool-dotbar-fill{height:100%;border-radius:999px;background:#22c55e;transition:width .3s cubic-bezier(.16,1,.3,1),background-color .2s ease}
1857
+ .dsm-xdpool-dotbar-fill[data-full='true']{background:#f59e0b}
1858
+ .dsm-xdpool-dotpanel-more{display:flex;flex-direction:column;gap:2px;padding-top:6px;margin-top:1px;border-top:1px solid color-mix(in srgb, var(--dsw-alias-border-l2,#ffffff1f) 62%, transparent)}
1859
+ .dsm-xdpool-dotpanel-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;font-size:11px;line-height:16px}
1860
+ .dsm-xdpool-dotpanel-rowname{color:var(--dsw-alias-label-tertiary,#8a90a0);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1861
+ /* The channel that served today gets a brighter label; the rest still count
1862
+ toward the total and are shown, because hiding them is what made the composer
1863
+ dot disagree with the account-pool card. */
1864
+ .dsm-xdpool-dotpanel-rowname[data-used='true']{color:var(--dsw-alias-label-secondary,#b9bfcc)}
1865
+ .dsm-xdpool-dotpanel-rowval{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary,#b9bfcc)}
1866
+ `;
1867
+ /**
1868
+ * The account-backup card.
1869
+ *
1870
+ * Its OWN export, for the same reason `CREDIT_DOT_CSS` exists: this component renders in
1871
+ * the account-pool page, and the rule that hid its raw file input had been parked in
1872
+ * `CREDIT_DOT_CSS` — a stylesheet this page never loads. The visible symptom was the
1873
+ * browser's native picker sitting on screen beside the buttons that were meant to replace
1874
+ * it, so the row offered three "choose file" controls for one action.
1875
+ */
1876
+ const BACKUP_CSS = `
1877
+ .dsm-xdpool-bak{display:flex;flex-direction:column;gap:8px;padding:12px 14px 13px;border-radius:12px;border:1px solid color-mix(in srgb, var(--dsw-alias-border-l2,#ffffff1f) 82%, transparent);background:color-mix(in srgb, var(--dsw-alias-bg-layer-2,#1c1e22) 72%, transparent);min-width:0}
1878
+ .dsm-xdpool-bak-head{display:flex;align-items:center;gap:8px;min-width:0}
1879
+ .dsm-xdpool-bak-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,#e8eaf0)}
1880
+ /* A one-word badge, not a sentence: the fact that the file is encrypted is worth a
1881
+ glance, not a paragraph. */
1882
+ .dsm-xdpool-bak-warn{margin-left:auto;padding:1px 7px;border-radius:999px;font-size:10px;line-height:15px;color:#22c55e;background:rgb(34 197 94 / 13%)}
1883
+ .dsm-xdpool-bak-hint{margin:0;font-size:11px;line-height:17px;color:var(--dsw-alias-label-tertiary,#8a90a0)}
1884
+ .dsm-xdpool-bak-field{display:flex;align-items:center;gap:8px;min-width:0}
1885
+ .dsm-xdpool-bak-label{font-size:11px;color:var(--dsw-alias-label-secondary,#b9bfcc);white-space:nowrap;flex:none}
1886
+ .dsm-xdpool-bak-input{flex:1 1 auto;min-width:0;max-width:220px;appearance:none;-webkit-appearance:none;padding:5px 9px;border-radius:7px;border:1px solid color-mix(in srgb, var(--dsw-alias-border-l2,#ffffff1f) 92%, transparent);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1,#191a1e) 88%, transparent);color:var(--dsw-alias-label-primary,#e8eaf0);font-size:12px;line-height:17px;font-family:inherit}
1887
+ .dsm-xdpool-bak-input:focus{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px;border-color:transparent}
1888
+ .dsm-xdpool-bak-input::placeholder{color:var(--dsw-alias-label-tertiary,#8a90a0);opacity:.75}
1889
+ .dsm-xdpool-bak-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1890
+ .dsm-xdpool-bak-btn{appearance:none;-webkit-appearance:none;padding:5px 13px;border-radius:7px;cursor:pointer;font:inherit;font-size:12px;line-height:18px;border:1px solid color-mix(in srgb, var(--dsw-alias-border-l2,#ffffff1f) 95%, transparent);background:transparent;color:var(--dsw-alias-label-secondary,#b9bfcc);transition:border-color .15s ease,background-color .15s ease,color .15s ease;white-space:nowrap}
1891
+ .dsm-xdpool-bak-btn:hover:not(:disabled){border-color:color-mix(in srgb, var(--dsw-alias-brand-primary,#5686fe) 55%, transparent);color:var(--dsw-alias-label-primary,#e8eaf0)}
1892
+ .dsm-xdpool-bak-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:2px}
1893
+ .dsm-xdpool-bak-btn:disabled{opacity:.45;cursor:not-allowed}
1894
+ .dsm-xdpool-bak-btn[data-tone='primary']{border-color:color-mix(in srgb, var(--dsw-alias-brand-primary,#5686fe) 62%, transparent);background:color-mix(in srgb, var(--dsw-alias-brand-primary,#5686fe) 16%, transparent);color:var(--dsw-alias-brand-primary,#5686fe);font-weight:600}
1895
+ .dsm-xdpool-bak-btn[data-tone='primary']:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-brand-primary,#5686fe) 24%, transparent)}
1896
+ /* The chosen file as a removable chip, so "which backup am I about to restore" is
1897
+ answered before the confirm dialog rather than by it. */
1898
+ .dsm-xdpool-bak-file{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:7px;background:color-mix(in srgb, var(--dsw-alias-label-primary,#e8eaf0) 7%, transparent);min-width:0}
1899
+ .dsm-xdpool-bak-filename{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;font-family:var(--dsw-alias-font-mono,ui-monospace,Menlo,Consolas,monospace);color:var(--dsw-alias-label-secondary,#b9bfcc)}
1900
+ .dsm-xdpool-bak-clear{appearance:none;-webkit-appearance:none;flex:none;width:16px;height:16px;padding:0;border:0;border-radius:4px;background:transparent;cursor:pointer;font-size:13px;line-height:14px;color:var(--dsw-alias-label-tertiary,#8a90a0)}
1901
+ .dsm-xdpool-bak-clear:hover{background:color-mix(in srgb, var(--dsw-alias-label-primary,#e8eaf0) 12%, transparent);color:var(--dsw-alias-label-primary,#e8eaf0)}
1902
+ .dsm-xdpool-bak-note{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-state-success-primary,#22a06b)}
1903
+ .dsm-xdpool-bak-error{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-state-error-primary,#e5534b)}
1904
+ `;
1796
1905
  //#endregion
1797
1906
  //#region src/client/avatar.ts
1798
1907
  /**
@@ -2091,24 +2200,13 @@ window.__ModuleLoader__.load({
2091
2200
  })
2092
2201
  ]
2093
2202
  }),
2094
- hero === void 0 ? null : hero,
2095
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2096
- className: "dsm-xdpool-body",
2097
- children
2098
- }),
2099
2203
  !groupOpen ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2100
- className: "dsm-xdpool-groupmask",
2204
+ className: "dsm-xdpool-groupinline",
2101
2205
  role: "dialog",
2102
- "aria-modal": "true",
2206
+ "aria-modal": "false",
2103
2207
  "aria-label": t?.("shell.groupTitle") ?? "XD 交流群",
2104
- onClick: () => {
2105
- setGroupOpen(false);
2106
- },
2107
2208
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2108
2209
  className: "dsm-xdpool-groupcard",
2109
- onClick: (event) => {
2110
- event.stopPropagation();
2111
- },
2112
2210
  children: [
2113
2211
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2114
2212
  className: "dsm-xdpool-groupcardhead",
@@ -2170,6 +2268,11 @@ window.__ModuleLoader__.load({
2170
2268
  })
2171
2269
  ]
2172
2270
  })
2271
+ }),
2272
+ hero === void 0 ? null : hero,
2273
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2274
+ className: "dsm-xdpool-body",
2275
+ children
2173
2276
  })
2174
2277
  ]
2175
2278
  });
@@ -9612,7 +9715,7 @@ window.__ModuleLoader__.load({
9612
9715
  function countOf(value) {
9613
9716
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
9614
9717
  }
9615
- function statusFrom(value) {
9718
+ function statusFrom$1(value) {
9616
9719
  if (value === null || typeof value !== "object") return void 0;
9617
9720
  const models = value.models;
9618
9721
  const accounts = value.accounts;
@@ -9642,7 +9745,7 @@ window.__ModuleLoader__.load({
9642
9745
  const table = value.statuses;
9643
9746
  if (table === null || typeof table !== "object") return statuses;
9644
9747
  for (const [provider, raw] of Object.entries(table)) {
9645
- const status = statusFrom(raw);
9748
+ const status = statusFrom$1(raw);
9646
9749
  if (status !== void 0) statuses.set(provider, status);
9647
9750
  }
9648
9751
  return statuses;
@@ -10954,6 +11057,44 @@ window.__ModuleLoader__.load({
10954
11057
  });
10955
11058
  }
10956
11059
  //#endregion
11060
+ //#region src/client/clipboard.ts
11061
+ /** `navigator`, when there is one and it exposes a clipboard. */
11062
+ function clipboardOf() {
11063
+ const clipboard = globalThis.navigator?.clipboard;
11064
+ return clipboard !== void 0 && typeof clipboard.writeText === "function" ? clipboard : void 0;
11065
+ }
11066
+ /** `document`, when there is one. */
11067
+ function documentOf() {
11068
+ return globalThis.document;
11069
+ }
11070
+ async function copyText(value) {
11071
+ if (value === "") return false;
11072
+ const clipboard = clipboardOf();
11073
+ if (clipboard !== void 0) try {
11074
+ await clipboard.writeText(value);
11075
+ return true;
11076
+ } catch {}
11077
+ const doc = documentOf();
11078
+ if (doc === void 0) return false;
11079
+ try {
11080
+ const area = doc.createElement("textarea");
11081
+ area.value = value;
11082
+ area.setAttribute("readonly", "");
11083
+ area.style.position = "fixed";
11084
+ area.style.top = "-1000px";
11085
+ area.style.left = "-1000px";
11086
+ area.style.opacity = "0";
11087
+ doc.body.appendChild(area);
11088
+ area.select();
11089
+ area.setSelectionRange(0, value.length);
11090
+ const ok = doc.execCommand("copy");
11091
+ doc.body.removeChild(area);
11092
+ return ok;
11093
+ } catch {
11094
+ return false;
11095
+ }
11096
+ }
11097
+ //#endregion
10957
11098
  //#region src/client/GatewayPage.tsx
10958
11099
  /**
10959
11100
  * The gateway page: the relay that re-exposes the pool to the rest of the LAN.
@@ -10997,7 +11138,7 @@ window.__ModuleLoader__.load({
10997
11138
  fallback: "every device on this network"
10998
11139
  }];
10999
11140
  /** Poll interval. Slower than the pool card: a relay does not change on its own. */
11000
- const POLL_MS = 15e3;
11141
+ const POLL_MS$1 = 15e3;
11001
11142
  /**
11002
11143
  * A masked key for display, never the full value.
11003
11144
  *
@@ -11015,6 +11156,15 @@ window.__ModuleLoader__.load({
11015
11156
  const [status, setStatus] = (0, react.useState)(void 0);
11016
11157
  const [error, setError] = (0, react.useState)(void 0);
11017
11158
  const [flash, setFlash] = (0, react.useState)(void 0);
11159
+ /**
11160
+ * Which field was last copied, so its own button can say "Copied" for a moment.
11161
+ *
11162
+ * Held per-field rather than one global flag: a single boolean would put "Copied"
11163
+ * on the key button right after the base URL was copied, which reads as a bug in a
11164
+ * panel whose whole job is to hand over exactly the right strings.
11165
+ */
11166
+ const [copiedField, setCopiedField] = (0, react.useState)(void 0);
11167
+ const copyTimer = (0, react.useRef)(void 0);
11018
11168
  const [busy, setBusy] = (0, react.useState)(false);
11019
11169
  const [revealed, setRevealed] = (0, react.useState)(false);
11020
11170
  const [host, setHost] = (0, react.useState)("127.0.0.1");
@@ -11087,7 +11237,7 @@ window.__ModuleLoader__.load({
11087
11237
  load(controller.signal);
11088
11238
  const timer = setInterval(() => {
11089
11239
  load(controller.signal);
11090
- }, POLL_MS);
11240
+ }, POLL_MS$1);
11091
11241
  return () => {
11092
11242
  controller.abort();
11093
11243
  clearInterval(timer);
@@ -11126,11 +11276,67 @@ window.__ModuleLoader__.load({
11126
11276
  if (mounted.current) setBusy(false);
11127
11277
  }
11128
11278
  }, [t]);
11279
+ /**
11280
+ * Copy one of the strings a client needs, and say so on that field's own button.
11281
+ *
11282
+ * A copy button earns its place here because the alternative is transcription: the
11283
+ * base URL, the key and the `/v1` suffix have to reach another program exactly, and
11284
+ * a masked key cannot be read off the screen at all. The reference gateway solves
11285
+ * the same problem by printing a shell one-liner that pipes a file into the
11286
+ * clipboard — necessary there because its key lives only in a local file. Here the
11287
+ * key is already in this document, so the button can be honest and direct.
11288
+ *
11289
+ * Failures are reported rather than swallowed: a copy that silently does nothing
11290
+ * leaves the user pasting a stale value and blaming the key.
11291
+ */
11292
+ const copy = (0, react.useCallback)(async (field, value) => {
11293
+ const ok = await copyText(value);
11294
+ if (!mounted.current) return;
11295
+ if (!ok) {
11296
+ setFlash(t?.("gw.copyFailed") ?? "Could not copy — select the text and copy it manually.");
11297
+ return;
11298
+ }
11299
+ setFlash(void 0);
11300
+ setCopiedField(field);
11301
+ if (copyTimer.current !== void 0) window.clearTimeout(copyTimer.current);
11302
+ copyTimer.current = window.setTimeout(() => {
11303
+ if (mounted.current) setCopiedField(void 0);
11304
+ }, 1600);
11305
+ }, [t]);
11306
+ (0, react.useEffect)(() => () => {
11307
+ if (copyTimer.current !== void 0) window.clearTimeout(copyTimer.current);
11308
+ }, []);
11129
11309
  const enabled = status?.enabled === true;
11130
11310
  const listening = status?.listening === true;
11131
11311
  const key = status?.key ?? "";
11132
11312
  const parsedPort = Number(port);
11133
11313
  const portUsable = Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535;
11314
+ /**
11315
+ * The origin, without `/v1`.
11316
+ *
11317
+ * WHAT GETS COPIED DEPENDS ON THE CLIENT, and getting it wrong produces a 404 that
11318
+ * blames the relay:
11319
+ *
11320
+ * - clients that append the path themselves (`{base}/v1/chat/completions`) want
11321
+ * the BARE origin — handed `/v1`, they ask for `/v1/v1/chat/completions`,
11322
+ * which is exactly the `404 /v1/v1/responses` a user reported;
11323
+ * - clients that want the full prefix pasted in (many "OpenAI compatible"
11324
+ * fields, and `OPENAI_BASE_URL`) want it WITH `/v1`.
11325
+ *
11326
+ * There is no single right answer, so the panel offers both and labels each with
11327
+ * the client behaviour it suits. Silently picking one is what produced the report.
11328
+ */
11329
+ const origin = (status?.endpoint ?? "").replace(/\/+$/u, "");
11330
+ const baseUrl = origin === "" ? "" : `${origin}/v1`;
11331
+ /**
11332
+ * Base URL and key together, in the shape every OpenAI client asks for.
11333
+ *
11334
+ * Not JSON: pasting a JSON blob into a client that wants two separate fields is
11335
+ * more work than pasting nothing, and the user still has to split it by eye. Two
11336
+ * labelled lines survive that trip — and if they paste it into a chat to ask a
11337
+ * question, the labels make it readable.
11338
+ */
11339
+ const configSnippet = baseUrl === "" || key === "" ? "" : `Base URL: ${baseUrl}\nAPI Key: ${key}`;
11134
11340
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11135
11341
  className: "dsm-xdpool-gw",
11136
11342
  children: [
@@ -11242,10 +11448,51 @@ window.__ModuleLoader__.load({
11242
11448
  className: "dsm-xdpool-gw-warn",
11243
11449
  children: t?.("gw.portInvalid") ?? ""
11244
11450
  }),
11245
- listening && status?.endpoint !== void 0 && status.endpoint !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
11246
- className: "dsm-xdpool-gw-note",
11247
- children: t?.("gw.endpoint", { endpoint: status.endpoint }) ?? status.endpoint
11248
- }) : null
11451
+ listening && status?.endpoint !== void 0 && status.endpoint !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
11452
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
11453
+ className: "dsm-xdpool-gw-note",
11454
+ children: t?.("gw.endpoint", { endpoint: status.endpoint }) ?? status.endpoint
11455
+ }),
11456
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11457
+ className: "dsm-xdpool-gw-actions",
11458
+ children: [
11459
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11460
+ type: "button",
11461
+ className: "dsm-xdpool-gw-btn",
11462
+ "data-on": copiedField === "base" ? "true" : "false",
11463
+ onClick: () => {
11464
+ copy("base", origin);
11465
+ },
11466
+ title: t?.("gw.copyOriginHint") ?? "客户端自己会补 /v1 时用这个(大多数 SDK)",
11467
+ children: copiedField === "base" ? t?.("gw.copied") ?? "Copied" : t?.("gw.copyOrigin") ?? "Copy base URL"
11468
+ }),
11469
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11470
+ type: "button",
11471
+ className: "dsm-xdpool-gw-btn",
11472
+ "data-on": copiedField === "baseV1" ? "true" : "false",
11473
+ onClick: () => {
11474
+ copy("baseV1", baseUrl);
11475
+ },
11476
+ title: t?.("gw.copyV1Hint") ?? "要填完整前缀时用这个(含 /v1)",
11477
+ children: copiedField === "baseV1" ? t?.("gw.copied") ?? "Copied" : t?.("gw.copyV1") ?? "Copy with /v1"
11478
+ }),
11479
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11480
+ type: "button",
11481
+ className: "dsm-xdpool-gw-btn",
11482
+ "data-on": copiedField === "all" ? "true" : "false",
11483
+ onClick: () => {
11484
+ copy("all", configSnippet);
11485
+ },
11486
+ title: t?.("gw.copyAllHint") ?? "base URL + key,可直接粘进客户端",
11487
+ children: copiedField === "all" ? t?.("gw.copied") ?? "Copied" : t?.("gw.copyAll") ?? "Copy both"
11488
+ })
11489
+ ]
11490
+ }),
11491
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
11492
+ className: "dsm-xdpool-gw-note",
11493
+ children: t?.("gw.wire") ?? "只支持 Chat Completions(/v1/chat/completions 与 /v1/models)。若客户端用 Responses 或 Anthropic 协议,请改成 Chat Completions。"
11494
+ })
11495
+ ] }) : null
11249
11496
  ]
11250
11497
  }),
11251
11498
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -11261,25 +11508,38 @@ window.__ModuleLoader__.load({
11261
11508
  }),
11262
11509
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11263
11510
  className: "dsm-xdpool-gw-actions",
11264
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11265
- type: "button",
11266
- className: "dsm-xdpool-gw-btn",
11267
- "data-on": "false",
11268
- disabled: busy || key === "",
11269
- onClick: () => {
11270
- setRevealed((current) => !current);
11271
- },
11272
- children: revealed ? t?.("gw.hideKey") ?? "Hide" : t?.("gw.showKey") ?? "Show"
11273
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11274
- type: "button",
11275
- className: "dsm-xdpool-gw-btn",
11276
- "data-on": "false",
11277
- disabled: busy,
11278
- onClick: () => {
11279
- write({ key: "" });
11280
- },
11281
- children: t?.("gw.rotate") ?? "Rotate"
11282
- })]
11511
+ children: [
11512
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11513
+ type: "button",
11514
+ className: "dsm-xdpool-gw-btn",
11515
+ "data-on": copiedField === "key" ? "true" : "false",
11516
+ disabled: busy || key === "",
11517
+ onClick: () => {
11518
+ copy("key", key);
11519
+ },
11520
+ children: copiedField === "key" ? t?.("gw.copied") ?? "Copied" : t?.("gw.copyKey") ?? "Copy key"
11521
+ }),
11522
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11523
+ type: "button",
11524
+ className: "dsm-xdpool-gw-btn",
11525
+ "data-on": "false",
11526
+ disabled: busy || key === "",
11527
+ onClick: () => {
11528
+ setRevealed((current) => !current);
11529
+ },
11530
+ children: revealed ? t?.("gw.hideKey") ?? "Hide" : t?.("gw.showKey") ?? "Show"
11531
+ }),
11532
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11533
+ type: "button",
11534
+ className: "dsm-xdpool-gw-btn",
11535
+ "data-on": "false",
11536
+ disabled: busy,
11537
+ onClick: () => {
11538
+ write({ key: "" });
11539
+ },
11540
+ children: t?.("gw.rotate") ?? "Rotate"
11541
+ })
11542
+ ]
11283
11543
  }),
11284
11544
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
11285
11545
  className: "dsm-xdpool-gw-note",
@@ -11363,9 +11623,17 @@ window.__ModuleLoader__.load({
11363
11623
  className: "dsm-xdpool-gw-model-name",
11364
11624
  children: row.name
11365
11625
  }),
11366
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11367
- className: "dsm-xdpool-gw-model-id",
11368
- children: row.id
11626
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11627
+ type: "button",
11628
+ className: "dsm-xdpool-gw-model-id dsm-xdpool-gw-copyid",
11629
+ "data-on": copiedField === `id:${row.id}` ? "true" : "false",
11630
+ title: t?.("gw.copyModelIdHint") ?? "复制模型 id —— 客户端里要填的就是这个",
11631
+ onClick: (event) => {
11632
+ event.preventDefault();
11633
+ event.stopPropagation();
11634
+ copy(`id:${row.id}`, row.id);
11635
+ },
11636
+ children: copiedField === `id:${row.id}` ? t?.("gw.copied") ?? "Copied" : row.id
11369
11637
  })
11370
11638
  ]
11371
11639
  }, row.id))]
@@ -11779,6 +12047,27 @@ window.__ModuleLoader__.load({
11779
12047
  let providerStatusCache;
11780
12048
  const PROVIDER_STATUS_TTL_MS = 3e4;
11781
12049
  /**
12050
+ * Channels whose rewards are collected through `onboarding.claim`, not a daily
12051
+ * check-in.
12052
+ *
12053
+ * Kept as a literal rather than derived from `channel.credits`, because the two
12054
+ * rewards are different in kind: `credits.claim` is repeatable tomorrow and pays
12055
+ * daily points, while these pay once per account and are then gone. The pack exposes
12056
+ * them on separate endpoints for exactly that reason, and a channel can have either,
12057
+ * both, or neither:
12058
+ *
12059
+ * - `raccoon` — the daily points arrive on their own (the server credits them),
12060
+ * so its `credits` field is EMPTY on purpose. Only the desktop-login bonus is
12061
+ * claimable, and it lives here.
12062
+ * - `loomy` — onboarding tasks, topped up to a maximum rather than granted per
12063
+ * task.
12064
+ *
12065
+ * Both are implemented in the pack; before this the card simply never called the
12066
+ * endpoint, so a signed-in Raccoon account had no way to collect the bonus its own
12067
+ * note told the reader to look for.
12068
+ */
12069
+ const ONBOARDING_CHANNELS = /* @__PURE__ */ new Set(["raccoon", "loomy"]);
12070
+ /**
11782
12071
  * Drop the snapshot after anything that changes the data behind it.
11783
12072
  *
11784
12073
  * Logins, deletions, toggles and manual balance refreshes all make the snapshot
@@ -12253,6 +12542,71 @@ window.__ModuleLoader__.load({
12253
12542
  if (mountedRef.current) markBusy(provider, false);
12254
12543
  }
12255
12544
  };
12545
+ /**
12546
+ * Claim the ONE-OFF rewards a channel hands out per account.
12547
+ *
12548
+ * Two channels have such a reward and neither is a daily check-in:
12549
+ *
12550
+ * - Raccoon (商汤) pays a desktop-login bonus once per account. Its daily
12551
+ * points are credited by the server on their own, which is why
12552
+ * `credits.claimAll` answers "不支持每日签到" for it — that answer is
12553
+ * correct and is not the whole story.
12554
+ * - Loomy (讯飞) has onboarding tasks that pay once each, with the reward
12555
+ * topped up to the maximum rather than granted per task.
12556
+ *
12557
+ * Both live behind `onboarding.claim`, a SEPARATE endpoint from
12558
+ * `credits.claimAll` because the semantics differ in the way that matters: a
12559
+ * daily claim may be repeated tomorrow, this one may not be repeated at all. The
12560
+ * pack has implemented it for both channels all along; the card simply never
12561
+ * called it, so a user with a signed-in Raccoon account saw no way to collect
12562
+ * the login bonus the pack's own note told them to look for.
12563
+ *
12564
+ * Per ACCOUNT, not per channel: the endpoint takes an `accountId`, because one
12565
+ * account having claimed does not mean the next one has.
12566
+ */
12567
+ const claimOnboarding = async (provider, accounts) => {
12568
+ if (accounts.length === 0) {
12569
+ setNotice(t?.("channelpool.onboardingNoAccounts") ?? "No account to claim for.");
12570
+ return;
12571
+ }
12572
+ markBusy(provider, true);
12573
+ let claimed = 0;
12574
+ let earned = 0;
12575
+ let skipped = 0;
12576
+ let failed = 0;
12577
+ let firstError = "";
12578
+ try {
12579
+ for (const account of accounts) {
12580
+ const envelope = await callPack("onboarding.claim", {
12581
+ provider,
12582
+ accountId: account.id
12583
+ });
12584
+ if (!mountedRef.current) return;
12585
+ if (envelope.ok === false) {
12586
+ failed += 1;
12587
+ if (firstError === "") firstError = envelope.error?.message ?? "";
12588
+ continue;
12589
+ }
12590
+ const value = envelope.value;
12591
+ const got = Array.isArray(value?.claimed) ? value.claimed.length : 0;
12592
+ const total = typeof value?.earned === "number" ? value.earned : 0;
12593
+ claimed += got;
12594
+ earned += total;
12595
+ if (got === 0) skipped += 1;
12596
+ }
12597
+ if (!mountedRef.current) return;
12598
+ if (failed > 0) setNotice(firstError !== "" ? firstError : t?.("channelpool.onboardingFailed", { failed }) ?? `${failed} account(s) could not claim.`);
12599
+ else if (claimed === 0) setNotice(t?.("channelpool.onboardingNone") ?? "Nothing left to claim — already collected.");
12600
+ else setNotice(t?.("channelpool.onboardingResult", {
12601
+ claimed,
12602
+ credit: amount(earned),
12603
+ skipped
12604
+ }) ?? `Claimed ${claimed} reward(s): +${amount(earned)}.`);
12605
+ await refreshBalances(provider, true);
12606
+ } finally {
12607
+ if (mountedRef.current) markBusy(provider, false);
12608
+ }
12609
+ };
12256
12610
  const accountAction = async (provider, accountId, action) => {
12257
12611
  if (action === "delete" && typeof window !== "undefined") {
12258
12612
  const prompt = t?.("channelpool.deleteConfirm") ?? "Remove this account?";
@@ -12367,6 +12721,17 @@ window.__ModuleLoader__.load({
12367
12721
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
12368
12722
  className: "dsm-xdpool-cpool-acts",
12369
12723
  children: [
12724
+ ONBOARDING_CHANNELS.has(channel.id) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
12725
+ type: "button",
12726
+ className: "dsm-xdpool-btn",
12727
+ "data-tone": "primary",
12728
+ disabled: isBusy,
12729
+ onClick: () => {
12730
+ claimOnboarding(channel.id, enabledAccounts);
12731
+ },
12732
+ title: t?.("channelpool.onboardingHint") ?? "一次性奖励(登录奖励 / 新手任务),每号只能领一次",
12733
+ children: isBusy ? t?.("channelpool.claiming") ?? "Claiming…" : t?.("channelpool.onboarding") ?? "领取一次性奖励"
12734
+ }),
12370
12735
  channel.credits?.claim === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
12371
12736
  type: "button",
12372
12737
  className: "dsm-xdpool-btn",
@@ -12669,6 +13034,418 @@ window.__ModuleLoader__.load({
12669
13034
  });
12670
13035
  }
12671
13036
  //#endregion
13037
+ //#region src/client/backup-crypto.ts
13038
+ /**
13039
+ * Password-encrypted backup envelope for the credential export.
13040
+ *
13041
+ * WHAT IS BEING PROTECTED
13042
+ *
13043
+ * The exported payload is not a summary — it contains every account's credential
13044
+ * value, which is enough to sign in as those accounts. An unencrypted file dropped in
13045
+ * a downloads folder, a chat window, or a cloud-synced desktop is a credential leak
13046
+ * with extra steps. So the default is encrypted, and the password is the only thing
13047
+ * that opens it.
13048
+ *
13049
+ * THE PRIMITIVES, AND WHY THESE
13050
+ *
13051
+ * PBKDF2-SHA256 at 310,000 iterations derives the key, then AES-GCM encrypts with a
13052
+ * random 12-byte IV. Both are in WebCrypto with no dependencies, which matters for a
13053
+ * bundle that ships into someone else's host:
13054
+ *
13055
+ * - PBKDF2 over a raw hash because a password has no entropy of its own; the
13056
+ * iteration count is the only thing standing between a leaked file and an
13057
+ * offline dictionary attack. 310k follows OWASP's current floor for SHA-256.
13058
+ * - AES-GCM (not CBC) because it authenticates: a wrong password and a tampered
13059
+ * file are both a hard failure rather than a plausible-looking plaintext.
13060
+ *
13061
+ * The salt and IV are random per export and travel in the envelope's header — they are
13062
+ * not secrets, and reusing either across exports would be the actual mistake.
13063
+ *
13064
+ * WHAT THIS DOES NOT PROTECT AGAINST
13065
+ *
13066
+ * A weak password. Nothing here can fix that, so `MIN_PASSWORD` exists to refuse the
13067
+ * obviously-bad cases, and the UI says plainly that the file is only as strong as the
13068
+ * password.
13069
+ *
13070
+ * @module dsh-xdpool/client/backup-crypto
13071
+ */
13072
+ /** Marks an encrypted envelope so a plain payload is never mistaken for one. */
13073
+ const ENCRYPTED_FORMAT = "dsh-xdpool/backup-encrypted";
13074
+ /** PBKDF2 rounds. OWASP's floor for PBKDF2-SHA256; see the module note. */
13075
+ const ITERATIONS = 31e4;
13076
+ function toBase64(bytes) {
13077
+ let binary = "";
13078
+ for (const byte of bytes) binary += String.fromCharCode(byte);
13079
+ return btoa(binary);
13080
+ }
13081
+ function fromBase64(text) {
13082
+ const binary = atob(text);
13083
+ const bytes = new Uint8Array(binary.length);
13084
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
13085
+ return bytes;
13086
+ }
13087
+ async function deriveKey(password, salt, iterations) {
13088
+ const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]);
13089
+ return await crypto.subtle.deriveKey({
13090
+ name: "PBKDF2",
13091
+ salt,
13092
+ iterations,
13093
+ hash: "SHA-256"
13094
+ }, material, {
13095
+ name: "AES-GCM",
13096
+ length: 256
13097
+ }, false, ["encrypt", "decrypt"]);
13098
+ }
13099
+ /**
13100
+ * Encrypt a JSON payload.
13101
+ *
13102
+ * `plaintext` is the payload as a string, not an object: the caller already has it in
13103
+ * the shape the pack produced, and re-serialising here would be a second chance to
13104
+ * lose a field.
13105
+ */
13106
+ async function encryptBackup(plaintext, password) {
13107
+ const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
13108
+ const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
13109
+ const key = await deriveKey(password, salt, ITERATIONS);
13110
+ const cipher = await crypto.subtle.encrypt({
13111
+ name: "AES-GCM",
13112
+ iv
13113
+ }, key, new TextEncoder().encode(plaintext));
13114
+ return {
13115
+ format: ENCRYPTED_FORMAT,
13116
+ version: 1,
13117
+ salt: toBase64(salt),
13118
+ iv: toBase64(iv),
13119
+ iterations: ITERATIONS,
13120
+ data: toBase64(new Uint8Array(cipher))
13121
+ };
13122
+ }
13123
+ /** True when `value` looks like this module's envelope rather than a plain payload. */
13124
+ function isEncryptedEnvelope(value) {
13125
+ if (value === null || typeof value !== "object") return false;
13126
+ const record = value;
13127
+ return record["format"] === "dsh-xdpool/backup-encrypted" && typeof record["salt"] === "string" && typeof record["iv"] === "string" && typeof record["data"] === "string";
13128
+ }
13129
+ /**
13130
+ * Decrypt an envelope back to the payload string.
13131
+ *
13132
+ * Throws on a wrong password — AES-GCM's tag check fails, and there is no partial
13133
+ * plaintext to leak. The message is deliberately identical for "wrong password" and
13134
+ * "corrupt file": telling them apart would let someone confirm a guess about which it
13135
+ * was, and the user's next step is the same either way.
13136
+ */
13137
+ async function decryptBackup(envelope, password) {
13138
+ const salt = fromBase64(envelope.salt);
13139
+ const iv = fromBase64(envelope.iv);
13140
+ const key = await deriveKey(password, salt, Number.isFinite(envelope.iterations) ? envelope.iterations : ITERATIONS);
13141
+ try {
13142
+ const plain = await crypto.subtle.decrypt({
13143
+ name: "AES-GCM",
13144
+ iv
13145
+ }, key, fromBase64(envelope.data));
13146
+ return new TextDecoder().decode(plain);
13147
+ } catch {
13148
+ throw new Error("wrong password, or the file is damaged");
13149
+ }
13150
+ }
13151
+ //#endregion
13152
+ //#region src/client/BackupPanel.tsx
13153
+ /**
13154
+ * Export and import the whole credential set, as one password-protected file.
13155
+ *
13156
+ * WHAT MOVES
13157
+ *
13158
+ * Every channel account's credential value, the model blacklist, the permanent-lock
13159
+ * table, and — through the pool half — the WorkBuddy accounts with their selection and
13160
+ * routing settings. The point is moving to a new machine without re-scanning a dozen QR
13161
+ * codes.
13162
+ *
13163
+ * WHY ENCRYPTION IS NOT OPTIONAL
13164
+ *
13165
+ * The payload contains credential values, which are enough to sign in as those accounts. A
13166
+ * plain file in a downloads folder or a chat window is a leak with extra steps, so the
13167
+ * export refuses to run without a password.
13168
+ *
13169
+ * ONE ACTION AT A TIME
13170
+ *
13171
+ * The first version put a raw `<input type="file">` on screen beside two buttons, which
13172
+ * rendered as three separate "choose file" controls — the browser's own picker, plus the
13173
+ * buttons meant to hide it. The input's `display:none` rule lived in a stylesheet that
13174
+ * this page never loads, so the native control was never hidden.
13175
+ *
13176
+ * This version carries its OWN stylesheet and hides the input structurally: there is one
13177
+ * button per action, and the file is chosen as part of the import, not before it. Picking
13178
+ * a file and then pressing "import" was two steps for one intention.
13179
+ *
13180
+ * @module dsh-xdpool/client/BackupPanel
13181
+ */
13182
+ function statusFrom(value) {
13183
+ if (value === null || typeof value !== "object") return void 0;
13184
+ const record = value;
13185
+ const accounts = record["accounts"];
13186
+ if (typeof accounts !== "number") return void 0;
13187
+ return {
13188
+ accounts,
13189
+ withoutExpiry: typeof record["withoutExpiry"] === "number" ? record["withoutExpiry"] : 0
13190
+ };
13191
+ }
13192
+ function BackupPanel({ t }) {
13193
+ const [password, setPassword] = (0, react.useState)("");
13194
+ const [busy, setBusy] = (0, react.useState)(false);
13195
+ const [notice, setNotice] = (0, react.useState)(void 0);
13196
+ const [error, setError] = (0, react.useState)(void 0);
13197
+ /**
13198
+ * The chosen file, held in state rather than only in a ref.
13199
+ *
13200
+ * The old version read `input.files[0]` at import time, which meant the user had to
13201
+ * pick a file BEFORE knowing whether the password was even long enough — and the button
13202
+ * that did the picking sat beside the one that used it. Holding the name lets the row
13203
+ * say what is selected and lets one button do the whole job.
13204
+ */
13205
+ const [picked, setPicked] = (0, react.useState)(void 0);
13206
+ const fileRef = (0, react.useRef)(null);
13207
+ const reset = () => {
13208
+ setNotice(void 0);
13209
+ setError(void 0);
13210
+ };
13211
+ const tooShort = password.length < 8;
13212
+ /**
13213
+ * Export: ask the pack for the payload, encrypt it here, hand the user a file.
13214
+ *
13215
+ * The RPC answer is never written to disk in plaintext — it lives in this closure and in
13216
+ * memory for the length of one encrypt.
13217
+ */
13218
+ const exportBackup = (0, react.useCallback)(async () => {
13219
+ reset();
13220
+ if (tooShort) {
13221
+ setError(t?.("backup.passwordShort", { min: 8 }) ?? `Password must be at least ${String(8)} characters.`);
13222
+ return;
13223
+ }
13224
+ setBusy(true);
13225
+ try {
13226
+ const answer = await callPack("backup.export", {});
13227
+ if (answer.ok === false) {
13228
+ setError(answer.error?.message ?? t?.("backup.exportFailed") ?? "Export failed.");
13229
+ return;
13230
+ }
13231
+ const value = answer.value;
13232
+ if (value?.payload === void 0) {
13233
+ setError(t?.("backup.exportEmpty") ?? "The pack returned no payload.");
13234
+ return;
13235
+ }
13236
+ const envelope = await encryptBackup(JSON.stringify(value.payload), password);
13237
+ const blob = new Blob([JSON.stringify(envelope, null, 2)], { type: "application/json" });
13238
+ const url = URL.createObjectURL(blob);
13239
+ const anchor = document.createElement("a");
13240
+ anchor.href = url;
13241
+ anchor.download = `xdpool-accounts-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`;
13242
+ anchor.click();
13243
+ URL.revokeObjectURL(url);
13244
+ const warnings = Array.isArray(value.warnings) ? value.warnings.length : 0;
13245
+ setNotice(warnings > 0 ? t?.("backup.exportedWarn", { count: warnings }) ?? `Exported, with ${String(warnings)} warning(s).` : t?.("backup.exported") ?? "Exported. Keep the password — the file cannot be opened without it.");
13246
+ } catch (cause) {
13247
+ setError(cause instanceof Error ? cause.message : String(cause));
13248
+ } finally {
13249
+ setBusy(false);
13250
+ }
13251
+ }, [
13252
+ password,
13253
+ tooShort,
13254
+ t
13255
+ ]);
13256
+ /** Remember the file the user chose, and clear the input so the same one can be re-picked. */
13257
+ const chooseFile = (0, react.useCallback)(() => {
13258
+ const input = fileRef.current;
13259
+ if (input === null) return;
13260
+ input.click();
13261
+ }, []);
13262
+ const onFileChosen = (0, react.useCallback)(() => {
13263
+ reset();
13264
+ const file = fileRef.current?.files?.[0];
13265
+ if (file === void 0) {
13266
+ setPicked(void 0);
13267
+ return;
13268
+ }
13269
+ setPicked({
13270
+ name: file.name,
13271
+ text: file.text()
13272
+ });
13273
+ if (fileRef.current !== null) fileRef.current.value = "";
13274
+ }, []);
13275
+ /**
13276
+ * Import: decrypt in the browser, then hand the pack a plaintext payload.
13277
+ *
13278
+ * `backup.import` REPLACES the account set, so the overwrite count is read first and
13279
+ * shown in the confirm dialog.
13280
+ */
13281
+ const importBackup = (0, react.useCallback)(async () => {
13282
+ reset();
13283
+ if (picked === void 0) {
13284
+ chooseFile();
13285
+ return;
13286
+ }
13287
+ setBusy(true);
13288
+ try {
13289
+ let text;
13290
+ try {
13291
+ text = await picked.text;
13292
+ } catch {
13293
+ setError(t?.("backup.fileUnreadable") ?? "The file could not be read.");
13294
+ setPicked(void 0);
13295
+ return;
13296
+ }
13297
+ let parsed;
13298
+ try {
13299
+ parsed = JSON.parse(text);
13300
+ } catch {
13301
+ setError(t?.("backup.notJson") ?? "That file is not a backup.");
13302
+ setPicked(void 0);
13303
+ return;
13304
+ }
13305
+ let payload;
13306
+ if (isEncryptedEnvelope(parsed)) {
13307
+ if (tooShort) {
13308
+ setError(t?.("backup.passwordShort", { min: 8 }) ?? `Password must be at least ${String(8)} characters.`);
13309
+ return;
13310
+ }
13311
+ try {
13312
+ payload = JSON.parse(await decryptBackup(parsed, password));
13313
+ } catch {
13314
+ setError(t?.("backup.badPassword") ?? "Wrong password, or the file is damaged.");
13315
+ return;
13316
+ }
13317
+ } else payload = parsed;
13318
+ const replacing = statusFrom((await callPack("backup.status", {})).value)?.accounts ?? 0;
13319
+ const message = t?.("backup.confirm", { count: replacing }) ?? `This REPLACES all ${String(replacing)} account(s). Continue?`;
13320
+ if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(message)) return;
13321
+ const answer = await callPack("backup.import", { payload });
13322
+ if (answer.ok === false) {
13323
+ setError(answer.error?.message ?? t?.("backup.importFailed") ?? "Import failed.");
13324
+ return;
13325
+ }
13326
+ const result = answer.value;
13327
+ const accounts = typeof result?.accountsImported === "number" ? result.accountsImported : 0;
13328
+ const credentials = typeof result?.credentialsImported === "number" ? result.credentialsImported : 0;
13329
+ const skipped = Array.isArray(result?.skipped) ? result.skipped.length : 0;
13330
+ setNotice(skipped > 0 ? t?.("backup.importedPartial", {
13331
+ accounts,
13332
+ credentials,
13333
+ skipped
13334
+ }) ?? `Imported ${String(accounts)} account(s), ${String(skipped)} skipped.` : t?.("backup.imported", {
13335
+ accounts,
13336
+ credentials
13337
+ }) ?? `Imported ${String(accounts)} account(s) with ${String(credentials)} credential(s).`);
13338
+ setPicked(void 0);
13339
+ } catch (cause) {
13340
+ setError(cause instanceof Error ? cause.message : String(cause));
13341
+ } finally {
13342
+ setBusy(false);
13343
+ }
13344
+ }, [
13345
+ chooseFile,
13346
+ password,
13347
+ picked,
13348
+ tooShort,
13349
+ t
13350
+ ]);
13351
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
13352
+ className: "dsm-xdpool-bak",
13353
+ children: [
13354
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BACKUP_CSS }),
13355
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
13356
+ className: "dsm-xdpool-bak-head",
13357
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
13358
+ className: "dsm-xdpool-bak-title",
13359
+ children: t?.("backup.title") ?? "账号备份"
13360
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
13361
+ className: "dsm-xdpool-bak-warn",
13362
+ children: t?.("backup.encrypted") ?? "加密"
13363
+ })]
13364
+ }),
13365
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
13366
+ className: "dsm-xdpool-bak-hint",
13367
+ children: t?.("backup.hint") ?? ""
13368
+ }),
13369
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
13370
+ className: "dsm-xdpool-bak-field",
13371
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
13372
+ className: "dsm-xdpool-bak-label",
13373
+ children: t?.("backup.password") ?? "密码"
13374
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
13375
+ type: "password",
13376
+ className: "dsm-xdpool-bak-input",
13377
+ value: password,
13378
+ disabled: busy,
13379
+ autoComplete: "new-password",
13380
+ placeholder: t?.("backup.passwordPlaceholder", { min: 8 }) ?? `至少 ${String(8)} 位`,
13381
+ onChange: (event) => {
13382
+ setPassword(event.target.value);
13383
+ }
13384
+ })]
13385
+ }),
13386
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
13387
+ className: "dsm-xdpool-bak-actions",
13388
+ children: [
13389
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
13390
+ type: "button",
13391
+ className: "dsm-xdpool-bak-btn",
13392
+ "data-tone": "primary",
13393
+ disabled: busy || tooShort,
13394
+ title: tooShort ? t?.("backup.passwordShort", { min: 8 }) ?? "" : t?.("backup.exportHint") ?? "导出为加密文件",
13395
+ onClick: () => {
13396
+ exportBackup();
13397
+ },
13398
+ children: t?.("backup.export") ?? "导出"
13399
+ }),
13400
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
13401
+ type: "button",
13402
+ className: "dsm-xdpool-bak-btn",
13403
+ "data-tone": "quiet",
13404
+ disabled: busy,
13405
+ title: picked === void 0 ? t?.("backup.pickHint") ?? "选择要导入的备份文件" : t?.("backup.importHint") ?? "导入会整体替换现有账号",
13406
+ onClick: () => {
13407
+ importBackup();
13408
+ },
13409
+ children: picked === void 0 ? t?.("backup.pick") ?? "选择文件并导入" : t?.("backup.import") ?? "导入"
13410
+ }),
13411
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
13412
+ ref: fileRef,
13413
+ type: "file",
13414
+ accept: "application/json,.json",
13415
+ hidden: true,
13416
+ onChange: onFileChosen
13417
+ })
13418
+ ]
13419
+ }),
13420
+ picked === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
13421
+ className: "dsm-xdpool-bak-file",
13422
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
13423
+ className: "dsm-xdpool-bak-filename",
13424
+ title: picked.name,
13425
+ children: picked.name
13426
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
13427
+ type: "button",
13428
+ className: "dsm-xdpool-bak-clear",
13429
+ "aria-label": t?.("backup.clearFile") ?? "取消选择",
13430
+ disabled: busy,
13431
+ onClick: () => {
13432
+ setPicked(void 0);
13433
+ },
13434
+ children: "×"
13435
+ })]
13436
+ }),
13437
+ notice === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
13438
+ className: "dsm-xdpool-bak-note",
13439
+ children: notice
13440
+ }),
13441
+ error === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
13442
+ className: "dsm-xdpool-bak-error",
13443
+ children: error
13444
+ })
13445
+ ]
13446
+ });
13447
+ }
13448
+ //#endregion
12672
13449
  //#region src/version.ts
12673
13450
  /**
12674
13451
  * The plugin's own version, hand-maintained for the browser bundle.
@@ -12683,7 +13460,7 @@ window.__ModuleLoader__.load({
12683
13460
  *
12684
13461
  * @module dsh-xdpool/version
12685
13462
  */
12686
- const POOL_VERSION = "2.6.0";
13463
+ const POOL_VERSION = "2.6.2";
12687
13464
  //#endregion
12688
13465
  //#region src/client/PoolCard.tsx
12689
13466
  /**
@@ -13076,6 +13853,10 @@ window.__ModuleLoader__.load({
13076
13853
  {
13077
13854
  id: "gateway",
13078
13855
  label: t?.("shell.tabGateway") ?? "Gateway"
13856
+ },
13857
+ {
13858
+ id: "backup",
13859
+ label: t?.("shell.tabBackup") ?? "Backup"
13079
13860
  }
13080
13861
  ];
13081
13862
  const [page, setPage] = (0, react.useState)(() => initialShellTab(rememberedShellTab(), shellTabs));
@@ -13962,7 +14743,7 @@ window.__ModuleLoader__.load({
13962
14743
  children: page === "aggregate" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AggregatePage, { t }) : page === "channels" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChannelsPage, { t }) : page === "ledger" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LedgerPage, {
13963
14744
  t,
13964
14745
  usage
13965
- }) : page === "routing" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RoutingPage, { t }) : page === "gateway" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GatewayPage, { t }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
14746
+ }) : page === "routing" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RoutingPage, { t }) : page === "gateway" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GatewayPage, { t }) : page === "backup" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BackupPanel, { t }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
13966
14747
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: POOL_CARD_CSS }),
13967
14748
  usage === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
13968
14749
  className: "dsm-xdpool-card dsm-xdpool-usage",
@@ -14309,7 +15090,7 @@ window.__ModuleLoader__.load({
14309
15090
  children: entry.file
14310
15091
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
14311
15092
  className: "dsm-xdpool-skipped-reason",
14312
- children: entry.reason === "encrypted" ? t?.("row.skippedEncrypted") ?? "encrypted — start WorkBuddy once" : entry.reason === "unreadable" ? t?.("row.skippedUnreadable") ?? "file could not be read" : t?.("row.skippedMalformed") ?? "not a credential file"
15093
+ children: entry.reason === "encrypted" ? t?.("row.skippedEncrypted") ?? "encrypted — start WorkBuddy once" : entry.reason === "expired" ? t?.("row.skippedExpired") ?? "expired — sign in again, or delete it" : entry.reason === "unreadable" ? t?.("row.skippedUnreadable") ?? "file could not be read" : t?.("row.skippedMalformed") ?? "not a credential file"
14313
15094
  })]
14314
15095
  }, entry.file))]
14315
15096
  }),
@@ -15179,6 +15960,36 @@ window.__ModuleLoader__.load({
15179
15960
  });
15180
15961
  }
15181
15962
  //#endregion
15963
+ //#region src/client/credit-strip.tsx
15964
+ /**
15965
+ * The credit-spend strip above the composer.
15966
+ *
15967
+ * WHAT IT ANSWERS
15968
+ *
15969
+ * "How much have I spent, and on which channel." The pool's data panel shows tokens
15970
+ * and per-request rows, which is the wrong shape for the question people actually ask
15971
+ * while typing: *am I burning quota, and on what*. So this is one line, one total, and
15972
+ * a per-channel breakdown behind a click.
15973
+ *
15974
+ * IT IS AN ESTIMATE, AND SAYS SO
15975
+ *
15976
+ * No channel publishes a credit counter we can read per request; the pack records
15977
+ * tokens. The figure is `tokens ÷ 1e6 × the model's multiplier`, and the multiplier is
15978
+ * parsed from the model's display name (`Hy3 · x0.05`) — the same number the user sees
15979
+ * in their picker. A model whose name carries no multiplier is counted in tokens and
15980
+ * reported as unpriced, never billed at zero: "we do not know the price" and "this was
15981
+ * free" are different facts and the panel must not merge them.
15982
+ *
15983
+ * WHY IT POLLS SLOWLY
15984
+ *
15985
+ * Every turn changes the ledger, but the ledger is a file on disk read by the host —
15986
+ * polling on each keystroke would be pure cost for a number that moves once per
15987
+ * request. Thirty seconds matches the pool card and is comfortably faster than a
15988
+ * person notices.
15989
+ *
15990
+ * @module dsh-xdpool/client/credit-strip
15991
+ */
15992
+ //#endregion
15182
15993
  //#region src/client/nav-icon.ts
15183
15994
  /**
15184
15995
  * Nav glyph for the WorkBuddy XD Pool settings page.
@@ -15241,6 +16052,468 @@ window.__ModuleLoader__.load({
15241
16052
  */
15242
16053
  const POOL_NAV_ICON_MASK_URL = `data:image/svg+xml;utf8,${encodeURIComponent(POOL_NAV_ICON_SVG)}`;
15243
16054
  //#endregion
16055
+ //#region src/client/CreditDot.tsx
16056
+ /**
16057
+ * The credit indicator beside the model name in the composer, and the panel it opens.
16058
+ *
16059
+ * A DOT, NOT A STRIP
16060
+ *
16061
+ * The composer row already carries a back button, the model name, its multiplier and the
16062
+ * reasoning level. A table of balances there would be a second toolbar. So the inline
16063
+ * form is a single 9px dot whose COLOUR is the whole at-a-glance message, and the detail
16064
+ * lives behind a click:
16065
+ *
16066
+ * - **green** — this session's channel still has credits;
16067
+ * - **grey** — it has run out (or the channel cannot be priced), which is the state
16068
+ * that means "switch models before starting something long".
16069
+ *
16070
+ * WHICH CHANNEL IS "THIS SESSION'S"
16071
+ *
16072
+ * The channel that serves the model the composer currently has selected. That is read
16073
+ * from the pool status (`selection` / the active account) rather than guessed, because
16074
+ * the dot is a claim about the model the user is about to send to; a dot describing some
16075
+ * other channel would be worse than no dot.
16076
+ *
16077
+ * THE PANEL ANSWERS THREE THINGS
16078
+ *
16079
+ * Remaining credits for that channel, credits used today, and how much of the model is
16080
+ * being consumed (its multiplier and the token totals). Those are the three numbers a
16081
+ * person checks before starting a long task, and they come from two sources: the balance
16082
+ * from the status document, the spend from the token ledger.
16083
+ *
16084
+ * POSITIONING AND DISMISSAL ARE DONE HERE
16085
+ *
16086
+ * The host's own floating primitive is internal to its packages, so this uses a fixed
16087
+ * anchor measured from the button plus a document-level listener for outside clicks and
16088
+ * Escape. That is the same contract the host's meters use, implemented locally rather
16089
+ * than depending on a module this bundle cannot import.
16090
+ *
16091
+ * @module dsh-xdpool/client/CreditDot
16092
+ */
16093
+ /** Re-read cadence. A balance moves only when something spends. */
16094
+ const POLL_MS = 45e3;
16095
+ /** `21887` → `21,887`. */
16096
+ function grouped(value) {
16097
+ return Number.isFinite(value) ? Math.round(value).toLocaleString("en-US") : "0";
16098
+ }
16099
+ /** `0.11` → `0.11`, `0` → `0`. Kept exact: a multiplier is a price, not a count. */
16100
+ function rate(value) {
16101
+ return Number.isFinite(value) ? String(value) : "—";
16102
+ }
16103
+ /**
16104
+ * Group accounts the way the pool CARD groups them: by REGION, not by credential origin.
16105
+ *
16106
+ * WHY THIS CHANGED
16107
+ *
16108
+ * The first version keyed channels off the credential's domain, which split one pool card
16109
+ * into two rows — `www.codebuddy.cn` 13,990 and `www.workbuddy.cn` 99 — while the card
16110
+ * itself showed a single "WorkBuddy 国内版 · 14,089". Both numbers were on screen at once
16111
+ * and they disagreed, which is the defect this fixes.
16112
+ *
16113
+ * The card is right and the domain split was wrong. A pool is a set of accounts that serve
16114
+ * the same lane; signing in through `copilot.tencent.com` (CodeBuddy's endpoint) or
16115
+ * `www.workbuddy.cn` only changes which origin issued the credential, not which pool the
16116
+ * account belongs to. `regionOf()` already owns that mapping for the host half, and a
16117
+ * credential's domain is exactly what decides its region — so the grouping follows the
16118
+ * domain's REGION rather than the domain string itself.
16119
+ *
16120
+ * One consequence worth stating: an account whose balance has not been read is skipped
16121
+ * from the total (it would understate), but it still belongs to its region — which is why
16122
+ * the count can be lower than the card's account count without the two disagreeing about
16123
+ * money.
16124
+ */
16125
+ function regionOfDomain(domain) {
16126
+ return domain.toLowerCase().includes("workbuddy.ai") ? "global" : "cn";
16127
+ }
16128
+ function channelsOf(value) {
16129
+ if (value === null || typeof value !== "object") return [];
16130
+ const accounts = value.accounts;
16131
+ if (!Array.isArray(accounts)) return [];
16132
+ const byRegion = /* @__PURE__ */ new Map();
16133
+ for (const raw of accounts) {
16134
+ if (raw === null || typeof raw !== "object") continue;
16135
+ const row = raw;
16136
+ const credits = row["credits"];
16137
+ if (credits === null || typeof credits !== "object") continue;
16138
+ const total = credits["total"];
16139
+ if (typeof total !== "number" || !Number.isFinite(total)) continue;
16140
+ const id = regionOfDomain(typeof row["domain"] === "string" ? row["domain"] : "");
16141
+ const current = byRegion.get(id) ?? {
16142
+ total: 0,
16143
+ accounts: 0
16144
+ };
16145
+ current.total += total;
16146
+ current.accounts += 1;
16147
+ byRegion.set(id, current);
16148
+ }
16149
+ return [...byRegion.entries()].map(([id, value2]) => ({
16150
+ id,
16151
+ name: id === "global" ? "WorkBuddy 国际版" : "WorkBuddy 国内版",
16152
+ total: value2.total,
16153
+ accounts: value2.accounts
16154
+ })).sort((a, b) => b.total - a.total);
16155
+ }
16156
+ /**
16157
+ * The model the composer currently has selected.
16158
+ *
16159
+ * WHY THIS READS THE DOM
16160
+ *
16161
+ * The pool's status document has no "current model" field — only `selection`, which lists
16162
+ * the models the user has ENABLED. Reading its first entry always reported `hy4-preview`
16163
+ * no matter what was selected, which is the reported bug.
16164
+ *
16165
+ * The live choice lives in the host's model-selection store, which is owned by the
16166
+ * `conversation.input.model` slot's `inject`. A plugin cannot reach that service, but the
16167
+ * trigger it renders is right next to this dot and displays exactly the answer we want —
16168
+ * `DeepSeek-V4.1-Flash · x0.11 High`. Reading it means the dot and the row can never
16169
+ * disagree, which is the whole requirement.
16170
+ *
16171
+ * The multiplier and effort are stripped off, because only the model id is used: the
16172
+ * caption appends them with a middle dot, and they must not end up inside the id.
16173
+ *
16174
+ * Returns '' when the row is not rendered yet (a fresh session, or the hero screen), and
16175
+ * the panel then shows an em dash rather than naming a model it cannot see.
16176
+ */
16177
+ function readSelectedModel() {
16178
+ if (typeof document === "undefined") return "";
16179
+ const roots = document.querySelectorAll("[data-composer-seat], [data-composer-dock]");
16180
+ for (const root of roots) for (const button of root.querySelectorAll("button")) {
16181
+ const text = (button.textContent ?? "").trim();
16182
+ if (text === "") continue;
16183
+ if (!/·\s*x\d/u.test(text)) continue;
16184
+ const model = text.split("·")[0]?.trim() ?? "";
16185
+ if (model !== "") return model;
16186
+ }
16187
+ return "";
16188
+ }
16189
+ function CreditDot({ t }) {
16190
+ const [snapshot, setSnapshot] = (0, react.useState)(void 0);
16191
+ const [open, setOpen] = (0, react.useState)(false);
16192
+ const [failed, setFailed] = (0, react.useState)(false);
16193
+ const anchorRef = (0, react.useRef)(null);
16194
+ const panelRef = (0, react.useRef)(null);
16195
+ const [pos, setPos] = (0, react.useState)(void 0);
16196
+ const mounted = (0, react.useRef)(true);
16197
+ const load = (0, react.useCallback)(async () => {
16198
+ try {
16199
+ const [statusRes, spendRes] = await Promise.all([fetch(POOL_STATUS_PATH, { headers: { accept: "application/json" } }), fetch(`${POOL_CREDITS_SPEND_PATH}?days=1`, { headers: { accept: "application/json" } }).catch(() => void 0)]);
16200
+ if (!statusRes.ok) throw new Error(String(statusRes.status));
16201
+ const status = await statusRes.json();
16202
+ if (!mounted.current) return;
16203
+ const all = channelsOf(status);
16204
+ /**
16205
+ * The model the composer is actually on.
16206
+ *
16207
+ * NOT the first entry of `selection`'s enabled list — that is the set of models the
16208
+ * user has ENABLED, in picker order, so reading its first item always reported
16209
+ * `hy4-preview` no matter what was selected. The reported symptom was exactly that:
16210
+ * the panel said hy4-preview while the composer said DeepSeek-V4.1-Flash.
16211
+ *
16212
+ * The host keeps the live choice in `localStorage` under the model-selection
16213
+ * store; its `current` field is what the composer renders. Reading it is the only
16214
+ * way to see the same answer the row shows, because the pool's own status document
16215
+ * does not carry a "currently selected" field at all.
16216
+ */
16217
+ const chosen = readSelectedModel();
16218
+ const models = Array.isArray(status["models"]) ? status["models"] : [];
16219
+ const row = chosen === "" ? void 0 : models.find((candidate) => candidate["id"] === chosen);
16220
+ const multiplier = typeof row?.["multiplier"] === "number" ? row["multiplier"] : void 0;
16221
+ const channel = all[0];
16222
+ let spentToday;
16223
+ let requestsToday;
16224
+ /** Channel ids that served at least one request today. */
16225
+ const activeChannels = /* @__PURE__ */ new Set();
16226
+ if (spendRes !== void 0 && spendRes.ok) {
16227
+ const spend = await spendRes.json();
16228
+ const rows = Array.isArray(spend.channels) ? spend.channels : [];
16229
+ let total = 0;
16230
+ let requests = 0;
16231
+ let any = false;
16232
+ for (const raw of rows) {
16233
+ if (raw === null || typeof raw !== "object") continue;
16234
+ const record = raw;
16235
+ const credits = record["credits"];
16236
+ if (typeof credits === "number" && Number.isFinite(credits)) {
16237
+ total += credits;
16238
+ any = true;
16239
+ }
16240
+ const count = record["requests"];
16241
+ if (typeof count === "number" && Number.isFinite(count) && count > 0) {
16242
+ requests += count;
16243
+ const id = record["channel"];
16244
+ if (typeof id === "string" && id !== "") activeChannels.add(id);
16245
+ }
16246
+ }
16247
+ if (any) spentToday = Math.round(total * 100) / 100;
16248
+ if (requests > 0) requestsToday = requests;
16249
+ }
16250
+ /**
16251
+ * Which channels served today.
16252
+ *
16253
+ * The ledger names a channel the way the PACK does — `buddy` for the domestic pool
16254
+ * and `workbuddy` for the international one — which is the same split
16255
+ * `regionOfDomain` applies, so the ids line up directly.
16256
+ */
16257
+ const usedChannels = all.filter((channel) => activeChannels.has(channel.id));
16258
+ const baseline = (channel?.total ?? 0) + (spentToday ?? 0);
16259
+ if (!mounted.current) return;
16260
+ setSnapshot({
16261
+ channel,
16262
+ all,
16263
+ spentToday,
16264
+ requestsToday,
16265
+ model: chosen,
16266
+ multiplier,
16267
+ baseline,
16268
+ usedChannels
16269
+ });
16270
+ setFailed(false);
16271
+ } catch {
16272
+ if (mounted.current) setFailed(true);
16273
+ }
16274
+ }, []);
16275
+ (0, react.useEffect)(() => {
16276
+ mounted.current = true;
16277
+ load();
16278
+ const timer = window.setInterval(() => {
16279
+ load();
16280
+ }, POLL_MS);
16281
+ return () => {
16282
+ mounted.current = false;
16283
+ window.clearInterval(timer);
16284
+ };
16285
+ }, [load]);
16286
+ /**
16287
+ * Watch the composer's model caption and follow it.
16288
+ *
16289
+ * The 60s poll is right for a BALANCE (it moves only when something spends) and wrong
16290
+ * for the MODEL (it changes the moment the user picks another one). Re-reading the
16291
+ * caption on a MutationObserver keeps the panel honest without polling the network:
16292
+ * a caption swap is a DOM change, so the observer fires exactly when there is news.
16293
+ *
16294
+ * Debounced to the next frame so a burst of mutations during a re-render collapses into
16295
+ * one read.
16296
+ */
16297
+ (0, react.useEffect)(() => {
16298
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return void 0;
16299
+ let scheduled = false;
16300
+ const sync = () => {
16301
+ scheduled = false;
16302
+ if (!mounted.current) return;
16303
+ const next = readSelectedModel();
16304
+ if (next === "") return;
16305
+ setSnapshot((current) => {
16306
+ if (current === void 0 || current.model === next) return current;
16307
+ return {
16308
+ ...current,
16309
+ model: next
16310
+ };
16311
+ });
16312
+ };
16313
+ const observer = new MutationObserver(() => {
16314
+ if (scheduled) return;
16315
+ scheduled = true;
16316
+ window.requestAnimationFrame(sync);
16317
+ });
16318
+ observer.observe(document.body, {
16319
+ childList: true,
16320
+ subtree: true,
16321
+ characterData: true
16322
+ });
16323
+ return () => {
16324
+ observer.disconnect();
16325
+ };
16326
+ }, []);
16327
+ /**
16328
+ * Place the panel above the dot.
16329
+ *
16330
+ * Measured rather than CSS-anchored because the composer row can be at the bottom of
16331
+ * the viewport, and a panel that opens downward would be clipped by it. `side: top`
16332
+ * with a viewport clamp is the same rule the host's own meters follow.
16333
+ */
16334
+ (0, react.useLayoutEffect)(() => {
16335
+ if (!open) {
16336
+ setPos(void 0);
16337
+ return;
16338
+ }
16339
+ const anchor = anchorRef.current;
16340
+ if (anchor === null) return;
16341
+ const box = anchor.getBoundingClientRect();
16342
+ /**
16343
+ * The panel sizes to its content (`width: max-content`), so its width can only be
16344
+ * read AFTER it has rendered. A hard-coded number here was what made a short line of
16345
+ * text sit in a 300px box: the width came from this layout maths rather than from
16346
+ * what the text needed. Measuring the element keeps the two in step.
16347
+ *
16348
+ * Falling back to 200 when the ref is not attached yet (first frame) is safe: the
16349
+ * effect re-runs on `open`, and the panel is `visibility: hidden` until `pos` exists.
16350
+ */
16351
+ const width = panelRef.current?.offsetWidth ?? 200;
16352
+ const margin = 8;
16353
+ const left = Math.min(Math.max(margin, box.left + box.width / 2 - width / 2), Math.max(margin, window.innerWidth - width - margin));
16354
+ const panelHeight = panelRef.current?.offsetHeight ?? 240;
16355
+ const above = box.top - panelHeight - 8;
16356
+ const top = above >= margin ? above : box.bottom + 8;
16357
+ setPos({
16358
+ top,
16359
+ left
16360
+ });
16361
+ }, [open]);
16362
+ /**
16363
+ * Re-measure once the panel has painted.
16364
+ *
16365
+ * `width: max-content` means the first pass measures a zero-width element: the panel is
16366
+ * attached but `pos` is still undefined, so it is laid out at its min-width. Re-running
16367
+ * after the content exists is what makes the width follow the text rather than the
16368
+ * placeholder. Keyed on the model and the channel so a longer name (a real possibility
16369
+ * with a `· 14,031` suffix) triggers a fresh measurement.
16370
+ */
16371
+ (0, react.useLayoutEffect)(() => {
16372
+ if (!open) return;
16373
+ const anchor = anchorRef.current;
16374
+ const panel = panelRef.current;
16375
+ if (anchor === null || panel === null) return;
16376
+ const box = anchor.getBoundingClientRect();
16377
+ const width = panel.offsetWidth;
16378
+ const margin = 8;
16379
+ const left = Math.min(Math.max(margin, box.left + box.width / 2 - width / 2), Math.max(margin, window.innerWidth - width - margin));
16380
+ const above = box.top - panel.offsetHeight - 8;
16381
+ setPos((current) => {
16382
+ if (current === void 0) return current;
16383
+ const top = above >= margin ? above : box.bottom + 8;
16384
+ if (current.top === top && current.left === left) return current;
16385
+ return {
16386
+ top,
16387
+ left
16388
+ };
16389
+ });
16390
+ }, [
16391
+ open,
16392
+ snapshot?.model,
16393
+ snapshot?.channel?.total,
16394
+ snapshot?.spentToday
16395
+ ]);
16396
+ /** Outside click and Escape, the two ways any popover on this host closes. */
16397
+ (0, react.useEffect)(() => {
16398
+ if (!open) return void 0;
16399
+ const onPointer = (event) => {
16400
+ const target = event.target;
16401
+ if (target === null) return;
16402
+ if (anchorRef.current?.contains(target) === true) return;
16403
+ if (panelRef.current?.contains(target) === true) return;
16404
+ setOpen(false);
16405
+ };
16406
+ const onKey = (event) => {
16407
+ if (event.key === "Escape") setOpen(false);
16408
+ };
16409
+ document.addEventListener("mousedown", onPointer, true);
16410
+ document.addEventListener("keydown", onKey);
16411
+ return () => {
16412
+ document.removeEventListener("mousedown", onPointer, true);
16413
+ document.removeEventListener("keydown", onKey);
16414
+ };
16415
+ }, [open]);
16416
+ if (failed || snapshot === void 0) return null;
16417
+ /**
16418
+ * Green when the serving channel still has credits, grey when it does not.
16419
+ *
16420
+ * `total > 0` is the whole test: the pool's own gate already refuses an account at its
16421
+ * reserved floor, so a non-zero total is genuinely spendable. A channel that could not
16422
+ * be read is grey — the dot must not claim "you are fine" on missing data.
16423
+ */
16424
+ const alive = snapshot.channel !== void 0 && snapshot.channel.total > 0;
16425
+ const label = alive ? t?.("dot.alive") ?? "还有积分" : t?.("dot.empty") ?? "没有积分了";
16426
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16427
+ className: "dsm-xdpool-dotwrap",
16428
+ style: { ["--xdpool-dot-mask"]: `url("${POOL_NAV_ICON_MASK_URL}")` },
16429
+ children: [
16430
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: CREDIT_DOT_CSS }),
16431
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
16432
+ ref: anchorRef,
16433
+ type: "button",
16434
+ className: "dsm-xdpool-dot",
16435
+ "data-state": alive ? "alive" : "empty",
16436
+ "aria-label": label,
16437
+ "aria-expanded": open,
16438
+ title: `${label} · ${snapshot.channel === void 0 ? "—" : grouped(snapshot.channel.total)}`,
16439
+ onClick: () => {
16440
+ setOpen((current) => !current);
16441
+ },
16442
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16443
+ className: "dsm-xdpool-dot-glyph",
16444
+ "aria-hidden": "true"
16445
+ })
16446
+ }),
16447
+ !open ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16448
+ ref: panelRef,
16449
+ className: "dsm-xdpool-dotpanel",
16450
+ role: "dialog",
16451
+ "aria-label": t?.("dot.panelTitle") ?? "本会话积分",
16452
+ style: pos === void 0 ? { visibility: "hidden" } : {
16453
+ top: `${String(pos.top)}px`,
16454
+ left: `${String(pos.left)}px`
16455
+ },
16456
+ children: [
16457
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16458
+ className: "dsm-xdpool-dotpanel-head",
16459
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16460
+ className: "dsm-xdpool-dotpanel-channel",
16461
+ children: snapshot.all.length === 0 ? t?.("dot.channelUnknown") ?? "未连接渠道" : `${snapshot.channel?.name ?? ""} · ${grouped(snapshot.all.reduce((sum, row) => sum + row.total, 0))}`
16462
+ })
16463
+ }),
16464
+ snapshot.baseline <= 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16465
+ className: "dsm-xdpool-dotbar",
16466
+ role: "img",
16467
+ "aria-label": t?.("dot.barLabel") ?? "今日已用占比",
16468
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16469
+ className: "dsm-xdpool-dotbar-fill",
16470
+ "data-full": snapshot.spentToday !== void 0 && snapshot.baseline > 0 && snapshot.spentToday / snapshot.baseline >= .9 ? "true" : "false",
16471
+ style: { width: `${String(Math.min(100, Math.max(0, snapshot.baseline > 0 ? (snapshot.spentToday ?? 0) / snapshot.baseline * 100 : 0)))}%` }
16472
+ })
16473
+ }),
16474
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16475
+ className: "dsm-xdpool-dotpanel-sec",
16476
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16477
+ className: "dsm-xdpool-dotpanel-k",
16478
+ children: t?.("dot.spentToday") ?? "今日已用"
16479
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16480
+ className: "dsm-xdpool-dotpanel-v",
16481
+ children: snapshot.spentToday === void 0 ? "—" : grouped(snapshot.spentToday)
16482
+ })]
16483
+ }),
16484
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16485
+ className: "dsm-xdpool-dotpanel-sec",
16486
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16487
+ className: "dsm-xdpool-dotpanel-k",
16488
+ children: t?.("dot.model") ?? "模型"
16489
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16490
+ className: "dsm-xdpool-dotpanel-v",
16491
+ children: [snapshot.model === "" ? "—" : snapshot.model, snapshot.multiplier === void 0 ? "" : ` · x${rate(snapshot.multiplier)}`]
16492
+ })]
16493
+ }),
16494
+ snapshot.all.length > 1 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16495
+ className: "dsm-xdpool-dotpanel-more",
16496
+ children: snapshot.all.map((channel) => {
16497
+ const used = snapshot.usedChannels?.some((row) => row.id === channel.id) === true;
16498
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16499
+ className: "dsm-xdpool-dotpanel-row",
16500
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16501
+ className: "dsm-xdpool-dotpanel-rowname",
16502
+ "data-used": used ? "true" : "false",
16503
+ children: channel.name
16504
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16505
+ className: "dsm-xdpool-dotpanel-rowval",
16506
+ children: grouped(channel.total)
16507
+ })]
16508
+ }, channel.id);
16509
+ })
16510
+ }) : null
16511
+ ]
16512
+ })
16513
+ ]
16514
+ });
16515
+ }
16516
+ //#endregion
15244
16517
  //#region src/client/panel-icon.tsx
15245
16518
  /**
15246
16519
  * Render the pool's mark.
@@ -15458,6 +16731,7 @@ window.__ModuleLoader__.load({
15458
16731
  "row.skippedEncrypted": "encrypted — start WorkBuddy once so its key can be read",
15459
16732
  "row.skippedUnreadable": "file could not be read",
15460
16733
  "row.skippedMalformed": "not a credential file",
16734
+ "row.skippedExpired": "expired — sign in again, or delete it",
15461
16735
  "row.imageCapable": "image input",
15462
16736
  "row.rate": "{rate}x credits",
15463
16737
  "row.accountsRescan": "Detect accounts again",
@@ -15684,6 +16958,7 @@ window.__ModuleLoader__.load({
15684
16958
  "shell.tabRouting": "Routing",
15685
16959
  "shell.subRouting": "Chains that try models in order, so one busy or rate-limited model falls through to the next.",
15686
16960
  "shell.tabGateway": "Gateway",
16961
+ "shell.tabBackup": "Backup",
15687
16962
  "shell.subGateway": "Open the pool to the other devices on your network.",
15688
16963
  "gw.title": "Gateway",
15689
16964
  "gw.sub": "An OpenAI-compatible endpoint other devices on your network can point at. Off until you turn it on, and bound to this computer only until you say otherwise.",
@@ -15708,6 +16983,80 @@ window.__ModuleLoader__.load({
15708
16983
  "gw.showKey": "Show",
15709
16984
  "gw.hideKey": "Hide",
15710
16985
  "gw.rotate": "Rotate key",
16986
+ /** One-click copy for the two strings a client must transcribe exactly. */
16987
+ "gw.copyOrigin": "Copy base URL",
16988
+ "gw.copyOriginHint": "For clients that append /v1 themselves (most SDKs).",
16989
+ "gw.copyV1": "Copy with /v1",
16990
+ "gw.copyV1Hint": "For a field that wants the full prefix, e.g. OPENAI_BASE_URL.",
16991
+ "gw.copyBase": "Copy base URL",
16992
+ "gw.copyKey": "Copy key",
16993
+ "gw.copyAll": "Copy both",
16994
+ "gw.copyAllHint": "Base URL and key on two labelled lines — paste both into your client.",
16995
+ "gw.copied": "Copied",
16996
+ "gw.copyFailed": "Could not copy. Select the text and copy it manually.",
16997
+ "gw.copyModelIdHint": "Copy the model id — this exact string is what the client must send.",
16998
+ "gw.wire": "Chat Completions only: /v1/chat/completions and /v1/models. A client set to the Responses or Anthropic API will get a 404 here — switch it to Chat Completions.",
16999
+ /** Composer credit strip: an estimate, and it says so. */
17000
+ "strip.today": "Spent today",
17001
+ "strip.credits": "credits",
17002
+ "strip.hint": "Estimated from tokens × the model multiplier. Not an invoice.",
17003
+ "strip.unpriced": "{count} unpriced",
17004
+ "strip.unpricedHint": "These models publish no multiplier, so only their tokens are counted.",
17005
+ "strip.calls": "calls",
17006
+ "strip.gatewayExcluded": "{count} more came through the gateway (other devices) and are not counted here.",
17007
+ /** Credential backup: export/import, encrypted. */
17008
+ "backup.title": "Account backup",
17009
+ "backup.encrypted": "Encrypted",
17010
+ "backup.passwordPlaceholder": "at least {min} characters",
17011
+ "backup.exportHint": "Export an encrypted file",
17012
+ "backup.pickHint": "Choose a backup file to import",
17013
+ "backup.importHint": "Import replaces every existing account",
17014
+ "backup.clearFile": "Clear selection",
17015
+ "backup.hint": "Exports every account credential plus the model and routing settings into one file. The file is encrypted with the password below — without it the file cannot be opened, and it contains enough to sign in as these accounts.",
17016
+ "backup.password": "Password",
17017
+ "backup.passwordShort": "The password needs at least {min} characters.",
17018
+ "backup.export": "Export",
17019
+ "backup.import": "Import",
17020
+ "backup.pick": "Choose file",
17021
+ "backup.pickFile": "Choose a backup file first.",
17022
+ "backup.fileUnreadable": "The file could not be read.",
17023
+ "backup.notJson": "That file is not a backup.",
17024
+ "backup.badPassword": "Wrong password, or the file is damaged.",
17025
+ "backup.exported": "Exported. Keep the password — the file cannot be opened without it.",
17026
+ "backup.exportedWarn": "Exported, with {count} warning(s).",
17027
+ "backup.exportFailed": "Export failed.",
17028
+ "backup.exportEmpty": "The pack returned no payload.",
17029
+ "backup.importFailed": "Import failed.",
17030
+ "backup.confirm": "This REPLACES all {count} account(s). Continue?",
17031
+ "backup.imported": "Imported {accounts} account(s) with {credentials} credential(s).",
17032
+ "backup.importedPartial": "Imported {accounts} account(s), {skipped} skipped.",
17033
+ /** Balance panel: what is LEFT, not what was spent. */
17034
+ "bal.title": "Channel credits",
17035
+ "bal.refresh": "Refresh",
17036
+ "bal.accounts": "{count} accounts",
17037
+ "bal.morePacks": "{count} more package(s)",
17038
+ /** The composer strip: remaining credits, one line, expandable. */
17039
+ "bstrip.label": "Channel credits",
17040
+ "bstrip.accounts": "{count} accounts",
17041
+ "bstrip.hint": "Remaining credits per channel. Open for the per-account breakdown.",
17042
+ /** The sidebar credit panel, above the account block. */
17043
+ "rail.title": "Channel credits",
17044
+ "rail.hint": "Remaining credits per channel. Open a row for its accounts.",
17045
+ "rail.accounts": "{count} accounts",
17046
+ "rail.footCount": "{read}/{all} accounts",
17047
+ "rail.today": "Used today {n}",
17048
+ "rail.morePacks": "{count} more package(s)",
17049
+ "rail.refresh": "Refresh",
17050
+ /** The composer dot and its popover. */
17051
+ "dot.alive": "Credits available",
17052
+ "dot.empty": "Out of credits",
17053
+ "dot.panelTitle": "This session",
17054
+ "dot.channel": "Channel",
17055
+ "dot.spentToday": "Used today",
17056
+ "dot.model": "Model",
17057
+ "dot.channelUnknown": "No channel connected",
17058
+ "dot.remaining": "Remaining",
17059
+ "dot.barLabel": "Share of the period budget used",
15711
17060
  "gw.keyHint": "This key survives a DSH restart. Rotating it drops every device that still holds the old one. This is not the per-process secret of the pool itself — a client that learned that one would stop working the next time DSH restarts.",
15712
17061
  "gw.usage": "Using it",
15713
17062
  "gw.usageHint": "Any OpenAI-compatible app works. Set its base URL to the endpoint above, pick a model out of /v1/models, and send this key as the API key. The relay passes /v1/models and /v1/chat/completions only; everything else is refused before a byte is forwarded.",
@@ -15796,6 +17145,13 @@ window.__ModuleLoader__.load({
15796
17145
  "channelpool.claiming": "Claiming…",
15797
17146
  "channelpool.claimResult": "Claimed {claimed} of {total}: +{credit} {unit}.",
15798
17147
  "channelpool.claimNone": "Nothing to claim today ({already} already claimed, {inactive} with no daily credit).",
17148
+ /** The one-off rewards (Raccoon's login bonus, Loomy's onboarding tasks). */
17149
+ "channelpool.onboarding": "Claim one-off rewards",
17150
+ "channelpool.onboardingHint": "Login bonus / onboarding tasks — collectable once per account.",
17151
+ "channelpool.onboardingNoAccounts": "No account to claim for.",
17152
+ "channelpool.onboardingNone": "Nothing left to claim — already collected.",
17153
+ "channelpool.onboardingFailed": "{failed} account(s) could not claim.",
17154
+ "channelpool.onboardingResult": "Claimed {claimed} reward(s): +{credit} ({skipped} already collected).",
15799
17155
  "channelpool.claimFailed": "{failed} account(s) could not claim; the others are done.",
15800
17156
  "channelpool.refreshBalance": "Refresh balances",
15801
17157
  "channelpool.refreshAccount": "Refresh",
@@ -15969,6 +17325,7 @@ window.__ModuleLoader__.load({
15969
17325
  "row.skippedEncrypted": "已加密 —— 启动一次 WorkBuddy 才能取到密钥",
15970
17326
  "row.skippedUnreadable": "文件读不出来",
15971
17327
  "row.skippedMalformed": "不是凭据文件",
17328
+ "row.skippedExpired": "已过期(refresh token 失效)—— 重新登录或删除该文件即可",
15972
17329
  "row.imageCapable": "图片输入",
15973
17330
  "row.rate": "{rate}x 积分",
15974
17331
  "row.accountsRescan": "重新检测账号",
@@ -16195,6 +17552,7 @@ window.__ModuleLoader__.load({
16195
17552
  "shell.tabRouting": "路由",
16196
17553
  "shell.subRouting": "把几个模型串成一条链,按顺序试;一个忙了或被限流,就自动落到下一个。",
16197
17554
  "shell.tabGateway": "网关",
17555
+ "shell.tabBackup": "备份",
16198
17556
  "shell.subGateway": "把账号池开放给同网络里的其它设备。",
16199
17557
  "gw.title": "网关",
16200
17558
  "gw.sub": "开一个 OpenAI 兼容的入口,同网络的其它设备可以指过来。默认关闭,默认只绑定本机 —— 你不改,它就一直只在你这台电脑上。",
@@ -16219,6 +17577,80 @@ window.__ModuleLoader__.load({
16219
17577
  "gw.showKey": "显示",
16220
17578
  "gw.hideKey": "隐藏",
16221
17579
  "gw.rotate": "轮换密钥",
17580
+ /** 一键复制客户端必须逐字填对的那两个值。 */
17581
+ "gw.copyOrigin": "复制 Base URL",
17582
+ "gw.copyOriginHint": "客户端自己会补 /v1 时用这个(大多数 SDK)。",
17583
+ "gw.copyV1": "复制含 /v1 的地址",
17584
+ "gw.copyV1Hint": "要填完整前缀时用这个,例如 OPENAI_BASE_URL。",
17585
+ "gw.copyBase": "复制 Base URL",
17586
+ "gw.copyKey": "复制密钥",
17587
+ "gw.copyAll": "复制全部",
17588
+ "gw.copyAllHint": "Base URL 和密钥分两行带标签,可直接粘进客户端。",
17589
+ "gw.copied": "已复制",
17590
+ "gw.copyFailed": "复制失败,请手动选中文本复制。",
17591
+ "gw.copyModelIdHint": "复制模型 id —— 客户端里要逐字填这个,不是上面的显示名。",
17592
+ "gw.wire": "只支持 Chat Completions(/v1/chat/completions 与 /v1/models)。若客户端设成 Responses 或 Anthropic 协议,在这里会拿到 404 —— 请改成 Chat Completions。",
17593
+ /** 输入框上方的积分估算条。 */
17594
+ "strip.today": "今日消耗",
17595
+ "strip.credits": "积分",
17596
+ "strip.hint": "按 token × 模型倍率估算,不是账单。",
17597
+ "strip.unpriced": "{count} 个未定价",
17598
+ "strip.unpricedHint": "这些模型没有公布倍率,只统计 token 不计积分。",
17599
+ "strip.calls": "次",
17600
+ "strip.gatewayExcluded": "另有 {count} 次来自网关(其他设备),未计入。",
17601
+ /** 凭据备份:导出 / 导入,加密。 */
17602
+ "backup.title": "账号备份",
17603
+ "backup.encrypted": "加密",
17604
+ "backup.passwordPlaceholder": "至少 {min} 位",
17605
+ "backup.exportHint": "导出为加密文件",
17606
+ "backup.pickHint": "选择要导入的备份文件",
17607
+ "backup.importHint": "导入会整体替换现有账号",
17608
+ "backup.clearFile": "取消选择",
17609
+ "backup.hint": "把所有账号凭据连同模型选择与路由设置导出成一个文件。文件用下面的密码加密 —— 没有密码打不开;而它包含的东西足以登录这些账号,请妥善保存。",
17610
+ "backup.password": "密码",
17611
+ "backup.passwordShort": "密码至少要 {min} 个字符。",
17612
+ "backup.export": "导出",
17613
+ "backup.import": "导入",
17614
+ "backup.pick": "选择文件",
17615
+ "backup.pickFile": "先选一个备份文件。",
17616
+ "backup.fileUnreadable": "文件读不出来。",
17617
+ "backup.notJson": "这个文件不是备份。",
17618
+ "backup.badPassword": "密码不对,或文件已损坏。",
17619
+ "backup.exported": "已导出。请记住密码 —— 没有它打不开这个文件。",
17620
+ "backup.exportedWarn": "已导出,有 {count} 条警告。",
17621
+ "backup.exportFailed": "导出失败。",
17622
+ "backup.exportEmpty": "渠道包没有返回内容。",
17623
+ "backup.importFailed": "导入失败。",
17624
+ "backup.confirm": "这会**整体替换**现有 {count} 个账号,继续吗?",
17625
+ "backup.imported": "已导入 {accounts} 个账号、{credentials} 份凭据。",
17626
+ "backup.importedPartial": "已导入 {accounts} 个账号,{skipped} 个被跳过。",
17627
+ /** 余额看板:显示还剩多少,不是花了多少。 */
17628
+ "bal.title": "渠道积分",
17629
+ "bal.refresh": "刷新",
17630
+ "bal.accounts": "{count} 个账号",
17631
+ "bal.morePacks": "还有 {count} 个包",
17632
+ /** 输入框上方的余额条:剩余积分,一行,可展开。 */
17633
+ "bstrip.label": "渠道积分",
17634
+ "bstrip.accounts": "{count} 个账号",
17635
+ "bstrip.hint": "各渠道剩余积分,点开看每个账号的明细。",
17636
+ /** 侧栏积分面板,在账号行上方。 */
17637
+ "rail.title": "渠道积分",
17638
+ "rail.hint": "各渠道剩余积分,点开某一行看账号明细。",
17639
+ "rail.accounts": "{count} 账号",
17640
+ "rail.footCount": "{read}/{all} 账号",
17641
+ "rail.today": "今日已用 {n}",
17642
+ "rail.morePacks": "还有 {count} 个包",
17643
+ "rail.refresh": "刷新",
17644
+ /** 输入框旁的圆点与弹层。 */
17645
+ "dot.alive": "还有积分",
17646
+ "dot.empty": "积分已用完",
17647
+ "dot.panelTitle": "本会话",
17648
+ "dot.channel": "渠道",
17649
+ "dot.spentToday": "今日已用",
17650
+ "dot.model": "模型",
17651
+ "dot.channelUnknown": "未连接渠道",
17652
+ "dot.remaining": "剩余积分",
17653
+ "dot.barLabel": "本期已用占比",
16222
17654
  "gw.keyHint": "这把密钥在 DSH 重启后仍然有效。轮换会让所有还在用旧密钥的设备立刻断线。这里给的也不是账号池自己每进程生成的秘密 —— 客户端要是拿到它,DSH 下次一重启就全废了。",
16223
17655
  "gw.usage": "怎么用",
16224
17656
  "gw.usageHint": "任何 OpenAI 兼容的客户端都行:接口地址填上面那个入口,模型从 /v1/models 里挑,密钥填到这个 API 密钥的位置。中继只放行 /v1/models 和 /v1/chat/completions,其余路径在转发任何字节之前就拒掉。",
@@ -16307,6 +17739,13 @@ window.__ModuleLoader__.load({
16307
17739
  "channelpool.claiming": "领取中…",
16308
17740
  "channelpool.claimResult": "共 {total} 个账号,领取 {claimed} 个:+{credit} {unit}。",
16309
17741
  "channelpool.claimNone": "今天没有可领的({already} 个已领,{inactive} 个无每日积分)。",
17742
+ /** 一次性奖励(商汤的登录奖励、讯飞的新手任务)。 */
17743
+ "channelpool.onboarding": "领取一次性奖励",
17744
+ "channelpool.onboardingHint": "登录奖励 / 新手任务 —— 每个账号只能领一次。",
17745
+ "channelpool.onboardingNoAccounts": "没有可领取的账号。",
17746
+ "channelpool.onboardingNone": "没有可领的了 —— 已经领过。",
17747
+ "channelpool.onboardingFailed": "有 {failed} 个账号领取失败。",
17748
+ "channelpool.onboardingResult": "领到 {claimed} 项:+{credit}({skipped} 个之前已领)。",
16310
17749
  "channelpool.claimFailed": "有 {failed} 个账号领取失败,其余已完成。",
16311
17750
  "channelpool.refreshBalance": "刷新余额",
16312
17751
  "channelpool.refreshAccount": "刷新凭据",
@@ -16416,6 +17855,30 @@ window.__ModuleLoader__.load({
16416
17855
  } catch (error) {
16417
17856
  console.error("[dsh-xdpool] sidebar entry unavailable (settings page unaffected):", error);
16418
17857
  }
17858
+ /**
17859
+ * The credit dot beside the model name, in the composer's own row.
17860
+ *
17861
+ * `conversation.composer.dock` is inside `InputBar`, on the line that already holds
17862
+ * the back button, the model name and the reasoning level. That adjacency is the
17863
+ * point: the dot is a statement about the model it sits next to, so putting it
17864
+ * anywhere else would leave the reader to work out which channel it describes.
17865
+ *
17866
+ * It renders on the `composer` variant only (the host gates it), and does not appear
17867
+ * on the hero screen where no session exists yet.
17868
+ *
17869
+ * Own try/catch: a host line without this slot must not take the settings page down
17870
+ * with it.
17871
+ */
17872
+ try {
17873
+ ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
17874
+ name: "conversation.composer.dock",
17875
+ id: "xdpool-credit-dot",
17876
+ order: 20,
17877
+ inject: () => t === void 0 ? {} : { t }
17878
+ }, CreditDot));
17879
+ } catch (error) {
17880
+ console.error("[dsh-xdpool] composer credit dot unavailable (everything else unaffected):", error);
17881
+ }
16419
17882
  } catch (error) {
16420
17883
  console.error("[dsh-xdpool] client page failed to load (host provider unaffected):", error);
16421
17884
  }