dsh-connect-workbuddy 4.8.1 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -690,7 +690,8 @@ window.__ModuleLoader__.load({
690
690
  const slot = {
691
691
  enabled: preferences.enabled,
692
692
  targetModelId: preferences.targetModelId,
693
- memberAccountIds: preferences.memberAccountIds
693
+ memberAccountIds: preferences.memberAccountIds,
694
+ orderAccountIds: preferences.orderAccountIds
694
695
  };
695
696
  if (preferences.probeInputTokens !== void 0) slot["probeInputTokens"] = preferences.probeInputTokens;
696
697
  const next = nextRegionPool(scope.getSnapshot().value, region, slot)[region];
@@ -704,10 +705,73 @@ window.__ModuleLoader__.load({
704
705
  if (!Array.isArray(stored) || stored.length !== preferences.memberAccountIds.length) return false;
705
706
  const wanted = new Set(preferences.memberAccountIds);
706
707
  if (!stored.every((id) => typeof id === "string" && wanted.has(id))) return false;
708
+ const storedOrder = pool["orderAccountIds"];
709
+ if (!Array.isArray(storedOrder) || storedOrder.length !== preferences.orderAccountIds.length) return false;
710
+ if (!storedOrder.every((id, index) => id === preferences.orderAccountIds[index])) return false;
707
711
  return true;
708
712
  });
709
713
  }
710
714
  //#endregion
715
+ //#region src/client/manual-order.ts
716
+ /**
717
+ * Manual priority order, shared by every surface that lists member accounts.
718
+ *
719
+ * The defect this exists to prevent: the pool card sorts its rows by
720
+ * `orderAccountIds` while the composer credits panel listed the same accounts
721
+ * in whatever order the Host returned. Two views of one pool, disagreeing — the
722
+ * user sorts the pool, opens the credit panel, and sees a different order, so
723
+ * neither list looks authoritative. A user cannot tell which one is "the" order,
724
+ * and the manual sorting feature looks broken even though it saved correctly.
725
+ *
726
+ * So the order lives here, once, and both lists call it. The alternative —
727
+ * each component sorting however it likes — is exactly what drifted.
728
+ */
729
+ /**
730
+ * The saved manual order out of a settings snapshot, or an empty array.
731
+ *
732
+ * Reads defensively: the snapshot is Host-supplied JSON, so `regions` may be
733
+ * absent (a profile that never used this feature), `pool` may be absent, and
734
+ * `orderAccountIds` may be anything at all. Every shape that is not a list of
735
+ * strings means "no manual order", which is also an older profile's spelling —
736
+ * never a crash.
737
+ */
738
+ function readOrderAccountIds(value, region) {
739
+ if (region === void 0) return [];
740
+ const regions = value?.regions;
741
+ if (typeof regions !== "object" || regions === null) return [];
742
+ const slot = regions[region];
743
+ if (typeof slot !== "object" || slot === null) return [];
744
+ const order = slot.pool;
745
+ if (typeof order !== "object" || order === null) return [];
746
+ const ids = order.orderAccountIds;
747
+ if (!Array.isArray(ids)) return [];
748
+ return ids.filter((id) => typeof id === "string");
749
+ }
750
+ /**
751
+ * Reorder `accounts` to follow `orderIds`, leaving unordered accounts in place
752
+ * AFTER the ordered ones, and preserving their original relative order.
753
+ *
754
+ * Returns a new array; never mutates the input, because the caller's array is
755
+ * usually a state value that must not change identity behind React's back.
756
+ *
757
+ * Ids in `orderIds` that no longer name an account are simply ignored: the pool
758
+ * can drop a member while an order still mentions it, and that must degrade to
759
+ * "that position is empty" rather than hide a real account.
760
+ */
761
+ function applyManualOrder(accounts, orderIds) {
762
+ if (orderIds.length === 0) return [...accounts];
763
+ const rank = /* @__PURE__ */ new Map();
764
+ orderIds.forEach((id, index) => {
765
+ if (!rank.has(id)) rank.set(id, index);
766
+ });
767
+ const ordered = [];
768
+ const rest = [];
769
+ for (const account of accounts) if (rank.has(account.id)) ordered.push(account);
770
+ else rest.push(account);
771
+ ordered.sort((left, right) => (rank.get(left.id) ?? 0) - (rank.get(right.id) ?? 0));
772
+ return [...ordered, ...rest];
773
+ }
774
+ //#endregion
711
775
  //#region src/client/exclusion-text.ts
712
776
  /** The exclusion label for one account, or undefined when it is usable. */
713
777
  function exclusionText(t, excludedBy) {
@@ -724,6 +788,449 @@ window.__ModuleLoader__.load({
724
788
  }
725
789
  }
726
790
  //#endregion
791
+ //#region src/account-pool.ts
792
+ /**
793
+ * How long an account stays out of the pool when the upstream stated no time.
794
+ *
795
+ * A conservative FALLBACK, not a measurement: it applies whenever a failed
796
+ * outcome carries no `retryAtMs`, which is every rate limit that came without a
797
+ * reset sentence AND every transient failure (a transport error, a 5xx). In both
798
+ * cases "when can I use this again" has no honest answer.
799
+ *
800
+ * The two failure directions it avoids: keeping the account in the pool would
801
+ * re-bill it immediately and re-trip the same limit; dropping it FOREVER would
802
+ * discard a working account over an event that said nothing about it. A short,
803
+ * stated cooldown is the least-wrong of the two — and it is what lets the pool
804
+ * heal itself, since nothing else re-probes an excluded member.
805
+ */
806
+ const POOL_UNKNOWN_COOLDOWN_MS = 18e5;
807
+ /**
808
+ * Whether one account can be billed at `nowMs`.
809
+ *
810
+ * Returns the exclusion reason, or undefined when the account is a candidate.
811
+ * The order matters and is NOT arbitrary:
812
+ *
813
+ * - A `credential-rejected` account is excluded regardless of any cooldown: its
814
+ * token is not accepted, so waiting cannot help and the user must re-auth.
815
+ * - A limited, unreachable or failed outcome is checked against its stated (or
816
+ * fallback) cooldown, so an account whose limit has already reset — or whose
817
+ * gateway blipped once — is back in the pool without needing another test
818
+ * first.
819
+ * - An `ok` measurement, and no measurement at all, are both candidates. The
820
+ * latter matters: a freshly discovered account has never been tested and must
821
+ * not be invisible.
822
+ *
823
+ * WHY TRANSIENT OUTCOMES GET A COOLDOWN. They are not statements about the
824
+ * ACCOUNT. A transport failure can be a DNS blip, and — before the shim learned
825
+ * to skip reporting an aborted request — it was routinely the user closing the
826
+ * panel mid-flight. A 5xx is the gateway's afternoon, not this account's health.
827
+ * Excluding them permanently meant one transient event idled a perfectly good
828
+ * account until the user happened to press "test" again, and with every member
829
+ * so idled the pool had no candidate at all: failover then had nowhere to go and
830
+ * the raw upstream error was reported as-is. The cooldown is what makes the pool
831
+ * RECOVER instead of staying broken.
832
+ *
833
+ * `not-found` and `credential-rejected` deliberately get none: one describes a
834
+ * model the region no longer offers (a catalog fact, not a transient one) and
835
+ * the other needs the user to sign in again. Neither is fixed by waiting, and
836
+ * pretending otherwise would re-bill a token the upstream has already refused.
837
+ *
838
+ * Each unusable outcome keeps its OWN value rather than being folded into
839
+ * `credential-rejected`. The fold made one row state two contradictory things —
840
+ * the name column said "被拒绝" (sign in again) while the probe column, reading
841
+ * the same measurement, said "连不上上游——这是网络问题,不是模型问题" — and it
842
+ * told a user whose upstream had merely blipped that their sign-in was bad.
843
+ * (That label has since been made cause-NEUTRAL: it used to assert "not the
844
+ * model" for a bucket that also holds gateway 5xx and request timeouts, where
845
+ * the claim is simply unfounded. The recorded `message` now carries the cause.)
846
+ * The distinctions are already computed upstream in `outcomeOfFailure`
847
+ * (`src/probe.ts:290-299`); this only stops discarding them.
848
+ */
849
+ function exclusionOf(probe, nowMs) {
850
+ if (probe === void 0) return void 0;
851
+ switch (probe.outcome) {
852
+ case "ok": return;
853
+ case "credential-rejected": return "credential-rejected";
854
+ case "not-found": return "not-found";
855
+ case "unavailable": return retryDue(probe, nowMs) ? void 0 : "unavailable";
856
+ case "failed": return retryDue(probe, nowMs) ? void 0 : "failed";
857
+ case "rate-limited": return retryDue(probe, nowMs) ? void 0 : "rate-limited";
858
+ case "model-busy": return retryDue(probe, nowMs, 12e4) ? void 0 : "model-busy";
859
+ case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
860
+ case "policy-rejected": return;
861
+ default: return "unusable";
862
+ }
863
+ }
864
+ /**
865
+ * Whether an account's cooldown has elapsed (or was never stated).
866
+ *
867
+ * The fallback is the ONLY thing a transient outcome relies on: `cooldownOf`
868
+ * attaches `retryAtMs` for a rate limit or a drained quota, never for a
869
+ * transport failure, so `atMs + POOL_UNKNOWN_COOLDOWN_MS` is what brings those
870
+ * back. That is deliberate — the alternative is a permanent exclusion decided by
871
+ * an event that said nothing about the account.
872
+ *
873
+ * `fallbackMs` lets one outcome carry a SHORTER clock than the generic one: the
874
+ * model-scoped throttle is upstream's own "about a minute", and applying the
875
+ * half-hour account fallback to it is issue #56. It is a parameter rather than a
876
+ * second function so every outcome keeps sharing one definition of "due".
877
+ */
878
+ function retryDue(probe, nowMs, fallbackMs = POOL_UNKNOWN_COOLDOWN_MS) {
879
+ return nowMs >= (probe.retryAtMs ?? probe.atMs + fallbackMs);
880
+ }
881
+ /**
882
+ * The checked ids that still resolve to a local sign-in, in the caller's order.
883
+ *
884
+ * This is THE definition of "effective members", and it exists because the same
885
+ * predicate was written five separate times: the status document
886
+ * (`web-status.ts`), the batch route's empty-pool guard, the Host dependency the
887
+ * card reads, `poolMemberAccounts` and the browser helper. Five copies of one
888
+ * rule is this project's single most productive bug shape ("fixed one end,
889
+ * missed the other"), so the rule now has one home and every site delegates to
890
+ * it.
891
+ *
892
+ * ORDER IS THE CALLER'S, and comes from `orderedIds`: the answer preserves the
893
+ * order of THAT argument. Callers pass whichever list's order they want to show
894
+ * — the saved list for the user's own order, the store's roster for the account
895
+ * table's. Neither is silently reshuffled by the filter. (The first parameter was
896
+ * once named `saved`, which read as "this must be the saved list" even though
897
+ * `web-status.ts` legitimately passes the roster for store order; the name was
898
+ * the trap, so it now describes the ROLE instead of the source.)
899
+ *
900
+ * Pure, so it is unit-testable without a store, and it never mutates its input.
901
+ */
902
+ function effectiveMembersOf(orderedIds, listedIds) {
903
+ return orderedIds.filter((id) => listedIds.has(id));
904
+ }
905
+ /**
906
+ * A latest-wins guard for one region's asynchronous work.
907
+ *
908
+ * Exists because `applyRotation` awaits (it fetches credits per member), so two
909
+ * overlapping calls can interleave: a slow call that read "rotation is ON" can
910
+ * resume AFTER a newer call cleared the override for "rotation is OFF" and write
911
+ * the stale account back — leaving the card showing the pool switched off while
912
+ * a rotated account is still billed.
913
+ *
914
+ * `begin()` claims a new generation and returns a probe; the caller must check
915
+ * it after EVERY await and bail when it reports stale. Extracted as a pure
916
+ * factory rather than inlined so the rule is unit-testable: an inlined token is
917
+ * only observable by reproducing a real interleaving, which is slow and easy to
918
+ * get subtly wrong (a harness that never actually overlaps passes either way).
919
+ */
920
+ function createLatestWins() {
921
+ const generations = {};
922
+ return { begin(key) {
923
+ const generation = (generations[key] ?? 0) + 1;
924
+ generations[key] = generation;
925
+ return () => generations[key] !== generation;
926
+ } };
927
+ }
928
+ //#endregion
929
+ //#region src/client/remaining.ts
930
+ const MINUTE_MS$1 = 6e4;
931
+ const HOUR_MS$1 = 60 * MINUTE_MS$1;
932
+ const DAY_MS$1 = 24 * HOUR_MS$1;
933
+ /**
934
+ * How far out hours stop being the useful unit.
935
+ *
936
+ * Below this, hours; at or above it, days. The cut is at TWO days rather than
937
+ * one so that 30 hours reads as "约 30 小时后" instead of rounding to "约 1 天后",
938
+ * which would understate it by six hours.
939
+ */
940
+ const DAYS_AT_MS = 2 * DAY_MS$1;
941
+ /**
942
+ * A short "约 N 分钟后 / 约 N 小时后 / 约 N 天后", or `''` once the moment passed.
943
+ *
944
+ * The empty string is the contract callers depend on: an elapsed cooldown means
945
+ * the account is already back, so appending "in 0 minutes" would assert the
946
+ * opposite of the state the card is showing. Callers append this only when it is
947
+ * non-empty.
948
+ *
949
+ * Rounding is deliberately coarse — "约 1 小时后" for 69 minutes is what a reader
950
+ * wants, and a precision that changes every minute would be noise rather than
951
+ * information. The absolute instant sits right beside it for anyone who needs it.
952
+ */
953
+ function remainingText(t, untilMs, nowMs) {
954
+ const left = untilMs - nowMs;
955
+ if (left <= 0) return "";
956
+ if (left < MINUTE_MS$1) return t("row.remainingImminent");
957
+ if (left < HOUR_MS$1) return t("row.remainingMinutes", { count: Math.round(left / MINUTE_MS$1) });
958
+ if (left < DAYS_AT_MS) return t("row.remainingHours", { count: Math.round(left / HOUR_MS$1) });
959
+ return t("row.remainingDays", { count: Math.round(left / DAY_MS$1) });
960
+ }
961
+ //#endregion
962
+ //#region src/client/account-state.ts
963
+ /** Whether a probe outcome means the model answered. */
964
+ function outcomeOk(outcome) {
965
+ return outcome === "ok";
966
+ }
967
+ /** A short, localizable label for one probe outcome. */
968
+ function outcomeText(t, outcome) {
969
+ switch (outcome) {
970
+ case "ok": return t("row.probeOk");
971
+ case "rate-limited": return t("row.poolExcludedRateLimited");
972
+ case "model-busy": return t("row.poolExcludedModelBusy");
973
+ case "out-of-credit": return t("row.poolExcludedOutOfCredit");
974
+ case "credential-rejected": return t("row.probeCredentialRejected");
975
+ case "not-found": return t("row.probeNotFound");
976
+ case "unavailable": return t("row.probeUnavailable");
977
+ default: return t("row.probeFailed");
978
+ }
979
+ }
980
+ /**
981
+ * `MM/DD, HH:mm` on a 24-hour clock.
982
+ *
983
+ * `hourCycle: 'h23'` is REQUIRED, not decorative: without it `Intl` follows the
984
+ * browser locale's preference, and en-US renders `09/30, 01:23 PM`. A wall-clock
985
+ * glued to "AM/PM" reads as 12-hour no matter what the surrounding UI assumes,
986
+ * and it is the one thing a Chinese user cannot scan past — the card otherwise
987
+ * speaks 24-hour everywhere (see `formatClock`).
988
+ *
989
+ * `h23` rather than `hour12: false`, because the latter maps to `h24` in some
990
+ * engines and would print midnight as `24:00`. `h23` pins 00–23 exactly, which
991
+ * is also the cycle DSH's own timestamps use.
992
+ */
993
+ function formatShortClock(value) {
994
+ return new Intl.DateTimeFormat(void 0, {
995
+ month: "2-digit",
996
+ day: "2-digit",
997
+ hour: "2-digit",
998
+ minute: "2-digit",
999
+ hourCycle: "h23"
1000
+ }).format(new Date(value));
1001
+ }
1002
+ /**
1003
+ * The "why can't this serve, and until when" sentence for one account.
1004
+ *
1005
+ * Returns `undefined` when there is nothing to say — an account with no
1006
+ * measurement and no exclusion is simply unknown, and inventing "可用" for it
1007
+ * would be a claim nobody made.
1008
+ *
1009
+ * The retry wording has three states, matching the card's three:
1010
+ * - a stated instant → "被限流 · 10/19 01:32 之后可再用"
1011
+ * - no instant but a timed → "被限流 · 未提供恢复时间"
1012
+ * exclusion
1013
+ * - any other outcome → the bare outcome ("连不上上游")
1014
+ *
1015
+ * That third case is deliberate: "未提供恢复时间" beside a credential rejection
1016
+ * would imply waiting helps, when the only fix is signing in again.
1017
+ */
1018
+ function availabilityLine(t, account, nowMs = Date.now()) {
1019
+ const probe = account.probe;
1020
+ if (probe !== void 0) {
1021
+ if (probe.retryAtMs !== void 0) {
1022
+ const left = remainingText(t, probe.retryAtMs, nowMs);
1023
+ const at = t("row.poolRetryAt", { at: formatShortClock(probe.retryAtMs) });
1024
+ return `${outcomeText(t, probe.outcome)} · ${at}${left === "" ? "" : ` · ${left}`}`;
1025
+ }
1026
+ if (probe.outcome === "rate-limited" || probe.outcome === "model-busy" || probe.outcome === "out-of-credit") return `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}`;
1027
+ return outcomeText(t, probe.outcome);
1028
+ }
1029
+ if (account.excludedBy !== void 0) return exclusionLabel(t, account.excludedBy);
1030
+ }
1031
+ /**
1032
+ * The SHORT form of the same fact: why the account cannot serve, and how long
1033
+ * until it can — without the absolute instant.
1034
+ *
1035
+ * This exists because the composer's balance panel prints one line per account
1036
+ * and the absolute date made every limited row read like a log entry
1037
+ * ("被限流 · 10/19, 01:32 之后可再用 · 约 6 小时后") beside a number the row is
1038
+ * actually about. The panel keeps the reason and the wait; the pool card keeps
1039
+ * the full sentence from {@link availabilityLine}, which is the one place a
1040
+ * reader can afford the exact instant.
1041
+ *
1042
+ * It is a sibling rather than a parameter so the two surfaces cannot half-adopt
1043
+ * a flag: the card has no way to reach this function, and the panel has no way
1044
+ * to lose the wait.
1045
+ */
1046
+ function availabilityMark(t, account, nowMs = Date.now()) {
1047
+ const probe = account.probe;
1048
+ if (probe !== void 0) {
1049
+ if (probe.retryAtMs !== void 0) {
1050
+ const left = remainingText(t, probe.retryAtMs, nowMs);
1051
+ return left === "" ? outcomeText(t, probe.outcome) : `${outcomeText(t, probe.outcome)} · ${left}`;
1052
+ }
1053
+ if (probe.outcome === "rate-limited" || probe.outcome === "model-busy" || probe.outcome === "out-of-credit") return `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}`;
1054
+ return outcomeText(t, probe.outcome);
1055
+ }
1056
+ if (account.excludedBy !== void 0) return exclusionLabel(t, account.excludedBy);
1057
+ }
1058
+ /**
1059
+ * The exclusion's own label, for the case where no probe backs it.
1060
+ *
1061
+ * Kept private: callers that have a probe must go through `availabilityLine` or
1062
+ * `availabilityMark`, so neither surface can invent its own vocabulary for the
1063
+ * same measurement.
1064
+ */
1065
+ function exclusionLabel(t, excludedBy) {
1066
+ switch (excludedBy) {
1067
+ case "rate-limited": return t("row.poolExcludedRateLimited");
1068
+ case "model-busy": return t("row.poolExcludedModelBusy");
1069
+ case "out-of-credit": return t("row.poolExcludedOutOfCredit");
1070
+ case "credential-rejected": return t("row.poolExcludedRejected");
1071
+ case "not-found": return t("row.probeNotFound");
1072
+ case "unavailable": return t("row.probeUnavailable");
1073
+ case "failed": return t("row.probeFailed");
1074
+ case "unusable": return t("row.poolExcludedUnusable");
1075
+ default: return t("row.poolExcludedUnusable");
1076
+ }
1077
+ }
1078
+ /**
1079
+ * The dot's state as a class suffix, so CSS can colour it without inline styles.
1080
+ *
1081
+ * `error` is separate from `warn` because the advice differs: one means "wait",
1082
+ * the other means "do something".
1083
+ */
1084
+ function stateTone(account) {
1085
+ if (account.excludedBy !== void 0) return account.excludedBy === "rate-limited" || account.excludedBy === "model-busy" || account.excludedBy === "unavailable" ? "warn" : "error";
1086
+ if (account.probe === void 0) return "unknown";
1087
+ return outcomeOk(account.probe.outcome) ? "ok" : "warn";
1088
+ }
1089
+ /**
1090
+ * The tone as a CSS colour variable.
1091
+ *
1092
+ * Derived from `stateTone` rather than repeating its branches: an inlined copy
1093
+ * would be a second definition of the rule, free to drift the first time one is
1094
+ * edited. The pool table draws a dot from a computed colour, so it needs a value
1095
+ * rather than a class name.
1096
+ */
1097
+ function stateColor(account) {
1098
+ switch (stateTone(account)) {
1099
+ case "ok": return "var(--dsw-alias-state-success-primary, #22a06b)";
1100
+ case "warn": return "var(--dsw-alias-state-warn-primary, #f59e0b)";
1101
+ case "error": return "var(--dsw-alias-state-error-primary, #ef4444)";
1102
+ default: return "var(--dsw-alias-label-dimmed, #9aa0a6)";
1103
+ }
1104
+ }
1105
+ /**
1106
+ * Read one line of the test batch's stream as a test row.
1107
+ *
1108
+ * The stream is `{ action, modelId }`, then one `{ row }` per account, then
1109
+ * `{ done: true }` — or `{ reason, error }` when the batch itself broke. Only the
1110
+ * row shape is recognized here; the rest is the caller's business.
1111
+ *
1112
+ * Every field is validated rather than trusted. This parses a wire the panel did
1113
+ * not write, and a half-shaped row reaching the component's state would render
1114
+ * as a measurement nobody took — worse than ignoring the line, because the
1115
+ * subsequent re-read is what would have quietly corrected it.
1116
+ */
1117
+ function testRowOf(line) {
1118
+ const raw = line["row"];
1119
+ if (typeof raw !== "object" || raw === null) return void 0;
1120
+ const row = raw;
1121
+ const accountId = row["accountId"];
1122
+ const name = row["accountName"];
1123
+ const rawResult = row["result"];
1124
+ if (typeof accountId !== "string" || accountId === "") return void 0;
1125
+ if (typeof rawResult !== "object" || rawResult === null) return void 0;
1126
+ const result = probeResultOf(rawResult);
1127
+ if (result === void 0) return void 0;
1128
+ return {
1129
+ accountId,
1130
+ accountName: typeof name === "string" && name !== "" ? name : accountId,
1131
+ result
1132
+ };
1133
+ }
1134
+ /** The probe result inside a test row, when it is complete enough to render. */
1135
+ function probeResultOf(raw) {
1136
+ const outcome = raw["outcome"];
1137
+ const modelId = raw["modelId"];
1138
+ if (typeof outcome !== "string" || !isProbeOutcome(outcome)) return void 0;
1139
+ if (typeof modelId !== "string") return void 0;
1140
+ const elapsedMs = raw["elapsedMs"];
1141
+ const status = raw["status"];
1142
+ const message = raw["message"];
1143
+ const retryAtMs = raw["retryAtMs"];
1144
+ const retrySource = raw["retrySource"];
1145
+ return {
1146
+ modelId,
1147
+ outcome,
1148
+ ...typeof elapsedMs === "number" ? { elapsedMs } : {},
1149
+ ...typeof status === "number" ? { status } : {},
1150
+ ...typeof message === "string" ? { message } : {},
1151
+ ...typeof retryAtMs === "number" ? { retryAtMs } : {},
1152
+ ...isRetrySource(retrySource) ? { retrySource } : {}
1153
+ };
1154
+ }
1155
+ /** The outcome vocabulary, as a value — the type alone cannot be tested against a wire. */
1156
+ const PROBE_OUTCOMES = [
1157
+ "ok",
1158
+ "rate-limited",
1159
+ "model-busy",
1160
+ "out-of-credit",
1161
+ "credential-rejected",
1162
+ "policy-rejected",
1163
+ "unavailable",
1164
+ "not-found",
1165
+ "failed"
1166
+ ];
1167
+ function isProbeOutcome(value) {
1168
+ return PROBE_OUTCOMES.includes(value);
1169
+ }
1170
+ function isRetrySource(value) {
1171
+ return value === "retry-after" || value === "upstream-message" || value === "quota-refresh" || value === "upstream-tips";
1172
+ }
1173
+ /**
1174
+ * Fold ONE streamed test row into the usage document the panel is rendering.
1175
+ *
1176
+ * Why fold at all, when the batch is followed by a re-read: the batch is SERIAL
1177
+ * and a stuck member can take seconds, so a panel that changes nothing until the
1178
+ * LAST account answers is exactly the dead-looking button the streamed batch was
1179
+ * built to avoid. Folding each row as it lands makes the row change the moment
1180
+ * its measurement exists; the re-read afterwards then replaces the fold with the
1181
+ * same facts, this time from the Host's own store.
1182
+ *
1183
+ * The exclusion is RE-DERIVED here rather than left as it was: it is derived
1184
+ * state (`exclusionOf`), so copying the previous one forward would keep a
1185
+ * just-measured-good account red until the re-read — and would keep a
1186
+ * just-measured-limited one green, which is the more expensive error.
1187
+ */
1188
+ function withTestRow(usage, row, nowMs) {
1189
+ if (!("pool" in usage) || usage.pool === void 0) return usage;
1190
+ const pool = usage.pool;
1191
+ const index = indexOfTestedAccount(pool.accounts, row);
1192
+ if (index === void 0) return usage;
1193
+ const measured = pool.accounts[index];
1194
+ if (measured === void 0) return usage;
1195
+ const { excludedBy: _previous, ...account } = measured;
1196
+ const probe = {
1197
+ outcome: row.result.outcome,
1198
+ atMs: nowMs,
1199
+ source: "test-batch",
1200
+ modelId: row.result.modelId,
1201
+ ...row.result.retryAtMs === void 0 ? {} : { retryAtMs: row.result.retryAtMs },
1202
+ ...row.result.message === void 0 ? {} : { message: row.result.message }
1203
+ };
1204
+ const excludedBy = exclusionOf(probe, nowMs);
1205
+ const folded = {
1206
+ ...account,
1207
+ probe,
1208
+ ...excludedBy === void 0 ? {} : { excludedBy }
1209
+ };
1210
+ const accounts = pool.accounts.map((candidate, at) => at === index ? folded : candidate);
1211
+ return {
1212
+ ...usage,
1213
+ pool: {
1214
+ ...pool,
1215
+ accounts
1216
+ }
1217
+ };
1218
+ }
1219
+ /**
1220
+ * Which listed account a test row measured, or `undefined` when it measured none
1221
+ * of them.
1222
+ *
1223
+ * Matched by ID first. The NAME is only a fallback for a row whose id this
1224
+ * document no longer lists, and only when exactly one listed account carries it:
1225
+ * two accounts sharing a name must never have one measurement written onto both.
1226
+ */
1227
+ function indexOfTestedAccount(accounts, row) {
1228
+ const byId = accounts.findIndex((account) => account.accountId === row.accountId);
1229
+ if (byId >= 0) return byId;
1230
+ const matches = accounts.flatMap((account, at) => account.accountName === row.accountName ? [at] : []);
1231
+ return matches.length === 1 ? matches[0] : void 0;
1232
+ }
1233
+ //#endregion
727
1234
  //#region src/client/credit-format.ts
728
1235
  /**
729
1236
  * How a credit balance is written on screen.
@@ -936,9 +1443,9 @@ window.__ModuleLoader__.load({
936
1443
  }
937
1444
  //#endregion
938
1445
  //#region src/client/probe-age.ts
939
- const MINUTE_MS$1 = 6e4;
940
- const HOUR_MS$1 = 60 * MINUTE_MS$1;
941
- const DAY_MS$1 = 24 * HOUR_MS$1;
1446
+ const MINUTE_MS = 6e4;
1447
+ const HOUR_MS = 60 * MINUTE_MS;
1448
+ const DAY_MS = 24 * HOUR_MS;
942
1449
  /**
943
1450
  * How long ago a measurement was taken, coarsely: "刚刚", "N 分钟前",
944
1451
  * "N 小时前", "N 天前".
@@ -954,10 +1461,10 @@ window.__ModuleLoader__.load({
954
1461
  */
955
1462
  function probeAgeText(t, atMs, nowMs) {
956
1463
  const age = nowMs - atMs;
957
- if (age < MINUTE_MS$1) return t("row.poolProbeJustNow");
958
- if (age < HOUR_MS$1) return t("row.poolProbeMinutesAgo", { count: Math.round(age / MINUTE_MS$1) });
959
- if (age < 2 * DAY_MS$1) return t("row.poolProbeHoursAgo", { count: Math.round(age / HOUR_MS$1) });
960
- return t("row.poolProbeDaysAgo", { count: Math.round(age / DAY_MS$1) });
1464
+ if (age < MINUTE_MS) return t("row.poolProbeJustNow");
1465
+ if (age < HOUR_MS) return t("row.poolProbeMinutesAgo", { count: Math.round(age / MINUTE_MS) });
1466
+ if (age < 2 * DAY_MS) return t("row.poolProbeHoursAgo", { count: Math.round(age / HOUR_MS) });
1467
+ return t("row.poolProbeDaysAgo", { count: Math.round(age / DAY_MS) });
961
1468
  }
962
1469
  //#endregion
963
1470
  //#region src/probe.ts
@@ -993,88 +1500,6 @@ window.__ModuleLoader__.load({
993
1500
  new RegExp(`将在?\\s*${RESET_STAMP}[\\s\\S]*?重置`, "u");
994
1501
  new RegExp(`(?:reset|resets|refresh|refreshes)\\s+(?:at|on)\\s+${RESET_STAMP}`, "iu");
995
1502
  //#endregion
996
- //#region src/client/remaining.ts
997
- const MINUTE_MS = 6e4;
998
- const HOUR_MS = 60 * MINUTE_MS;
999
- const DAY_MS = 24 * HOUR_MS;
1000
- /**
1001
- * How far out hours stop being the useful unit.
1002
- *
1003
- * Below this, hours; at or above it, days. The cut is at TWO days rather than
1004
- * one so that 30 hours reads as "约 30 小时后" instead of rounding to "约 1 天后",
1005
- * which would understate it by six hours.
1006
- */
1007
- const DAYS_AT_MS = 2 * DAY_MS;
1008
- /**
1009
- * A short "约 N 分钟后 / 约 N 小时后 / 约 N 天后", or `''` once the moment passed.
1010
- *
1011
- * The empty string is the contract callers depend on: an elapsed cooldown means
1012
- * the account is already back, so appending "in 0 minutes" would assert the
1013
- * opposite of the state the card is showing. Callers append this only when it is
1014
- * non-empty.
1015
- *
1016
- * Rounding is deliberately coarse — "约 1 小时后" for 69 minutes is what a reader
1017
- * wants, and a precision that changes every minute would be noise rather than
1018
- * information. The absolute instant sits right beside it for anyone who needs it.
1019
- */
1020
- function remainingText(t, untilMs, nowMs) {
1021
- const left = untilMs - nowMs;
1022
- if (left <= 0) return "";
1023
- if (left < MINUTE_MS) return t("row.remainingImminent");
1024
- if (left < HOUR_MS) return t("row.remainingMinutes", { count: Math.round(left / MINUTE_MS) });
1025
- if (left < DAYS_AT_MS) return t("row.remainingHours", { count: Math.round(left / HOUR_MS) });
1026
- return t("row.remainingDays", { count: Math.round(left / DAY_MS) });
1027
- }
1028
- //#endregion
1029
- //#region src/account-pool.ts
1030
- /**
1031
- * The checked ids that still resolve to a local sign-in, in the caller's order.
1032
- *
1033
- * This is THE definition of "effective members", and it exists because the same
1034
- * predicate was written five separate times: the status document
1035
- * (`web-status.ts`), the batch route's empty-pool guard, the Host dependency the
1036
- * card reads, `poolMemberAccounts` and the browser helper. Five copies of one
1037
- * rule is this project's single most productive bug shape ("fixed one end,
1038
- * missed the other"), so the rule now has one home and every site delegates to
1039
- * it.
1040
- *
1041
- * ORDER IS THE CALLER'S, and comes from `orderedIds`: the answer preserves the
1042
- * order of THAT argument. Callers pass whichever list's order they want to show
1043
- * — the saved list for the user's own order, the store's roster for the account
1044
- * table's. Neither is silently reshuffled by the filter. (The first parameter was
1045
- * once named `saved`, which read as "this must be the saved list" even though
1046
- * `web-status.ts` legitimately passes the roster for store order; the name was
1047
- * the trap, so it now describes the ROLE instead of the source.)
1048
- *
1049
- * Pure, so it is unit-testable without a store, and it never mutates its input.
1050
- */
1051
- function effectiveMembersOf(orderedIds, listedIds) {
1052
- return orderedIds.filter((id) => listedIds.has(id));
1053
- }
1054
- /**
1055
- * A latest-wins guard for one region's asynchronous work.
1056
- *
1057
- * Exists because `applyRotation` awaits (it fetches credits per member), so two
1058
- * overlapping calls can interleave: a slow call that read "rotation is ON" can
1059
- * resume AFTER a newer call cleared the override for "rotation is OFF" and write
1060
- * the stale account back — leaving the card showing the pool switched off while
1061
- * a rotated account is still billed.
1062
- *
1063
- * `begin()` claims a new generation and returns a probe; the caller must check
1064
- * it after EVERY await and bail when it reports stale. Extracted as a pure
1065
- * factory rather than inlined so the rule is unit-testable: an inlined token is
1066
- * only observable by reproducing a real interleaving, which is slow and easy to
1067
- * get subtly wrong (a harness that never actually overlaps passes either way).
1068
- */
1069
- function createLatestWins() {
1070
- const generations = {};
1071
- return { begin(key) {
1072
- const generation = (generations[key] ?? 0) + 1;
1073
- generations[key] = generation;
1074
- return () => generations[key] !== generation;
1075
- } };
1076
- }
1077
- //#endregion
1078
1503
  //#region src/client/pool-state.ts
1079
1504
  /**
1080
1505
  * Pure decision helpers for the account pool's card section.
@@ -1339,47 +1764,19 @@ window.__ModuleLoader__.load({
1339
1764
  const wanted = new Set(right);
1340
1765
  return left.every((id) => wanted.has(id));
1341
1766
  }
1342
- /** Whether a probe outcome means the model answered. */
1343
- function outcomeOk(outcome) {
1344
- return outcome === "ok";
1345
- }
1346
- /** A short, localizable label for one probe outcome. */
1347
- function outcomeText(t, outcome) {
1348
- switch (outcome) {
1349
- case "ok": return t("row.probeOk");
1350
- case "rate-limited": return t("row.poolExcludedRateLimited");
1351
- case "model-busy": return t("row.poolExcludedModelBusy");
1352
- case "out-of-credit": return t("row.poolExcludedOutOfCredit");
1353
- case "credential-rejected": return t("row.probeCredentialRejected");
1354
- case "not-found": return t("row.probeNotFound");
1355
- case "unavailable": return t("row.probeUnavailable");
1356
- default: return t("row.probeFailed");
1357
- }
1358
- }
1359
- function formatNumber$1(value) {
1360
- return formatCredits(value);
1361
- }
1362
1767
  /**
1363
- * `MM/DD, HH:mm` on a 24-hour clock.
1768
+ * Whether two id lists are equal IN ORDER.
1364
1769
  *
1365
- * `hourCycle: 'h23'` is REQUIRED, not decorative: without it `Intl` follows the
1366
- * browser locale's preference, and en-US renders `09/30, 01:23 PM`. A wall-clock
1367
- * glued to "AM/PM" reads as 12-hour no matter what the surrounding UI assumes,
1368
- * and it is the one thing a Chinese user cannot scan past — the card otherwise
1369
- * speaks 24-hour everywhere (see `formatClock`).
1370
- *
1371
- * `h23` rather than `hour12: false`, because the latter maps to `h24` in some
1372
- * engines and would print midnight as `24:00`. `h23` pins 00–23 exactly, which
1373
- * is also the cycle DSH's own timestamps use.
1770
+ * Unlike `sameIds`, position matters: manual priority is order-sensitive, so a
1771
+ * swapped pair is a real change and must light up "unsaved changes". A set
1772
+ * comparison here would treat reordering as a no-op and drop the user's sort.
1374
1773
  */
1375
- function formatShort(value) {
1376
- return new Intl.DateTimeFormat(void 0, {
1377
- month: "2-digit",
1378
- day: "2-digit",
1379
- hour: "2-digit",
1380
- minute: "2-digit",
1381
- hourCycle: "h23"
1382
- }).format(new Date(value));
1774
+ function sameOrder(left, right) {
1775
+ if (left.length !== right.length) return false;
1776
+ return left.every((id, index) => id === right[index]);
1777
+ }
1778
+ function formatNumber$1(value) {
1779
+ return formatCredits(value);
1383
1780
  }
1384
1781
  function formatClock$1(value) {
1385
1782
  return new Intl.DateTimeFormat(void 0, {
@@ -1389,12 +1786,6 @@ window.__ModuleLoader__.load({
1389
1786
  hourCycle: "h23"
1390
1787
  }).format(new Date(value));
1391
1788
  }
1392
- /** A dot colour for one account's state. */
1393
- function stateColor(account) {
1394
- if (account.excludedBy !== void 0) return account.excludedBy === "rate-limited" || account.excludedBy === "model-busy" || account.excludedBy === "unavailable" ? "var(--dsw-alias-state-warn-primary, #f59e0b)" : "var(--dsw-alias-state-error-primary, #ef4444)";
1395
- if (account.probe === void 0) return "var(--dsw-alias-label-dimmed, #9aa0a6)";
1396
- return outcomeOk(account.probe.outcome) ? "var(--dsw-alias-state-success-primary, #22a06b)" : "var(--dsw-alias-state-warn-primary, #f59e0b)";
1397
- }
1398
1789
  /**
1399
1790
  * The pool section.
1400
1791
  *
@@ -1414,6 +1805,7 @@ window.__ModuleLoader__.load({
1414
1805
  setLog(logStore.get(region) ?? []);
1415
1806
  }, [region]);
1416
1807
  const [draftRegion, setDraftRegion] = (0, react.useState)(void 0);
1808
+ const [orderRulesOpen, setOrderRulesOpen] = (0, react.useState)(false);
1417
1809
  const mounted = (0, react.useRef)(true);
1418
1810
  (0, react.useEffect)(() => {
1419
1811
  mounted.current = true;
@@ -1425,7 +1817,8 @@ window.__ModuleLoader__.load({
1425
1817
  enabled: pool?.enabled ?? false,
1426
1818
  targetModelId: pool?.targetModelId || pool?.staleTargetModelId || "",
1427
1819
  probeInputTokens: pool?.probeInputTokens ?? 0,
1428
- memberAccountIds: pool?.memberAccountIds ?? []
1820
+ memberAccountIds: pool?.memberAccountIds ?? [],
1821
+ orderAccountIds: pool?.orderAccountIds ?? []
1429
1822
  };
1430
1823
  const active = draftRegion === region && draft !== null ? draft : saved;
1431
1824
  /**
@@ -1446,7 +1839,7 @@ window.__ModuleLoader__.load({
1446
1839
  * the pending edit.
1447
1840
  */
1448
1841
  const poolRunning = saved.enabled;
1449
- const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameIds(draft.memberAccountIds, saved.memberAccountIds));
1842
+ const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameIds(draft.memberAccountIds, saved.memberAccountIds) || !sameOrder(draft.orderAccountIds, saved.orderAccountIds));
1450
1843
  /**
1451
1844
  * Membership that actually resolves to a local sign-in — what a batch runs on.
1452
1845
  *
@@ -1460,6 +1853,13 @@ window.__ModuleLoader__.load({
1460
1853
  * the field.
1461
1854
  */
1462
1855
  const listedIds = new Set((pool?.accounts ?? []).map((account) => account.accountId));
1856
+ const orderIds = (() => {
1857
+ const members = active.memberAccountIds.filter((id) => listedIds.has(id));
1858
+ const explicit = active.orderAccountIds.filter((id) => members.includes(id));
1859
+ const rest = members.filter((id) => !explicit.includes(id));
1860
+ return [...explicit, ...rest];
1861
+ })();
1862
+ const orderIsManual = active.orderAccountIds.some((id) => active.memberAccountIds.includes(id));
1463
1863
  const effectiveMembers = effectiveMemberIds({
1464
1864
  savedEffective: pool?.effectiveMemberAccountIds ?? [],
1465
1865
  draftMembers: active.memberAccountIds,
@@ -1607,6 +2007,36 @@ window.__ModuleLoader__.load({
1607
2007
  };
1608
2008
  });
1609
2009
  }, [editDraft]);
2010
+ /**
2011
+ * Move one member one step up (`-1`) or down (`+1`) in the manual priority.
2012
+ *
2013
+ * The FIRST click is "the user starts sorting manually": it persists the WHOLE
2014
+ * currently-visible order into the draft, not just the one swapped pair. A
2015
+ * click that wrote only the moved id would leave the other rows unsorted (and
2016
+ * `orderIsManual` false), so the draft and the tables they power stay in sync.
2017
+ * `delta` is restricted to ±1 — a single swap per click — which is what makes
2018
+ * the first/last rows' extra buttons (rather than fields) correct.
2019
+ */
2020
+ const moveMember = (0, react.useCallback)((accountId, delta) => {
2021
+ editDraft((current) => {
2022
+ const listed = new Set((pool?.accounts ?? []).map((account) => account.accountId));
2023
+ const members = current.memberAccountIds.filter((id) => listed.has(id));
2024
+ const explicit = current.orderAccountIds.filter((id) => members.includes(id));
2025
+ const rest = members.filter((id) => !explicit.includes(id));
2026
+ const visible = [...explicit, ...rest];
2027
+ const index = visible.indexOf(accountId);
2028
+ if (index < 0) return current;
2029
+ const to = index + delta;
2030
+ if (to < 0 || to >= visible.length) return current;
2031
+ const next = [...visible];
2032
+ next.splice(index, 1);
2033
+ next.splice(to, 0, accountId);
2034
+ return {
2035
+ ...current,
2036
+ orderAccountIds: next
2037
+ };
2038
+ });
2039
+ }, [editDraft, pool]);
1610
2040
  /** Check every listed account, or none of them. */
1611
2041
  const selectAll = (0, react.useCallback)((next) => {
1612
2042
  editDraft((current) => ({
@@ -1915,23 +2345,50 @@ window.__ModuleLoader__.load({
1915
2345
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
1916
2346
  disabled: effectiveMembers.length === 0 || !canEditPool,
1917
2347
  onClick: () => selectAll(false)
1918
- }, t("row.poolSelectNone")))), (0, react.createElement)("div", { className: `dsm-workbuddy-pool-table${pool.checkinSupported ? " dsm-workbuddy-pool-table-checkin" : ""}` }, (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")), pool.checkinSupported ? (0, react.createElement)("span", null, t("row.poolColumnCheckin")) : null, (0, react.createElement)("span", null)), ...pool.accounts.map((account) => renderAccountRow({
1919
- t,
1920
- account,
1921
- checked: active.memberAccountIds.includes(account.accountId),
1922
- canEdit: canEditPool,
1923
- checkinSupported: pool.checkinSupported,
1924
- onToggle: (next) => toggleMember(account.accountId, next),
1925
- busy: rowBusyAccountId !== void 0,
1926
- testDisabled: pool.targetModelSource === "none" || pool.targetModelSource === "stale",
1927
- targetHint: pool.targetModelSource === "none" ? t("row.poolTargetNone") : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : void 0,
1928
- onTest: () => {
1929
- runRowTest(account.accountId);
1930
- },
1931
- onRemove: () => {
1932
- runRowRemove(account.accountId, account.accountName);
1933
- }
1934
- }))), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
2348
+ }, t("row.poolSelectNone")))), (0, react.createElement)("details", {
2349
+ className: "dsm-workbuddy-pool-order-rules",
2350
+ open: orderRulesOpen,
2351
+ onToggle: (event) => setOrderRulesOpen(event.currentTarget.open)
2352
+ }, (0, react.createElement)("summary", { className: "dsm-workbuddy-pool-order-rules-summary" }, t("row.poolOrderRulesTitle")), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-order-rules-body" }, (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolOrderRulesIntro")), (0, react.createElement)("ol", { className: "dsm-workbuddy-pool-order-rules-list" }, ...ORDER_RULE_KEYS.map((key) => (0, react.createElement)("li", { key }, t(key)))), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-order-rules-why" }, t("row.poolOrderWhy")), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolOrderBadgeNote")))), (0, react.createElement)("div", { className: `dsm-workbuddy-pool-table${pool.checkinSupported ? " dsm-workbuddy-pool-table-checkin" : ""}` }, (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")), pool.checkinSupported ? (0, react.createElement)("span", null, t("row.poolColumnCheckin")) : null, (0, react.createElement)("span", null)), ...(() => {
2353
+ const ordered = sortAccountsByOrder(pool.accounts, orderIds);
2354
+ let memberSeen = 0;
2355
+ let memberTotal = 0;
2356
+ for (const account of ordered) if (active.memberAccountIds.includes(account.accountId)) memberTotal += 1;
2357
+ return ordered.map((account) => {
2358
+ const isMember = active.memberAccountIds.includes(account.accountId);
2359
+ const left = isMember ? memberSeen : -1;
2360
+ const right = isMember ? memberTotal - 1 - memberSeen : -1;
2361
+ if (isMember) memberSeen += 1;
2362
+ return renderAccountRow({
2363
+ t,
2364
+ account,
2365
+ checked: isMember,
2366
+ canEdit: canEditPool,
2367
+ checkinSupported: pool.checkinSupported,
2368
+ onToggle: (next) => toggleMember(account.accountId, next),
2369
+ busy: rowBusyAccountId !== void 0,
2370
+ testDisabled: pool.targetModelSource === "none" || pool.targetModelSource === "stale",
2371
+ targetHint: pool.targetModelSource === "none" ? t("row.poolTargetNone") : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : void 0,
2372
+ onTest: () => {
2373
+ runRowTest(account.accountId);
2374
+ },
2375
+ onRemove: () => {
2376
+ runRowRemove(account.accountId, account.accountName);
2377
+ },
2378
+ orderIndex: isMember && orderIsManual ? left + 1 : void 0,
2379
+ orderIsManual,
2380
+ moveEnabled: pool.enabled === true && isMember && canEditPool,
2381
+ onMoveUp: () => {
2382
+ moveMember(account.accountId, -1);
2383
+ },
2384
+ onMoveDown: () => {
2385
+ moveMember(account.accountId, 1);
2386
+ },
2387
+ moveUpDisabled: left <= 0,
2388
+ moveDownDisabled: right <= 0
2389
+ });
2390
+ });
2391
+ })()), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
1935
2392
  className: "dsm-workbuddy-pool-warn",
