dsh-connect-workbuddy 2.3.1 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.en.md +59 -0
- package/README.md +60 -0
- package/docs/assets/account-pool-demo.html +986 -0
- package/docs/assets/dsh-connect-workbuddy-account-pool.png +0 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +1462 -18
- package/lib/{host-heartbeat-CDZRWbFv.js → host-heartbeat-CfXJht7R.js} +29 -1
- package/lib/index.d.ts +340 -17
- package/lib/index.js +1085 -23
- package/package.json +13 -10
- package/screenshots.json +2 -1
package/lib/client.js
CHANGED
|
@@ -37,6 +37,25 @@ window.__ModuleLoader__.load({
|
|
|
37
37
|
* browser half.
|
|
38
38
|
*/
|
|
39
39
|
const WORKBUDDY_PROBE_PATH = "/plugins/dsh-connect-workbuddy/probe";
|
|
40
|
+
/**
|
|
41
|
+
* Plugin-owned account-pool endpoint: one batch action per call.
|
|
42
|
+
*
|
|
43
|
+
* `?action=checkin` claims the daily reward for every account of a region;
|
|
44
|
+
* `?action=test` probes every account against the region's target model. Both
|
|
45
|
+
* are POST and loopback-only because both cost something real — check-in writes
|
|
46
|
+
* to the user's account, a probe spends credits.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately ONE endpoint rather than two: the two actions share every
|
|
49
|
+
* guard (method, origin, region, pool-enabled) and differ only in the work, so
|
|
50
|
+
* splitting them would duplicate the guards and invite them to drift apart.
|
|
51
|
+
*/
|
|
52
|
+
const WORKBUDDY_POOL_PATH = "/plugins/dsh-connect-workbuddy/pool";
|
|
53
|
+
/** Query parameter selecting which pool batch action a request means. */
|
|
54
|
+
const WORKBUDDY_POOL_ACTION_PARAM = "action";
|
|
55
|
+
/** Address one region's pool endpoint with an action. */
|
|
56
|
+
function withWorkBuddyRegionAndAction(path, region, action) {
|
|
57
|
+
return `${withWorkBuddyRegion(path, region)}&${WORKBUDDY_POOL_ACTION_PARAM}=${action}`;
|
|
58
|
+
}
|
|
40
59
|
/** Query parameter naming the region a card request addresses. */
|
|
41
60
|
const WORKBUDDY_REGION_PARAM = "region";
|
|
42
61
|
/** Every region, in card tab order. */
|
|
@@ -113,6 +132,33 @@ window.__ModuleLoader__.load({
|
|
|
113
132
|
* value is always the `regions` map, i.e. exactly what `settingsScope.set(
|
|
114
133
|
* 'regions', ...)` needs.
|
|
115
134
|
*/
|
|
135
|
+
/**
|
|
136
|
+
* Build the next `regions` settings value for a MODEL-LIST save.
|
|
137
|
+
*
|
|
138
|
+
* The model save replaces a region slot wholesale, so the merge has to start
|
|
139
|
+
* from the EXISTING slot rather than from a fresh object: the pool's
|
|
140
|
+
* preferences live in that same slot, and a save that omitted them would delete
|
|
141
|
+
* settings the user configured elsewhere in the card. Every writer that owns
|
|
142
|
+
* only part of a slot goes through a helper like this one.
|
|
143
|
+
*/
|
|
144
|
+
function nextRegionModels(value, region, models) {
|
|
145
|
+
return nextRegionSlots(regionsMapOf(value), region, {
|
|
146
|
+
...regionSlotOf(value, region),
|
|
147
|
+
...models
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Build the next `regions` settings value for a POOL-PREFERENCES save.
|
|
152
|
+
*
|
|
153
|
+
* Same reasoning as {@link nextRegionModels}: the pool is one field of a slot
|
|
154
|
+
* that also holds the model list, so the merge starts from the existing slot.
|
|
155
|
+
*/
|
|
156
|
+
function nextRegionPool(value, region, pool) {
|
|
157
|
+
return nextRegionSlots(regionsMapOf(value), region, {
|
|
158
|
+
...regionSlotOf(value, region),
|
|
159
|
+
pool
|
|
160
|
+
});
|
|
161
|
+
}
|
|
116
162
|
function nextRegionEnabled(value, region, enabled) {
|
|
117
163
|
return nextRegionSlots(regionsMapOf(value), region, {
|
|
118
164
|
...regionSlotOf(value, region),
|
|
@@ -218,6 +264,31 @@ window.__ModuleLoader__.load({
|
|
|
218
264
|
}
|
|
219
265
|
};
|
|
220
266
|
/**
|
|
267
|
+
* Whether a save failure is a FILE-CONTENTION refusal.
|
|
268
|
+
*
|
|
269
|
+
* On Windows, replacing a file that another process holds open fails with
|
|
270
|
+
* `EPERM` / `EBUSY` / `EACCES` — an antivirus scanner, a sync client (OneDrive),
|
|
271
|
+
* or an editor with the profile's patch file open. POSIX `rename` replaces
|
|
272
|
+
* outright regardless of open handles, so this branch effectively never fires
|
|
273
|
+
* on macOS/Linux: the diagnosis is Windows-shaped because the failure is.
|
|
274
|
+
*
|
|
275
|
+
* It exists so a save failure can say WHAT TO DO. Without it the card pasted the
|
|
276
|
+
* raw error at the user — `EPERM: operation not permitted, rename
|
|
277
|
+
* 'C:\…\cordis.patch.yml.79498ff8fb27.tmp' -> '…\cordis.patch.yml'` — which
|
|
278
|
+
* names a temp path and no remedy. Measured live against the 3.0.0 host with the
|
|
279
|
+
* file held open: both the `regions` and `accounts` writes came back HTTP 500
|
|
280
|
+
* with exactly that text.
|
|
281
|
+
*
|
|
282
|
+
* Deliberately matched on the message rather than on `WorkBuddySettingsWriteError`
|
|
283
|
+
* alone: a validation refusal is also that class but has nothing to do with file
|
|
284
|
+
* contention, and telling such a user to close their editor would send them
|
|
285
|
+
* chasing a cause that is not there.
|
|
286
|
+
*/
|
|
287
|
+
function isFileContentionWriteError(error) {
|
|
288
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
289
|
+
return /\b(?:EPERM|EBUSY|EACCES)\b/u.test(message);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
221
292
|
* Read one settings field's current object value from the scope snapshot.
|
|
222
293
|
*
|
|
223
294
|
* The snapshot holds the RESOLVED section, where a volatile field
|
|
@@ -371,8 +442,9 @@ window.__ModuleLoader__.load({
|
|
|
371
442
|
* @throws {WorkBuddySettingsWriteError} when the catalog does not read back.
|
|
372
443
|
*/
|
|
373
444
|
async function writeRegionModels(scope, region, payload) {
|
|
445
|
+
const next = nextRegionModels(scope.getSnapshot().value, region, payload)[region];
|
|
374
446
|
const written = payload.lastCatalog.map((model) => model.id);
|
|
375
|
-
await writeField(scope, "regions", region,
|
|
447
|
+
await writeField(scope, "regions", region, next, (readBack) => {
|
|
376
448
|
const stored = readBack?.lastCatalog?.map((model) => model.id);
|
|
377
449
|
return stored !== void 0 && stored.length === written.length && stored.every((id, index) => id === written[index]);
|
|
378
450
|
});
|
|
@@ -409,6 +481,1004 @@ window.__ModuleLoader__.load({
|
|
|
409
481
|
return stored !== null && stored !== void 0 && stored["enabled"] === enabled;
|
|
410
482
|
});
|
|
411
483
|
}
|
|
484
|
+
/**
|
|
485
|
+
* Write one region's account-pool PREFERENCES, verifying they landed.
|
|
486
|
+
*
|
|
487
|
+
* Routed through {@link writeField} like every other settings write, so it gets
|
|
488
|
+
* the same Host-endpoint-first path and the same landed check. The card treats
|
|
489
|
+
* a resolved write as "the draft is now safe to discard", so a write that
|
|
490
|
+
* silently did nothing would throw away the user's edits while reporting
|
|
491
|
+
* success — unrecoverable, since the draft is the only copy.
|
|
492
|
+
*
|
|
493
|
+
* The COMPLETE region slot is passed in by the caller for the same reason
|
|
494
|
+
* {@link writeRegionEnabled} does it: the pool lives INSIDE the region slot
|
|
495
|
+
* alongside `lastCatalog`/`enabledModelIds`, so writing only the pool field
|
|
496
|
+
* would drop the model list.
|
|
497
|
+
*
|
|
498
|
+
* Deliberately does NOT carry the pool's measured facts. Those are written by
|
|
499
|
+
* the Host to its own file; keeping them out of settings is what stops a save
|
|
500
|
+
* from rolling back a test result the timer had just recorded.
|
|
501
|
+
*
|
|
502
|
+
* @param scope - the bound settings scope for this plugin's namespace.
|
|
503
|
+
* @param region - the region whose pool preferences are written.
|
|
504
|
+
* @param preferences - the four preference fields, as the user set them.
|
|
505
|
+
* @param slot - the region's COMPLETE next slot (caller owns the merge).
|
|
506
|
+
* @throws {WorkBuddySettingsWriteError} when neither writer persists the value.
|
|
507
|
+
*/
|
|
508
|
+
async function writePoolPreferences(scope, region, preferences) {
|
|
509
|
+
const next = nextRegionPool(scope.getSnapshot().value, region, preferences)[region];
|
|
510
|
+
await writeField(scope, "regions", region, next, (readBack) => {
|
|
511
|
+
const pool = readBack?.pool;
|
|
512
|
+
if (pool === void 0 || pool === null) return false;
|
|
513
|
+
if (pool["enabled"] !== preferences.enabled || pool["rotateByCredits"] !== preferences.rotateByCredits || pool["autoTestIntervalMinutes"] !== preferences.autoTestIntervalMinutes || pool["targetModelId"] !== preferences.targetModelId) return false;
|
|
514
|
+
const stored = pool["memberAccountIds"];
|
|
515
|
+
if (!Array.isArray(stored) || stored.length !== preferences.memberAccountIds.length) return false;
|
|
516
|
+
const wanted = new Set(preferences.memberAccountIds);
|
|
517
|
+
return stored.every((id) => typeof id === "string" && wanted.has(id));
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/account-pool.ts
|
|
522
|
+
/**
|
|
523
|
+
* The checked ids that still resolve to a local sign-in, in the caller's order.
|
|
524
|
+
*
|
|
525
|
+
* This is THE definition of "effective members", and it exists because the same
|
|
526
|
+
* predicate was written five separate times: the status document
|
|
527
|
+
* (`web-status.ts`), the batch route's empty-pool guard, the Host dependency the
|
|
528
|
+
* card reads, `poolMemberAccounts` and the browser helper. Five copies of one
|
|
529
|
+
* rule is this project's single most productive bug shape ("fixed one end,
|
|
530
|
+
* missed the other"), so the rule now has one home and every site delegates to
|
|
531
|
+
* it.
|
|
532
|
+
*
|
|
533
|
+
* ORDER IS THE CALLER'S, and comes from `orderedIds`: the answer preserves the
|
|
534
|
+
* order of THAT argument. Callers pass whichever list's order they want to show
|
|
535
|
+
* — the saved list for the user's own order, the store's roster for the account
|
|
536
|
+
* table's. Neither is silently reshuffled by the filter. (The first parameter was
|
|
537
|
+
* once named `saved`, which read as "this must be the saved list" even though
|
|
538
|
+
* `web-status.ts` legitimately passes the roster for store order; the name was
|
|
539
|
+
* the trap, so it now describes the ROLE instead of the source.)
|
|
540
|
+
*
|
|
541
|
+
* Pure, so it is unit-testable without a store, and it never mutates its input.
|
|
542
|
+
*/
|
|
543
|
+
function effectiveMembersOf(orderedIds, listedIds) {
|
|
544
|
+
return orderedIds.filter((id) => listedIds.has(id));
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* A latest-wins guard for one region's asynchronous work.
|
|
548
|
+
*
|
|
549
|
+
* Exists because `applyRotation` awaits (it fetches credits per member), so two
|
|
550
|
+
* overlapping calls can interleave: a slow call that read "rotation is ON" can
|
|
551
|
+
* resume AFTER a newer call cleared the override for "rotation is OFF" and write
|
|
552
|
+
* the stale account back — leaving the card showing the pool switched off while
|
|
553
|
+
* a rotated account is still billed.
|
|
554
|
+
*
|
|
555
|
+
* `begin()` claims a new generation and returns a probe; the caller must check
|
|
556
|
+
* it after EVERY await and bail when it reports stale. Extracted as a pure
|
|
557
|
+
* factory rather than inlined so the rule is unit-testable: an inlined token is
|
|
558
|
+
* only observable by reproducing a real interleaving, which is slow and easy to
|
|
559
|
+
* get subtly wrong (a harness that never actually overlaps passes either way).
|
|
560
|
+
*/
|
|
561
|
+
function createLatestWins() {
|
|
562
|
+
const generations = {};
|
|
563
|
+
return { begin(key) {
|
|
564
|
+
const generation = (generations[key] ?? 0) + 1;
|
|
565
|
+
generations[key] = generation;
|
|
566
|
+
return () => generations[key] !== generation;
|
|
567
|
+
} };
|
|
568
|
+
}
|
|
569
|
+
//#endregion
|
|
570
|
+
//#region src/client/pool-state.ts
|
|
571
|
+
/**
|
|
572
|
+
* Pure decision helpers for the account pool's card section.
|
|
573
|
+
*
|
|
574
|
+
* 参考:`src/account-pool.ts` 的做法——把「判定规则」从组件里抽出来,让规则本身
|
|
575
|
+
* 可被单测直接钉住,而不是只能通过渲染整棵组件树间接观察。
|
|
576
|
+
* 改动:新增本模块,收纳三组此前内联在 `AccountPool.tsx` 里的判定。抽出它们的
|
|
577
|
+
* 直接原因是**独立验证发现 H-1/H-2 两个最高危修复没有任何自动化回归测试**:
|
|
578
|
+
* 内联在组件里就只能靠真实渲染验证,而渲染测试既慢又容易被隔离问题骗过。
|
|
579
|
+
*
|
|
580
|
+
* 本模块不 import React、不碰网络,纯函数、全函数。
|
|
581
|
+
*
|
|
582
|
+
* @module dsh-connect-workbuddy/client/pool-state
|
|
583
|
+
*/
|
|
584
|
+
/**
|
|
585
|
+
* Whether rotation currently owns the region's billing choice, and whether the
|
|
586
|
+
* user has drafted turning it off.
|
|
587
|
+
*
|
|
588
|
+
* `locked` reads the COMMITTED preferences, never the draft. The Host decides
|
|
589
|
+
* who is billed from the saved config, so a draft-based lock let one click of
|
|
590
|
+
* "turn off rotation" re-enable the manual dropdown and hide the conflict
|
|
591
|
+
* notice while the Host kept rotating — the user then picked an account, the
|
|
592
|
+
* card showed it as current, and a different account was billed. That is
|
|
593
|
+
* exactly the failure the lock exists to prevent.
|
|
594
|
+
*
|
|
595
|
+
* `unlockPending` is the honest intermediate state: the draft says "stop
|
|
596
|
+
* rotating" but the Host has not been told yet, so the lock still applies and
|
|
597
|
+
* the UI must say so rather than looking broken.
|
|
598
|
+
*/
|
|
599
|
+
function rotationLockState(saved, active) {
|
|
600
|
+
const locked = saved.enabled && saved.rotateByCredits;
|
|
601
|
+
return {
|
|
602
|
+
locked,
|
|
603
|
+
unlockPending: locked && !(active.enabled && active.rotateByCredits)
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* The base a draft edit starts from.
|
|
608
|
+
*
|
|
609
|
+
* Resolved at EDIT time rather than captured when the callback was built. The
|
|
610
|
+
* defect this fixes: the callback's dependency list named individual scalar
|
|
611
|
+
* fields, so a field added later (`memberAccountIds`) was missing from it and
|
|
612
|
+
* the callback kept a STALE base — checking one account then silently dropped
|
|
613
|
+
* another that had been saved in between.
|
|
614
|
+
*
|
|
615
|
+
* `previous` is the draft already in state; it only applies to the region it
|
|
616
|
+
* was made on, because the two pools are independent and one region's unsaved
|
|
617
|
+
* edits must never seed the other's.
|
|
618
|
+
*/
|
|
619
|
+
function draftBaseFor(previous, previousRegion, region, saved) {
|
|
620
|
+
return previous !== null && previousRegion === region ? previous : saved;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Membership that resolves to a real local sign-in — what a batch runs on.
|
|
624
|
+
*
|
|
625
|
+
* The HOST computes this and sends it; this helper only chooses which answer to
|
|
626
|
+
* trust. Counting the raw saved list is what let the UI claim "已选 1 / 共 1"
|
|
627
|
+
* and enable both buttons while the batch ran on ZERO accounts and reported
|
|
628
|
+
* success, so:
|
|
629
|
+
*
|
|
630
|
+
* - while the draft is CLEAN, the Host's answer is authoritative (it reflects
|
|
631
|
+
* what would actually run, including sign-ins that have since disappeared);
|
|
632
|
+
* - while the draft is DIRTY, it must be derived from the edited list, since
|
|
633
|
+
* the Host's answer describes the SAVED state and would ignore the edit the
|
|
634
|
+
* user is looking at.
|
|
635
|
+
*/
|
|
636
|
+
function effectiveMemberIds(input) {
|
|
637
|
+
if (!input.dirty) return [...input.savedEffective];
|
|
638
|
+
return effectiveMembersOf(input.draftMembers, input.listedIds);
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Saved member ids with no matching sign-in.
|
|
642
|
+
*
|
|
643
|
+
* Surfaced rather than silently ignored: the ids remain in the user's saved
|
|
644
|
+
* list (a login can come back), so the card has to explain why the pool behaves
|
|
645
|
+
* as though they were absent.
|
|
646
|
+
*/
|
|
647
|
+
function ghostMemberIds(draftMembers, listedIds) {
|
|
648
|
+
return draftMembers.filter((id) => !listedIds.has(id));
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* How many accounts a batch will really touch, as the log announces it.
|
|
652
|
+
*
|
|
653
|
+
* Mirrors the HOST's own resolution exactly — `effective ?? saved` in the pool
|
|
654
|
+
* route — because the number in the activity log must describe what the Host
|
|
655
|
+
* runs, not what the card happens to display. Two wrong answers are possible and
|
|
656
|
+
* both were live at different times:
|
|
657
|
+
*
|
|
658
|
+
* - counting the raw SAVED list overstates it, so a ghost-only pool logged
|
|
659
|
+
* "checking in 1 account(s)" and was then refused with 409 having touched
|
|
660
|
+
* nothing;
|
|
661
|
+
* - counting the DRAFT-derived set announces unsaved edits the Host will not
|
|
662
|
+
* run (it reads the committed config).
|
|
663
|
+
*
|
|
664
|
+
* `savedEffective` is `undefined` only for a Host too old to send it, where the
|
|
665
|
+
* saved list is the best available answer — the same fallback the route uses.
|
|
666
|
+
*/
|
|
667
|
+
function announcedBatchCount(input) {
|
|
668
|
+
return (input.savedEffective ?? input.savedMembers).length;
|
|
669
|
+
}
|
|
670
|
+
const POOL_INTERVAL_MAX = 1440;
|
|
671
|
+
/**
|
|
672
|
+
* Localize a pool route failure from its structured cause.
|
|
673
|
+
*
|
|
674
|
+
* Lives here, not in `AccountPool.tsx`, because the card's module graph pulls
|
|
675
|
+
* DSH's browser packages and there is no jsdom in this project — a rule left
|
|
676
|
+
* inside the component cannot be tested at all. That is not hypothetical: the
|
|
677
|
+
* independent verification deleted this function's `no-live-members` branch AND
|
|
678
|
+
* both locale copies of its copy, and the whole 618-test suite still passed.
|
|
679
|
+
* Extracting it is what lets `tests/pool-state.spec.ts` pin every branch.
|
|
680
|
+
*
|
|
681
|
+
* Returns undefined for an unknown cause so the caller can fall back to the
|
|
682
|
+
* Host's own words — a new cause added upstream must still be visible rather
|
|
683
|
+
* than swallowed into a generic message.
|
|
684
|
+
*
|
|
685
|
+
* `detail` is the Host's raw `error` text, used only where the sentence has a
|
|
686
|
+
* slot for it (`pool-failed`). It is NOT the primary path: echoing English
|
|
687
|
+
* developer prose into a Chinese UI was the defect this function exists to
|
|
688
|
+
* prevent, and only the cases with no structured cause fall back to it.
|
|
689
|
+
*/
|
|
690
|
+
function poolErrorText(t, reason, detail) {
|
|
691
|
+
switch (reason) {
|
|
692
|
+
case "pool-disabled": return t("row.poolErrDisabled");
|
|
693
|
+
case "no-members": return t("row.poolErrNoMembers");
|
|
694
|
+
case "no-live-members": return t("row.poolErrNoLiveMembers");
|
|
695
|
+
case "no-free-model": return t("row.poolErrNoFreeModel");
|
|
696
|
+
case "target-model-stale": return t("row.poolErrStaleModel");
|
|
697
|
+
case "pool-unavailable": return t("row.poolErrUnavailable");
|
|
698
|
+
case "pool-failed": return t("row.poolErrFailed", { message: detail ?? "" });
|
|
699
|
+
default: return;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* The message to show for a failed pool request, from every signal available.
|
|
704
|
+
*
|
|
705
|
+
* The three-tier rule, in order, and why each tier exists:
|
|
706
|
+
*
|
|
707
|
+
* 1. the STRUCTURED cause, localized — the Host's `error` is an English
|
|
708
|
+
* developer sentence, and echoing it put untranslated prose in a Chinese UI;
|
|
709
|
+
* 2. the Host's raw text, for a cause this build does not know — still more
|
|
710
|
+
* informative than a generic message, and it keeps a new upstream cause
|
|
711
|
+
* visible rather than swallowed;
|
|
712
|
+
* 3. a message keyed on the STATUS — every pool exit that carries no reason
|
|
713
|
+
* (405/403/400 and the generic 500) otherwise reached the user as bare
|
|
714
|
+
* English like `HTTP 405`.
|
|
715
|
+
*
|
|
716
|
+
* Extracted from the card for the same reason as {@link poolErrorText}: a rule
|
|
717
|
+
* inside the browser-only `.tsx` cannot be tested, and the status tier in
|
|
718
|
+
* particular had no guard at all — reverting it to `` `HTTP ${status}` `` left
|
|
719
|
+
* the entire suite green.
|
|
720
|
+
*/
|
|
721
|
+
function poolFailureText(t, input) {
|
|
722
|
+
return poolErrorText(t, input.reason, input.error) ?? input.error ?? t("row.poolErrHttp", { status: String(input.status) });
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Parse an in-progress interval edit.
|
|
726
|
+
*
|
|
727
|
+
* Returns `undefined` while the text is not yet a plain non-negative integer —
|
|
728
|
+
* empty, `-`, `12e`, `1.5` — so the caller can leave EXACTLY what the user typed
|
|
729
|
+
* on screen instead of rewriting the control from a parsed number.
|
|
730
|
+
*
|
|
731
|
+
* That rewriting was the defect this replaces: the field clamped on every
|
|
732
|
+
* keystroke and wrote the clamped result back into a controlled `value`, so
|
|
733
|
+
* typing `120` produced `520` (each keystroke's clamped `5` was rendered, and the
|
|
734
|
+
* next digit appended to it) and clearing the field immediately snapped it to
|
|
735
|
+
* `5`. The user's original request was "设置一个时间间隔自动测试" — a field that
|
|
736
|
+
* cannot be typed into is that requirement unmet, not a cosmetic wart.
|
|
737
|
+
*/
|
|
738
|
+
function parseIntervalInput(raw) {
|
|
739
|
+
const trimmed = raw.trim();
|
|
740
|
+
if (!/^[0-9]+$/.test(trimmed)) return void 0;
|
|
741
|
+
const parsed = Number(trimmed);
|
|
742
|
+
if (!Number.isFinite(parsed)) return void 0;
|
|
743
|
+
return Math.min(POOL_INTERVAL_MAX, Math.max(5, Math.round(parsed)));
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* One keystroke in the interval field: what to DISPLAY, and what to commit.
|
|
747
|
+
*
|
|
748
|
+
* `text` is ALWAYS the raw keystroke, never a re-serialized number — that
|
|
749
|
+
* distinction is the whole defect. A field whose displayed value is derived from
|
|
750
|
+
* the parsed-and-clamped number cannot be typed into: each keystroke's clamped
|
|
751
|
+
* `5` is rendered, and the next digit appends to it (`120` → `520`).
|
|
752
|
+
*
|
|
753
|
+
* `commit` is the clamped number to fold into the draft, or `undefined` while the
|
|
754
|
+
* text is not yet a plain integer (so the draft is left alone until the entry is
|
|
755
|
+
* meaningful). Splitting the two makes the invariant unit-testable without a DOM,
|
|
756
|
+
* which this project has no way to render.
|
|
757
|
+
*/
|
|
758
|
+
function intervalEditOnInput(raw) {
|
|
759
|
+
return {
|
|
760
|
+
text: raw,
|
|
761
|
+
commit: parseIntervalInput(raw)
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Pool MEMBERS that can serve right now.
|
|
766
|
+
*
|
|
767
|
+
* Judged over members only. Using the whole account table made the "no usable
|
|
768
|
+
* account" warning unreachable: unchecked rows are listed for the user to opt
|
|
769
|
+
* into and never carry `excludedBy`, so any unchecked sign-in kept the count
|
|
770
|
+
* above zero — exactly when every member was limited and the explanation was
|
|
771
|
+
* most needed.
|
|
772
|
+
*/
|
|
773
|
+
function usableMemberIds(accounts, effectiveMembers, draftMembers) {
|
|
774
|
+
const wanted = /* @__PURE__ */ new Set([...effectiveMembers, ...draftMembers]);
|
|
775
|
+
return accounts.filter((account) => wanted.has(account.accountId) && account.excludedBy === void 0).map((account) => account.accountId);
|
|
776
|
+
}
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region src/client/AccountPool.tsx
|
|
779
|
+
/**
|
|
780
|
+
* The account pool's card section.
|
|
781
|
+
*
|
|
782
|
+
* 参考:`WorkBuddyCard.tsx` 的既有写法 —— 草稿态 + dirty 标记 + 「保存/放弃」、
|
|
783
|
+
* 60 秒轮询、`writeField` 验证写入,全部沿用;样式类名继续用
|
|
784
|
+
* `dsm-workbuddy-*` 前缀,好让这一块与卡片其余部分共用同一套外观语言。
|
|
785
|
+
* 改动:把「账号池」这一整块独立成模块。原因是它有三条别的区块没有的规则,
|
|
786
|
+
* 放在卡片里会被淹没:
|
|
787
|
+
*
|
|
788
|
+
* 1. **两项互斥。** 自动轮换开启时,上方卡片原有的手动账号下拉必须停用 ——
|
|
789
|
+
* 否则会出现"下拉显示 A、实际计费 B"。本模块通过 `onRotationChange`
|
|
790
|
+
* 把这个状态交回卡片,由卡片去禁用那个下拉。
|
|
791
|
+
* 2. **两种状态、两套写入。** 四项偏好走草稿 + 保存(保存成功才丢弃草稿);
|
|
792
|
+
* 测试结果与轮换是**运行时事实**,由 Host 自动落盘,绝不经这个保存按钮。
|
|
793
|
+
* 把它们混在一起,一次「保存」就会用旧草稿覆盖刚测出的结果。
|
|
794
|
+
* 3. **免费模型可能不存在。** 国内版静态目录不带倍率,所以「自动挑免费模型」
|
|
795
|
+
* 在国内版首次刷新前会挑到空。此时**不回退到收费模型**,而是如实说明并
|
|
796
|
+
* 停用测试按钮。
|
|
797
|
+
*
|
|
798
|
+
* @module dsh-connect-workbuddy/client/AccountPool
|
|
799
|
+
*/
|
|
800
|
+
/** How many activity lines are kept; the oldest are dropped. */
|
|
801
|
+
const LOG_LIMIT = 60;
|
|
802
|
+
/**
|
|
803
|
+
* Activity logs, kept per region OUTSIDE the component.
|
|
804
|
+
*
|
|
805
|
+
* The card unmounts its whole body when collapsed (`WorkBuddyCard.tsx:832`
|
|
806
|
+
* renders it only while `open`), and again whenever a poll briefly leaves the
|
|
807
|
+
* region unsigned-in. Component state therefore lost the entire log on every
|
|
808
|
+
* collapse, and — because the rotation bookkeeping reset with it — wrote a
|
|
809
|
+
* PHANTOM "switched from — to X" record on the next open. Holding both here
|
|
810
|
+
* keeps the history across remounts and across tab switches.
|
|
811
|
+
*/
|
|
812
|
+
const logStore = /* @__PURE__ */ new Map();
|
|
813
|
+
const rotationMemory = /* @__PURE__ */ new Map();
|
|
814
|
+
/**
|
|
815
|
+
* Whether two id sets are equal, ignoring order.
|
|
816
|
+
*
|
|
817
|
+
* The membership compare must not depend on click order: re-selecting the same
|
|
818
|
+
* accounts in a different sequence is not an edit, and treating it as one would
|
|
819
|
+
* light up "unsaved changes" for a no-op.
|
|
820
|
+
*/
|
|
821
|
+
function sameIds(left, right) {
|
|
822
|
+
if (left.length !== right.length) return false;
|
|
823
|
+
const wanted = new Set(right);
|
|
824
|
+
return left.every((id) => wanted.has(id));
|
|
825
|
+
}
|
|
826
|
+
/** Whether a probe outcome means the model answered. */
|
|
827
|
+
function outcomeOk(outcome) {
|
|
828
|
+
return outcome === "ok";
|
|
829
|
+
}
|
|
830
|
+
/** A short, localizable label for one probe outcome. */
|
|
831
|
+
function outcomeText(t, outcome) {
|
|
832
|
+
switch (outcome) {
|
|
833
|
+
case "ok": return t("row.probeOk");
|
|
834
|
+
case "rate-limited": return t("row.poolExcludedRateLimited");
|
|
835
|
+
case "out-of-credit": return t("row.poolExcludedOutOfCredit");
|
|
836
|
+
case "credential-rejected": return t("row.probeCredentialRejected");
|
|
837
|
+
case "not-found": return t("row.probeNotFound");
|
|
838
|
+
case "unavailable": return t("row.probeUnavailable");
|
|
839
|
+
default: return t("row.probeFailed");
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
function formatNumber$1(value) {
|
|
843
|
+
return new Intl.NumberFormat(void 0, { maximumFractionDigits: 0 }).format(value);
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* `MM/DD, HH:mm` on a 24-hour clock.
|
|
847
|
+
*
|
|
848
|
+
* `hourCycle: 'h23'` is REQUIRED, not decorative: without it `Intl` follows the
|
|
849
|
+
* browser locale's preference, and en-US renders `09/30, 01:23 PM`. A wall-clock
|
|
850
|
+
* glued to "AM/PM" reads as 12-hour no matter what the surrounding UI assumes,
|
|
851
|
+
* and it is the one thing a Chinese user cannot scan past — the card otherwise
|
|
852
|
+
* speaks 24-hour everywhere (see `formatClock`).
|
|
853
|
+
*
|
|
854
|
+
* `h23` rather than `hour12: false`, because the latter maps to `h24` in some
|
|
855
|
+
* engines and would print midnight as `24:00`. `h23` pins 00–23 exactly, which
|
|
856
|
+
* is also the cycle DSH's own timestamps use.
|
|
857
|
+
*/
|
|
858
|
+
function formatShort(value) {
|
|
859
|
+
return new Intl.DateTimeFormat(void 0, {
|
|
860
|
+
month: "2-digit",
|
|
861
|
+
day: "2-digit",
|
|
862
|
+
hour: "2-digit",
|
|
863
|
+
minute: "2-digit",
|
|
864
|
+
hourCycle: "h23"
|
|
865
|
+
}).format(new Date(value));
|
|
866
|
+
}
|
|
867
|
+
function formatClock(value) {
|
|
868
|
+
return new Intl.DateTimeFormat(void 0, {
|
|
869
|
+
hour: "2-digit",
|
|
870
|
+
minute: "2-digit",
|
|
871
|
+
second: "2-digit",
|
|
872
|
+
hourCycle: "h23"
|
|
873
|
+
}).format(new Date(value));
|
|
874
|
+
}
|
|
875
|
+
/** A dot colour for one account's state. */
|
|
876
|
+
function stateColor(account) {
|
|
877
|
+
if (account.excludedBy !== void 0) return account.excludedBy === "rate-limited" || account.excludedBy === "unavailable" ? "var(--dsw-alias-state-warn-primary, #f59e0b)" : "var(--dsw-alias-state-error-primary, #ef4444)";
|
|
878
|
+
if (account.probe === void 0) return "var(--dsw-alias-label-dimmed, #9aa0a6)";
|
|
879
|
+
return outcomeOk(account.probe.outcome) ? "var(--dsw-alias-state-success-primary, #22a06b)" : "var(--dsw-alias-state-warn-primary, #f59e0b)";
|
|
880
|
+
}
|
|
881
|
+
/** The exclusion label for one account, or undefined when it is usable. */
|
|
882
|
+
function exclusionText(t, account) {
|
|
883
|
+
switch (account.excludedBy) {
|
|
884
|
+
case "rate-limited": return t("row.poolExcludedRateLimited");
|
|
885
|
+
case "out-of-credit": return t("row.poolExcludedOutOfCredit");
|
|
886
|
+
case "credential-rejected": return t("row.poolExcludedRejected");
|
|
887
|
+
case "not-found": return t("row.probeNotFound");
|
|
888
|
+
case "unavailable": return t("row.probeUnavailable");
|
|
889
|
+
case "failed": return t("row.probeFailed");
|
|
890
|
+
case "unusable": return t("row.poolExcludedUnusable");
|
|
891
|
+
default: return;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* The pool section.
|
|
896
|
+
*
|
|
897
|
+
* Renders nothing when the Host reports no pool state at all: an older Host
|
|
898
|
+
* built without this feature should show an unmodified card, not an empty
|
|
899
|
+
* section implying the feature exists.
|
|
900
|
+
*/
|
|
901
|
+
function AccountPool(props) {
|
|
902
|
+
const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRotationChange } = props;
|
|
903
|
+
const [draft, setDraft] = (0, react.useState)(null);
|
|
904
|
+
const [saving, setSaving] = (0, react.useState)(false);
|
|
905
|
+
const [saveError, setSaveError] = (0, react.useState)(void 0);
|
|
906
|
+
const [busy, setBusy] = (0, react.useState)(void 0);
|
|
907
|
+
const [actionError, setActionError] = (0, react.useState)(void 0);
|
|
908
|
+
const [log, setLog] = (0, react.useState)(() => logStore.get(region) ?? []);
|
|
909
|
+
(0, react.useEffect)(() => {
|
|
910
|
+
setLog(logStore.get(region) ?? []);
|
|
911
|
+
setIntervalText(void 0);
|
|
912
|
+
}, [region]);
|
|
913
|
+
const [draftRegion, setDraftRegion] = (0, react.useState)(void 0);
|
|
914
|
+
/**
|
|
915
|
+
* The interval field's raw text while it is being typed.
|
|
916
|
+
*
|
|
917
|
+
* `undefined` means "not editing" and the control renders the committed
|
|
918
|
+
* number. Keeping the in-progress string here is what makes the field
|
|
919
|
+
* typeable: clamping on every keystroke and writing the result straight back
|
|
920
|
+
* into the controlled `value` turned `120` into `520` and made an empty field
|
|
921
|
+
* snap to `5`. See `parseIntervalInput`.
|
|
922
|
+
*/
|
|
923
|
+
const [intervalText, setIntervalText] = (0, react.useState)(void 0);
|
|
924
|
+
const mounted = (0, react.useRef)(true);
|
|
925
|
+
(0, react.useEffect)(() => {
|
|
926
|
+
mounted.current = true;
|
|
927
|
+
return () => {
|
|
928
|
+
mounted.current = false;
|
|
929
|
+
};
|
|
930
|
+
}, []);
|
|
931
|
+
const saved = {
|
|
932
|
+
enabled: pool?.enabled ?? false,
|
|
933
|
+
rotateByCredits: pool?.rotateByCredits ?? false,
|
|
934
|
+
autoTestIntervalMinutes: pool?.autoTestIntervalMinutes ?? 30,
|
|
935
|
+
targetModelId: pool?.targetModelId || pool?.staleTargetModelId || "",
|
|
936
|
+
memberAccountIds: pool?.memberAccountIds ?? []
|
|
937
|
+
};
|
|
938
|
+
const active = draftRegion === region && draft !== null ? draft : saved;
|
|
939
|
+
const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.rotateByCredits !== saved.rotateByCredits || draft.autoTestIntervalMinutes !== saved.autoTestIntervalMinutes || draft.targetModelId !== saved.targetModelId || !sameIds(draft.memberAccountIds, saved.memberAccountIds));
|
|
940
|
+
const current = pool?.accounts.find((account) => account.current);
|
|
941
|
+
/**
|
|
942
|
+
* Membership that actually resolves to a local sign-in — what a batch runs on.
|
|
943
|
+
*
|
|
944
|
+
* The HOST computes this and sends it, so the card cannot drift from the
|
|
945
|
+
* batch's real behaviour. Counting the raw saved list is what let the UI
|
|
946
|
+
* claim "已选 1 / 共 1" and enable both buttons while the batch ran on ZERO
|
|
947
|
+
* accounts and reported success.
|
|
948
|
+
*
|
|
949
|
+
* Derived locally as a FALLBACK for the case where the user has edited the
|
|
950
|
+
* draft (the Host's answer describes the SAVED state) or an older Host omits
|
|
951
|
+
* the field.
|
|
952
|
+
*/
|
|
953
|
+
const listedIds = new Set((pool?.accounts ?? []).map((account) => account.accountId));
|
|
954
|
+
const effectiveMembers = effectiveMemberIds({
|
|
955
|
+
savedEffective: pool?.effectiveMemberAccountIds ?? [],
|
|
956
|
+
draftMembers: active.memberAccountIds,
|
|
957
|
+
listedIds,
|
|
958
|
+
dirty
|
|
959
|
+
});
|
|
960
|
+
/** Saved ids with no matching sign-in; surfaced rather than silently ignored. */
|
|
961
|
+
const ghostMembers = ghostMemberIds(active.memberAccountIds, listedIds);
|
|
962
|
+
/**
|
|
963
|
+
* Pool MEMBERS that can serve right now.
|
|
964
|
+
*
|
|
965
|
+
* Judged over members only. Using the whole account table made the "no usable
|
|
966
|
+
* account" warning unreachable: unchecked rows are listed for the user to opt
|
|
967
|
+
* into and never carry `excludedBy`, so any unchecked sign-in kept the count
|
|
968
|
+
* above zero — exactly when every member was limited and the explanation was
|
|
969
|
+
* most needed.
|
|
970
|
+
*/
|
|
971
|
+
const usableMemberSet = new Set(usableMemberIds(pool?.accounts ?? [], effectiveMembers, active.memberAccountIds));
|
|
972
|
+
const usable = (pool?.accounts ?? []).filter((account) => usableMemberSet.has(account.accountId));
|
|
973
|
+
/**
|
|
974
|
+
* Whether rotation currently owns this region's choice — read from the
|
|
975
|
+
* COMMITTED preferences, not the draft.
|
|
976
|
+
*
|
|
977
|
+
* The distinction is the whole point. The Host decides who is billed from the
|
|
978
|
+
* saved config, so a draft-based lock let one click of "turn off rotation"
|
|
979
|
+
* re-enable the manual dropdown and hide the conflict notice while the Host
|
|
980
|
+
* kept rotating — the user then picked an account, the card showed it as
|
|
981
|
+
* current, and a different account was billed. That is exactly the failure
|
|
982
|
+
* the lock exists to prevent.
|
|
983
|
+
*
|
|
984
|
+
* Reading the committed value means the dropdown stays disabled until the
|
|
985
|
+
* change is SAVED, which is honest: until then the Host really is still
|
|
986
|
+
* rotating. The conflict notice says so and offers to save.
|
|
987
|
+
*/
|
|
988
|
+
const { locked: rotationLocked, unlockPending: rotationUnlockPending } = rotationLockState(saved, active);
|
|
989
|
+
const canEditPool = settingsScope !== void 0;
|
|
990
|
+
(0, react.useEffect)(() => {
|
|
991
|
+
onRotationChange?.(rotationLocked);
|
|
992
|
+
return () => {
|
|
993
|
+
onRotationChange?.(false);
|
|
994
|
+
};
|
|
995
|
+
}, [onRotationChange, rotationLocked]);
|
|
996
|
+
(0, react.useEffect)(() => {
|
|
997
|
+
onBusyChange?.(saving);
|
|
998
|
+
return () => {
|
|
999
|
+
onBusyChange?.(false);
|
|
1000
|
+
};
|
|
1001
|
+
}, [onBusyChange, saving]);
|
|
1002
|
+
const appendLog = (0, react.useCallback)((text, tone) => {
|
|
1003
|
+
setLog((previous) => {
|
|
1004
|
+
const next = [{
|
|
1005
|
+
atMs: Date.now(),
|
|
1006
|
+
text,
|
|
1007
|
+
tone
|
|
1008
|
+
}, ...previous].slice(0, LOG_LIMIT);
|
|
1009
|
+
logStore.set(region, next);
|
|
1010
|
+
return next;
|
|
1011
|
+
});
|
|
1012
|
+
}, [region]);
|
|
1013
|
+
/**
|
|
1014
|
+
* Record a rotation the HOST performed.
|
|
1015
|
+
*
|
|
1016
|
+
* Rotation can happen on a timer with no card interaction, so the browser
|
|
1017
|
+
* cannot infer it — the Host reports which account it moved to and this logs
|
|
1018
|
+
* the transition. Without it "every switch is recorded" was a promise the UI
|
|
1019
|
+
* had no way to keep.
|
|
1020
|
+
*/
|
|
1021
|
+
/**
|
|
1022
|
+
* Record rotation transitions the HOST performed.
|
|
1023
|
+
*
|
|
1024
|
+
* Rotation can happen on a timer with no card interaction, so the browser
|
|
1025
|
+
* cannot infer it — the Host reports which account it moved to and this logs
|
|
1026
|
+
* the change.
|
|
1027
|
+
*
|
|
1028
|
+
* Three defects are fixed here, all from keeping one `useRef` for a value
|
|
1029
|
+
* that is per-region and long-lived:
|
|
1030
|
+
*
|
|
1031
|
+
* 1. **No phantom first record.** The ref used to start `undefined`, so the
|
|
1032
|
+
* first rotation the card ever SAW was reported as a fresh switch ("from
|
|
1033
|
+
* — to X") even when it had happened long before. The first observation
|
|
1034
|
+
* now PRIMES the ref instead of logging.
|
|
1035
|
+
* 2. **Per-region state.** The card keeps ONE component instance across
|
|
1036
|
+
* tabs and only swaps the `region` prop, so a shared ref explained one
|
|
1037
|
+
* region's rotation with another region's account list — producing
|
|
1038
|
+
* records for switches that never happened. State is keyed by region.
|
|
1039
|
+
* 3. **The stop event is logged.** Rotation ending (`undefined`) used to
|
|
1040
|
+
* return early AND leave the ref set, so "rotation stopped, back to your
|
|
1041
|
+
* own account" never appeared, and rotating to the SAME account again
|
|
1042
|
+
* was silently skipped.
|
|
1043
|
+
*/
|
|
1044
|
+
const rotatedTo = pool?.rotatedToAccountId;
|
|
1045
|
+
(0, react.useEffect)(() => {
|
|
1046
|
+
const memory = rotationMemory.get(region) ?? {
|
|
1047
|
+
last: void 0,
|
|
1048
|
+
primed: false
|
|
1049
|
+
};
|
|
1050
|
+
const previous = memory.last;
|
|
1051
|
+
if (!memory.primed) {
|
|
1052
|
+
rotationMemory.set(region, {
|
|
1053
|
+
last: rotatedTo,
|
|
1054
|
+
primed: true
|
|
1055
|
+
});
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (rotatedTo === previous) return;
|
|
1059
|
+
if (rotatedTo === void 0) {
|
|
1060
|
+
rotationMemory.set(region, {
|
|
1061
|
+
last: void 0,
|
|
1062
|
+
primed: true
|
|
1063
|
+
});
|
|
1064
|
+
if (previous !== void 0) appendLog(t("row.poolLogRotationStopped", { from: nameOf(previous, pool) || t("row.accountUnnamed") }), "info");
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
rotationMemory.set(region, {
|
|
1068
|
+
last: rotatedTo,
|
|
1069
|
+
primed: true
|
|
1070
|
+
});
|
|
1071
|
+
appendLog(t("row.poolLogRotated", {
|
|
1072
|
+
from: previous === void 0 ? "—" : nameOf(previous, pool) || t("row.accountUnnamed"),
|
|
1073
|
+
to: nameOf(rotatedTo, pool) || t("row.accountUnnamed")
|
|
1074
|
+
}), "info");
|
|
1075
|
+
}, [
|
|
1076
|
+
appendLog,
|
|
1077
|
+
pool,
|
|
1078
|
+
region,
|
|
1079
|
+
rotatedTo,
|
|
1080
|
+
t
|
|
1081
|
+
]);
|
|
1082
|
+
/**
|
|
1083
|
+
* The latest saved preferences, for callbacks that must not capture a stale
|
|
1084
|
+
* copy.
|
|
1085
|
+
*
|
|
1086
|
+
* `saved` is rebuilt on every render, so putting it in a `useCallback` dep
|
|
1087
|
+
* list would rebuild the callback every render (making the memo pointless),
|
|
1088
|
+
* while listing individual fields by hand is exactly what caused the
|
|
1089
|
+
* data-loss defect this ref fixes: a field added later
|
|
1090
|
+
* (`memberAccountIds`) was missing from the list, so the callback kept a
|
|
1091
|
+
* stale base and checking one account silently dropped another.
|
|
1092
|
+
*
|
|
1093
|
+
* A ref gives both properties at once — a STABLE callback identity and a
|
|
1094
|
+
* base read at call time. It is synced in a LAYOUT effect, which flushes
|
|
1095
|
+
* synchronously after commit and BEFORE the browser paints: a user event
|
|
1096
|
+
* cannot be processed before a frame is painted, so by the time any click
|
|
1097
|
+
* reaches `editDraft` the ref is current. (A plain `useEffect` would leave a
|
|
1098
|
+
* window between paint and its flush; a render-phase write would be unsafe
|
|
1099
|
+
* under concurrent rendering, where a discarded pass could leave the ref
|
|
1100
|
+
* holding values from a tree that never committed.)
|
|
1101
|
+
*
|
|
1102
|
+
* Seeded with the initial value so the very first interaction, before any
|
|
1103
|
+
* effect has run, still reads real preferences.
|
|
1104
|
+
*/
|
|
1105
|
+
const savedRef = (0, react.useRef)(saved);
|
|
1106
|
+
(0, react.useLayoutEffect)(() => {
|
|
1107
|
+
savedRef.current = saved;
|
|
1108
|
+
});
|
|
1109
|
+
/** Read by `editDraft` so it can refuse edits mid-save without rebuilding. */
|
|
1110
|
+
const savingRef = (0, react.useRef)(saving);
|
|
1111
|
+
(0, react.useLayoutEffect)(() => {
|
|
1112
|
+
savingRef.current = saving;
|
|
1113
|
+
});
|
|
1114
|
+
const editDraft = (0, react.useCallback)((edit) => {
|
|
1115
|
+
if (savingRef.current) return;
|
|
1116
|
+
setDraftRegion(region);
|
|
1117
|
+
setDraft((previous) => {
|
|
1118
|
+
return edit(draftBaseFor(previous, draftRegion, region, savedRef.current));
|
|
1119
|
+
});
|
|
1120
|
+
}, [draftRegion, region]);
|
|
1121
|
+
const discard = (0, react.useCallback)(() => {
|
|
1122
|
+
setDraft(null);
|
|
1123
|
+
setDraftRegion(void 0);
|
|
1124
|
+
setIntervalText(void 0);
|
|
1125
|
+
setSaveError(void 0);
|
|
1126
|
+
}, []);
|
|
1127
|
+
/** Save the four preferences through the plugin's VERIFIED write path. */
|
|
1128
|
+
const save = (0, react.useCallback)(async () => {
|
|
1129
|
+
if (settingsScope === void 0 || !dirty || draft === null) return;
|
|
1130
|
+
setSaving(true);
|
|
1131
|
+
setSaveError(void 0);
|
|
1132
|
+
let committed = false;
|
|
1133
|
+
try {
|
|
1134
|
+
await writePoolPreferences(settingsScope, region, draft);
|
|
1135
|
+
if (await onSaved?.() === false) {
|
|
1136
|
+
if (mounted.current) setSaveError(t("row.poolSavedStaleRefresh"));
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
committed = true;
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
if (mounted.current) {
|
|
1142
|
+
const reason = error instanceof Error ? error.message : t("row.requestFailed");
|
|
1143
|
+
setSaveError(isFileContentionWriteError(error) ? `${reason}${t("row.saveContentionHint")}` : reason);
|
|
1144
|
+
}
|
|
1145
|
+
} finally {
|
|
1146
|
+
if (committed) discard();
|
|
1147
|
+
if (mounted.current) setSaving(false);
|
|
1148
|
+
}
|
|
1149
|
+
}, [
|
|
1150
|
+
dirty,
|
|
1151
|
+
discard,
|
|
1152
|
+
draft,
|
|
1153
|
+
onSaved,
|
|
1154
|
+
region,
|
|
1155
|
+
settingsScope,
|
|
1156
|
+
t
|
|
1157
|
+
]);
|
|
1158
|
+
/** Check or uncheck one account in this pool. */
|
|
1159
|
+
const toggleMember = (0, react.useCallback)((accountId, next) => {
|
|
1160
|
+
editDraft((current) => {
|
|
1161
|
+
const set = new Set(current.memberAccountIds);
|
|
1162
|
+
if (next) set.add(accountId);
|
|
1163
|
+
else set.delete(accountId);
|
|
1164
|
+
return {
|
|
1165
|
+
...current,
|
|
1166
|
+
memberAccountIds: [...set]
|
|
1167
|
+
};
|
|
1168
|
+
});
|
|
1169
|
+
}, [editDraft]);
|
|
1170
|
+
/** Check every listed account, or none of them. */
|
|
1171
|
+
const selectAll = (0, react.useCallback)((next) => {
|
|
1172
|
+
editDraft((current) => ({
|
|
1173
|
+
...current,
|
|
1174
|
+
memberAccountIds: next ? (pool?.accounts ?? []).map((account) => account.accountId) : []
|
|
1175
|
+
}));
|
|
1176
|
+
}, [editDraft, pool]);
|
|
1177
|
+
/**
|
|
1178
|
+
* The interval control's two halves, kept apart so the field is typeable.
|
|
1179
|
+
*
|
|
1180
|
+
* `onIntervalInput` records what the user typed and only folds a VALID value
|
|
1181
|
+
* into the draft; `onIntervalCommit` (blur / Enter) forces the text back to
|
|
1182
|
+
* the committed number, clamping an out-of-range or non-numeric entry exactly
|
|
1183
|
+
* once, after the user is done. Committing on every keystroke is what made
|
|
1184
|
+
* `120` land as `520`.
|
|
1185
|
+
*/
|
|
1186
|
+
const onIntervalInput = (0, react.useCallback)((raw) => {
|
|
1187
|
+
const { text, commit } = intervalEditOnInput(raw);
|
|
1188
|
+
setIntervalText(text);
|
|
1189
|
+
if (commit === void 0) return;
|
|
1190
|
+
editDraft((current) => ({
|
|
1191
|
+
...current,
|
|
1192
|
+
autoTestIntervalMinutes: commit
|
|
1193
|
+
}));
|
|
1194
|
+
}, [editDraft]);
|
|
1195
|
+
const onIntervalCommit = (0, react.useCallback)(() => {
|
|
1196
|
+
setIntervalText(void 0);
|
|
1197
|
+
}, []);
|
|
1198
|
+
/** Run one batch action against the Host. */
|
|
1199
|
+
const runAction = (0, react.useCallback)(async (action) => {
|
|
1200
|
+
setBusy(action);
|
|
1201
|
+
setActionError(void 0);
|
|
1202
|
+
const memberCount = announcedBatchCount({
|
|
1203
|
+
savedEffective: pool?.effectiveMemberAccountIds,
|
|
1204
|
+
savedMembers: pool?.memberAccountIds ?? []
|
|
1205
|
+
});
|
|
1206
|
+
appendLog(action === "checkin" ? t("row.poolLogCheckinStart", { count: memberCount }) : t("row.poolLogTestStart", {
|
|
1207
|
+
count: memberCount,
|
|
1208
|
+
model: pool?.targetModelId ?? ""
|
|
1209
|
+
}), "info");
|
|
1210
|
+
try {
|
|
1211
|
+
const response = await fetch(withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, region, action), {
|
|
1212
|
+
method: "POST",
|
|
1213
|
+
headers: { accept: "application/json" },
|
|
1214
|
+
credentials: "same-origin"
|
|
1215
|
+
});
|
|
1216
|
+
const body = await response.json().catch(() => void 0);
|
|
1217
|
+
if (!response.ok) throw new Error(poolFailureText(t, {
|
|
1218
|
+
status: response.status,
|
|
1219
|
+
reason: body?.reason,
|
|
1220
|
+
error: body?.error
|
|
1221
|
+
}));
|
|
1222
|
+
if (!mounted.current) return;
|
|
1223
|
+
if (action === "checkin") {
|
|
1224
|
+
for (const row of body?.rows ?? []) if (row.status === "claimed") appendLog(t("row.poolLogCheckinClaimed", {
|
|
1225
|
+
accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
|
|
1226
|
+
credit: String(row.credit ?? 0)
|
|
1227
|
+
}), "ok");
|
|
1228
|
+
else if (row.status === "already") appendLog(t("row.poolLogCheckinAlready", { accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName }), "info");
|
|
1229
|
+
else appendLog(t("row.poolLogCheckinFailed", {
|
|
1230
|
+
accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
|
|
1231
|
+
message: row.message ?? t("row.requestFailed")
|
|
1232
|
+
}), "error");
|
|
1233
|
+
appendLog(t("row.poolLogCheckinDone"), "ok");
|
|
1234
|
+
} else {
|
|
1235
|
+
for (const row of body?.rows ?? []) {
|
|
1236
|
+
const outcome = row?.result?.outcome;
|
|
1237
|
+
if (outcome === void 0) {
|
|
1238
|
+
appendLog(t("row.poolLogTestRowMalformed", { accountName: row?.accountName === "" || row?.accountName === void 0 ? t("row.accountUnnamed") : row.accountName }), "warn");
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
appendLog(t("row.poolLogTestRow", {
|
|
1242
|
+
accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
|
|
1243
|
+
outcome: outcomeText(t, outcome)
|
|
1244
|
+
}), outcomeOk(outcome) ? "ok" : "warn");
|
|
1245
|
+
}
|
|
1246
|
+
appendLog(t("row.poolLogTestDone"), "ok");
|
|
1247
|
+
}
|
|
1248
|
+
onRefresh?.();
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
const message = error instanceof Error ? error.message : t("row.requestFailed");
|
|
1251
|
+
if (mounted.current) {
|
|
1252
|
+
setActionError(message);
|
|
1253
|
+
appendLog(t("row.poolLogBatchFailed", { message }), "error");
|
|
1254
|
+
}
|
|
1255
|
+
} finally {
|
|
1256
|
+
if (mounted.current) setBusy(void 0);
|
|
1257
|
+
}
|
|
1258
|
+
}, [
|
|
1259
|
+
appendLog,
|
|
1260
|
+
onRefresh,
|
|
1261
|
+
pool,
|
|
1262
|
+
region,
|
|
1263
|
+
t
|
|
1264
|
+
]);
|
|
1265
|
+
if (pool === void 0) return null;
|
|
1266
|
+
/**
|
|
1267
|
+
* Where the target model came from.
|
|
1268
|
+
*
|
|
1269
|
+
* Rendered under the buttons. This was computed and then never used, so the
|
|
1270
|
+
* card never explained its own target — which is the entire reason
|
|
1271
|
+
* `targetModelSource` is sent by the Host.
|
|
1272
|
+
*/
|
|
1273
|
+
const targetLabel = pool.targetModelSource === "preferred" ? t("row.poolTargetPreferred", { model: pool.targetModelId ?? "" }) : pool.targetModelSource === "free" ? t("row.poolTargetFree", { model: pool.targetModelId ?? "" }) : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : t("row.poolTargetNone");
|
|
1274
|
+
return (0, react.createElement)("section", { className: "dsm-workbuddy-pool" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("div", null, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolTitle")), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-summary" }, t("row.poolSummary", {
|
|
1275
|
+
count: effectiveMembers.length,
|
|
1276
|
+
model: pool.targetModelId ?? (pool.targetModelSource === "stale" ? t("row.poolTargetStaleShort") : pool.targetModelSource === "none" ? t("row.poolTargetNoneShort") : t("row.poolTargetAuto"))
|
|
1277
|
+
}))), rotationLocked && current !== void 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-badge" }, t("row.poolRotating")) : null), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-actions" }, (0, react.createElement)("button", {
|
|
1278
|
+
type: "button",
|
|
1279
|
+
className: "dsm-btn dsm-btn-primary",
|
|
1280
|
+
disabled: busy !== void 0 || !active.enabled || effectiveMembers.length === 0,
|
|
1281
|
+
onClick: () => {
|
|
1282
|
+
runAction("checkin");
|
|
1283
|
+
}
|
|
1284
|
+
}, busy === "checkin" ? t("row.poolCheckingIn") : t("row.poolCheckinAll")), (0, react.createElement)("button", {
|
|
1285
|
+
type: "button",
|
|
1286
|
+
className: "dsm-btn dsm-btn-outline",
|
|
1287
|
+
disabled: busy !== void 0 || !active.enabled || effectiveMembers.length === 0 || pool.targetModelSource === "none" || pool.targetModelSource === "stale",
|
|
1288
|
+
title: pool.targetModelSource === "none" ? t("row.poolTargetNone") : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : void 0,
|
|
1289
|
+
onClick: () => {
|
|
1290
|
+
runAction("test");
|
|
1291
|
+
}
|
|
1292
|
+
}, busy === "test" ? t("row.poolTesting") : t("row.poolTestAll")), busy === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-hint" }, t("row.poolBusyHint"))), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, targetLabel), !active.enabled ? (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolEnabledHint")) : null, rotationLocked && usable.length === 0 ? (0, react.createElement)("p", {
|
|
1293
|
+
className: "dsm-workbuddy-pool-warn",
|
|
1294
|
+
role: "status"
|
|
1295
|
+
}, `${t("row.poolNoCandidate")} ${t("row.poolNoCandidateHint")}`) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-members" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-members-head" }, (0, react.createElement)("div", null, (0, react.createElement)("h4", { className: "dsm-workbuddy-pool-members-title" }, t("row.poolMembersTitle")), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolMembersHint"))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-members-actions" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-hint" }, t("row.poolSelectedCount", {
|
|
1296
|
+
count: effectiveMembers.length,
|
|
1297
|
+
total: pool.accounts.length
|
|
1298
|
+
})), (0, react.createElement)("button", {
|
|
1299
|
+
type: "button",
|
|
1300
|
+
className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
|
|
1301
|
+
disabled: pool.accounts.length === 0 || !canEditPool,
|
|
1302
|
+
onClick: () => selectAll(true)
|
|
1303
|
+
}, t("row.poolSelectAll")), (0, react.createElement)("button", {
|
|
1304
|
+
type: "button",
|
|
1305
|
+
className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
|
|
1306
|
+
disabled: effectiveMembers.length === 0 || !canEditPool,
|
|
1307
|
+
onClick: () => selectAll(false)
|
|
1308
|
+
}, t("row.poolSelectNone")))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-table" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-row dsm-workbuddy-pool-row-head" }, (0, react.createElement)("span", null, t("row.poolColumnAccount")), (0, react.createElement)("span", null, t("row.poolColumnCredits")), (0, react.createElement)("span", null, t("row.poolColumnProbe")), (0, react.createElement)("span", null, t("row.poolColumnCheckin"))), ...pool.accounts.map((account) => renderAccountRow({
|
|
1309
|
+
t,
|
|
1310
|
+
account,
|
|
1311
|
+
checked: active.memberAccountIds.includes(account.accountId),
|
|
1312
|
+
canEdit: canEditPool,
|
|
1313
|
+
onToggle: (next) => toggleMember(account.accountId, next)
|
|
1314
|
+
}))), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
|
|
1315
|
+
className: "dsm-workbuddy-pool-warn",
|
|
1316
|
+
role: "status"
|
|
1317
|
+
}, `${t("row.poolNoneSelected")} ${t("row.poolNoneSelectedHint")}`) : null, ghostMembers.length === 0 ? null : (0, react.createElement)("p", {
|
|
1318
|
+
className: "dsm-workbuddy-pool-warn",
|
|
1319
|
+
role: "status"
|
|
1320
|
+
}, t("row.poolGhostMembers", { count: ghostMembers.length })), dirty && !sameIds(active.memberAccountIds, saved.memberAccountIds) ? (0, react.createElement)("p", { className: "dsm-workbuddy-pool-dirty" }, t("row.poolUnsavedMembers")) : null), pool.targetModelSource === "none" ? (0, react.createElement)("p", {
|
|
1321
|
+
className: "dsm-workbuddy-pool-warn",
|
|
1322
|
+
role: "status"
|
|
1323
|
+
}, `${t("row.poolTargetNone")} ${t("row.poolTargetNoneHint")}`) : null, pool.targetModelSource === "stale" ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-conflict" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-conflict-main" }, (0, react.createElement)("b", null, t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" })), (0, react.createElement)("span", null, t("row.poolTargetStaleHint"))), (0, react.createElement)("button", {
|
|
1324
|
+
type: "button",
|
|
1325
|
+
className: "dsm-btn dsm-btn-outline",
|
|
1326
|
+
disabled: !canEditPool,
|
|
1327
|
+
onClick: () => {
|
|
1328
|
+
editDraft((current) => ({
|
|
1329
|
+
...current,
|
|
1330
|
+
targetModelId: ""
|
|
1331
|
+
}));
|
|
1332
|
+
}
|
|
1333
|
+
}, t("row.poolTargetStaleClear"))) : null, actionError === void 0 ? null : (0, react.createElement)("p", {
|
|
1334
|
+
className: "dsm-workbuddy-pool-error",
|
|
1335
|
+
role: "alert"
|
|
1336
|
+
}, actionError), renderSettings({
|
|
1337
|
+
t,
|
|
1338
|
+
pool,
|
|
1339
|
+
active,
|
|
1340
|
+
dirty,
|
|
1341
|
+
saving,
|
|
1342
|
+
siblingBusy: siblingBusy === true,
|
|
1343
|
+
rotationLocked,
|
|
1344
|
+
rotationUnlockPending,
|
|
1345
|
+
saveError,
|
|
1346
|
+
settingsScope,
|
|
1347
|
+
intervalText,
|
|
1348
|
+
onIntervalInput,
|
|
1349
|
+
onIntervalCommit,
|
|
1350
|
+
onEdit: editDraft,
|
|
1351
|
+
onSave: () => {
|
|
1352
|
+
save();
|
|
1353
|
+
},
|
|
1354
|
+
onDiscard: discard,
|
|
1355
|
+
onUnlock: () => {
|
|
1356
|
+
editDraft((current) => ({
|
|
1357
|
+
...current,
|
|
1358
|
+
rotateByCredits: false
|
|
1359
|
+
}));
|
|
1360
|
+
},
|
|
1361
|
+
appendLog
|
|
1362
|
+
}), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolRegionNote")), renderLog({
|
|
1363
|
+
t,
|
|
1364
|
+
log,
|
|
1365
|
+
busy: busy !== void 0,
|
|
1366
|
+
onClear: () => {
|
|
1367
|
+
logStore.set(region, []);
|
|
1368
|
+
setLog([]);
|
|
1369
|
+
}
|
|
1370
|
+
}));
|
|
1371
|
+
}
|
|
1372
|
+
/** An account's display name by id, or undefined when it is not listed. */
|
|
1373
|
+
function nameOf(accountId, pool) {
|
|
1374
|
+
const account = pool?.accounts.find((entry) => entry.accountId === accountId);
|
|
1375
|
+
return account === void 0 ? void 0 : account.accountName;
|
|
1376
|
+
}
|
|
1377
|
+
/** One account's row. */
|
|
1378
|
+
function renderAccountRow(input) {
|
|
1379
|
+
const { t, account, checked, canEdit, onToggle } = input;
|
|
1380
|
+
const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
|
|
1381
|
+
const excluded = exclusionText(t, account);
|
|
1382
|
+
const probe = account.probe;
|
|
1383
|
+
const probeLine = probe === void 0 ? t("row.poolNeverTested") : probe.retryAtMs !== void 0 ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryAt", { at: formatShort(probe.retryAtMs) })}` : probe.outcome === "rate-limited" || probe.outcome === "out-of-credit" ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}` : outcomeText(t, probe.outcome);
|
|
1384
|
+
return (0, react.createElement)("div", {
|
|
1385
|
+
className: `dsm-workbuddy-pool-row${account.current ? " dsm-workbuddy-pool-row-current" : ""}`,
|
|
1386
|
+
key: account.accountId
|
|
1387
|
+
}, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account" }, (0, react.createElement)("label", { className: "dsm-workbuddy-pool-check" }, (0, react.createElement)("input", {
|
|
1388
|
+
type: "checkbox",
|
|
1389
|
+
checked,
|
|
1390
|
+
disabled: !canEdit,
|
|
1391
|
+
"aria-label": t("row.poolSelectAria", { accountName: name }),
|
|
1392
|
+
onChange: (event) => onToggle(event.currentTarget.checked)
|
|
1393
|
+
})), (0, react.createElement)("span", {
|
|
1394
|
+
className: "dsm-workbuddy-usage-dot",
|
|
1395
|
+
style: { background: stateColor(account) }
|
|
1396
|
+
}), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account-name" }, (0, react.createElement)("b", null, name), (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShort(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShort(account.nearestExpiryMs) }))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe" }, probeLine), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
|
|
1397
|
+
className: "dsm-workbuddy-pool-unknown",
|
|
1398
|
+
title: t("row.poolNeverTested")
|
|
1399
|
+
}, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")));
|
|
1400
|
+
}
|
|
1401
|
+
/** The preference editor plus its save/discard actions. */
|
|
1402
|
+
function renderSettings(input) {
|
|
1403
|
+
const { t, pool, active, dirty, saving, siblingBusy, rotationLocked, rotationUnlockPending, saveError, settingsScope, intervalText, onEdit, onSave, onDiscard, onUnlock, onIntervalInput, onIntervalCommit } = input;
|
|
1404
|
+
const canEdit = settingsScope !== void 0;
|
|
1405
|
+
const toggle = (label, hint, checked, onChange, disabled = false) => (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, label), (0, react.createElement)("span", null, hint)), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("label", { className: "sw" }, (0, react.createElement)("input", {
|
|
1406
|
+
type: "checkbox",
|
|
1407
|
+
checked,
|
|
1408
|
+
disabled: disabled || !canEdit,
|
|
1409
|
+
onChange: (event) => onChange(event.currentTarget.checked)
|
|
1410
|
+
}), (0, react.createElement)("span", { className: "sw-track" }))));
|
|
1411
|
+
return (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings-wrap" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolSettingsTitle")), dirty ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-dirty" }, t("row.poolDirty")) : null), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolSettingsHint")), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings" }, toggle(t("row.poolEnabled"), t("row.poolEnabledHint"), active.enabled, (next) => onEdit((current) => ({
|
|
1412
|
+
...current,
|
|
1413
|
+
enabled: next
|
|
1414
|
+
}))), toggle(t("row.poolRotate"), t("row.poolRotateHint"), active.rotateByCredits, (next) => onEdit((current) => ({
|
|
1415
|
+
...current,
|
|
1416
|
+
rotateByCredits: next
|
|
1417
|
+
})), !active.enabled), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolInterval")), (0, react.createElement)("span", null, t("row.poolIntervalHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("input", {
|
|
1418
|
+
className: "dsm-workbuddy-pool-num",
|
|
1419
|
+
type: "number",
|
|
1420
|
+
min: 5,
|
|
1421
|
+
max: POOL_INTERVAL_MAX,
|
|
1422
|
+
step: 5,
|
|
1423
|
+
value: intervalText ?? String(active.autoTestIntervalMinutes),
|
|
1424
|
+
disabled: !active.enabled || !canEdit,
|
|
1425
|
+
onChange: (event) => {
|
|
1426
|
+
onIntervalInput(event.currentTarget.value);
|
|
1427
|
+
},
|
|
1428
|
+
onBlur: () => onIntervalCommit(),
|
|
1429
|
+
onKeyDown: (event) => {
|
|
1430
|
+
if (event.key === "Enter") onIntervalCommit();
|
|
1431
|
+
}
|
|
1432
|
+
}), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-hint" }, t("row.poolIntervalUnit")))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolTargetLabel")), (0, react.createElement)("span", null, t("row.poolTargetHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
|
|
1433
|
+
className: "dsm-workbuddy-pool-select",
|
|
1434
|
+
value: active.targetModelId,
|
|
1435
|
+
disabled: !active.enabled || !canEdit,
|
|
1436
|
+
onChange: (event) => {
|
|
1437
|
+
const value = event.currentTarget.value;
|
|
1438
|
+
onEdit((current) => ({
|
|
1439
|
+
...current,
|
|
1440
|
+
targetModelId: value
|
|
1441
|
+
}));
|
|
1442
|
+
}
|
|
1443
|
+
}, (0, react.createElement)("option", { value: "" }, t("row.poolTargetAutoOption")), active.targetModelId !== "" && !(pool.catalog ?? []).some((m) => m.id === active.targetModelId) ? (0, react.createElement)("option", {
|
|
1444
|
+
value: active.targetModelId,
|
|
1445
|
+
disabled: true
|
|
1446
|
+
}, t("row.poolTargetStaleOption", { model: active.targetModelId })) : null, ...(pool.catalog ?? []).map((model) => (0, react.createElement)("option", {
|
|
1447
|
+
value: model.id,
|
|
1448
|
+
key: model.id
|
|
1449
|
+
}, model.creditMultiplier === void 0 ? model.name : `${model.name} (x${model.creditMultiplier.toFixed(2)})`)))))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-save-bar" }, (0, react.createElement)("button", {
|
|
1450
|
+
type: "button",
|
|
1451
|
+
className: "dsm-btn dsm-btn-primary",
|
|
1452
|
+
disabled: !dirty || saving || siblingBusy || !canEdit,
|
|
1453
|
+
onClick: onSave
|
|
1454
|
+
}, saving ? t("row.poolSaving") : t("row.poolSaved")), (0, react.createElement)("button", {
|
|
1455
|
+
type: "button",
|
|
1456
|
+
className: "dsm-btn dsm-btn-outline",
|
|
1457
|
+
disabled: !dirty || saving || siblingBusy,
|
|
1458
|
+
onClick: onDiscard
|
|
1459
|
+
}, t("row.poolDiscard")), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-hint" }, dirty ? t("row.poolSaveDirty") : t("row.poolSaveIdle"))), saveError === void 0 ? null : (0, react.createElement)("p", {
|
|
1460
|
+
className: "dsm-workbuddy-pool-error",
|
|
1461
|
+
role: "alert"
|
|
1462
|
+
}, t("row.poolSaveFailed", { message: saveError })), rotationLocked ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-conflict" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-conflict-main" }, (0, react.createElement)("b", null, t("row.poolManualLocked")), (0, react.createElement)("span", null, t("row.poolManualLockedHint"))), rotationUnlockPending ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-pending" }, t("row.poolUnlockPending")) : null, (0, react.createElement)("button", {
|
|
1463
|
+
type: "button",
|
|
1464
|
+
className: "dsm-btn dsm-btn-outline",
|
|
1465
|
+
disabled: !canEdit || rotationUnlockPending,
|
|
1466
|
+
onClick: onUnlock
|
|
1467
|
+
}, rotationUnlockPending ? t("row.poolUnlockSaved") : t("row.poolManualUnlock"))) : null);
|
|
1468
|
+
}
|
|
1469
|
+
/** The activity log. */
|
|
1470
|
+
function renderLog(input) {
|
|
1471
|
+
const { t, log, busy, onClear } = input;
|
|
1472
|
+
return (0, react.createElement)("div", { className: "dsm-workbuddy-pool-log" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-log-head" }, (0, react.createElement)("span", null, t("row.poolLogTitle")), log.length === 0 ? null : (0, react.createElement)("button", {
|
|
1473
|
+
type: "button",
|
|
1474
|
+
className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-log-clear",
|
|
1475
|
+
disabled: busy === true,
|
|
1476
|
+
onClick: onClear
|
|
1477
|
+
}, t("row.poolLogClear"))), log.length === 0 ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-log-empty" }, t("row.poolLogEmpty")) : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-log-body" }, ...log.map((entry, index) => (0, react.createElement)("div", {
|
|
1478
|
+
className: "dsm-workbuddy-pool-log-row",
|
|
1479
|
+
key: `${entry.atMs}-${index}`
|
|
1480
|
+
}, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-log-time" }, formatClock(entry.atMs)), (0, react.createElement)("span", { className: `dsm-workbuddy-pool-log-text dsm-workbuddy-pool-log-${entry.tone}` }, entry.text)))));
|
|
1481
|
+
}
|
|
412
1482
|
//#endregion
|
|
413
1483
|
//#region src/native-modality.ts
|
|
414
1484
|
/**
|
|
@@ -618,6 +1688,86 @@ window.__ModuleLoader__.load({
|
|
|
618
1688
|
.dsm-workbuddy-usage-cheer-star{font-size:12px;line-height:1;display:inline-flex}
|
|
619
1689
|
.dsm-workbuddy-usage-cheer:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
620
1690
|
.dsm-workbuddy-usage-cheer:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:2px}
|
|
1691
|
+
|
|
1692
|
+
/* ---- Account pool ---- */
|
|
1693
|
+
.dsm-workbuddy-pool{display:flex;flex-direction:column;gap:12px;margin:0;padding:16px 0 4px;border-top:1px solid var(--dsw-alias-border-l2,#36373b)}
|
|
1694
|
+
.dsm-workbuddy-pool-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
1695
|
+
.dsm-workbuddy-pool-title{margin:0;font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
1696
|
+
.dsm-workbuddy-pool-summary{margin:2px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#999)}
|
|
1697
|
+
.dsm-workbuddy-pool-badge{flex:none;padding:2px 9px;border-radius:999px;font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-brand-primary,#5686fe);background:rgba(86,134,254,.12);border:1px solid rgba(86,134,254,.32)}
|
|
1698
|
+
.dsm-workbuddy-pool-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
|
1699
|
+
.dsm-workbuddy-pool-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#999)}
|
|
1700
|
+
.dsm-workbuddy-pool-note{margin:0;font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary,#999)}
|
|
1701
|
+
.dsm-workbuddy-pool-warn{margin:0;padding:9px 11px;border-radius:9px;font-size:12px;line-height:17px;color:var(--dsw-alias-state-warn-primary,#f59e0b);background:rgba(245,158,11,.09);border:1px solid rgba(245,158,11,.3)}
|
|
1702
|
+
.dsm-workbuddy-pool-error{margin:0;font-size:12px;line-height:17px;color:var(--dsw-alias-state-error-primary,#ef4444);white-space:pre-line}
|
|
1703
|
+
.dsm-workbuddy-pool-table{border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-layer-2,#24262c)}
|
|
1704
|
+
.dsm-workbuddy-pool-row{display:grid;grid-template-columns:1.6fr 1fr 1.3fr .9fr;gap:10px;align-items:center;padding:10px 14px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31);font-size:13px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
|
|
1705
|
+
.dsm-workbuddy-pool-row:first-child{border-top:0}
|
|
1706
|
+
.dsm-workbuddy-pool-row-head{padding:8px 14px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-weight:600;letter-spacing:.02em;text-transform:uppercase}
|
|
1707
|
+
.dsm-workbuddy-pool-row-current{background:rgba(86,134,254,.07)}
|
|
1708
|
+
.dsm-workbuddy-pool-account{display:flex;align-items:center;gap:8px;min-width:0}
|
|
1709
|
+
.dsm-workbuddy-pool-account-name{display:flex;flex-direction:column;gap:1px;min-width:0}
|
|
1710
|
+
.dsm-workbuddy-pool-account-name b{font-weight:500;color:var(--dsw-alias-label-primary,#e6e6e6);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1711
|
+
.dsm-workbuddy-pool-account-name span{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#999)}
|
|
1712
|
+
.dsm-workbuddy-pool-excluded{color:var(--dsw-alias-state-warn-primary,#f59e0b)}
|
|
1713
|
+
.dsm-workbuddy-pool-credits{display:flex;flex-direction:column;gap:1px;min-width:0;font-variant-numeric:tabular-nums}
|
|
1714
|
+
.dsm-workbuddy-pool-credits b{color:var(--dsw-alias-label-primary,#e6e6e6);font-weight:600}
|
|
1715
|
+
.dsm-workbuddy-pool-soon{font-size:11px;line-height:16px;color:var(--dsw-alias-state-warn-primary,#f59e0b)}
|
|
1716
|
+
.dsm-workbuddy-pool-soon-plain{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#999)}
|
|
1717
|
+
.dsm-workbuddy-pool-probe,.dsm-workbuddy-pool-checkin{font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary,#999);min-width:0;overflow-wrap:anywhere}
|
|
1718
|
+
.dsm-workbuddy-pool-members{display:flex;flex-direction:column;gap:10px}
|
|
1719
|
+
.dsm-workbuddy-pool-members-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
1720
|
+
.dsm-workbuddy-pool-members-title{margin:0 0 3px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
1721
|
+
.dsm-workbuddy-pool-members-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
1722
|
+
.dsm-workbuddy-pool-small-btn{padding:3px 10px;font-size:12px}
|
|
1723
|
+
.dsm-workbuddy-pool-check{display:inline-flex;align-items:center;flex:none;cursor:pointer}
|
|
1724
|
+
.dsm-workbuddy-pool-check input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
|
|
1725
|
+
.dsm-workbuddy-pool-check input:disabled{cursor:default;opacity:.5}
|
|
1726
|
+
.dsm-workbuddy-pool-checked{color:var(--dsw-alias-state-success-primary,#22a06b)}
|
|
1727
|
+
.dsm-workbuddy-pool-unknown{color:var(--dsw-alias-label-dimmed,#9aa0a6)}
|
|
1728
|
+
.dsm-workbuddy-pool-member{color:var(--dsw-alias-brand-primary,#5686fe)}
|
|
1729
|
+
.dsm-workbuddy-pool-not-member{color:var(--dsw-alias-label-dimmed,#9aa0a6)}
|
|
1730
|
+
.dsm-workbuddy-pool-settings-wrap{display:flex;flex-direction:column;gap:10px}
|
|
1731
|
+
.dsm-workbuddy-pool-dirty{font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-state-warn-primary,#f59e0b)}
|
|
1732
|
+
.dsm-workbuddy-pool-settings{display:flex;flex-direction:column;gap:1px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-layer-2,#24262c)}
|
|
1733
|
+
.dsm-workbuddy-pool-set{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:12px 14px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31)}
|
|
1734
|
+
.dsm-workbuddy-pool-set:first-child{border-top:0}
|
|
1735
|
+
.dsm-workbuddy-pool-set-copy{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}
|
|
1736
|
+
.dsm-workbuddy-pool-set-copy b{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
1737
|
+
.dsm-workbuddy-pool-set-copy span{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#999)}
|
|
1738
|
+
.dsm-workbuddy-pool-set-ctl{flex:none;display:flex;align-items:center;gap:8px}
|
|
1739
|
+
.dsm-workbuddy-pool-num{width:74px;padding:6px 10px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:8px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-primary,#e6e6e6);font:inherit;font-size:13px;text-align:center;font-variant-numeric:tabular-nums}
|
|
1740
|
+
.dsm-workbuddy-pool-select{max-width:260px;padding:6px 10px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:8px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-primary,#e6e6e6);font:inherit;font-size:13px;cursor:pointer}
|
|
1741
|
+
.dsm-workbuddy-pool-select:disabled,.dsm-workbuddy-pool-num:disabled{opacity:.5;cursor:default}
|
|
1742
|
+
.dsm-workbuddy-pool-save-bar{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
|
|
1743
|
+
.dsm-workbuddy-pool-conflict{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:11px 13px;border-radius:10px;background:rgba(245,158,11,.08);border:1px solid var(--dsw-alias-state-warn-primary,#f59e0b)}
|
|
1744
|
+
.dsm-workbuddy-pool-pending{flex:none;align-self:center;font-size:12px;line-height:17px;color:var(--dsw-alias-state-warn-primary,#f59e0b)}
|
|
1745
|
+
.dsm-workbuddy-pool-conflict-main{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}
|
|
1746
|
+
.dsm-workbuddy-pool-conflict-main b{font-size:12px;font-weight:600;color:var(--dsw-alias-state-warn-primary,#f59e0b)}
|
|
1747
|
+
.dsm-workbuddy-pool-conflict-main span{font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
|
|
1748
|
+
.dsm-workbuddy-pool-log{border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-layer-2,#24262c)}
|
|
1749
|
+
.dsm-workbuddy-pool-log-head{display:flex;align-items:center;justify-content:space-between;padding:9px 14px;background:var(--dsw-alias-bg-layer-3,#2a2c33);border-bottom:1px solid var(--dsw-alias-border-l1,#2c2d31);font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#c6c9d0)}
|
|
1750
|
+
.dsm-workbuddy-pool-log-clear{padding:2px 10px;font-size:12px}
|
|
1751
|
+
.dsm-workbuddy-pool-log-body{max-height:186px;overflow-y:auto}
|
|
1752
|
+
.dsm-workbuddy-pool-log-row{display:flex;gap:10px;padding:7px 14px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31);font-size:12px;line-height:18px}
|
|
1753
|
+
.dsm-workbuddy-pool-log-row:first-child{border-top:0}
|
|
1754
|
+
.dsm-workbuddy-pool-log-time{flex:none;color:var(--dsw-alias-label-tertiary,#999);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
1755
|
+
.dsm-workbuddy-pool-log-text{min-width:0;overflow-wrap:anywhere}
|
|
1756
|
+
.dsm-workbuddy-pool-log-ok{color:var(--dsw-alias-state-success-primary,#22a06b)}
|
|
1757
|
+
.dsm-workbuddy-pool-log-warn{color:var(--dsw-alias-state-warn-primary,#f59e0b)}
|
|
1758
|
+
.dsm-workbuddy-pool-log-error{color:var(--dsw-alias-state-error-primary,#ef4444)}
|
|
1759
|
+
.dsm-workbuddy-pool-log-info{color:var(--dsw-alias-label-secondary,#c6c9d0)}
|
|
1760
|
+
.dsm-workbuddy-pool-log-empty{padding:14px;font-size:12px;color:var(--dsw-alias-label-tertiary,#999)}
|
|
1761
|
+
@media (max-width:760px){.dsm-workbuddy-pool-row{grid-template-columns:1fr 1fr;row-gap:6px}.dsm-workbuddy-pool-select{max-width:180px}}
|
|
1762
|
+
/* Pool switch, namespaced under .dsm-workbuddy-pool so it cannot collide with host styles. */
|
|
1763
|
+
.dsm-workbuddy-pool .sw{position:relative;display:inline-block;width:38px;height:22px;flex:none;cursor:pointer}
|
|
1764
|
+
.dsm-workbuddy-pool .sw input{position:absolute;opacity:0;width:0;height:0}
|
|
1765
|
+
.dsm-workbuddy-pool .sw-track{position:absolute;inset:0;border-radius:999px;background:var(--dsw-alias-border-l2,#3a3d45);transition:background .18s}
|
|
1766
|
+
.dsm-workbuddy-pool .sw-track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;transition:transform .18s;box-shadow:0 1px 3px rgba(0,0,0,.3)}
|
|
1767
|
+
.dsm-workbuddy-pool .sw input:checked+.sw-track{background:var(--dsw-alias-brand-primary,#5686fe)}
|
|
1768
|
+
.dsm-workbuddy-pool .sw input:checked+.sw-track::after{transform:translateX(16px)}
|
|
1769
|
+
.dsm-workbuddy-pool .sw input:disabled+.sw-track{opacity:.4}
|
|
1770
|
+
.dsm-workbuddy-pool .sw input:disabled{cursor:default}
|
|
621
1771
|
`;
|
|
622
1772
|
//#endregion
|
|
623
1773
|
//#region src/client/searched-paths.ts
|
|
@@ -866,16 +2016,25 @@ window.__ModuleLoader__.load({
|
|
|
866
2016
|
month: "2-digit",
|
|
867
2017
|
day: "2-digit",
|
|
868
2018
|
hour: "2-digit",
|
|
869
|
-
minute: "2-digit"
|
|
2019
|
+
minute: "2-digit",
|
|
2020
|
+
hourCycle: "h23"
|
|
870
2021
|
}).format(new Date(value));
|
|
871
2022
|
}
|
|
872
|
-
/**
|
|
2023
|
+
/**
|
|
2024
|
+
* Compact package-date rendering with time, e.g. `08/25 14:44`.
|
|
2025
|
+
*
|
|
2026
|
+
* `hourCycle: 'h23'` is what makes that example TRUE. Without it `Intl` defers
|
|
2027
|
+
* to the browser locale, and en-US produced `08/25, 02:44 PM` — 12-hour, with a
|
|
2028
|
+
* comma the template never asked for. `h23` (not `hour12: false`) keeps midnight
|
|
2029
|
+
* at `00:00` instead of `24:00`, and matches DSH's own clock cycle.
|
|
2030
|
+
*/
|
|
873
2031
|
function formatDate(value) {
|
|
874
2032
|
return new Intl.DateTimeFormat(void 0, {
|
|
875
2033
|
month: "2-digit",
|
|
876
2034
|
day: "2-digit",
|
|
877
2035
|
hour: "2-digit",
|
|
878
|
-
minute: "2-digit"
|
|
2036
|
+
minute: "2-digit",
|
|
2037
|
+
hourCycle: "h23"
|
|
879
2038
|
}).format(new Date(value));
|
|
880
2039
|
}
|
|
881
2040
|
function formatCapacity(value, unknown) {
|
|
@@ -970,6 +2129,22 @@ window.__ModuleLoader__.load({
|
|
|
970
2129
|
/** Save failure surfaced next to the buttons; cleared by the next attempt. */
|
|
971
2130
|
const [saveError, setSaveError] = (0, react.useState)(void 0);
|
|
972
2131
|
const [switchingAccount, setSwitchingAccount] = (0, react.useState)(false);
|
|
2132
|
+
/**
|
|
2133
|
+
* Whether the account pool's rotation currently owns this region's choice.
|
|
2134
|
+
*
|
|
2135
|
+
* Reported up by the pool section. While true, the manual account dropdown is
|
|
2136
|
+
* disabled: both would write the same slot, and letting them disagree is how
|
|
2137
|
+
* the card ends up showing one account while another is billed. The pool
|
|
2138
|
+
* shows the reason and an inline way to switch rotation off.
|
|
2139
|
+
*/
|
|
2140
|
+
const [poolRotationLocked, setPoolRotationLocked] = (0, react.useState)(false);
|
|
2141
|
+
/**
|
|
2142
|
+
* Whether the account pool's save is in flight. Both sections write into one
|
|
2143
|
+
* region slot and the Host merges per-region, so two concurrent saves can
|
|
2144
|
+
* interleave on a stale base and revert each other while both report success
|
|
2145
|
+
* — the two save buttons are serialized to make that impossible.
|
|
2146
|
+
*/
|
|
2147
|
+
const [poolBusy, setPoolBusy] = (0, react.useState)(false);
|
|
973
2148
|
/** A refused account write (silently unpersisted settings on a locked file). */
|
|
974
2149
|
const [accountError, setAccountError] = (0, react.useState)(void 0);
|
|
975
2150
|
const [checkingIn, setCheckingIn] = (0, react.useState)(false);
|
|
@@ -998,7 +2173,37 @@ window.__ModuleLoader__.load({
|
|
|
998
2173
|
(0, react.useEffect)(() => settingsScope?.subscribe(() => {
|
|
999
2174
|
setSettingsRevision((value) => value + 1);
|
|
1000
2175
|
}), [settingsScope]);
|
|
2176
|
+
/**
|
|
2177
|
+
* Per-region latest-wins guard for the usage fetch.
|
|
2178
|
+
*
|
|
2179
|
+
* Two refreshes can overlap — the 60s poll, a save, a batch action, the
|
|
2180
|
+
* account re-scan — and they are plain `fetch`es with no ordering guarantee.
|
|
2181
|
+
* Without a guard the SLOWER response wins, so a snapshot taken before a
|
|
2182
|
+
* check-in could land after one taken after it and overwrite the newer
|
|
2183
|
+
* credits. The guard makes "the last request started" the one that is applied,
|
|
2184
|
+
* whatever order the responses arrive in.
|
|
2185
|
+
*
|
|
2186
|
+
* Keyed by region, not global: the card deliberately fetches BOTH regions at
|
|
2187
|
+
* once (`for (const region of WORKBUDDY_REGIONS)` below), so a single shared
|
|
2188
|
+
* counter would make each of those two calls cancel the other and leave one
|
|
2189
|
+
* tab permanently stale.
|
|
2190
|
+
*
|
|
2191
|
+
* The rule itself is the tested `createLatestWins` factory, not a second
|
|
2192
|
+
* implementation written here.
|
|
2193
|
+
*
|
|
2194
|
+
* `begin()` returns a probe that reports **stale**: `true` once a newer call
|
|
2195
|
+
* has claimed the same region (see `src/account-pool.ts`). So the variable is
|
|
2196
|
+
* named `stale` and the bail-out is `if (stale())` — the same polarity as the
|
|
2197
|
+
* Host's rotation guard in `src/index.ts`. Naming it `fresh` and writing
|
|
2198
|
+
* `if (!fresh())` inverts the poll: every response that is NOT superseded —
|
|
2199
|
+
* i.e. every response in the single-fetch case — gets discarded, and the card
|
|
2200
|
+
* renders its placeholder forever. That defect shipped once; the polarity is
|
|
2201
|
+
* now pinned by a rendered assertion rather than by the spelling of the
|
|
2202
|
+
* identifier.
|
|
2203
|
+
*/
|
|
2204
|
+
const usageGuard = (0, react.useRef)(createLatestWins());
|
|
1001
2205
|
const refreshUsage = (0, react.useCallback)(async (region, signal) => {
|
|
2206
|
+
const stale = usageGuard.current.begin(region);
|
|
1002
2207
|
try {
|
|
1003
2208
|
const response = await fetch(withWorkBuddyRegion(WORKBUDDY_USAGE_PATH, region), {
|
|
1004
2209
|
headers: { accept: "application/json" },
|
|
@@ -1008,19 +2213,34 @@ window.__ModuleLoader__.load({
|
|
|
1008
2213
|
const value = await response.json().catch(() => void 0);
|
|
1009
2214
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1010
2215
|
const usage = value;
|
|
2216
|
+
if (stale()) return void 0;
|
|
1011
2217
|
if (mounted.current && signal?.aborted !== true) setStatusByRegion((prev) => ({
|
|
1012
2218
|
...prev,
|
|
1013
2219
|
[region]: usage
|
|
1014
2220
|
}));
|
|
1015
2221
|
return usage;
|
|
1016
2222
|
} catch (error) {
|
|
1017
|
-
if (
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
2223
|
+
if (stale()) return void 0;
|
|
2224
|
+
if (mounted.current && signal?.aborted !== true) {
|
|
2225
|
+
const message = error instanceof Error ? error.message : t("row.requestFailed");
|
|
2226
|
+
setStatusByRegion((prev) => {
|
|
2227
|
+
const previous = prev[region];
|
|
2228
|
+
if (previous !== void 0 && previous.status === "signed-in") return {
|
|
2229
|
+
...prev,
|
|
2230
|
+
[region]: {
|
|
2231
|
+
...previous,
|
|
2232
|
+
refreshError: message
|
|
2233
|
+
}
|
|
2234
|
+
};
|
|
2235
|
+
return {
|
|
2236
|
+
...prev,
|
|
2237
|
+
[region]: {
|
|
2238
|
+
status: "error",
|
|
2239
|
+
message
|
|
2240
|
+
}
|
|
2241
|
+
};
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
1024
2244
|
return;
|
|
1025
2245
|
}
|
|
1026
2246
|
}, [t]);
|
|
@@ -1203,6 +2423,7 @@ window.__ModuleLoader__.load({
|
|
|
1203
2423
|
if (mounted.current) setStatusByRegion((prev) => ({
|
|
1204
2424
|
...prev,
|
|
1205
2425
|
[activeRegion]: {
|
|
2426
|
+
...prev[activeRegion],
|
|
1206
2427
|
status: "error",
|
|
1207
2428
|
message: error instanceof Error ? error.message : t("row.requestFailed")
|
|
1208
2429
|
}
|
|
@@ -1349,7 +2570,10 @@ window.__ModuleLoader__.load({
|
|
|
1349
2570
|
discardModels();
|
|
1350
2571
|
await refreshUsage(activeRegion);
|
|
1351
2572
|
} catch (error) {
|
|
1352
|
-
if (mounted.current)
|
|
2573
|
+
if (mounted.current) {
|
|
2574
|
+
const reason = error instanceof Error ? error.message : t("row.requestFailed");
|
|
2575
|
+
setSaveError(isFileContentionWriteError(error) ? `${reason}${t("row.saveContentionHint")}` : reason);
|
|
2576
|
+
}
|
|
1353
2577
|
} finally {
|
|
1354
2578
|
if (mounted.current) setSaving(false);
|
|
1355
2579
|
}
|
|
@@ -1454,7 +2678,7 @@ window.__ModuleLoader__.load({
|
|
|
1454
2678
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1455
2679
|
type: "checkbox",
|
|
1456
2680
|
checked: regionOnState,
|
|
1457
|
-
disabled: togglingRegion === region || !canWrite,
|
|
2681
|
+
disabled: togglingRegion === region || !canWrite || saving || poolBusy,
|
|
1458
2682
|
"aria-label": t("row.tabSwitchAria", { region: region === "cn" ? t("row.tabCn") : t("row.tabGlobal") }),
|
|
1459
2683
|
onChange: (event) => {
|
|
1460
2684
|
toggleRegion(region, event.target.checked);
|
|
@@ -1518,7 +2742,7 @@ window.__ModuleLoader__.load({
|
|
|
1518
2742
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
1519
2743
|
className: "dsm-workbuddy-usage-select",
|
|
1520
2744
|
value: status.accounts.find((account) => account.selected)?.id ?? "",
|
|
1521
|
-
disabled: switchingAccount || !canWrite,
|
|
2745
|
+
disabled: switchingAccount || !canWrite || poolRotationLocked,
|
|
1522
2746
|
onChange: (event) => {
|
|
1523
2747
|
switchAccount(event.currentTarget.value);
|
|
1524
2748
|
},
|
|
@@ -1640,6 +2864,11 @@ window.__ModuleLoader__.load({
|
|
|
1640
2864
|
className: "dsm-workbuddy-usage-error",
|
|
1641
2865
|
children: t("row.creditsError", { message: status.creditsError })
|
|
1642
2866
|
}),
|
|
2867
|
+
status.refreshError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2868
|
+
className: "dsm-workbuddy-usage-error",
|
|
2869
|
+
role: "alert",
|
|
2870
|
+
children: t("row.requestFailedHint", { message: status.refreshError })
|
|
2871
|
+
}),
|
|
1643
2872
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
1644
2873
|
className: "dsm-workbuddy-models",
|
|
1645
2874
|
"aria-label": t("row.modelsTitle"),
|
|
@@ -1801,13 +3030,13 @@ window.__ModuleLoader__.load({
|
|
|
1801
3030
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1802
3031
|
type: "button",
|
|
1803
3032
|
className: "dsm-btn dsm-btn-outline",
|
|
1804
|
-
disabled: !dirty || saving,
|
|
3033
|
+
disabled: !dirty || saving || poolBusy || togglingRegion !== void 0,
|
|
1805
3034
|
onClick: discardModels,
|
|
1806
3035
|
children: t("row.discard")
|
|
1807
3036
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1808
3037
|
type: "button",
|
|
1809
3038
|
className: "dsm-btn dsm-btn-primary",
|
|
1810
|
-
disabled: !dirty || saving || activeEnabledIds.size === 0,
|
|
3039
|
+
disabled: !dirty || saving || poolBusy || togglingRegion !== void 0 || activeEnabledIds.size === 0,
|
|
1811
3040
|
onClick: () => {
|
|
1812
3041
|
saveModels();
|
|
1813
3042
|
},
|
|
@@ -1817,6 +3046,19 @@ window.__ModuleLoader__.load({
|
|
|
1817
3046
|
]
|
|
1818
3047
|
})
|
|
1819
3048
|
]
|
|
3049
|
+
}),
|
|
3050
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountPool, {
|
|
3051
|
+
t,
|
|
3052
|
+
region: activeRegion,
|
|
3053
|
+
...status.pool === void 0 ? {} : { pool: status.pool },
|
|
3054
|
+
...settingsScope === void 0 ? {} : { settingsScope },
|
|
3055
|
+
siblingBusy: saving || togglingRegion !== void 0,
|
|
3056
|
+
onBusyChange: setPoolBusy,
|
|
3057
|
+
onSaved: async () => await refreshUsage(activeRegion) !== void 0,
|
|
3058
|
+
onRefresh: () => {
|
|
3059
|
+
refreshUsage(activeRegion);
|
|
3060
|
+
},
|
|
3061
|
+
onRotationChange: setPoolRotationLocked
|
|
1820
3062
|
})
|
|
1821
3063
|
] }) : null,
|
|
1822
3064
|
status.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
@@ -1883,6 +3125,15 @@ window.__ModuleLoader__.load({
|
|
|
1883
3125
|
"row.credentialRejectedRelogin": "No other local sign-in exists to switch to. Sign in again in the WorkBuddy desktop app.",
|
|
1884
3126
|
"row.credentialRejectedChoose": "Other local sign-ins exist but none of them answered the upstream just now. Try switching to one above; if none works, sign in again in the desktop app.",
|
|
1885
3127
|
"row.requestFailed": "Request failed",
|
|
3128
|
+
"row.requestFailedHint": "Refresh failed — showing the last loaded data: {message}",
|
|
3129
|
+
"row.poolErrUnavailable": "The account pool is not available in this build (or its batch route is missing). Restart the app; if it persists, the plugin needs reinstalling.",
|
|
3130
|
+
"row.poolErrFailed": "The batch failed on the host side: {message}",
|
|
3131
|
+
"row.poolErrHttp": "The batch was refused (HTTP {status}). Check the plugin is up to date and try again.",
|
|
3132
|
+
"row.poolErrDisabled": "The account pool is switched off for this region. Turn it on and save before running a batch.",
|
|
3133
|
+
"row.poolErrNoMembers": "No account is checked into this pool — the batch had nothing to run. Check at least one account and save.",
|
|
3134
|
+
"row.poolErrNoLiveMembers": "Every account checked into this pool has lost its local sign-in, so the batch had nothing to run. Sign in again in the WorkBuddy desktop app, or save to drop them.",
|
|
3135
|
+
"row.poolErrNoFreeModel": "This region has no zero-multiplier model to test with. Refresh the model catalog, or pick a target model yourself.",
|
|
3136
|
+
"row.poolErrStaleModel": "The target model is no longer offered by this region. Pick another, or switch back to automatic.",
|
|
1886
3137
|
"row.creditsTotalLabel": "Total remaining credits",
|
|
1887
3138
|
"row.creditsMonthlyRemain": "Remaining {remain} / {size} · Refreshes {at}",
|
|
1888
3139
|
"row.checkinClaim": "Check in",
|
|
@@ -1912,6 +3163,7 @@ window.__ModuleLoader__.load({
|
|
|
1912
3163
|
"row.save": "Save",
|
|
1913
3164
|
"row.saving": "Saving…",
|
|
1914
3165
|
"row.saveError": "Save failed: {message}",
|
|
3166
|
+
"row.saveContentionHint": " (The file was locked, so the write was refused — on Windows this is usually an antivirus scanner, a sync client such as OneDrive, or an editor with that file open. Close whatever holds it and save again; your edits are still here.)",
|
|
1915
3167
|
"row.modelContext": "Maximum context {context}",
|
|
1916
3168
|
"row.contextBudget": "DSH context budget",
|
|
1917
3169
|
"row.modelOutput": "Output {output}",
|
|
@@ -1938,7 +3190,98 @@ window.__ModuleLoader__.load({
|
|
|
1938
3190
|
"row.probeFailed": "✗ Test failed",
|
|
1939
3191
|
"row.probeFailedStatus": "✗ Test failed (HTTP {status})",
|
|
1940
3192
|
"row.probeError": "Could not run the test: {message}",
|
|
1941
|
-
"row.cheer": "Star on GitHub"
|
|
3193
|
+
"row.cheer": "Star on GitHub",
|
|
3194
|
+
"row.poolTitle": "Account pool",
|
|
3195
|
+
"row.poolSummary": "{count} account(s) in this region · target model {model}",
|
|
3196
|
+
"row.poolNoCandidate": "No account in this pool can be used right now.",
|
|
3197
|
+
"row.poolNoCandidateHint": "Every account is rate-limited, out of credits, rejected, unreachable, or in a state this build cannot use. Re-check the accounts, or sign in again in the desktop app.",
|
|
3198
|
+
"row.poolRotating": "Auto-rotating",
|
|
3199
|
+
"row.poolCheckinAll": "Check in all accounts",
|
|
3200
|
+
"row.poolCheckingIn": "Checking in…",
|
|
3201
|
+
"row.poolTestAll": "Test all accounts",
|
|
3202
|
+
"row.poolTesting": "Testing…",
|
|
3203
|
+
"row.poolBusyHint": "Running one account at a time…",
|
|
3204
|
+
"row.poolTargetAuto": "Auto",
|
|
3205
|
+
"row.poolTargetStaleShort": "target model unavailable",
|
|
3206
|
+
"row.poolTargetNoneShort": "no free model",
|
|
3207
|
+
"row.poolTargetFree": "Auto-picked free model: {model}",
|
|
3208
|
+
"row.poolTargetPreferred": "Your chosen model: {model}",
|
|
3209
|
+
"row.poolTargetNone": "This region has no zero-multiplier model yet.",
|
|
3210
|
+
"row.poolTargetStale": "The target model you saved is no longer offered: {model}",
|
|
3211
|
+
"row.poolTargetStaleHint": "The region's catalog changed and no longer lists this model, so testing is off rather than running against a model the upstream may not accept. Pick another target, or switch back to automatic.",
|
|
3212
|
+
"row.poolTargetStaleClear": "Switch back to automatic",
|
|
3213
|
+
"row.poolTargetStaleOption": "{model} (no longer offered)",
|
|
3214
|
+
"row.poolTargetNoneHint": "The domestic catalog only reveals its multipliers after a model refresh. Rather than quietly spending credits on a paid model, testing stays off until one appears — refresh the models, or pick a target model yourself.",
|
|
3215
|
+
"row.poolColumnAccount": "Account",
|
|
3216
|
+
"row.poolColumnCredits": "Credits",
|
|
3217
|
+
"row.poolColumnProbe": "Target model",
|
|
3218
|
+
"row.poolColumnCheckin": "Check-in",
|
|
3219
|
+
"row.poolNeverTested": "Not tested yet",
|
|
3220
|
+
"row.poolTestedAt": "Tested {at}",
|
|
3221
|
+
"row.poolCheckedIn": "Checked in",
|
|
3222
|
+
"row.poolNotCheckedIn": "Not checked in",
|
|
3223
|
+
"row.poolCheckinUnknown": "—",
|
|
3224
|
+
"row.poolCreditSoon": "{count} expiring within 3 days",
|
|
3225
|
+
"row.poolCreditNearest": "Nearest package expires {at}",
|
|
3226
|
+
"row.poolExcludedRateLimited": "Rate limited",
|
|
3227
|
+
"row.poolExcludedOutOfCredit": "Out of credits",
|
|
3228
|
+
"row.poolExcludedRejected": "Rejected",
|
|
3229
|
+
"row.poolExcludedUnusable": "Unusable",
|
|
3230
|
+
"row.poolRetryAt": "Back after {at}",
|
|
3231
|
+
"row.poolRetryUnknown": "The upstream gave no time for when this frees up",
|
|
3232
|
+
"row.poolSettingsTitle": "Account pool settings",
|
|
3233
|
+
"row.poolSettingsHint": "These four are preferences: they take effect when you save (same as the model list). Test results and rotation are written automatically and are not affected by this button.",
|
|
3234
|
+
"row.poolEnabled": "Enable the account pool",
|
|
3235
|
+
"row.poolEnabledHint": "While off, the plugin behaves exactly as before: it follows the account you selected and never reorders accounts.",
|
|
3236
|
+
"row.poolRotate": "Rotate by credits",
|
|
3237
|
+
"row.poolRotateHint": "Let the plugin pick the billing account by availability, then credits, then expiry (ties fall back to credential freshness and account id). Turning this on disables manual selection until you save; every switch is recorded.",
|
|
3238
|
+
"row.poolInterval": "Automatic test interval",
|
|
3239
|
+
"row.poolIntervalHint": "Minutes between automatic tests of this region's accounts. Runs only while BOTH the pool and this region's provider are enabled; switching either off stops the timer.",
|
|
3240
|
+
"row.poolIntervalUnit": "minutes",
|
|
3241
|
+
"row.poolTargetLabel": "Target test model",
|
|
3242
|
+
"row.poolTargetHint": "Auto (default) picks a zero-multiplier model from this region's catalog, preferring the largest context window. The two regions can pick different models. A manual choice always wins.",
|
|
3243
|
+
"row.poolTargetAutoOption": "Auto — pick a free model",
|
|
3244
|
+
"row.poolSaved": "Save",
|
|
3245
|
+
"row.poolSaving": "Saving…",
|
|
3246
|
+
"row.poolSaveFailed": "Could not save: {message}",
|
|
3247
|
+
"row.poolSavedStaleRefresh": "Saved, but the panel could not re-read it. The value is stored; the next refresh will show it.",
|
|
3248
|
+
"row.poolDiscard": "Discard changes",
|
|
3249
|
+
"row.poolDirty": "● Unsaved changes",
|
|
3250
|
+
"row.poolSaveIdle": "Change any setting above to save it.",
|
|
3251
|
+
"row.poolSaveDirty": "You have unsaved changes — saving writes them to this region.",
|
|
3252
|
+
"row.poolManualLocked": "Manual selection is disabled: this region is rotating automatically",
|
|
3253
|
+
"row.poolManualLockedHint": "The plugin picks the account by availability, then credits, then expiry, so you cannot pin one manually. The dropdown above is disabled to avoid showing one account while another is billed.",
|
|
3254
|
+
"row.poolManualUnlock": "Turn off rotation",
|
|
3255
|
+
"row.poolUnlockPending": "Save to apply — until then the plugin is still rotating.",
|
|
3256
|
+
"row.poolUnlockSaved": "Rotation off — save to unlock",
|
|
3257
|
+
"row.poolLogTitle": "Activity",
|
|
3258
|
+
"row.poolLogClear": "Clear",
|
|
3259
|
+
"row.poolLogEmpty": "Nothing yet. Check-ins, tests, and account switches are recorded here.",
|
|
3260
|
+
"row.poolLogCheckinStart": "Checking in {count} account(s), one at a time",
|
|
3261
|
+
"row.poolLogCheckinClaimed": "{accountName}: checked in (+{credit})",
|
|
3262
|
+
"row.poolLogCheckinAlready": "{accountName}: already checked in today — skipped",
|
|
3263
|
+
"row.poolLogCheckinFailed": "{accountName}: check-in failed — {message}",
|
|
3264
|
+
"row.poolLogCheckinDone": "Check-in finished",
|
|
3265
|
+
"row.poolLogBatchFailed": "Batch failed — nothing ran: {message}",
|
|
3266
|
+
"row.poolLogTestStart": "Testing {count} account(s) against {model}",
|
|
3267
|
+
"row.poolLogTestRow": "{accountName}: {outcome}",
|
|
3268
|
+
"row.poolLogTestDone": "Test finished; the pool was re-ranked",
|
|
3269
|
+
"row.poolLogTestRowMalformed": "{accountName}: the host returned no result for this account",
|
|
3270
|
+
"row.poolLogRotated": "Switched billing from {from} to {to}",
|
|
3271
|
+
"row.poolLogRotationStopped": "Rotation stopped — back to {from}",
|
|
3272
|
+
"row.poolRegionNote": "The domestic and international pools are fully independent: accounts, switches, intervals, and target models never affect each other.",
|
|
3273
|
+
"row.poolMembersTitle": "Accounts in this pool",
|
|
3274
|
+
"row.poolMembersHint": "Only checked accounts are checked in, tested, and used for rotation. Nothing is selected by default — check the ones you want, then save.",
|
|
3275
|
+
"row.poolSelectAll": "Select all",
|
|
3276
|
+
"row.poolSelectNone": "Clear all",
|
|
3277
|
+
"row.poolSelectedCount": "{count} of {total} selected",
|
|
3278
|
+
"row.poolNoneSelected": "No account is checked into this pool.",
|
|
3279
|
+
"row.poolNoneSelectedHint": "Check at least one account above and save. Until then both buttons stay disabled, because a pool with no members has nothing to run on.",
|
|
3280
|
+
"row.poolGhostMembers": "{count} saved account(s) are no longer signed in on this machine, so they are not counted and not used. Save again to drop them.",
|
|
3281
|
+
"row.poolSelectAria": "Include {accountName} in this region's account pool",
|
|
3282
|
+
"row.poolMember": "In pool",
|
|
3283
|
+
"row.poolNotMember": "Not in pool",
|
|
3284
|
+
"row.poolUnsavedMembers": "Selection changed — save to apply"
|
|
1942
3285
|
};
|
|
1943
3286
|
const zh = {
|
|
1944
3287
|
"row.title": "接入使用 WorkBuddy 积分与模型(dsh-connect-workbuddy)",
|
|
@@ -1975,6 +3318,15 @@ window.__ModuleLoader__.load({
|
|
|
1975
3318
|
"row.credentialRejectedRelogin": "本机没有其他账号可切换。请在 WorkBuddy 桌面端重新登录。",
|
|
1976
3319
|
"row.credentialRejectedChoose": "本机还有其他账号,但刚才都没能通过上游校验。可以在上方切换一个试试;如果都不行,请在桌面端重新登录。",
|
|
1977
3320
|
"row.requestFailed": "请求失败",
|
|
3321
|
+
"row.requestFailedHint": "刷新失败——下方为上次加载的数据:{message}",
|
|
3322
|
+
"row.poolErrUnavailable": "本版本没有可用的账号池(或其批量接口缺失)。请重启应用;若仍不行,插件可能需要重新安装。",
|
|
3323
|
+
"row.poolErrFailed": "批量操作在宿主侧失败:{message}",
|
|
3324
|
+
"row.poolErrHttp": "批量操作被拒绝(HTTP {status})。请确认插件是最新版本后重试。",
|
|
3325
|
+
"row.poolErrDisabled": "本区域的账号池是关闭的。请先启用并保存,再执行批量操作。",
|
|
3326
|
+
"row.poolErrNoMembers": "本池没有勾选任何账号,批量操作没有可执行对象。请至少勾选一个账号并保存。",
|
|
3327
|
+
"row.poolErrNoLiveMembers": "本池勾选的账号在本机都已不再登录,批量操作没有可执行对象。请在 WorkBuddy 桌面端重新登录,或保存以移除它们。",
|
|
3328
|
+
"row.poolErrNoFreeModel": "本区域没有可用的免费(倍率为 0)模型。请先刷新模型目录,或自己指定一个目标模型。",
|
|
3329
|
+
"row.poolErrStaleModel": "目标模型已不在本区域目录中。请改选一个,或切回自动。",
|
|
1978
3330
|
"row.creditsTotalLabel": "总剩余积分",
|
|
1979
3331
|
"row.creditsMonthlyRemain": "剩余 {remain} / {size} · {at} 刷新",
|
|
1980
3332
|
"row.checkinClaim": "立即签到",
|
|
@@ -2004,6 +3356,7 @@ window.__ModuleLoader__.load({
|
|
|
2004
3356
|
"row.save": "保存",
|
|
2005
3357
|
"row.saving": "保存中…",
|
|
2006
3358
|
"row.saveError": "保存失败:{message}",
|
|
3359
|
+
"row.saveContentionHint": "(该文件被占用,写入被拒——Windows 上通常是杀毒软件、OneDrive 这类同步盘,或正打开该文件的编辑器。请先关闭占用者再保存一次;你的改动仍然还在。)",
|
|
2007
3360
|
"row.modelContext": "最大上下文 {context}",
|
|
2008
3361
|
"row.contextBudget": "DSH 上下文预算",
|
|
2009
3362
|
"row.modelOutput": "最大输出 {output}",
|
|
@@ -2030,7 +3383,98 @@ window.__ModuleLoader__.load({
|
|
|
2030
3383
|
"row.probeFailed": "✗ 测试失败",
|
|
2031
3384
|
"row.probeFailedStatus": "✗ 测试失败(HTTP {status})",
|
|
2032
3385
|
"row.probeError": "无法执行测试:{message}",
|
|
2033
|
-
"row.cheer": "鼓励一下"
|
|
3386
|
+
"row.cheer": "鼓励一下",
|
|
3387
|
+
"row.poolTitle": "账号池",
|
|
3388
|
+
"row.poolSummary": "本区域 {count} 个账号 · 目标模型 {model}",
|
|
3389
|
+
"row.poolNoCandidate": "账号池里目前没有可用的账号。",
|
|
3390
|
+
"row.poolNoCandidateHint": "每个账号要么被限流、要么积分耗尽、要么被上游拒绝、要么连不上上游,或处于本版本无法使用的状态。请重新检测账号,或在桌面端重新登录。",
|
|
3391
|
+
"row.poolRotating": "自动轮换中",
|
|
3392
|
+
"row.poolCheckinAll": "一键签到所有账号",
|
|
3393
|
+
"row.poolCheckingIn": "签到中…",
|
|
3394
|
+
"row.poolTestAll": "一键测试所有账号",
|
|
3395
|
+
"row.poolTesting": "测试中…",
|
|
3396
|
+
"row.poolBusyHint": "正在逐个账号执行…",
|
|
3397
|
+
"row.poolTargetAuto": "自动",
|
|
3398
|
+
"row.poolTargetStaleShort": "目标模型已失效",
|
|
3399
|
+
"row.poolTargetNoneShort": "无免费模型",
|
|
3400
|
+
"row.poolTargetFree": "自动选中的免费模型:{model}",
|
|
3401
|
+
"row.poolTargetPreferred": "你指定的模型:{model}",
|
|
3402
|
+
"row.poolTargetNone": "本区域暂时没有倍率为 0 的免费模型。",
|
|
3403
|
+
"row.poolTargetStale": "你保存的目标模型已不在目录中:{model}",
|
|
3404
|
+
"row.poolTargetStaleHint": "本区域的模型目录已变化,不再包含这个模型,因此测试被停用——而不是拿一个上游可能不接受的模型去跑。请改选一个目标模型,或切回自动。",
|
|
3405
|
+
"row.poolTargetStaleClear": "切回自动",
|
|
3406
|
+
"row.poolTargetStaleOption": "{model}(已下架)",
|
|
3407
|
+
"row.poolTargetNoneHint": "国内版的倍率要等模型目录刷新后才会出现。为了不让你在不知情的情况下花掉积分,插件**不会**自动改用收费模型——请先刷新模型目录,或自己指定一个目标模型。",
|
|
3408
|
+
"row.poolColumnAccount": "账号",
|
|
3409
|
+
"row.poolColumnCredits": "积分",
|
|
3410
|
+
"row.poolColumnProbe": "目标模型",
|
|
3411
|
+
"row.poolColumnCheckin": "签到",
|
|
3412
|
+
"row.poolNeverTested": "尚未测试",
|
|
3413
|
+
"row.poolTestedAt": "测试于 {at}",
|
|
3414
|
+
"row.poolCheckedIn": "已签到",
|
|
3415
|
+
"row.poolNotCheckedIn": "未签到",
|
|
3416
|
+
"row.poolCheckinUnknown": "—",
|
|
3417
|
+
"row.poolCreditSoon": "{count} 积分 3 天内过期",
|
|
3418
|
+
"row.poolCreditNearest": "最近积分包 {at} 过期",
|
|
3419
|
+
"row.poolExcludedRateLimited": "被限流",
|
|
3420
|
+
"row.poolExcludedOutOfCredit": "积分耗尽",
|
|
3421
|
+
"row.poolExcludedRejected": "被拒绝",
|
|
3422
|
+
"row.poolExcludedUnusable": "不可用",
|
|
3423
|
+
"row.poolRetryAt": "{at} 之后可再用",
|
|
3424
|
+
"row.poolRetryUnknown": "上游未给出何时恢复",
|
|
3425
|
+
"row.poolSettingsTitle": "账号池设置",
|
|
3426
|
+
"row.poolSettingsHint": "下面四项是偏好设置:点保存才生效(与「模型管理」一致)。测试结果与轮换记录由插件自动写入,不受这个保存按钮影响。",
|
|
3427
|
+
"row.poolEnabled": "启用账号池",
|
|
3428
|
+
"row.poolEnabledHint": "关闭时插件行为与从前完全一致:跟随你选择的账号,绝不按积分重排。",
|
|
3429
|
+
"row.poolRotate": "按积分自动轮换",
|
|
3430
|
+
"row.poolRotateHint": "允许插件按「可用性 → 积分 → 可用时间」自动选择计费账号(并列时依次比凭据新鲜度与账号 id)。开启后需保存才停用手动选择;每次切换都会留下记录。",
|
|
3431
|
+
"row.poolInterval": "自动测试间隔",
|
|
3432
|
+
"row.poolIntervalHint": "每隔多少分钟自动测试本区域账号。需要「账号池」与本区域的供应商都启用;任一关闭即停止定时器。",
|
|
3433
|
+
"row.poolIntervalUnit": "分钟",
|
|
3434
|
+
"row.poolTargetLabel": "目标测试模型",
|
|
3435
|
+
"row.poolTargetHint": "「自动」(默认)会从本区域目录里挑一个倍率为 0 的免费模型,多个候选取上下文窗口最大的。国内版与国际版可能挑到不同的模型。手动指定优先于自动。",
|
|
3436
|
+
"row.poolTargetAutoOption": "自动 —— 挑一个免费模型",
|
|
3437
|
+
"row.poolSaved": "保存",
|
|
3438
|
+
"row.poolSaving": "保存中…",
|
|
3439
|
+
"row.poolSaveFailed": "保存失败:{message}",
|
|
3440
|
+
"row.poolSavedStaleRefresh": "已保存,但面板未能重新读取。设置已写入;下次刷新后会显示。",
|
|
3441
|
+
"row.poolDiscard": "放弃修改",
|
|
3442
|
+
"row.poolDirty": "● 有未保存的修改",
|
|
3443
|
+
"row.poolSaveIdle": "修改任一设置后即可保存。",
|
|
3444
|
+
"row.poolSaveDirty": "有未保存的修改 —— 保存后写入本区域配置。",
|
|
3445
|
+
"row.poolManualLocked": "手动选择已停用:本区域正在自动轮换",
|
|
3446
|
+
"row.poolManualLockedHint": "插件按「可用性 → 积分 → 可用时间」自动选择账号,因此不能手动指定。上面的下拉已置灰,避免出现\"显示 A 却计费 B\"。",
|
|
3447
|
+
"row.poolManualUnlock": "关闭自动轮换",
|
|
3448
|
+
"row.poolUnlockPending": "保存后生效 —— 在那之前插件仍在轮换。",
|
|
3449
|
+
"row.poolUnlockSaved": "已关闭轮换 —— 保存后解锁",
|
|
3450
|
+
"row.poolLogTitle": "活动记录",
|
|
3451
|
+
"row.poolLogClear": "清空",
|
|
3452
|
+
"row.poolLogEmpty": "尚无活动。签到、测试与账号切换都会记录在这里。",
|
|
3453
|
+
"row.poolLogCheckinStart": "开始签到 {count} 个账号(逐个执行)",
|
|
3454
|
+
"row.poolLogCheckinClaimed": "{accountName}:签到成功 +{credit}",
|
|
3455
|
+
"row.poolLogCheckinAlready": "{accountName}:今日已签到,跳过",
|
|
3456
|
+
"row.poolLogCheckinFailed": "{accountName}:签到失败 —— {message}",
|
|
3457
|
+
"row.poolLogCheckinDone": "签到完成",
|
|
3458
|
+
"row.poolLogBatchFailed": "批量操作失败 —— 没有执行:{message}",
|
|
3459
|
+
"row.poolLogTestStart": "开始测试 {count} 个账号 × {model}",
|
|
3460
|
+
"row.poolLogTestRow": "{accountName}:{outcome}",
|
|
3461
|
+
"row.poolLogTestDone": "测试完成,已重新计算候选账号",
|
|
3462
|
+
"row.poolLogTestRowMalformed": "{accountName}:Host 未返回该账号的结果",
|
|
3463
|
+
"row.poolLogRotated": "计费账号已从 {from} 切换到 {to}",
|
|
3464
|
+
"row.poolLogRotationStopped": "轮换已停止 —— 回到 {from}",
|
|
3465
|
+
"row.poolRegionNote": "国内版与国际版是完全独立的两个账号池:账号、开关、间隔、目标模型互不影响。",
|
|
3466
|
+
"row.poolMembersTitle": "本池包含的账号",
|
|
3467
|
+
"row.poolMembersHint": "只有勾选的账号才会被签到、测试和轮换。默认一个都不勾 —— 请勾选你要的账号,然后保存。",
|
|
3468
|
+
"row.poolSelectAll": "全选",
|
|
3469
|
+
"row.poolSelectNone": "全不选",
|
|
3470
|
+
"row.poolSelectedCount": "已选 {count} / {total}",
|
|
3471
|
+
"row.poolNoneSelected": "这个池里还没有勾选任何账号。",
|
|
3472
|
+
"row.poolNoneSelectedHint": "请在上方勾选至少一个账号并保存。在此之前两个按钮都不可用 —— 空池没有可执行的对象。",
|
|
3473
|
+
"row.poolGhostMembers": "有 {count} 个已保存的账号在本机已不再登录,因此不计入、也不会被使用。再保存一次即可移除它们。",
|
|
3474
|
+
"row.poolSelectAria": "把 {accountName} 加入本区域的账号池",
|
|
3475
|
+
"row.poolMember": "已在池中",
|
|
3476
|
+
"row.poolNotMember": "未入池",
|
|
3477
|
+
"row.poolUnsavedMembers": "勾选已改动 —— 保存后生效"
|
|
2034
3478
|
};
|
|
2035
3479
|
//#endregion
|
|
2036
3480
|
//#region src/client/index.tsx
|