dsh-workbuddy-xdpool 0.4.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -28,6 +28,8 @@ window.__ModuleLoader__.load({
28
28
  const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
29
29
  /** Plugin-owned daily check-in action endpoint (claim today's reward). */
30
30
  const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
31
+ /** Switch one account in or out of the pool (card toggle). */
32
+ const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
31
33
  //#endregion
32
34
  //#region src/client/icon.ts
33
35
  /**
@@ -133,6 +135,13 @@ window.__ModuleLoader__.load({
133
135
  .dsm-workbuddy-xdpool-account{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:14px;background:var(--dsw-alias-bg-layer-2,#24262c)}
134
136
  .dsm-workbuddy-xdpool-account-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
135
137
  .dsm-workbuddy-xdpool-account-label{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:14px;font-weight:600;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
138
+ .dsm-workbuddy-xdpool-account-head{display:flex;align-items:center;gap:8px;min-width:0}
139
+ /* Account switched off on the card: still listed (so it can be turned back on) but visually muted. */
140
+ .dsm-workbuddy-xdpool-account-off{opacity:.55}
141
+ /* Small pill switch: "in rotation" vs "off". Native checkbox styled by the label. */
142
+ .dsm-workbuddy-xdpool-account-toggle{display:inline-flex;align-items:center;gap:5px;flex:none;cursor:pointer;color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:11px;line-height:16px;white-space:nowrap}
143
+ .dsm-workbuddy-xdpool-account-toggle input{margin:0;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
144
+ .dsm-workbuddy-xdpool-account-toggle:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}
136
145
  .dsm-workbuddy-xdpool-account-tags{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
137
146
  .dsm-workbuddy-xdpool-account-tag{padding:1px 8px;border-radius:999px;font-size:11px;line-height:18px;background:var(--dsw-alias-state-success-subtle,rgba(34,160,107,.12));color:var(--dsw-alias-state-success-primary,#22a06b)}
138
147
  .dsm-workbuddy-xdpool-account-tag-cooling{background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.15));color:var(--dsw-alias-state-warning-primary,#d97706)}
@@ -374,12 +383,26 @@ window.__ModuleLoader__.load({
374
383
  const [flash, setFlash] = (0, react.useState)(void 0);
375
384
  /** Account id whose daily claim is currently in flight. */
376
385
  const [checkinBusyId, setCheckinBusyId] = (0, react.useState)(void 0);
386
+ /** Account id whose enable/disable switch is in flight, if any. */
387
+ const [accountBusyId, setAccountBusyId] = (0, react.useState)(void 0);
377
388
  /**
378
389
  * Draft model selection. `undefined` means "no local edits"; once a checkbox
379
390
  * is touched the draft takes over and is what the Save button posts. Discard
380
391
  * drops it back to the copy the server last reported.
381
392
  */
382
- const [draft, setDraft] = (0, react.useState)(void 0);
393
+ const [draftByRegion, setDraftByRegion] = (0, react.useState)({});
394
+ /**
395
+ * The draft for the tab on screen. Keyed by region: the two gateways have
396
+ * different rosters, so edits made on one tab must not leak into the other
397
+ * when the user switches tabs (or saves).
398
+ */
399
+ const draft = draftByRegion[activeRegion];
400
+ const setDraft = (next) => {
401
+ setDraftByRegion((prev) => ({
402
+ ...prev,
403
+ [activeRegion]: next
404
+ }));
405
+ };
383
406
  const [savingModels, setSavingModels] = (0, react.useState)(false);
384
407
  const mounted = (0, react.useRef)(true);
385
408
  (0, react.useEffect)(() => {
@@ -497,6 +520,41 @@ window.__ModuleLoader__.load({
497
520
  if (mounted.current) setCheckinBusyId(void 0);
498
521
  }
499
522
  };
523
+ /**
524
+ * Switch one account in or out of the pool.
525
+ *
526
+ * The id travels in the body and the host validates it against the accounts it
527
+ * really knows, so a stale tab cannot write an orphan id. Disabling only stops
528
+ * the account from being picked — it stays listed so it can be turned back on —
529
+ * and the change is saved through the settings section, so it survives a
530
+ * restart and is re-applied after every re-scan.
531
+ */
532
+ const toggleAccountDisabled = async (accountId, disabled) => {
533
+ setAccountBusyId(accountId);
534
+ setFlash(void 0);
535
+ try {
536
+ const response = await fetch(POOL_ACCOUNT_DISABLE_PATH, {
537
+ method: "POST",
538
+ headers: {
539
+ accept: "application/json",
540
+ "content-type": "application/json"
541
+ },
542
+ credentials: "same-origin",
543
+ body: JSON.stringify({
544
+ accountId,
545
+ disabled
546
+ })
547
+ });
548
+ const body = await response.json().catch(() => void 0);
549
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
550
+ await refresh(activeRegion);
551
+ } catch (cause) {
552
+ const message = cause instanceof Error ? cause.message : String(cause);
553
+ if (mounted.current) setError(t?.("row.accountToggleError", { message }) ?? `Could not switch the account: ${message}`);
554
+ } finally {
555
+ if (mounted.current) setAccountBusyId(void 0);
556
+ }
557
+ };
500
558
  /** Keep the draft in step with the server copy while nothing is dirty. */
501
559
  const modelDraft = draft ?? (status === void 0 ? {} : draftFromStatus(status));
502
560
  /** Model edits need a writable settings scope; otherwise the rows are read-only. */
@@ -600,9 +658,12 @@ window.__ModuleLoader__.load({
600
658
  const imageModelIds = Object.entries(draft).filter(([, e]) => e.images).map(([id]) => id);
601
659
  const contextBudgets = {};
602
660
  for (const [id, entry] of Object.entries(draft)) if (entry.budget !== void 0) contextBudgets[id] = entry.budget;
603
- await write.call(settingsScope, "enabledModelIds", enabledModelIds);
604
- await write.call(settingsScope, "imageModelIds", imageModelIds);
605
- await write.call(settingsScope, "contextBudgets", contextBudgets);
661
+ const key = activeRegion === "cn" ? "modelSelectionCn" : "modelSelectionGlobal";
662
+ await write.call(settingsScope, key, {
663
+ enabledModelIds,
664
+ imageModelIds,
665
+ contextBudgets
666
+ });
606
667
  setDraft(void 0);
607
668
  if (mounted.current) setFlash(t?.("row.modelsSaved") ?? "Saved");
608
669
  } catch (cause) {
@@ -707,17 +768,21 @@ window.__ModuleLoader__.load({
707
768
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
708
769
  className: "dsm-workbuddy-xdpool-dist-title",
709
770
  children: t?.("row.distTitle") ?? "Account usage"
710
- }), ["priority", "round-robin"].map((option) => {
771
+ }), [
772
+ "priority",
773
+ "balanced",
774
+ "round-robin"
775
+ ].map((option) => {
711
776
  const active = (status.distribution ?? "priority") === option;
712
- const label = option === "priority" ? t?.("row.distPriority") ?? "Priority" : t?.("row.distRoundRobin") ?? "Round-robin";
713
- const hint = option === "priority" ? t?.("row.distPriorityHint") ?? "" : t?.("row.distRoundRobinHint") ?? "";
777
+ const label = option === "priority" ? t?.("row.distPriority") ?? "Priority" : option === "balanced" ? t?.("row.distBalanced") ?? "Balanced" : t?.("row.distRoundRobin") ?? "Round-robin";
778
+ const hint = option === "priority" ? t?.("row.distPriorityHint") ?? "" : option === "balanced" ? t?.("row.distBalancedHint") ?? "" : t?.("row.distRoundRobinHint") ?? "";
714
779
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
715
780
  type: "button",
716
781
  role: "radio",
717
782
  "aria-checked": active,
718
783
  title: hint,
719
784
  disabled: !modelsEditable,
720
- className: `dsm-workbuddy-xdpool-dist-option${active ? " dsm-workbuddy-xdpool-dist-option-active" : ""}`,
785
+ className: "dsm-workbuddy-xdpool-dist-option" + (active ? " dsm-workbuddy-xdpool-dist-option-active" : ""),
721
786
  onClick: () => {
722
787
  setDistribution(option);
723
788
  },
@@ -805,6 +870,10 @@ window.__ModuleLoader__.load({
805
870
  onClaimCheckin: (accountId) => {
806
871
  claimCheckin(accountId);
807
872
  },
873
+ onToggleDisabled: (accountId, disabled) => {
874
+ toggleAccountDisabled(accountId, disabled);
875
+ },
876
+ ...accountBusyId === void 0 ? {} : { accountBusyId },
808
877
  t
809
878
  }, account.id))]
810
879
  }) : null,
@@ -865,8 +934,9 @@ window.__ModuleLoader__.load({
865
934
  });
866
935
  }
867
936
  /** One account block: label + status tag + meta + optional credit panels. */
868
- function AccountBlock({ account, activeAccountId, t, checkinBusyId, onClaimCheckin }) {
937
+ function AccountBlock({ account, activeAccountId, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled }) {
869
938
  const isActive = account.id === activeAccountId;
939
+ const isDisabled = account.disabled === true;
870
940
  const isCooling = account.cooling === true;
871
941
  const cooldownUntil = account.cooldownUntil !== void 0 ? Date.parse(account.cooldownUntil) : void 0;
872
942
  const modelCooldowns = account.modelCooldowns ?? [];
@@ -878,13 +948,27 @@ window.__ModuleLoader__.load({
878
948
  cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-cooling"
879
949
  } : null;
880
950
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
881
- className: "dsm-workbuddy-xdpool-account",
951
+ className: isDisabled ? "dsm-workbuddy-xdpool-account dsm-workbuddy-xdpool-account-off" : "dsm-workbuddy-xdpool-account",
882
952
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
883
953
  className: "dsm-workbuddy-xdpool-account-copy",
884
954
  children: [
885
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
886
- className: "dsm-workbuddy-xdpool-account-label",
887
- children: account.label
955
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
956
+ className: "dsm-workbuddy-xdpool-account-head",
957
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
958
+ className: "dsm-workbuddy-xdpool-account-label",
959
+ children: account.label
960
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
961
+ className: "dsm-workbuddy-xdpool-account-toggle",
962
+ title: t?.("row.accountToggleHint") ?? "Include this account in the pool",
963
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
964
+ type: "checkbox",
965
+ checked: !isDisabled,
966
+ disabled: accountBusyId === account.id,
967
+ onChange: (event) => {
968
+ onToggleDisabled(account.id, !event.target.checked);
969
+ }
970
+ }), isDisabled ? t?.("row.accountOff") ?? "Off" : t?.("row.accountInRotation") ?? "In rotation"]
971
+ })]
888
972
  }),
889
973
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
890
974
  className: "dsm-workbuddy-xdpool-account-tags",
@@ -1171,6 +1255,10 @@ window.__ModuleLoader__.load({
1171
1255
  "row.ok": "Healthy — requests auto-rotate across accounts",
1172
1256
  "row.allCooling": "Every account is rate-limited right now; requests pause until a cooldown lifts.",
1173
1257
  "row.accountNext": "Next up",
1258
+ "row.accountInRotation": "In rotation",
1259
+ "row.accountOff": "Off",
1260
+ "row.accountToggleHint": "Include this account in the pool",
1261
+ "row.accountToggleError": "Could not switch the account: {message}",
1174
1262
  "row.cooling": "Cooling (rate-limited)",
1175
1263
  "row.cooldownHits": "{hits} hit(s)",
1176
1264
  "row.cooldownUntil": "until {time}",
@@ -1223,6 +1311,8 @@ window.__ModuleLoader__.load({
1223
1311
  "row.distPriorityHint": "Drain one account before moving to the next",
1224
1312
  "row.distRoundRobin": "Round-robin",
1225
1313
  "row.distRoundRobinHint": "Spread the spend evenly across accounts",
1314
+ "row.distBalanced": "Balanced",
1315
+ "row.distBalancedHint": "Draw from every account, favouring whichever has been idle longest",
1226
1316
  "row.accountsScanning": "Detecting…",
1227
1317
  "row.resetCooldowns": "Clear all cooldowns",
1228
1318
  "row.resetCooldownsBusy": "Clearing…",
@@ -1257,6 +1347,10 @@ window.__ModuleLoader__.load({
1257
1347
  "row.ok": "运行健康 —— 请求会在各账号间自动轮换",
1258
1348
  "row.allCooling": "当前所有账号都处于限流冷却,请求会暂停直到某个冷却结束。",
1259
1349
  "row.accountNext": "下一个",
1350
+ "row.accountInRotation": "参与轮换",
1351
+ "row.accountOff": "已停用",
1352
+ "row.accountToggleHint": "把这个账号计入池子",
1353
+ "row.accountToggleError": "切换账号失败:{message}",
1260
1354
  "row.cooling": "冷却中(被限流)",
1261
1355
  "row.cooldownHits": "触发 {hits} 次",
1262
1356
  "row.cooldownUntil": "至 {time}",
@@ -1309,6 +1403,8 @@ window.__ModuleLoader__.load({
1309
1403
  "row.distPriorityHint": "优先用完一个账号再换下一个",
1310
1404
  "row.distRoundRobin": "轮换模式",
1311
1405
  "row.distRoundRobinHint": "积分平均分摊到各账号",
1406
+ "row.distBalanced": "均衡",
1407
+ "row.distBalancedHint": "每个账号都参与抽取,闲置越久权重越高",
1312
1408
  "row.accountsScanning": "正在检测…",
1313
1409
  "row.resetCooldowns": "清除所有冷却",
1314
1410
  "row.resetCooldownsBusy": "正在清除…",
package/lib/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
2
  import { Api, Model } from "@earendil-works/pi-ai";
3
3
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
4
- import { Context } from "@deepseek-ai/cordis";
4
+ import { Context, Context as Context$1 } from "@deepseek-ai/cordis";
5
5
  import { SettingsNamespace } from "@deepseek-ai/dsh-settings";
6
6
  //#region src/upstream.d.ts
7
7
  /** Upstream failure classes the shim maps onto distinct HTTP answers. */
@@ -228,7 +228,7 @@ export declare function workbuddyAccountId(credential: Pick<WorkBuddyCredential,
228
228
  /** Every directory the pool should scan, in probe order. */
229
229
  export declare function candidateAuthDirs(env?: NodeJS.ProcessEnv): string[];
230
230
  /** How the pool chooses which account serves the next request. */
231
- type AccountDistribution = 'priority' | 'round-robin';
231
+ type AccountDistribution = 'priority' | 'round-robin' | 'balanced';
232
232
  interface AccountPoolOptions {
233
233
  /** Logger for discovery and rotation events. */
234
234
  logger?: {
@@ -256,10 +256,6 @@ interface AccountPoolOptions {
256
256
  */
257
257
  distribution?: AccountDistribution;
258
258
  }
259
- /**
260
- * Read-only pool of every discovered WorkBuddy account, with rate-limit
261
- * cooldown and round-robin failover.
262
- */
263
259
  export declare class WorkBuddyAccountPool {
264
260
  private readonly logger;
265
261
  private authDirs;
@@ -272,6 +268,24 @@ export declare class WorkBuddyAccountPool {
272
268
  private cursor;
273
269
  private lastScanAtMs;
274
270
  private preferredId;
271
+ /**
272
+ * Account ids the user switched off on the card.
273
+ *
274
+ * Disabling is a user preference rather than a property of the credential:
275
+ * `scan()` rebuilds every account object from the auth files, so the set
276
+ * lives on the pool and is re-applied from settings after each scan.
277
+ */
278
+ private disabledIds;
279
+ /**
280
+ * Last time each account served a request, epoch ms. Drives the idle term
281
+ * of the priority-mode weighting below: an account that just served loses to
282
+ * one that has been idle, so a small pool stops hammering a single account.
283
+ *
284
+ * In-memory on purpose: it only biases the next pick, so a cold start that
285
+ * treats every account as idle is the right default. Not keyed by id lookup
286
+ * misses because a removed account simply disappears from the map on re-scan.
287
+ */
288
+ private lastUsedAt;
275
289
  private refreshInflight;
276
290
  constructor(options?: AccountPoolOptions);
277
291
  /**
@@ -283,6 +297,7 @@ export declare class WorkBuddyAccountPool {
283
297
  authDirs?: readonly string[];
284
298
  cooldownMs?: number;
285
299
  distribution?: AccountDistribution;
300
+ disabledAccountIds?: readonly string[];
286
301
  }): void;
287
302
  /** Rescan the auth directories and merge newly discovered accounts. */
288
303
  scan(): Promise<WorkBuddyAccount[]>;
@@ -298,6 +313,22 @@ export declare class WorkBuddyAccountPool {
298
313
  * model, e.g. CLI diagnostics).
299
314
  */
300
315
  private available;
316
+ /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
317
+ private pickRoundRobin;
318
+ /**
319
+ * Priority mode: weighted random over the eligible accounts.
320
+ *
321
+ * The weight is an idle bonus — `1 + min(idleHours * perHour, max)` — so an
322
+ * account that has never served (or has been idle for a while) outranks one
323
+ * that just answered. Reference panel logic drops its success-rate term
324
+ * entirely because a lifetime error counter penalises an account forever;
325
+ * instantaneous health is already handled by cooldowns, which is why those
326
+ * accounts never reach this list.
327
+ *
328
+ * A pool with no idle history (fresh process) hashes to equal weights, which
329
+ * spreads the very first picks instead of always returning index 0.
330
+ */
331
+ private pickByWeight;
301
332
  /**
302
333
  * Pick the account to serve a request.
303
334
  *
@@ -323,6 +354,10 @@ export declare class WorkBuddyAccountPool {
323
354
  /** How the pool currently spreads requests. Shown on the card. */
324
355
  currentDistribution(): AccountDistribution;
325
356
  prefer(accountId: string | undefined): void;
357
+ /** Whether the user switched this account off on the card. */
358
+ isDisabled(accountId: string): boolean;
359
+ /** Every account id the user switched off, in discovery order. */
360
+ disabledIdsInOrder(): string[];
326
361
  /** Best-effort refresh of one account after a session-dead upstream answer. */
327
362
  refreshAccount(accountId: string): Promise<void>;
328
363
  /**
@@ -445,6 +480,8 @@ export declare const WORKBUDDY_POOL_PROVIDER = "workbuddy-xdpool";
445
480
  interface WorkBuddyAdapterOptions {
446
481
  shim: WorkBuddyShim;
447
482
  catalog: WorkBuddyCatalog;
483
+ /** Plugin context; the pi-ai adapter reads `attachments`/`fs` from it. */
484
+ ctx: Context$1;
448
485
  providerId?: string;
449
486
  displayName?: string;
450
487
  }
@@ -570,6 +607,11 @@ interface PoolWebAccount {
570
607
  modelId: string;
571
608
  until: string;
572
609
  }>;
610
+ /**
611
+ * Whether the user switched this account off. A disabled account never
612
+ * serves a request, but it stays listed so the card can switch it back on.
613
+ */
614
+ disabled: boolean;
573
615
  rateLimitHits: number;
574
616
  /** ISO timestamp of the last successful use (best-effort pool bookkeeping). */
575
617
  lastUsedAt?: string;
@@ -655,7 +697,6 @@ interface PoolWebModel {
655
697
  /** Whether this model is currently enabled in the picker. */
656
698
  enabled: boolean;
657
699
  }
658
- /** The user's saved model selection, echoed back so the card can diff a draft. */
659
700
  interface PoolWebModelSelection {
660
701
  /** Absent = every model is enabled. */
661
702
  enabledModelIds?: readonly string[];
@@ -695,7 +736,7 @@ interface PoolWebStatus {
695
736
  */
696
737
  type PoolRegion = 'cn' | 'global';
697
738
  /** How the pool spreads requests across its accounts. */
698
- type PoolDistribution = 'priority' | 'round-robin';
739
+ type PoolDistribution = 'priority' | 'round-robin' | 'balanced';
699
740
  //#endregion
700
741
  //#region src/index.d.ts
701
742
  /** Stable Cordis plugin name. */
@@ -723,6 +764,13 @@ export interface Config {
723
764
  * spend evenly instead. Absent reads as `priority`.
724
765
  */
725
766
  distribution?: 'priority' | 'round-robin';
767
+ /**
768
+ * Account ids switched off on the card. A disabled account is never picked
769
+ * to serve a request, but it stays in the pool and on the card so it can be
770
+ * switched back on. Ids are the pool's stable per-credential keys, which
771
+ * survive re-scans (see WorkBuddyAccountPool.disabledIds).
772
+ */
773
+ disabledAccountIds?: string[];
726
774
  /**
727
775
  * Model ids enabled in the picker. Absent means "every model the catalog
728
776
  * advertises" — an unconfigured install should never present an empty model
@@ -741,9 +789,25 @@ export interface Config {
741
789
  * user cap a model without touching the catalog.
742
790
  */
743
791
  contextBudgets?: Record<string, number>;
792
+ /**
793
+ * Per-region model selection. The two gateways advertise different rosters, so
794
+ * one shared list would let a save on one tab silently rewrite the other tab's
795
+ * picker. Each region owns its own copy; a region with no entry falls back to
796
+ * the legacy flat keys above, so an upgrade keeps the list already in use.
797
+ */
798
+ modelSelectionCn?: ModelSelectionConfig;
799
+ modelSelectionGlobal?: ModelSelectionConfig;
800
+ }
801
+ /** One region's saved model selection. */
802
+ export interface ModelSelectionConfig {
803
+ enabledModelIds?: string[];
804
+ imageModelIds?: string[];
805
+ contextBudgets?: Record<string, number>;
744
806
  }
745
807
  /** Upper bound the card offers as the "default" context window, in tokens. */
746
808
  export declare const DEFAULT_CONTEXT_BUDGET = 200000;
809
+ /** Settings key holding one region's saved selection. */
810
+ export declare const modelSelectionKeyFor: (region: 'cn' | 'global') => string;
747
811
  /**
748
812
  * Plugin configuration schema.
749
813
  *