1936
2393
  role: "status"
1937
2394
  }, `${t("row.poolNoneSelected")} ${t("row.poolNoneSelectedHint")}`) : null, ghostMembers$1.length === 0 ? null : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-ghosts" }, (0, react.createElement)("p", {
@@ -1997,12 +2454,11 @@ window.__ModuleLoader__.load({
1997
2454
  }
1998
2455
  /** One account's row. */
1999
2456
  function renderAccountRow(input) {
2000
- const { t, account, checked, canEdit, checkinSupported, onToggle, busy, testDisabled, targetHint, onTest, onRemove } = input;
2457
+ const { t, account, checked, canEdit, checkinSupported, onToggle, busy, testDisabled, targetHint, onTest, onRemove, orderIndex, orderIsManual, moveEnabled, onMoveUp, onMoveDown, moveUpDisabled, moveDownDisabled } = input;
2001
2458
  const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
2002
2459
  const excluded = exclusionText(t, account.excludedBy);
2003
2460
  const probe = account.probe;
2004
- const outcomeLine = 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 === "model-busy" || probe.outcome === "out-of-credit" ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}` : outcomeText(t, probe.outcome);
2005
- const remaining = probe?.retryAtMs === void 0 ? "" : remainingText(t, probe.retryAtMs, Date.now());
2461
+ const outcomeLine = availabilityLine(t, account) ?? t("row.poolNeverTested");
2006
2462
  const reason = probe === void 0 ? void 0 : inlineProbeReason(probe.outcome, probe.message);
2007
2463
  const age = probe === void 0 ? void 0 : probeAgeText(t, probe.atMs, Date.now());
2008
2464
  const probeLabel = probe === void 0 ? void 0 : probe.source === void 0 ? {
@@ -2015,11 +2471,7 @@ window.__ModuleLoader__.load({
2015
2471
  text: t("row.poolProbeSourceTest"),
2016
2472
  className: "dsm-workbuddy-pool-prov-tag"
2017
2473
  };
2018
- const probeLine = [
2019
- outcomeLine,
2020
- remaining,
2021
- reason
2022
- ].filter((part) => part !== void 0 && part !== "").join(" · ");
2474
+ const probeLine = [outcomeLine, reason].filter((part) => part !== void 0 && part !== "").join(" · ");
2023
2475
  return (0, react.createElement)("div", {
2024
2476
  className: `dsm-workbuddy-pool-row${account.current ? " dsm-workbuddy-pool-row-current" : ""}`,
2025
2477
  key: account.accountId
@@ -2032,7 +2484,27 @@ window.__ModuleLoader__.load({
2032
2484
  })), (0, react.createElement)("span", {
2033
2485
  className: "dsm-workbuddy-usage-dot",
2034
2486
  style: { background: stateColor(account) }
2035
- }), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account-name" }, (0, react.createElement)("b", null, name), account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (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", {
2487
+ }), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account-name" }, (0, react.createElement)("b", null, name), checked ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-order" }, orderIsManual ? (0, react.createElement)("span", {
2488
+ className: "dsm-workbuddy-pool-rank",
2489
+ title: t("row.poolRankManualTitle")
2490
+ }, t("row.poolRankLabel", { rank: orderIndex ?? 0 })) : (0, react.createElement)("span", {
2491
+ className: "dsm-workbuddy-pool-rank dsm-workbuddy-pool-rank-auto",
2492
+ title: t("row.poolRankAutoTitle")
2493
+ }, t("row.poolRankAuto")), moveEnabled ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-move" }, (0, react.createElement)("button", {
2494
+ type: "button",
2495
+ className: "dsm-workbuddy-pool-move-btn",
2496
+ title: t("row.poolMoveUpTitle"),
2497
+ "aria-label": t("row.poolMoveUpAria", { accountName: name }),
2498
+ disabled: moveUpDisabled,
2499
+ onClick: onMoveUp
2500
+ }, "↑"), (0, react.createElement)("button", {
2501
+ type: "button",
2502
+ className: "dsm-workbuddy-pool-move-btn",
2503
+ title: t("row.poolMoveDownTitle"),
2504
+ "aria-label": t("row.poolMoveDownAria", { accountName: name }),
2505
+ disabled: moveDownDisabled,
2506
+ onClick: onMoveDown
2507
+ }, "↓")) : null) : null, account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (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: formatShortClock(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: formatShortClock(account.nearestExpiryMs) }))), (0, react.createElement)("span", {
2036
2508
  className: "dsm-workbuddy-pool-probe",
2037
2509
  ...probe?.message === void 0 ? {} : { title: probe.message }
2038
2510
  }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-main" }, probeLine), probeLabel === void 0 && age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-prov" }, probeLabel === void 0 ? null : (0, react.createElement)("span", { className: probeLabel.className }, probeLabel.text), probeLabel === void 0 || age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-age" }, age), probe === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-model" }, probe.modelId === void 0 ? t("row.poolProbeModelUnknown") : t("row.poolProbeModel", { model: probe.modelId })))), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
@@ -2134,6 +2606,61 @@ window.__ModuleLoader__.load({
2134
2606
  key: `${entry.atMs}-${index}`
2135
2607
  }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-log-time" }, formatClock$1(entry.atMs)), (0, react.createElement)("span", { className: `dsm-workbuddy-pool-log-text dsm-workbuddy-pool-log-${entry.tone}` }, entry.text)))));
2136
2608
  }
2609
+ /**
2610
+ * Sort accounts by the manual priority order, without losing stability.
2611
+ *
2612
+ * Helpers/rows are rendered in this order so a click on ↑/↓ reorders the row
2613
+ * immediately (client-side), before any save + host round-trip. Ids in `orderIds`
2614
+ * that are not actually members are skipped; members not listed fall back to the
2615
+ * END in their original relative order.
2616
+ *
2617
+ * The sort is STABLE: two ids with the same priority rank keep their original
2618
+ * array order. A `rank` lookup returns `orderIds.length` as the sentinel for
2619
+ * "not ordered", which is larger than any real rank, so unlisted members sort
2620
+ * after every listed one. An EMPTY `orderIds` short-circuits: the input order is
2621
+ * already the answer, and we avoid building the rank map / comparator for the
2622
+ * common (never-sorted) case.
2623
+ *
2624
+ * TWO subtleties that a naive rank comparator gets wrong, both of which would
2625
+ * make the table visibly wrong:
2626
+ *
2627
+ * - DUPLICATE ids keep the FIRST occurrence's rank (`if (!rank.has(id))`).
2628
+ * Overwriting instead would let a stray duplicate silently push the account
2629
+ * it names to the duplicate's later position, so the row would drift away
2630
+ * from the badge that claims to describe it.
2631
+ * - The sort key is the PAIR (rank, original index), not rank alone. Ranks are
2632
+ * unique per id, but the `Infinity` fallback is shared by every unlisted
2633
+ * account, and `Array.prototype.sort` is NOT guaranteed stable across engines
2634
+ * for arbitrary inputs; relying on it would let the unlisted tail reshuffle
2635
+ * between renders. Breaking the tie on the original index makes the result a
2636
+ * pure function of the input, which is what stops the table from jittering
2637
+ * when the same snapshot arrives twice.
2638
+ */
2639
+ /**
2640
+ * The ordering rules, in EXACTLY the comparator's priority order.
2641
+ *
2642
+ * Kept as one table rather than inline literals so the order is a single thing
2643
+ * a reader can compare against `src/account-pool.ts` — the rule text and the
2644
+ * code must never drift, and a flat list makes that diff trivial. Typed as
2645
+ * `WorkBuddySettingsKey` so a renamed or removed locale key is a compile error
2646
+ * instead of a raw key rendered to the user.
2647
+ */
2648
+ const ORDER_RULE_KEYS = [
2649
+ "row.poolOrderRuleUsable",
2650
+ "row.poolOrderRuleExpiring",
2651
+ "row.poolOrderRuleManual",
2652
+ "row.poolOrderRuleNearestExpiry",
2653
+ "row.poolOrderRuleExpiringAmount",
2654
+ "row.poolOrderRuleTotal",
2655
+ "row.poolOrderRuleFreshness",
2656
+ "row.poolOrderRuleId"
2657
+ ];
2658
+ function sortAccountsByOrder(accounts, orderIds) {
2659
+ return applyManualOrder(accounts.map((account) => ({
2660
+ id: account.accountId,
2661
+ account
2662
+ })), orderIds).map((entry) => entry.account);
2663
+ }
2137
2664
  //#endregion
2138
2665
  //#region src/client/OAuthSignIn.tsx
2139
2666
  /**
@@ -3343,7 +3870,48 @@ window.__ModuleLoader__.load({
3343
3870
  /* Declared AFTER the blanket span rule so it wins: the badge must not inherit
3344
3871
  the muted 11px tertiary treatment the membership line uses. */
3345
3872
  .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-current-tag{align-self:flex-start;margin-top:2px;padding:1px 6px;border-radius:999px;background:rgba(86,134,254,.16);color:var(--dsw-alias-brand-primary,#5686fe);font-size:11px;font-weight:600;line-height:16px;white-space:nowrap}
3873
+ /* Manual-priority chrome (order badge + up/down arrows). The rank badge is a SPAN,
3874
+ not a button: it only DESCRIBES the current order and must not read as a
3875
+ control (nor join the row's button list). The solid vs dashed distinction is
3876
+ load-bearing — a numeric rank must NOT appear when the credit policy still
3877
+ decides the order, so the -auto modifier is the dashed/grey state. */
3878
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-order{display:flex;align-items:center;gap:6px;margin-top:2px;flex-wrap:wrap}
3879
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-rank{display:inline-flex;align-items:center;border-radius:999px;border:1px solid rgba(86,134,254,.32);background:rgba(86,134,254,.12);color:var(--dsw-alias-brand-primary,#5686fe);font-size:11px;font-weight:600;line-height:16px;padding:1px 6px;white-space:nowrap;cursor:default;font-variant-numeric:tabular-nums}
3880
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-rank-auto{border:1px dashed var(--dsw-alias-border-l1,#2c2d31);background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-secondary,#c6c9d0)}
3881
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-move{display:inline-flex;align-items:center;gap:2px}
3882
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-move-btn{width:18px;height:18px;padding:0;display:inline-flex;align-items:center;justify-content:center;border-radius:6px;border:1px solid var(--dsw-alias-border-l1,#2c2d31);background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:11px;line-height:1;cursor:pointer}
3883
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-move-btn:disabled{opacity:.4;cursor:default}
3884
+ /* Hover/focus must change BOTH halves of the contrast pair.
3885
+ The rule this replaces recoloured only the glyph — to
3886
+ --dsw-alias-label-primary (#e6e6e6) — and left the background as it was. In
3887
+ the LIGHT theme that token is near-WHITE, so on hover the arrow painted
3888
+ white-on-light and vanished under the cursor (reported as "the up arrow
3889
+ disappears when I hover"). Recolouring text without also repainting its
3890
+ backdrop is unsafe whenever the two come from independent tokens, so this
3891
+ sets an explicit tinted background and keeps the glyph on the brand colour,
3892
+ which is legible in both themes. The outline is a third, theme-independent
3893
+ affordance so the target is findable even if a token is remapped. */
3894
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-move-btn:not(:disabled):hover,
3895
+ .dsm-workbuddy-pool-account-name .dsm-workbuddy-pool-move-btn:not(:disabled):focus-visible{background:rgba(86,134,254,.16);border-color:rgba(86,134,254,.55);color:var(--dsw-alias-brand-primary,#5686fe);outline:2px solid rgba(86,134,254,.45);outline-offset:1px}
3346
3896
  .dsm-workbuddy-pool-excluded{color:var(--dsw-alias-state-warn-primary,#f59e0b)}
3897
+ /* The ordering-rule disclosure, placed directly above the member table.
3898
+ A native <details>: the marker is left to the browser so the control keeps
3899
+ its built-in keyboard and screen-reader behaviour. The summary is a full-width
3900
+ row because a 12px grey label on its own is a small click target, and this is
3901
+ the one place a confused user is meant to click. */
3902
+ .dsm-workbuddy-pool-order-rules{border:1px solid var(--dsw-alias-border-l1,#2c2d31);border-radius:10px;background:var(--dsw-alias-bg-layer-2,#232529);margin:0 0 10px;overflow:hidden}
3903
+ .dsm-workbuddy-pool-order-rules-summary{padding:8px 12px;cursor:pointer;user-select:none;color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:12px;font-weight:600;line-height:18px}
3904
+ .dsm-workbuddy-pool-order-rules-summary:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}
3905
+ .dsm-workbuddy-pool-order-rules-summary:focus-visible{outline:2px solid rgba(86,134,254,.55);outline-offset:-2px}
3906
+ .dsm-workbuddy-pool-order-rules-body{display:flex;flex-direction:column;gap:8px;padding:0 12px 12px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31);padding-top:10px}
3907
+ /* The rules already carry their own "1." … "8." inside the sentence, so the <ol>
3908
+ is semantic only. Suppressing the marker keeps a single number on screen
3909
+ instead of "1. 1. …". */
3910
+ .dsm-workbuddy-pool-order-rules-list{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
3911
+ .dsm-workbuddy-pool-order-rules-list li{padding-left:2px}
3912
+ /* The "why expiring credits win" note is the sentence users push back on, so
3913
+ it is tinted rather than buried in the same grey as the eight rules. */
3914
+ .dsm-workbuddy-pool-order-rules-why{margin:0;padding:8px 10px;border-radius:8px;border-left:3px solid var(--dsw-alias-state-warn-primary,#f59e0b);background:rgba(245,158,11,.10);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
3347
3915
  .dsm-workbuddy-pool-credits{display:flex;flex-direction:column;gap:1px;min-width:0;font-variant-numeric:tabular-nums}
3348
3916
  .dsm-workbuddy-pool-credits b{color:var(--dsw-alias-label-primary,#e6e6e6);font-weight:600}
3349
3917
  .dsm-workbuddy-pool-soon{font-size:11px;line-height:16px;color:var(--dsw-alias-state-warn-primary,#f59e0b)}
@@ -3623,11 +4191,24 @@ window.__ModuleLoader__.load({
3623
4191
  instead of the name. */
3624
4192
  .dsm-workbuddy-composer-panel-who{display:flex;flex-direction:column;min-width:0;gap:1px}
3625
4193
  .dsm-workbuddy-composer-panel-name{overflow:hidden;text-overflow:ellipsis}
3626
- /* Why the account cannot serve right now ("被限流"), in the warn colour the pool
3627
- table uses for the same fact — so one measurement reads the same on both. */
4194
+ /* The second line under the name: TWO facts, two tones.
4195
+ .dsm-workbuddy-composer-panel-mark-current -- "当前选用", green: it says which
4196
+ account is serving this tab, which is not a problem and must not be painted
4197
+ like one. The owner asked for exactly this ("框出来的,当前选用用绿色的字").
4198
+ .dsm-workbuddy-composer-panel-mark-reason -- why it cannot serve ("被限流"),
4199
+ in the warn colour the pool table uses for the same fact, so one
4200
+ measurement reads the same on both surfaces.
4201
+ The two are siblings in a flex line and the separator between them is drawn
4202
+ here, not in the text: rendered text stays exactly "当前选用 · 被限流 · 约 6
4203
+ 小时后" for anything that reads the DOM as a string. */
3628
4204
  .dsm-workbuddy-composer-panel-mark{
4205
+ display:flex;flex-wrap:wrap;align-items:baseline;
3629
4206
  font-size:11px;line-height:15px;
3630
- color:var(--dsw-alias-state-warn-primary,#f59e0b);
4207
+ }
4208
+ .dsm-workbuddy-composer-panel-mark-current{color:var(--dsw-alias-state-success-primary,#22a06b)}
4209
+ .dsm-workbuddy-composer-panel-mark-reason{color:var(--dsw-alias-state-warn-primary,#f59e0b)}
4210
+ .dsm-workbuddy-composer-panel-mark-current+.dsm-workbuddy-composer-panel-mark-reason::before{
4211
+ content:" · ";color:var(--dsw-alias-label-dimmed,rgba(127,127,127,.6));
3631
4212
  }
3632
4213
  /* Hover belongs to the switch, not the row: the number cell is not clickable,
3633
4214
  so lighting it up would promise an action it cannot perform. */
@@ -3642,9 +4223,21 @@ window.__ModuleLoader__.load({
3642
4223
  flex:none;width:7px;height:7px;border-radius:50%;
3643
4224
  background:var(--dsw-alias-label-dimmed,rgba(127,127,127,.5));
3644
4225
  }
3645
- .dsm-workbuddy-composer-panel-table tr.dsm-workbuddy-composer-panel-row-current .dsm-workbuddy-composer-panel-dot{
3646
- background:var(--dsw-state-success,#22a06b);
3647
- }
4226
+ /* Availability, matching the pool table's dot EXACTLY (see
4227
+ account-state.ts, stateTone, which is the one place the rule lives).
4228
+ Three states, and colour answers only the first question: can this account
4229
+ serve the next message? The second question -- which account is serving this
4230
+ tab -- is answered in words on the row ("current"), so the colour is free to
4231
+ stay honest about usability. Green for usable (selected or not), red for a
4232
+ refusal of any kind (a stated rate limit included: amber used to sit here and
4233
+ read as "mostly fine"), and the neutral grey above for an account nobody has
4234
+ measured yet, which is neither.
4235
+ NOTE: no backticks in this comment. The whole stylesheet is a template
4236
+ literal, and a stray backtick silently closes it (the file then fails to
4237
+ parse with a misleading "Expected a semicolon"). */
4238
+ .dsm-workbuddy-composer-panel-dot-ok{background:var(--dsw-alias-state-success-primary,#22a06b)}
4239
+ .dsm-workbuddy-composer-panel-dot-error{background:var(--dsw-alias-state-error-primary,#ef4444)}
4240
+ .dsm-workbuddy-composer-panel-dot-unknown{background:var(--dsw-alias-label-dimmed,rgba(127,127,127,.5))}
3648
4241
  .dsm-workbuddy-composer-panel-foot{
3649
4242
  display:flex;justify-content:space-between;gap:8px;
3650
4243
  margin-top:9px;padding-top:7px;
@@ -5454,6 +6047,57 @@ window.__ModuleLoader__.load({
5454
6047
  minute: "2-digit"
5455
6048
  });
5456
6049
  }
6050
+ /**
6051
+ * The dot's classes, one per tone.
6052
+ *
6053
+ * Written out in full instead of `\`…-dot-${tone}\`` on purpose: the panel's
6054
+ * CSS-contract test harvests class names from this file with
6055
+ * /dsm-workbuddy-composer-[a-z-]+/ and a template literal would contribute a
6056
+ * dangling "…-dot-" prefix that no rule defines.
6057
+ */
6058
+ const DOT_CLASS = {
6059
+ ok: "dsm-workbuddy-composer-panel-dot dsm-workbuddy-composer-panel-dot-ok",
6060
+ error: "dsm-workbuddy-composer-panel-dot dsm-workbuddy-composer-panel-dot-error",
6061
+ unknown: "dsm-workbuddy-composer-panel-dot dsm-workbuddy-composer-panel-dot-unknown"
6062
+ };
6063
+ /**
6064
+ * The dot class for one row: green when the account can serve, red when it cannot.
6065
+ *
6066
+ * The rule the owner settled on, after three rounds of this: colour answers
6067
+ * "can it be used", and the row's second line answers "is it the one I am
6068
+ * using". Those two questions used to share one channel — an earlier revision
6069
+ * painted green only on the selected account, which made every usable row grey
6070
+ * and the list read as one live row among dead ones ("这样无法知道当前用的是什么"):
6071
+ * true about selection, false about usability, and the panel is opened mainly to
6072
+ * ask the second question.
6073
+ *
6074
+ * So green is now the ordinary state of a usable account, selected or not, and
6075
+ * "当前选用" is printed under the name of the selected one. Red carries the
6076
+ * refusal — including a stated rate limit, which used to be amber; amber read as
6077
+ * "mostly fine", and an account that cannot answer this message is not fine.
6078
+ *
6079
+ * Grey is reserved for an account nobody has measured yet. Painting it green
6080
+ * would assert a health nobody checked, and red would assert a failure nobody
6081
+ * saw; the absence of a reading is its own state.
6082
+ */
6083
+ function dotClass(account) {
6084
+ const tone = stateTone(account);
6085
+ if (tone === "ok") return DOT_CLASS.ok;
6086
+ if (tone === "warn" || tone === "error") return DOT_CLASS.error;
6087
+ return DOT_CLASS.unknown;
6088
+ }
6089
+ /**
6090
+ * The row's second line, from the facts that belong in it.
6091
+ *
6092
+ * Two independent ones: whether this is the account serving the tab, and — when
6093
+ * it cannot serve — why, with the wait but never the absolute instant. Joined
6094
+ * with the same separator the rest of this panel uses, and dropped entirely when
6095
+ * there is nothing to say, so a plain usable row stays one line tall.
6096
+ */
6097
+ function detailLine(...parts) {
6098
+ const kept = parts.filter((part) => part !== void 0 && part !== "");
6099
+ return kept.length === 0 ? void 0 : kept.join(" · ");
6100
+ }
5457
6101
  function ComposerCredits(props) {
5458
6102
  const { t, provider, region, settingsScope } = props;
5459
6103
  if (t === void 0) throw new Error("Composer credits readout requires its translation function");
@@ -5462,11 +6106,24 @@ window.__ModuleLoader__.load({
5462
6106
  const [usage, setUsage] = (0, react.useState)(void 0);
5463
6107
  const [accountCredits, setAccountCredits] = (0, react.useState)(void 0);
5464
6108
  const [busy, setBusy] = (0, react.useState)(false);
6109
+ /**
6110
+ * A test batch is running.
6111
+ *
6112
+ * Separate from `busy`, which tracks a credits READ: the two take wildly
6113
+ * different times (one round trip against one probe per account, serial with
6114
+ * a gap), and sharing a flag would either freeze the readout for a minute or
6115
+ * let a press be swallowed as "already refreshing".
6116
+ */
6117
+ const [testing, setTesting] = (0, react.useState)(false);
6118
+ /** The batch's failure, in the user's language; `failed` is for reads. */
6119
+ const [testError, setTestError] = (0, react.useState)(void 0);
5465
6120
  const [switchingId, setSwitchingId] = (0, react.useState)(void 0);
5466
6121
  const [failed, setFailed] = (0, react.useState)(false);
5467
6122
  const [lastRefresh, setLastRefresh] = (0, react.useState)(void 0);
5468
6123
  const [open, setOpen] = (0, react.useState)(false);
5469
6124
  const inFlight = (0, react.useRef)(false);
6125
+ /** A batch is running; guards a second press that lands before `setTesting`. */
6126
+ const testingRef = (0, react.useRef)(false);
5470
6127
  /** Account-table reads in flight; see `fetchAccountCredits`. */
5471
6128
  const accountsInFlight = (0, react.useRef)(0);
5472
6129
  /** Which account-table read is the newest; see `fetchAccountCredits`. */
@@ -5654,26 +6311,85 @@ window.__ModuleLoader__.load({
5654
6311
  }
5655
6312
  };
5656
6313
  /**
5657
- * One manual refresh, of both things the panel shows.
6314
+ * The header button: test EVERY account in this region, then re-read what the
6315
+ * batch changed.
5658
6316
  *
5659
- * It used to call `fetchUsage` alone, so pressing 刷新 on the account table
5660
- * refreshed the trigger's balance and left every ROW exactly as it was —
5661
- * including its 被限流 / 模型繁忙 mark. The button sits inside the table's own
5662
- * header, so the user reads it as "refresh this table"; a control that
5663
- * refreshes something else is worse than no control, and it is one of the
5664
- * reasons the panel could keep claiming a state the card had already moved
5665
- * past.
6317
+ * It used to be a plain refresh. The owner asked for the other thing this
6318
+ * panel's readers actually want — the same "一键测试所有账号" the pool card
6319
+ * runs (`src/account-pool-run.ts testAllAccounts`, one account at a time with
6320
+ * a gap, because hitting the whole pool at once is how the upstream's volume
6321
+ * limit gets tripped).
5666
6322
  *
5667
- * Both reads run concurrently rather than in sequence: neither depends on the
5668
- * other, and awaiting the usage document first would delay the table by one
5669
- * round trip for no benefit. `refreshUsage` resolves rather than throws on
5670
- * failure (it records `failed` itself), so the table's read is never skipped
5671
- * because the other half went wrong.
6323
+ * Two reasons the batch, not a read, is the right control HERE. A mark in this
6324
+ * table is a MEASUREMENT (`被限流 · 约 6 小时后` is a probe result), and
6325
+ * re-reading the usage document cannot improve a stale measurement — only
6326
+ * running it again can. And the rows are the pool's members, so the question
6327
+ * "which of these can serve?" is answered by testing them, not by asking the
6328
+ * credits endpoint again.
6329
+ *
6330
+ * The re-read afterwards is not a courtesy: the probes live in the USAGE
6331
+ * document (`usage.pool.accounts[].probe`), so the dots and marks only move
6332
+ * once that document is fetched again. Without it the button would leave the
6333
+ * table showing the state it just measured away.
6334
+ *
6335
+ * The batch answers NDJSON (one line per account, streamed) on a current Host
6336
+ * and one document on an older one, so both shapes are read; the stream is
6337
+ * drained even after unmount, because dropping the reader leaves the Host
6338
+ * writing into a socket nobody reads.
5672
6339
  */
5673
- const refresh = (0, react.useCallback)(async () => {
5674
- setFailed(false);
5675
- await Promise.all([fetchUsage(), fetchAccountCredits(void 0, true)]);
5676
- }, [fetchUsage, fetchAccountCredits]);
6340
+ const testEveryAccount = (0, react.useCallback)(async () => {
6341
+ if (activeRegion === void 0 || testingRef.current) return;
6342
+ testingRef.current = true;
6343
+ if (mounted.current) {
6344
+ setTesting(true);
6345
+ setTestError(void 0);
6346
+ setFailed(false);
6347
+ }
6348
+ try {
6349
+ const response = await fetch(withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, activeRegion, "test"), {
6350
+ method: "POST",
6351
+ headers: { accept: "application/json" },
6352
+ credentials: "same-origin"
6353
+ });
6354
+ let failure;
6355
+ if ((response.headers.get("content-type") ?? "").includes("ndjson")) {
6356
+ if (!response.ok) failure = poolFailureText(t, { status: response.status });
6357
+ else await readNdjson(response.body, (line) => {
6358
+ const row = testRowOf(line);
6359
+ if (row !== void 0) {
6360
+ if (mounted.current) setUsage((previous) => previous === void 0 ? previous : withTestRow(previous, row, Date.now()));
6361
+ return;
6362
+ }
6363
+ const reason = line["reason"];
6364
+ if (typeof reason !== "string") return;
6365
+ failure = poolFailureText(t, {
6366
+ status: 200,
6367
+ reason,
6368
+ error: typeof line["error"] === "string" ? line["error"] : void 0
6369
+ });
6370
+ });
6371
+ } else {
6372
+ const body = await response.json().catch(() => void 0);
6373
+ if (!response.ok) failure = poolFailureText(t, {
6374
+ status: response.status,
6375
+ reason: body?.reason,
6376
+ error: body?.error
6377
+ });
6378
+ }
6379
+ if (mounted.current && failure !== void 0) setTestError(failure);
6380
+ } catch {
6381
+ if (mounted.current) setTestError(t("composer.testFailed"));
6382
+ } finally {
6383
+ testingRef.current = false;
6384
+ if (mounted.current) setTesting(false);
6385
+ await Promise.all([fetchUsage(), fetchAccountCredits(void 0, true)]);
6386
+ }
6387
+ }, [
6388
+ activeRegion,
6389
+ fetchUsage,
6390
+ fetchAccountCredits,
6391
+ t
6392
+ ]);
5677
6393
  if (owned === void 0) return null;
5678
6394
  /**
5679
6395
  * The Host's own answer for this region, when the document carries one.
@@ -5717,6 +6433,9 @@ window.__ModuleLoader__.load({
5717
6433
  };
5718
6434
  });
5719
6435
  const canSwitch = settingsScope !== void 0 && settingsScope.getSnapshot().writable === true;
6436
+ const poolOrder = usage?.status === "signed-in" ? usage.pool?.orderAccountIds : void 0;
6437
+ const orderedRows = applyManualOrder(rows, poolOrder !== void 0 && poolOrder.length > 0 ? poolOrder : settingsScope === void 0 ? [] : readOrderAccountIds(settingsScope.getSnapshot().value, activeRegion));
6438
+ const poolById = new Map((usage?.status === "signed-in" ? usage.pool?.accounts ?? [] : []).map((account) => [account.accountId, account]));
5720
6439
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5721
6440
  ref: rootRef,
5722
6441
  className: "dsm-workbuddy-composer-credits",
@@ -5752,11 +6471,12 @@ window.__ModuleLoader__.load({
5752
6471
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5753
6472
  type: "button",
5754
6473
  className: "dsm-workbuddy-composer-panel-refresh",
5755
- disabled: busy || switchingId !== void 0,
6474
+ disabled: busy || testing || switchingId !== void 0,
6475
+ title: t("composer.testAllHint"),
5756
6476
  onClick: () => {
5757
- refresh();
6477
+ testEveryAccount();
5758
6478
  },
5759
- children: busy ? t("composer.refreshing") : t("composer.refresh")
6479
+ children: testing ? t("composer.testing") : t("composer.testAll")
5760
6480
  })]
5761
6481
  }),
5762
6482
  signedOut ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -5774,13 +6494,25 @@ window.__ModuleLoader__.load({
5774
6494
  scope: "col",
5775
6495
  className: "dsm-workbuddy-composer-panel-num",
5776
6496
  children: t("composer.credits")
5777
- })] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: rows.map((row) => {
6497
+ })] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: orderedRows.map((row) => {
5778
6498
  const current = row.id === selectedId;
5779
- const excluded = exclusionText(t, row.excludedBy);
6499
+ const poolRow = poolById.get(row.id);
6500
+ const live = poolRow === void 0 ? current : poolRow.current;
6501
+ const availability = poolRow ?? {
6502
+ excludedBy: row.excludedBy,
6503
+ probe: void 0
6504
+ };
6505
+ const usable = stateTone(availability) === "ok";
6506
+ const currentMark = live ? t("composer.current") : void 0;
6507
+ const reasonMark = usable ? void 0 : availabilityMark(t, availability);
6508
+ const spoken = detailLine(live ? t("composer.current") : void 0, usable ? void 0 : availabilityLine(t, availability));
6509
+ const whoLabel = spoken === void 0 ? row.accountName : `${row.accountName} — ${spoken}`;
5780
6510
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", {
5781
6511
  className: current ? "dsm-workbuddy-composer-panel-row-current" : void 0,
6512
+ title: spoken,
5782
6513
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
5783
6514
  scope: "row",
6515
+ "aria-label": whoLabel,
5784
6516
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5785
6517
  type: "button",
5786
6518
  className: "dsm-workbuddy-composer-panel-switch",
@@ -5790,16 +6522,22 @@ window.__ModuleLoader__.load({
5790
6522
  switchTo(row.id);
5791
6523
  },
5792
6524
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5793
- className: "dsm-workbuddy-composer-panel-dot",
6525
+ className: dotClass(availability),
5794
6526
  "aria-hidden": "true"
5795
6527
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5796
6528
  className: "dsm-workbuddy-composer-panel-who",
5797
6529
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5798
6530
  className: "dsm-workbuddy-composer-panel-name",
5799
6531
  children: row.accountName
5800
- }), excluded === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6532
+ }), currentMark === void 0 && reasonMark === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5801
6533
  className: "dsm-workbuddy-composer-panel-mark",
5802
- children: excluded
6534
+ children: [currentMark === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6535
+ className: "dsm-workbuddy-composer-panel-mark-current",
6536
+ children: currentMark
6537
+ }), reasonMark === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6538
+ className: "dsm-workbuddy-composer-panel-mark-reason",
6539
+ children: reasonMark
6540
+ })]
5803
6541
  })]
5804
6542
  })]
5805
6543
  })
@@ -5822,7 +6560,12 @@ window.__ModuleLoader__.load({
5822
6560
  className: "dsm-workbuddy-composer-panel-error",
5823
6561
  role: "status",
5824
6562
  children: t("composer.refreshFailed")
5825
- }) : null
6563
+ }) : null,
6564
+ testError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6565
+ className: "dsm-workbuddy-composer-panel-error",
6566
+ role: "status",
6567
+ children: testError
6568
+ })
5826
6569
  ]
5827
6570
  }), document.body)]
5828
6571
  });
@@ -6277,6 +7020,19 @@ window.__ModuleLoader__.load({
6277
7020
  "row.poolSelectAll": "Select all",
6278
7021
  "row.poolSelectNone": "Clear all",
6279
7022
  "row.poolSelectedCount": "{count} of {total} selected",
7023
+ "row.poolOrderRulesTitle": "How this pool chooses an account",
7024
+ "row.poolOrderRulesIntro": "The pool always picks the first account that can actually be billed right now. The rules below are checked in order — the first one that separates two accounts decides; later rules only break ties.",
7025
+ "row.poolOrderRuleUsable": "1. Can it be billed right now? Accounts that are rate-limited, out of credit, credential-rejected, or failing are moved to the back until their cooldown ends. This outranks everything, including your own order below.",
7026
+ "row.poolOrderRuleExpiring": "2. Does it have credits expiring within 3 days? Yes goes first. This also outranks your manual order — see the note below.",
7027
+ "row.poolOrderRuleManual": "3. Your manual order. The position you set with the ↑ / ↓ arrows. Accounts you never ordered are placed after every ordered one.",
7028
+ "row.poolOrderRuleNearestExpiry": "4. Which expiry comes first? Between accounts that both have credits expiring, the one losing them sooner goes first.",
7029
+ "row.poolOrderRuleExpiringAmount": "5. How many are expiring? Between those same accounts, the one with more at stake goes first. With nothing expiring this is equivalent to comparing totals.",
7030
+ "row.poolOrderRuleTotal": "6. Total credits. The account holding more goes first, so a nearly-drained account is kept in reserve.",
7031
+ "row.poolOrderRuleFreshness": "7. Freshness of the sign-in. The more recently issued credential wins a draw.",
7032
+ "row.poolOrderRuleId": "8. Account id. A final tie-break, so the same pool always produces the same order.",
7033
+ "row.poolOrderWhy": "Why expiring credits outrank your manual order: expiring credits are money about to disappear, while a manual order may be stale or set by accident. If the manual order won, one mis-drag could let credits lapse unused. So an account with credits expiring within 3 days always goes ahead of one without, even if you dragged it to the bottom.",
7034
+ "row.poolOrderBadgeNote": "The badge shows the position you SAVED, not the live ranking. An account can read \"No. 2\" while being benched for a rate limit — the number is your saved preference, and the pool skips accounts it cannot bill right now.",
7035
+ "row.poolOrderRulesToggle": "Show the full ordering rule",
6280
7036
  "row.poolNoneSelected": "No account is checked into this pool.",
6281
7037
  "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.",
6282
7038
  "row.poolGhostMembers": "{count} pool member(s) are no longer signed in on this machine, so they are not counted and not used: {names}. Remove any you no longer need below.",
@@ -6289,6 +7045,14 @@ window.__ModuleLoader__.load({
6289
7045
  "row.poolCurrentHeader": "In use: {account}",
6290
7046
  "row.poolNotMember": "Not in pool",
6291
7047
  "row.poolUnsavedMembers": "Selection changed — save to apply",
7048
+ "row.poolRankLabel": "No. {rank}",
7049
+ "row.poolRankManualTitle": "You placed this account at position {rank} in the pool manually.",
7050
+ "row.poolRankAuto": "Auto",
7051
+ "row.poolRankAutoTitle": "Not manually sorted yet: the pool spends expiring credits first, then picks accounts by remaining balance. Use ↑/↓ to set your own order.",
7052
+ "row.poolMoveUpTitle": "Move account up in this pool's order",
7053
+ "row.poolMoveDownTitle": "Move account down in this pool's order",
7054
+ "row.poolMoveUpAria": "Move {accountName} up in this pool's order",
7055
+ "row.poolMoveDownAria": "Move {accountName} down in this pool's order",
6292
7056
  "row.poolRowTest": "Test",
6293
7057
  "row.poolRowRemove": "Remove",
6294
7058
  "row.poolRowRemoveHint": "Remove this account from the list. The WorkBuddy desktop app is not signed out — it keeps its own sign-in. Removing only hides the account here; 「Detect accounts again」 brings it back.",
@@ -6317,10 +7081,13 @@ window.__ModuleLoader__.load({
6317
7081
  "composer.account": "Account",
6318
7082
  "composer.credits": "Credits",
6319
7083
  "composer.switchTo": "Switch to {account}",
7084
+ "composer.current": "In use",
6320
7085
  "composer.noAccounts": "No account in this region",
6321
7086
  "composer.signedOut": "Not signed in",
6322
- "composer.refresh": "Refresh credits",
6323
- "composer.refreshing": "Refreshing credits…",
7087
+ "composer.testAll": "Test all",
7088
+ "composer.testing": "Testing…",
7089
+ "composer.testAllHint": "Test every account in this region, one at a time",
7090
+ "composer.testFailed": "Could not test the accounts",
6324
7091
  "composer.refreshFailed": "Could not refresh credits",
6325
7092
  "composer.lastRefresh": "Last refreshed",
6326
7093
  "composer.panelTitle": "WorkBuddy credits",
@@ -6565,6 +7332,19 @@ window.__ModuleLoader__.load({
6565
7332
  "row.poolSelectAll": "全选",
6566
7333
  "row.poolSelectNone": "全不选",
6567
7334
  "row.poolSelectedCount": "已选 {count} / {total}",
7335
+ "row.poolOrderRulesTitle": "本池如何挑选账号",
7336
+ "row.poolOrderRulesIntro": "池子总是先挑「现在确实能扣费的」账号。下面这些规则按顺序逐条比较,第一条分出胜负就停止,后面的只在平局时才看。",
7337
+ "row.poolOrderRuleUsable": "1. 现在能扣费吗?被限流、积分耗尽、凭证被拒或调用失败的账号会被挪到后面,直到冷却结束。这一条优先级最高,压过下面的手动顺序。",
7338
+ "row.poolOrderRuleExpiring": "2. 有 3 天内会过期的积分吗?有就排前面。这一条同样压过手动顺序,原因见下方说明。",
7339
+ "row.poolOrderRuleManual": "3. 你排的手动顺序。就是用 ↑ / ↓ 设定的位置。没有手动排过的账号,会排在所有已排序账号之后。",
7340
+ "row.poolOrderRuleNearestExpiry": "4. 谁先过期?两个都有临期积分时,先到期的排前面。",
7341
+ "row.poolOrderRuleExpiringAmount": "5. 过期多少?同样是两个都有临期积分时,临期数额大的排前面。没有临期积分时,这一步等同于比较总积分。",
7342
+ "row.poolOrderRuleTotal": "6. 总积分。积分多的排前面,接近耗尽的号先留着备用。",
7343
+ "row.poolOrderRuleFreshness": "7. 登录凭证新旧。凭证签发更晚的赢下平局。",
7344
+ "row.poolOrderRuleId": "8. 账号 id。最终兜底,保证同样的池子每次排出来的顺序都一样。",
7345
+ "row.poolOrderWhy": "为什么临期积分压过手动顺序:临期积分是马上要过期的钱,而手动顺序可能已经过时、也可能是手滑拖错了。如果让手动顺序赢,一次误拖就足以让积分白白过期。所以有 3 天内到期积分的账号,永远排在没有的账号前面 —— 哪怕你把它拖到了最后一位。",
7346
+ "row.poolOrderBadgeNote": "徽标显示的是你「保存」的位置,不是此刻的实际排序。一个账号可能写着「第 2 位」却因为被限流而暂时不被选中 —— 那个数字代表你的偏好,池子会跳过当下无法扣费的账号。",
7347
+ "row.poolOrderRulesToggle": "查看完整排序规则",
6568
7348
  "row.poolNoneSelected": "这个池里还没有勾选任何账号。",
6569
7349
  "row.poolNoneSelectedHint": "请在上方勾选至少一个账号并保存。在此之前两个按钮都不可用 —— 空池没有可执行的对象。",
6570
7350
  "row.poolGhostMembers": "有 {count} 个已选入本池的账号在本机已不再登录,因此不计入、也不会被使用:{names}。不需要的可在下方逐个移除。",
@@ -6577,6 +7357,14 @@ window.__ModuleLoader__.load({
6577
7357
  "row.poolCurrentHeader": "当前使用:{account}",
6578
7358
  "row.poolNotMember": "未入池",
6579
7359
  "row.poolUnsavedMembers": "勾选已改动 —— 保存后生效",
7360
+ "row.poolRankLabel": "第 {rank} 位",
7361
+ "row.poolRankManualTitle": "你在本池中手动把该账号排在第 {rank} 位。",
7362
+ "row.poolRankAuto": "自动排序",
7363
+ "row.poolRankAutoTitle": "还没有手动排序:池会先花临期积分,再按余额多寡选号。用 ↑/↓ 自己指定顺序。",
7364
+ "row.poolMoveUpTitle": "在本池顺序中上移该账号",
7365
+ "row.poolMoveDownTitle": "在本池顺序中下移该账号",
7366
+ "row.poolMoveUpAria": "在本池顺序中上移 {accountName}",
7367
+ "row.poolMoveDownAria": "在本池顺序中下移 {accountName}",
6580
7368
  "row.poolRowTest": "测试",
6581
7369
  "row.poolRowRemove": "删除",
6582
7370
  "row.poolRowRemoveHint": "把这个账号从列表中移除。桌面 App 不会被退出登录——它保留自己的登录状态。移除只是暂时隐藏,点「重新检测账号」即可找回。",
@@ -6605,10 +7393,13 @@ window.__ModuleLoader__.load({
6605
7393
  "composer.account": "账号",
6606
7394
  "composer.credits": "积分",
6607
7395
  "composer.switchTo": "切换到 {account}",
7396
+ "composer.current": "当前选用",
6608
7397
  "composer.noAccounts": "该区域暂无账号",
6609
7398
  "composer.signedOut": "未登录",
6610
- "composer.refresh": "刷新积分",
6611
- "composer.refreshing": "正在刷新积分…",
7399
+ "composer.testAll": "一键测试",
7400
+ "composer.testing": "正在测试…",
7401
+ "composer.testAllHint": "逐个测试本区所有账号(一次一个,避免触发上游限流)",
7402
+ "composer.testFailed": "测试失败",
6612
7403
  "composer.refreshFailed": "积分刷新失败",
6613
7404
  "composer.lastRefresh": "最近刷新",
6614
7405
  "composer.panelTitle": "WorkBuddy 积分",