dsh-workbuddy-xdpool 1.7.1 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bin.js CHANGED
@@ -10,6 +10,39 @@ import "@earendil-works/pi-ai/api/openai-completions.lazy";
10
10
  import "@deepseek-ai/dsh-llm";
11
11
  import "@deepseek-ai/dsh-llm-pi-ai";
12
12
  //#region src/upstream.ts
13
+ /**
14
+ * Backoff between catalog-fetch attempts.
15
+ *
16
+ * Three retries after the first try, so a startup network hiccup does not cost
17
+ * the user their model list for the whole session. The window covers the case
18
+ * that actually bit: several independent components reported `fetch failed`
19
+ * within the same second while the machine was still bringing its network up,
20
+ * and the very next request succeeded 0.7s later.
21
+ *
22
+ * Deliberately short and bounded. This runs during plugin startup, so a long
23
+ * ladder would delay the provider appearing at all; the total added latency
24
+ * here is ~12s in the worst case, and only when the network is genuinely down.
25
+ */
26
+ const CATALOG_RETRY_BACKOFF_MS = [
27
+ 1e3,
28
+ 3e3,
29
+ 8e3
30
+ ];
31
+ /**
32
+ * Sleep helper for the retry ladder.
33
+ *
34
+ * Deliberately NOT unref'd. An unref'd timer does not keep the event loop
35
+ * alive, so awaiting one can hang forever when nothing else is pending — the
36
+ * retry would simply never resume. That is exactly what the first version of
37
+ * this did, and it wedged the very first end-to-end run: `fetchModels` never
38
+ * settled. The wait is at most a few seconds and only happens on failure, so
39
+ * holding the loop open is the correct trade.
40
+ */
41
+ function delay(ms) {
42
+ return new Promise((resolve) => {
43
+ setTimeout(resolve, ms);
44
+ });
45
+ }
13
46
  /** CN chat base per dingminhua's on-machine probe (HTTP 200 for chat/models). */
14
47
  const CN_CHAT_BASE = "https://copilot.tencent.com";
15
48
  /** Billing/console base — `www.codebuddy.cn` is the billing origin. */
@@ -31,6 +64,13 @@ const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
31
64
  const GLOBAL_CONFIG_PATH = "/v3/config";
32
65
  const JSON_TIMEOUT_MS = 3e4;
33
66
  const ERROR_BODY_LIMIT = 4096;
67
+ /** True for a request the CALLER aborted, which must never be retried. */
68
+ function isAbortError(error) {
69
+ if (typeof error !== "object" || error === null) return false;
70
+ if (error.name === "AbortError") return true;
71
+ const cause = error.cause;
72
+ return typeof cause === "object" && cause !== null && cause.name === "AbortError";
73
+ }
34
74
  /** Cap on the reassembled compaction reply, guarding against a runaway stream. */
35
75
  const COMPLETION_TEXT_LIMIT = 65536;
36
76
  /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
