dsh-xdpool 2.6.1 → 2.6.3
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/CHANGELOG.md +110 -6
- package/lib/bin.js +271 -18
- package/lib/client.js +1496 -33
- package/lib/index.d.ts +86 -2
- package/lib/index.js +874 -20
- package/package.json +1 -1
- package/vendor/channel-pack/pack.js +91 -2
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:
|
|
@@ -1378,6 +1389,12 @@ window.__ModuleLoader__.load({
|
|
|
1378
1389
|
.dsm-xdpool-gw-model input:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
|
|
1379
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}
|
|
1380
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}
|
|
1381
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)}
|
|
1382
1399
|
.dsm-xdpool-gw-btn:active:not(:disabled){transform:translateY(0);box-shadow:inset 0 2px 4px rgb(0 0 0 / 22%)}
|
|
1383
1400
|
.dsm-xdpool-gw-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:2px}
|
|
@@ -1799,6 +1816,92 @@ window.__ModuleLoader__.load({
|
|
|
1799
1816
|
.dsm-xdpool-page > .dsm-xdpool-shell{margin:0 auto}
|
|
1800
1817
|
@media (max-width:720px){.dsm-xdpool-page{padding:14px 16px 26px}}
|
|
1801
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
|
+
`;
|
|
1802
1905
|
//#endregion
|
|
1803
1906
|
//#region src/client/avatar.ts
|
|
1804
1907
|
/**
|
|
@@ -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.
|
|
11246
|
-
|
|
11247
|
-
|
|
11248
|
-
|
|
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: [
|
|
11265
|
-
|
|
11266
|
-
|
|
11267
|
-
|
|
11268
|
-
|
|
11269
|
-
|
|
11270
|
-
|
|
11271
|
-
|
|
11272
|
-
|
|
11273
|
-
|
|
11274
|
-
|
|
11275
|
-
|
|
11276
|
-
|
|
11277
|
-
|
|
11278
|
-
|
|
11279
|
-
|
|
11280
|
-
|
|
11281
|
-
|
|
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)("
|
|
11367
|
-
|
|
11368
|
-
|
|
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.
|
|
13463
|
+
const POOL_VERSION = "2.6.3";
|
|
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
|
}
|