@@ -638,9 +678,20 @@ function parseTask(value) {
638
678
  var WorkBuddyUpstreamClient = class {
639
679
  fetchImpl;
640
680
  clientVersion;
681
+ /** Backoff ladder for `fetchModels`; empty means a single attempt. */
682
+ catalogRetryBackoffMs;
683
+ /**
684
+ * Optional logger for retry notices.
685
+ *
686
+ * Set by the host so a retry is visible in the log with its attempt count —
687
+ * without it, a retry that eventually succeeds is invisible, and an operator
688
+ * debugging "why was the catalog slow" has nothing to look at.
689
+ */
690
+ logger;
641
691
  constructor(options = {}) {
642
692
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
643
693
  this.clientVersion = options.clientVersion ?? "2.0.4";
694
+ this.catalogRetryBackoffMs = options.catalogRetryBackoffMs ?? CATALOG_RETRY_BACKOFF_MS;
644
695
  }
645
696
  /**
646
697
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
@@ -797,6 +848,30 @@ var WorkBuddyUpstreamClient = class {
797
848
  * below is common to the two branches.
798
849
  */
799
850
  async fetchModels(credential, signal) {
851
+ const attempts = this.catalogRetryBackoffMs.length + 1;
852
+ let lastError;
853
+ for (let attempt = 0; attempt < attempts; attempt += 1) try {
854
+ return await this.fetchModelsOnce(credential, signal);
855
+ } catch (error) {
856
+ lastError = error;
857
+ if (isAbortError(error)) throw error;
858
+ const wait = this.catalogRetryBackoffMs[attempt];
859
+ if (wait === void 0) break;
860
+ this.logger?.warn?.(`dsh-workbuddy-xdpool: model catalog fetch failed (attempt ${attempt + 1}/${attempts}), retrying in ${wait}ms`, error);
861
+ await delay(wait);
862
+ }
863
+ throw lastError;
864
+ }
865
+ /**
866
+ * One catalog attempt, without retries.
867
+ *
868
+ * Split out so {@link fetchModels} can retry it: the failure this guards
869
+ * against is a startup network hiccup, where several independent components
870
+ * see `fetch failed` inside the same second and the very next request
871
+ * succeeds — exactly the case a single attempt turns into "the user's model
872
+ * list is missing half its entries for the rest of the session".
873
+ */
874
+ async fetchModelsOnce(credential, signal) {
800
875
  const global = regionOf(credential.domain) === "global";
801
876
  const url = global ? `${globalBase(credential)}${GLOBAL_CONFIG_PATH}` : `${chatBase(credential)}${MODELS_CATALOG_PATH}`;
802
877
  const headers = global ? {
@@ -2879,38 +2954,97 @@ ${new Date(until).toISOString()}
2879
2954
  * Static fallback used before the first live catalog fetch, and whenever the
2880
2955
  * upstream cannot be reached.
2881
2956
  *
2882
- * The multipliers are carried on purpose. Without them the provider's model
2883
- * picker silently loses every rate and every free badge the moment the live
2884
- * fetch fails — which reads to the user as "the plugin broke my model list"
2885
- * rather than "the upstream is unreachable". The values are the ones the two
2886
- * gateways actually advertise for these ids (`credits: "x0.79 credits"` and so
2887
- * on), so a fallback row looks the same as a live one.
2957
+ * This table is the DOMESTIC (CN) roster, and it is kept in step with what
2958
+ * `copilot.tencent.com` actually advertises. It matters more than a "just in
2959
+ * case" list usually would: a failed startup fetch falls back to it, and the
2960
+ * user sees that as "half my models were deleted" — `deepseek-v4.1-flash`
2961
+ * and friends simply vanishing from the picker, with no visible explanation.
2962
+ *
2963
+ * Every row below was captured from the live endpoint, INCLUDING the window
2964
+ * sizes: the previous version carried a 32K window for `hy3` when the gateway
2965
+ * says 192K, and listed `kimi-k3` under an id the gateway no longer uses
2966
+ * (`kimi-k3-1`). A stale fallback is worse than a short one — it looks
2967
+ * authoritative while being wrong.
2888
2968
  *
2889
2969
  * `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
2890
2970
  * which is what turns on the free badge.
2891
2971
  */
2892
2972
  const FALLBACK_WORKBUDDY_MODELS = [
2973
+ {
2974
+ id: "auto",
2975
+ name: "Auto",
2976
+ contextWindow: 256e3,
2977
+ maxOutputTokens: 32e3,
2978
+ supportsImages: true
2979
+ },
2980
+ {
2981
+ id: "hy4-preview",
2982
+ name: "Hy4 preview",
2983
+ contextWindow: 1e6,
2984
+ maxOutputTokens: 64e3,
2985
+ supportsImages: true,
2986
+ multiplier: .29
2987
+ },
2988
+ {
2989
+ id: "hy3",
2990
+ name: "Hy3",
2991
+ contextWindow: 192e3,
2992
+ maxOutputTokens: 64e3,
2993
+ supportsImages: true,
2994
+ multiplier: 0
2995
+ },
2996
+ {
2997
+ id: "hy3-x",
2998
+ name: "Hy3",
2999
+ contextWindow: 192e3,
3000
+ maxOutputTokens: 64e3,
3001
+ supportsImages: true,
3002
+ multiplier: .05
3003
+ },
3004
+ {
3005
+ id: "deepseek-v4.1-flash",
3006
+ name: "Deepseek-V4.1-Flash",
3007
+ contextWindow: 1e6,
3008
+ maxOutputTokens: 128e3,
3009
+ supportsImages: true,
3010
+ multiplier: .11
3011
+ },
3012
+ {
3013
+ id: "deepseek-v4-pro",
3014
+ name: "Deepseek-V4-Pro",
3015
+ contextWindow: 1e6,
3016
+ maxOutputTokens: 128e3,
3017
+ supportsImages: true,
3018
+ multiplier: .51
3019
+ },
3020
+ {
3021
+ id: "deepseek-v4-flash",
3022
+ name: "DeepSeek-V4-Flash",
3023
+ contextWindow: 1e6,
3024
+ maxOutputTokens: 128e3,
3025
+ supportsImages: true
3026
+ },
2893
3027
  {
2894
3028
  id: "glm-5.3",
2895
3029
  name: "GLM-5.3",
2896
- contextWindow: 2e5,
2897
- maxOutputTokens: 128e3,
3030
+ contextWindow: 1e6,
3031
+ maxOutputTokens: 64e3,
2898
3032
  supportsImages: true,
2899
3033
  multiplier: .79
2900
3034
  },
2901
3035
  {
2902
3036
  id: "glm-5.3-flash",
2903
3037
  name: "GLM-5.3-Flash",
2904
- contextWindow: 2e5,
2905
- maxOutputTokens: 128e3,
3038
+ contextWindow: 1e6,
3039
+ maxOutputTokens: 131072,
2906
3040
  supportsImages: true,
2907
3041
  multiplier: .06
2908
3042
  },
2909
3043
  {
2910
3044
  id: "glm-5.2",
2911
3045
  name: "GLM-5.2",
2912
- contextWindow: 2e5,
2913
- maxOutputTokens: 128e3,
3046
+ contextWindow: 1e6,
3047
+ maxOutputTokens: 64e3,
2914
3048
  supportsImages: true,
2915
3049
  multiplier: .79
2916
3050
  },
@@ -2918,53 +3052,57 @@ const FALLBACK_WORKBUDDY_MODELS = [
2918
3052
  id: "glm-5.1",
2919
3053
  name: "GLM-5.1",
2920
3054
  contextWindow: 2e5,
2921
- maxOutputTokens: 128e3,
2922
- supportsImages: false
3055
+ maxOutputTokens: 48e3,
3056
+ supportsImages: false,
3057
+ multiplier: .79
2923
3058
  },
2924
3059
  {
2925
- id: "deepseek-v4-pro",
2926
- name: "DeepSeek-V4-Pro",
3060
+ id: "glm-5v-turbo",
3061
+ name: "GLM-5v-Turbo",
2927
3062
  contextWindow: 2e5,
2928
- maxOutputTokens: 128e3,
2929
- supportsImages: true
2930
- },
2931
- {
2932
- id: "deepseek-v4-flash",
2933
- name: "DeepSeek-V4-Flash",
2934
- contextWindow: 2e5,
2935
- maxOutputTokens: 128e3,
2936
- supportsImages: true
3063
+ maxOutputTokens: 64e3,
3064
+ supportsImages: true,
3065
+ multiplier: .71
2937
3066
  },
2938
3067
  {
2939
- id: "kimi-k3",
3068
+ id: "kimi-k3-1",
2940
3069
  name: "Kimi-K3",
2941
- contextWindow: 2e5,
2942
- maxOutputTokens: 128e3,
3070
+ contextWindow: 1e6,
3071
+ maxOutputTokens: 32e3,
2943
3072
  supportsImages: true,
2944
3073
  multiplier: 1.62
2945
3074
  },
2946
3075
  {
2947
- id: "minimax-m3",
2948
- name: "MiniMax-M3",
2949
- contextWindow: 2e5,
2950
- maxOutputTokens: 128e3,
2951
- supportsImages: true
3076
+ id: "kimi-k2.8-preview",
3077
+ name: "Kimi-K2.8-Preview",
3078
+ contextWindow: 1e6,
3079
+ maxOutputTokens: 64e3,
3080
+ supportsImages: true,
3081
+ multiplier: .77
2952
3082
  },
2953
3083
  {
2954
- id: "hy3",
2955
- name: "Hy3",
2956
- contextWindow: 32e3,
2957
- maxOutputTokens: 8e3,
3084
+ id: "kimi-k2.7",
3085
+ name: "Kimi-K2.7-Code",
3086
+ contextWindow: 256e3,
3087
+ maxOutputTokens: 32e3,
2958
3088
  supportsImages: true,
2959
- multiplier: 0
3089
+ multiplier: .57
2960
3090
  },
2961
3091
  {
2962
- id: "hy4-preview",
2963
- name: "Hy4-Preview",
2964
- contextWindow: 1e6,
2965
- maxOutputTokens: 128e3,
3092
+ id: "kimi-k2.6",
3093
+ name: "Kimi-K2.6",
3094
+ contextWindow: 256e3,
3095
+ maxOutputTokens: 32e3,
2966
3096
  supportsImages: true,
2967
- multiplier: .29
3097
+ multiplier: .52
3098
+ },
3099
+ {
3100
+ id: "minimax-m3",
3101
+ name: "MiniMax-M3",
3102
+ contextWindow: 512e3,
3103
+ maxOutputTokens: 64e3,
3104
+ supportsImages: true,
3105
+ multiplier: .25
2968
3106
  }
2969
3107
  ];
2970
3108
  /** Live catalog with a static fallback behind it. */
@@ -2973,6 +3111,19 @@ var WorkBuddyCatalog = class {
2973
3111
  listeners = /* @__PURE__ */ new Set();
2974
3112
  /** User's model selection. Empty object = follow the catalog unfiltered. */
2975
3113
  selection = {};
3114
+ /**
3115
+ * Whether `models` came from the gateway or from the static table.
3116
+ *
3117
+ * Tracked so the card can SAY which it is showing. A failed fetch used to be
3118
+ * invisible on screen: the picker simply held fewer models than before, which
3119
+ * the user reasonably read as "the plugin deleted my models" rather than "the
3120
+ * network hiccuped at startup".
3121
+ */
3122
+ source = "fallback";
3123
+ /** When the live list last landed. */
3124
+ lastUpdatedAt;
3125
+ /** Why the last fetch failed, for display. */
3126
+ lastError;
2976
3127
  current() {
2977
3128
  return this.models;
2978
3129
  }
@@ -3007,6 +3158,7 @@ var WorkBuddyCatalog = class {
3007
3158
  /** Restore the static fallback, e.g. when the upstream stops answering. */
3008
3159
  reset() {
3009
3160
  this.models = FALLBACK_WORKBUDDY_MODELS;
3161
+ this.source = "fallback";
3010
3162
  this.notify();
3011
3163
  }
3012
3164
  /** Replace the user's selection; the adapter rebuilds from `visible()`. */
@@ -3018,6 +3170,31 @@ var WorkBuddyCatalog = class {
3018
3170
  currentSelection() {
3019
3171
  return this.selection;
3020
3172
  }
3173
+ /**
3174
+ * Whether this catalog is serving live data or the built-in table.
3175
+ *
3176
+ * `fallback` is not an error state, but it IS a degraded one: the user is
3177
+ * looking at a shorter roster than the gateway offers, so the card says so
3178
+ * and offers a retry instead of letting them wonder where the models went.
3179
+ */
3180
+ currentSource() {
3181
+ return this.source;
3182
+ }
3183
+ /**
3184
+ * When the live list last landed, if it ever did.
3185
+ *
3186
+ * Named `catalogUpdatedAt()` rather than `updatedAt()` because the class
3187
+ * already had an `updatedAt` member; two members of the same name is a
3188
+ * compile error, and the awkwardness is a useful signal that the concept is
3189
+ * "when THIS catalog was refreshed", not a generic timestamp.
3190
+ */
3191
+ catalogUpdatedAt() {
3192
+ return this.lastUpdatedAt;
3193
+ }
3194
+ /** Why the last fetch failed, if it did. */
3195
+ lastFetchError() {
3196
+ return this.lastError;
3197
+ }
3021
3198
  onChange(listener) {
3022
3199
  this.listeners.add(listener);
3023
3200
  return () => this.listeners.delete(listener);
@@ -3028,6 +3205,20 @@ var WorkBuddyCatalog = class {
3028
3205
  /** Replace the catalog from the live upstream list; keeps the fallback if empty. */
3029
3206
  updateFromUpstream(models) {
3030
3207
  this.update(catalogFromUpstream(models));
3208
+ this.source = "live";
3209
+ this.lastUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
3210
+ this.lastError = void 0;
3211
+ }
3212
+ /**
3213
+ * Record that a fetch attempt failed, leaving the current list in place.
3214
+ *
3215
+ * The list is deliberately NOT reset here: a refresh that fails should keep
3216
+ * whatever working catalog is already loaded, rather than demoting a healthy
3217
+ * session to the static table because one retry ran out.
3218
+ */
3219
+ noteFetchFailure(message) {
3220
+ this.lastError = message;
3221
+ this.notify();
3031
3222
  }
3032
3223
  notify() {
3033
3224
  for (const listener of this.listeners) listener();
@@ -3798,7 +3989,7 @@ var WorkBuddyScheduler = class {
3798
3989
  async runNow(kind, force = false) {
3799
3990
  const today = dayKey(this.now());
3800
3991
  const state = this.states[kind];
3801
- if (!force && state.lastRunSlot === slotKey(this.now())) return { ...state };
3992
+ if (!force && state.lastFiredHour === slotKey(this.now())) return { ...state };
3802
3993
  this.busy = true;
3803
3994
  try {
3804
3995
  await this.runJob(kind, today);
@@ -3896,13 +4087,46 @@ var WorkBuddyScheduler = class {
3896
4087
  * two hours still runs twice a day — but a job whose hour passed while DSH was
3897
4088
  * closed runs immediately on the next tick instead of waiting for tomorrow.
3898
4089
  */
3899
- isDue(kind, now) {
4090
+ /**
4091
+ * The configured slot `kind` should consume at `now`, or undefined when none.
4092
+ *
4093
+ * Returns the SLOT STRING (not a boolean) because the caller must record the
4094
+ * same value it acted on. Returning a boolean was the original bug's enabler:
4095
+ * the tick asked "is it due", then `runJob` independently wrote "what time is
4096
+ * it now" — and those two answers were almost never equal.
4097
+ *
4098
+ * CATCH-UP semantics are deliberate: a candidate fires once its hour has
4099
+ * PASSED and its slot is still unconsumed, so a laptop that slept through
4100
+ * 10:00 still runs the job when it wakes, on the same day.
4101
+ *
4102
+ * The EARLIEST unconsumed candidate wins, which is what keeps a two-hour job
4103
+ * (`travelHours: [9, 21]`) whole: consuming the earliest due slot leaves the
4104
+ * later one for its own hour.
4105
+ */
4106
+ dueSlot(kind, now) {
3900
4107
  const hours = this.hoursOf(kind);
3901
- if (hours.length === 0) return false;
3902
- const { hour } = zonedParts(now);
3903
- const current = Number(hour);
4108
+ if (hours.length === 0) return void 0;
4109
+ const current = Number(zonedParts(now).hour);
3904
4110
  const today = dayKey(now);
3905
- return hours.some((candidate) => candidate <= current && this.states[kind].lastRunSlot !== `${today}T${String(candidate).padStart(2, "0")}`);
4111
+ const fired = this.firedSlotsOf(kind);
4112
+ for (const candidate of [...hours].sort((a, b) => a - b)) {
4113
+ if (candidate > current) break;
4114
+ const slot = `${today}T${String(candidate).padStart(2, "0")}`;
4115
+ if (!fired.has(slot)) return slot;
4116
+ }
4117
+ }
4118
+ /** Today's consumed slots for one job, as a set. */
4119
+ firedSlotsOf(kind) {
4120
+ const stored = this.states[kind].firedSlots;
4121
+ if (stored === void 0) return /* @__PURE__ */ new Set();
4122
+ const today = dayKey(this.now());
4123
+ return new Set(stored.filter((slot) => slot.startsWith(`${today}T`)));
4124
+ }
4125
+ /** Record one consumed slot on a job's state. */
4126
+ consumeSlot(kind, slot) {
4127
+ const state = this.states[kind];
4128
+ const today = dayKey(this.now());
4129
+ state.firedSlots = [.../* @__PURE__ */ new Set([...this.firedSlotsOf(kind), slot])].filter((entry) => entry.startsWith(`${today}T`)).sort();
3906
4130
  }
3907
4131
  async tick() {
3908
4132
  if (!this.enabled || this.stopped || this.busy) return;
@@ -3913,9 +4137,10 @@ var WorkBuddyScheduler = class {
3913
4137
  const today = dayKey(now);
3914
4138
  for (const kind of JOB_KINDS) {
3915
4139
  if (this.stopped) return;
3916
- if (this.states[kind].lastRunSlot === slot) continue;
3917
- if (!this.isDue(kind, now)) continue;
3918
- await this.runJob(kind, today);
4140
+ if (this.states[kind].lastFiredHour === slot) continue;
4141
+ const consumed = this.dueSlot(kind, now);
4142
+ if (consumed === void 0) continue;
4143
+ await this.runJob(kind, today, consumed);
3919
4144
  }
3920
4145
  } catch (error) {
3921
4146
  this.logger.warn?.("dsh-workbuddy-xdpool automation tick failed:", error);
@@ -4001,6 +4226,12 @@ var WorkBuddyScheduler = class {
4001
4226
  /**
4002
4227
  * Run one job against every eligible account and record the outcome.
4003
4228
  *
4229
+ * `consumedSlot` is the configured slot this run spends (`dueSlot`'s answer).
4230
+ * It is passed IN rather than recomputed here so the value recorded is exactly
4231
+ * the value the schedule decided on — the defect this replaces wrote
4232
+ * `slotKey(now)` instead, i.e. the clock time the run finished at, which
4233
+ * almost never equals the configured candidate the gate had compared against.
4234
+ *
4004
4235
  * The task job runs in TWO passes. The first sends the event chains that light
4005
4236
  * up client-scored tasks; the second collects rewards. They are separate
4006
4237
  * because scoring lands asynchronously — a chain sent and claimed within the
@@ -4008,7 +4239,7 @@ var WorkBuddyScheduler = class {
4008
4239
  * while claiming wants the whole pool to have been lit up first. Splitting
4009
4240
  * them costs one shared wait instead of one wait per account.
4010
4241
  */
4011
- async runJob(kind, today) {
4242
+ async runJob(kind, today, consumedSlot) {
4012
4243
  const accountWord = kind === "report" ? "report" : kind;
4013
4244
  let ok = 0;
4014
4245
  let failed = 0;
@@ -4073,7 +4304,8 @@ var WorkBuddyScheduler = class {
4073
4304
  if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
4074
4305
  }
4075
4306
  const state = this.states[kind];
4076
- state.lastRunSlot = slotKey(this.now());
4307
+ if (consumedSlot !== void 0) this.consumeSlot(kind, consumedSlot);
4308
+ state.lastFiredHour = slotKey(this.now());
4077
4309
  state.lastRunDate = today;
4078
4310
  state.lastRunAtMs = this.now().getTime();
4079
4311
  state.ok = ok;
package/lib/client.js CHANGED
@@ -23,6 +23,16 @@ window.__ModuleLoader__.load({
23
23
  const POOL_STATUS_PATH = "/plugins/dsh-workbuddy-xdpool/status";
24
24
  /** Plugin-owned local account rescan endpoint (re-read desktop snapshots). */
25
25
  const POOL_RESCAN_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/rescan";
26
+ /**
27
+ * Re-fetch the upstream model catalog for both regions.
28
+ *
29
+ * Separate from {@link POOL_RESCAN_PATH} because they answer different
30
+ * questions, and conflating them misled users: "detect accounts again" only
31
+ * re-read the desktop snapshots, so it could NOT recover a model list that had
32
+ * fallen back to the static table after a failed startup fetch. The only way
33
+ * out was restarting DSH.
34
+ */
35
+ const POOL_CATALOG_REFRESH_PATH = "/plugins/dsh-workbuddy-xdpool/models/refresh";
26
36
  /** Plugin-owned cooldown reset endpoint (clear all 429 cooldowns). */
27
37
  const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
28
38
  /** Plugin-owned daily check-in action endpoint (claim today's reward). */
@@ -276,6 +286,9 @@ window.__ModuleLoader__.load({
276
286
  .dsm-workbuddy-xdpool-model-budget input{margin:0;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
277
287
  .dsm-workbuddy-xdpool-models-heading{display:flex;flex-direction:column;gap:2px;min-width:0}
278
288
  .dsm-workbuddy-xdpool-models-actions{display:flex;align-items:center;gap:8px;flex:none}
289
+ /* "Built-in list (offline)" chip: a warning tint, since the user is looking at
290
+ a SHORTER roster than the gateway offers and may wonder where models went. */
291
+ .dsm-workbuddy-xdpool-catalog-offline{flex:none;padding:2px 9px;border-radius:999px;font-size:11px;line-height:17px;font-weight:600;background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.15));color:var(--dsw-alias-state-warning-primary,#d97706);white-space:nowrap}
279
292
 
280
293
 
281
294
  /* Automation: run-now button and the per-job progress line. */
@@ -585,6 +598,8 @@ window.__ModuleLoader__.load({
585
598
  const [error, setError] = (0, react.useState)(void 0);
586
599
  const [busy, setBusy] = (0, react.useState)(false);
587
600
  const [cooldownBusy, setCooldownBusy] = (0, react.useState)(false);
601
+ /** Model-catalog refresh in flight (separate from the account rescan). */
602
+ const [catalogBusy, setCatalogBusy] = (0, react.useState)(false);
588
603
  const [automationBusy, setAutomationBusy] = (0, react.useState)(false);
589
604
  /** The automation job currently running from the card, if any. */
590
605
  const [automationRun, setAutomationRun] = (0, react.useState)(void 0);
@@ -679,6 +694,35 @@ window.__ModuleLoader__.load({
679
694
  if (mounted.current) setBusy(false);
680
695
  }
681
696
  };
697
+ /**
698
+ * Re-fetch the upstream model catalog for both regions.
699
+ *
700
+ * Its own action, and its own BUTTON, because the account rescan beside it
701
+ * could not do this job: when the startup fetch failed, the picker held the
702
+ * shorter built-in list and "detect accounts again" left it untouched, so the
703
+ * only recovery was restarting DSH. Users reasonably assumed the button
704
+ * covered both.
705
+ */
706
+ const refreshCatalog = async () => {
707
+ setCatalogBusy(true);
708
+ setFlash(void 0);
709
+ try {
710
+ const response = await fetch(POOL_CATALOG_REFRESH_PATH, {
711
+ method: "POST",
712
+ headers: { accept: "application/json" },
713
+ credentials: "same-origin"
714
+ });
715
+ const body = await response.json();
716
+ if (!response.ok) throw new Error(body.error ?? `HTTP ${response.status}`);
717
+ await refresh(activeRegion);
718
+ const here = body.regions?.[activeRegion];
719
+ if (mounted.current) setFlash(here?.source === "live" ? t?.("row.catalogRefreshed", { count: here.models ?? 0 }) ?? "" : t?.("row.catalogRefreshOffline") ?? "");
720
+ } catch (cause) {
721
+ if (mounted.current) setError(cause instanceof Error ? cause.message : String(cause));
722
+ } finally {
723
+ if (mounted.current) setCatalogBusy(false);
724
+ }
725
+ };
682
726
  const resetCooldowns = async () => {
683
727
  setCooldownBusy(true);
684
728
  setFlash(void 0);
@@ -1270,7 +1314,7 @@ window.__ModuleLoader__.load({
1270
1314
  }),
1271
1315
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1272
1316
  className: "dsm-workbuddy-xdpool-auto-job-last",
1273
- children: job?.lastRunDate === void 0 ? t?.("row.autoNever") ?? "not run yet" : `${job.lastRunDate} · ${job.ok}${job.failed > 0 ? `/${job.failed}` : ""}`
1317
+ children: job?.lastRunAtMs === void 0 ? t?.("row.autoNever") ?? "not run yet" : `${formatTime(job.lastRunAtMs)} · ${job.ok}${job.failed > 0 ? `/${job.failed}` : ""}`
1274
1318
  }),
1275
1319
  job?.progress === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1276
1320
  className: "dsm-workbuddy-xdpool-auto-job-note",
@@ -1430,21 +1474,39 @@ window.__ModuleLoader__.load({
1430
1474
  })]
1431
1475
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1432
1476
  className: "dsm-workbuddy-xdpool-models-actions",
1433
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1434
- type: "button",
1435
- className: "dsm-btn dsm-btn-outline",
1436
- disabled: !modelsDirty || savingModels,
1437
- onClick: discardModels,
1438
- children: t?.("row.modelsDiscard") ?? "Discard"
1439
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1440
- type: "button",
1441
- className: "dsm-btn dsm-btn-primary",
1442
- disabled: !modelsDirty || savingModels || enabledCount === 0,
1443
- onClick: () => {
1444
- saveModels();
1445
- },
1446
- children: savingModels ? t?.("row.modelsSaving") ?? "Saving…" : t?.("row.modelsSave") ?? "Save"
1447
- })]
1477
+ children: [
1478
+ status?.catalogSource !== "fallback" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1479
+ className: "dsm-workbuddy-xdpool-catalog-offline",
1480
+ title: status.catalogError ?? void 0,
1481
+ children: t?.("row.catalogOffline") ?? "built-in list (offline)"
1482
+ }),
1483
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1484
+ type: "button",
1485
+ className: "dsm-btn dsm-btn-outline",
1486
+ disabled: catalogBusy,
1487
+ onClick: () => {
1488
+ refreshCatalog();
1489
+ },
1490
+ title: t?.("row.catalogRefreshHint") ?? "Fetch the model list again from WorkBuddy",
1491
+ children: catalogBusy ? t?.("row.catalogRefreshing") ?? "Fetching…" : t?.("row.catalogRefresh") ?? "Refresh models"
1492
+ }),
1493
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1494
+ type: "button",
1495
+ className: "dsm-btn dsm-btn-outline",
1496
+ disabled: !modelsDirty || savingModels,
1497
+ onClick: discardModels,
1498
+ children: t?.("row.modelsDiscard") ?? "Discard"
1499
+ }),
1500
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1501
+ type: "button",
1502
+ className: "dsm-btn dsm-btn-primary",
1503
+ disabled: !modelsDirty || savingModels || enabledCount === 0,
1504
+ onClick: () => {
1505
+ saveModels();
1506
+ },
1507
+ children: savingModels ? t?.("row.modelsSaving") ?? "Saving…" : t?.("row.modelsSave") ?? "Save"
1508
+ })
1509
+ ]
1448
1510
  })]
1449
1511
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1450
1512
  className: "dsm-workbuddy-xdpool-model-list",
@@ -2023,6 +2085,12 @@ window.__ModuleLoader__.load({
2023
2085
  "row.modelsSaved": "Model selection saved",
2024
2086
  "row.modelsSaveError": "Could not save: {message}",
2025
2087
  "row.modelsEmpty": "No model enabled — enable at least one before saving.",
2088
+ "row.catalogOffline": "built-in list (offline)",
2089
+ "row.catalogRefresh": "Refresh models",
2090
+ "row.catalogRefreshing": "Fetching…",
2091
+ "row.catalogRefreshHint": "Fetch the model list again from WorkBuddy",
2092
+ "row.catalogRefreshed": "Model list updated ({count} models)",
2093
+ "row.catalogRefreshOffline": "Still offline — kept the built-in list",
2026
2094
  "row.free": "free",
2027
2095
  "row.limitedFree": "limited free",
2028
2096
  "row.nightDiscount": "night",
@@ -2162,6 +2230,12 @@ window.__ModuleLoader__.load({
2162
2230
  "row.modelsSaved": "模型选择已保存",
2163
2231
  "row.modelsSaveError": "保存失败:{message}",
2164
2232
  "row.modelsEmpty": "至少要启用一个模型才能保存。",
2233
+ "row.catalogOffline": "内置列表(离线)",
2234
+ "row.catalogRefresh": "重新获取模型",
2235
+ "row.catalogRefreshing": "获取中…",
2236
+ "row.catalogRefreshHint": "重新从 WorkBuddy 拉取模型列表",
2237
+ "row.catalogRefreshed": "模型列表已更新({count} 个模型)",
2238
+ "row.catalogRefreshOffline": "仍然离线,继续使用内置列表",
2165
2239
  "row.free": "免费",
2166
2240
  "row.limitedFree": "限量免费",
2167
2241
  "row.nightDiscount": "夜间",