dsh-workbuddy-connect 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -20,581 +20,214 @@ window.__ModuleLoader__.load({
20
20
  * document.
21
21
  */
22
22
  const WORKBUDDY_PROBE_PATH = "/plugins/dsh-workbuddy-connect/probe";
23
- //#endregion
24
- //#region src/client/WorkBuddyProbeControl.tsx
25
23
  /**
26
- * Per-model reasoning-effort entry beside the Composer's model selector.
27
- *
28
- * Interaction follows the Fast Mode control `dsh-codex-connect` ships in this
29
- * same seat, which is the established shape for composer chrome here:
30
- *
31
- * - a **static inline label** next to the icon names the feature ("Reasoning
32
- * levels"), set smaller and dimmer than the surrounding chrome so it reads as
33
- * an annotation on the icon. It never carries state: the verified levels
34
- * already appear in the model dropdown (the adapter exposes them as
35
- * selectable efforts), so repeating them here would duplicate the real answer
36
- * and make the label's width jump as results change.
37
- * - a **hover/focus tooltip** carries the state and the click's purpose, the way
38
- * Fast Mode's tooltip explains its current speed.
39
- * - the **confirmation** is a small bubble anchored to the control, not a
40
- * `window.confirm`. Probing spends real credit, so a confirmation stays — but
41
- * it belongs next to the thing it acts on, sized to one line plus two small
42
- * buttons.
24
+ * The international (WorkBuddy AI) variant's own pair of routes.
43
25
  *
44
- * @module dsh-workbuddy-connect/client/probe-control
26
+ * Kept as separate constants rather than a computed suffix so both halves
27
+ * reference literal strings: the browser bundle and the host bundle are built
28
+ * independently, and a shared expression is one build-config drift away from
29
+ * the desk asking a route the host never mounted.
45
30
  */
46
- /** How often the control re-checks state when the window regains focus. */
47
- const RECONCILE_MS = 6e4;
48
- const wrapperStyle = {
49
- display: "inline-flex",
50
- position: "relative",
51
- alignItems: "center",
52
- transform: "translateY(2px)",
53
- marginRight: -8
31
+ const WORKBUDDY_AI_STATUS_PATH = "/plugins/dsh-workbuddy-connect/ai/status";
32
+ const WORKBUDDY_AI_PROBE_PATH = "/plugins/dsh-workbuddy-connect/ai/probe";
33
+ //#endregion
34
+ //#region src/client/WorkBuddyPluginCard.tsx
35
+ /** WorkBuddy status card contributed to Harness Plugin configuration. */
36
+ /** CN WorkBuddy; the plugin's long-standing card and default. */
37
+ const CN_CARD_VARIANT = {
38
+ id: "workbuddy",
39
+ titleKey: "title",
40
+ introKey: "intro",
41
+ signedOutKey: "signedOutHint",
42
+ statusPath: WORKBUDDY_STATUS_PATH,
43
+ probePath: WORKBUDDY_PROBE_PATH
54
44
  };
55
- const buttonStyle$1 = {
56
- display: "inline-flex",
45
+ /** Both cards, in display order. */
46
+ const CARD_VARIANTS = [CN_CARD_VARIANT, {
47
+ id: "workbuddy-ai",
48
+ titleKey: "titleAI",
49
+ introKey: "introAI",
50
+ signedOutKey: "signedOutHintAI",
51
+ statusPath: WORKBUDDY_AI_STATUS_PATH,
52
+ probePath: WORKBUDDY_AI_PROBE_PATH
53
+ }];
54
+ const POLL_INTERVAL_MS = 6e4;
55
+ const cardStyle = {
56
+ overflow: "hidden",
57
+ border: "1px solid var(--dsw-alias-border-l2)",
58
+ borderRadius: 10,
59
+ background: "var(--dsw-alias-bg-module-platform)"
60
+ };
61
+ const headerStyle = {
62
+ boxSizing: "border-box",
63
+ width: "100%",
64
+ display: "flex",
57
65
  alignItems: "center",
58
- justifyContent: "center",
59
- gap: 2,
60
- height: 30,
61
- padding: "0 6px",
66
+ justifyContent: "space-between",
67
+ gap: 16,
62
68
  border: 0,
63
- borderRadius: 8,
69
+ padding: "13px 14px",
64
70
  background: "transparent",
65
- color: "var(--dsw-alias-label-secondary)",
71
+ color: "var(--dsw-alias-label-primary)",
66
72
  font: "inherit",
67
- whiteSpace: "nowrap",
73
+ textAlign: "left",
68
74
  cursor: "pointer"
69
75
  };
70
- /**
71
- * The inline label. Smaller and dimmer than the surrounding chrome on purpose:
72
- * it names the feature, so it should read as an annotation attached to the icon
73
- * rather than compete with the adjacent model selector.
74
- */
75
- const labelStyle = {
76
- fontSize: 11,
77
- lineHeight: "16px",
78
- color: "var(--dsw-alias-label-tertiary)"
76
+ const headTextStyle = {
77
+ display: "flex",
78
+ minWidth: 0,
79
+ flexDirection: "column",
80
+ gap: 3
79
81
  };
80
- /** Tooltip bubble: the Fast Mode shape (nowrap, one line, above the control). */
81
- const tooltipStyle = {
82
- position: "absolute",
83
- left: "50%",
84
- bottom: "calc(100% + 8px)",
85
- zIndex: 1e3,
86
- transform: "translateX(-50%)",
87
- padding: "4px 8px",
88
- borderRadius: 6,
89
- background: "var(--dsw-specific-tip, #1f2329)",
90
- boxShadow: "var(--dsw-shadow-lv2)",
91
- color: "var(--dsw-alias-label-primary, #fff)",
92
- fontSize: 12,
82
+ const nameStyle = {
83
+ fontSize: 14,
84
+ lineHeight: "20px",
85
+ fontWeight: 600
86
+ };
87
+ const descriptionStyle = {
88
+ fontSize: 13,
93
89
  lineHeight: "18px",
94
- whiteSpace: "nowrap",
95
- pointerEvents: "none"
90
+ color: "var(--dsw-alias-label-tertiary)"
96
91
  };
97
- /** Confirmation bubble: same anchor, but interactive and allowed to wrap. */
98
- const confirmStyle = {
99
- position: "absolute",
100
- right: 0,
101
- bottom: "calc(100% + 8px)",
102
- zIndex: 1001,
92
+ const chevronStyle = {
93
+ flex: "0 0 auto",
94
+ fontSize: 18,
95
+ lineHeight: 1,
96
+ transition: "transform 120ms ease"
97
+ };
98
+ const cardBodyStyle = {
99
+ borderTop: "1px solid var(--dsw-alias-border-l2)",
100
+ padding: "16px 14px 18px"
101
+ };
102
+ const bodyStyle = {
103
+ margin: 0,
104
+ fontSize: 14,
105
+ lineHeight: "22px",
106
+ color: "var(--dsw-alias-label-secondary)"
107
+ };
108
+ const rowStyle = {
103
109
  display: "flex",
104
- flexDirection: "column",
105
- gap: 8,
106
- width: 260,
107
- padding: "10px 12px",
108
- border: "1px solid var(--dsw-alias-border-l2)",
109
- borderRadius: 8,
110
- background: "var(--dsw-alias-bg-layer-1, #fff)",
111
- boxShadow: "var(--dsw-shadow-lv2)",
112
- color: "var(--dsw-alias-label-primary)",
113
- fontSize: 12,
114
- lineHeight: "18px"
110
+ alignItems: "center",
111
+ justifyContent: "space-between",
112
+ flexWrap: "wrap",
113
+ gap: 12
115
114
  };
116
- const confirmRowStyle$1 = {
115
+ const statusStyle = {
117
116
  display: "flex",
118
- justifyContent: "flex-end",
119
- gap: 8
117
+ alignItems: "center",
118
+ gap: 9,
119
+ fontSize: 15,
120
+ fontWeight: 500,
121
+ color: "var(--dsw-alias-label-primary)"
120
122
  };
121
- const confirmButtonStyle = {
122
- padding: "3px 10px",
123
+ const buttonStyle$1 = {
124
+ boxSizing: "border-box",
125
+ minHeight: 34,
126
+ padding: "6px 14px",
123
127
  border: "1px solid var(--dsw-alias-border-l2)",
124
- borderRadius: 6,
125
- background: "transparent",
126
- color: "inherit",
128
+ borderRadius: 18,
129
+ background: "var(--dsw-alias-bg-layer-1)",
130
+ color: "var(--dsw-alias-label-primary)",
127
131
  font: "inherit",
128
- fontSize: 12,
132
+ fontSize: 14,
129
133
  cursor: "pointer"
130
134
  };
131
- /**
132
- * Primary action inside the confirmation bubble.
133
- *
134
- * The fill and its text colour must come as a pair: `brand-primary` resolves to
135
- * a light accent in this theme, so hardcoding `color: #fff` on top of it renders
136
- * white-on-white. `button-primary-fill` + `label-primary-foreground` is the
137
- * theme's own pair for exactly this, and is what `dsh-codex-connect` uses for
138
- * the same job.
139
- */
140
- const primaryButtonStyle$1 = {
141
- ...confirmButtonStyle,
142
- border: "1px solid var(--dsw-alias-button-primary-fill)",
143
- background: "var(--dsw-alias-button-primary-fill)",
144
- color: "var(--dsw-alias-label-primary-foreground)"
135
+ const errorStyle = {
136
+ ...bodyStyle,
137
+ color: "var(--dsw-alias-state-error-primary)"
145
138
  };
146
- /**
147
- * Result note: a single line + a dismiss button, anchored to the control's
148
- * right side. Smaller than the confirmation bubble because it carries an
149
- * *outcome*, not a *decision* — the work is done, the user only has to read
150
- * and dismiss.
151
- */
152
- const noteStyle = {
153
- position: "absolute",
154
- right: 0,
155
- bottom: "calc(100% + 8px)",
156
- zIndex: 1001,
139
+ const quotaListStyle = {
157
140
  display: "flex",
158
- alignItems: "center",
141
+ flexDirection: "column",
142
+ gap: 18,
143
+ paddingTop: 2
144
+ };
145
+ const quotaGroupStyle = {
146
+ display: "flex",
147
+ flexDirection: "column",
148
+ gap: 10
149
+ };
150
+ const quotaTitleStyle = {
151
+ margin: 0,
152
+ fontSize: 14,
153
+ lineHeight: "20px",
154
+ fontWeight: 600,
155
+ color: "var(--dsw-alias-label-primary)"
156
+ };
157
+ const quotaLabelStyle = {
158
+ display: "flex",
159
+ justifyContent: "space-between",
159
160
  gap: 12,
160
- padding: "6px 10px",
161
- border: "1px solid var(--dsw-alias-border-l2)",
162
- borderRadius: 8,
163
- background: "var(--dsw-alias-bg-layer-1)",
164
- boxShadow: "var(--dsw-shadow-lv2)",
165
- color: "var(--dsw-alias-label-primary)",
161
+ fontSize: 13,
162
+ lineHeight: "20px",
163
+ color: "var(--dsw-alias-label-secondary)"
164
+ };
165
+ const modelBadgeStyle = {
166
+ display: "flex",
167
+ alignItems: "center",
168
+ gap: 6,
169
+ flexWrap: "wrap"
170
+ };
171
+ const modelOfferStyle = {
172
+ display: "flex",
173
+ flexDirection: "column",
174
+ gap: 2
175
+ };
176
+ const modelRateStyle = {
166
177
  fontSize: 12,
167
178
  lineHeight: "18px",
168
- whiteSpace: "nowrap"
179
+ color: "var(--dsw-alias-label-tertiary)"
169
180
  };
170
- /**
171
- * The note's dismiss action. Outlined rather than bare text: inside an already
172
- * bordered bubble, an unbordered word does not read as something you can click.
173
- * Matches the outlined pill convention the plugin's other secondary actions use.
174
- */
175
- const noteDismissStyle = {
176
- padding: "2px 8px",
177
- border: "1px solid var(--dsw-alias-border-l2)",
178
- borderRadius: 6,
179
- background: "transparent",
180
- color: "var(--dsw-alias-label-secondary)",
181
- font: "inherit",
182
- fontSize: 12,
181
+ const modelBadgeChipStyle = {
182
+ padding: "1px 8px",
183
+ borderRadius: 999,
184
+ fontSize: 11,
183
185
  lineHeight: "18px",
184
- cursor: "pointer"
186
+ background: "var(--dsw-alias-state-success-subtle, rgba(34, 160, 107, 0.12))",
187
+ color: "var(--dsw-alias-state-success-primary, #22a06b)"
185
188
  };
186
189
  /**
187
- * The feature's static inline label. Deliberately not a state readout — see the
188
- * module comment.
190
+ * Localize an upstream promotional badge label, with an unknown-badge fallback.
191
+ *
192
+ * The CN catalog spells badges in Chinese (`限时免费`, `夜间折扣`); the
193
+ * international document's `modelPromotions` carries English (`Free now`). Both
194
+ * are mapped so the same promotion reads consistently in either UI language,
195
+ * and anything else passes through verbatim — an unrecognized badge is still
196
+ * information the upstream chose to show.
189
197
  */
190
- function useLabel(t) {
191
- return t("probeLabel");
192
- }
193
- /** Pick the model's recorded observation out of the probe section. */
194
- function resultFor(status, model) {
195
- if (status.status !== "signed-in") return void 0;
196
- return status.probe?.results.find((result) => result.id === model);
198
+ function modelBadgeLabel(badge, t) {
199
+ if (badge === "限时免费") return t("badgeLimitedFree");
200
+ if (badge === "夜间折扣") return t("badgeNightDiscount");
201
+ if (badge === "Free now") return t("badgeFreeNow");
202
+ return badge;
197
203
  }
204
+ const progressTrackStyle = {
205
+ height: 8,
206
+ overflow: "hidden",
207
+ borderRadius: 999,
208
+ background: "var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.08))"
209
+ };
198
210
  /**
199
- * The one-line tooltip: current state first, then what a click does — the same
200
- * two-part shape Fast Mode uses.
211
+ * Inline confirmation box for a paid detection. Replaces the previous
212
+ * `window.confirm`: the decision is one line plus two buttons, and a modal
213
+ * alert for that is heavier than the action it guards.
201
214
  */
202
- function tooltipText(t, model, state) {
203
- if (state.busy) return t("probeRunning", { model });
204
- if (state.failed) return t("probeTooltipRetry");
205
- const result = state.result;
206
- if (result === void 0) return t("probeTooltipIdle", { model });
207
- if (result.validation === "validating" && result.efforts.length > 0) return t("probeTooltipVerified", { levels: result.efforts.join(" / ") });
208
- if (result.validation === "non-validating") return t("probeTooltipNotValidating");
209
- return t("probeTooltipRetry");
210
- }
211
- /** Model-independent shell: resolves the selection, then delegates per model. */
212
- function WorkBuddyProbeControl({ directory, t }) {
213
- const subscribe = (0, react.useCallback)((listener) => directory.subscribe(listener), [directory]);
214
- const snapshot = (0, react.useCallback)(() => directory.getSnapshot(), [directory]);
215
- const selection = (0, react.useSyncExternalStore)(subscribe, snapshot, snapshot).current;
216
- const model = selection?.provider === "workbuddy" ? selection.model : void 0;
217
- const label = useLabel(t);
218
- return model === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelProbe, {
219
- model,
220
- label,
221
- t
222
- }, model);
223
- }
224
- function ModelProbe({ model, label, t }) {
225
- const [status, setStatus] = (0, react.useState)();
226
- const [busy, setBusy] = (0, react.useState)(false);
227
- const [confirming, setConfirming] = (0, react.useState)(false);
228
- const [tooltipVisible, setTooltipVisible] = (0, react.useState)(false);
229
- const [failed, setFailed] = (0, react.useState)(false);
230
- const [note, setNote] = (0, react.useState)();
231
- const inFlight = (0, react.useRef)(false);
232
- const mounted = (0, react.useRef)(false);
233
- const tooltipId = (0, react.useId)();
234
- const refresh = (0, react.useCallback)(async (signal) => {
235
- const response = await fetch(WORKBUDDY_STATUS_PATH, {
236
- credentials: "same-origin",
237
- headers: { accept: "application/json" },
238
- ...signal === void 0 ? {} : { signal }
239
- });
240
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
241
- const value = await response.json();
242
- if (mounted.current && !signal?.aborted) setStatus(value);
243
- }, []);
244
- (0, react.useEffect)(() => {
245
- mounted.current = true;
246
- const controller = new AbortController();
247
- const load = () => {
248
- refresh(controller.signal).catch(() => {});
249
- };
250
- load();
251
- const timer = window.setInterval(load, RECONCILE_MS);
252
- window.addEventListener("focus", load);
253
- return () => {
254
- mounted.current = false;
255
- controller.abort();
256
- window.clearInterval(timer);
257
- window.removeEventListener("focus", load);
258
- };
259
- }, [refresh]);
260
- const probe = status?.status === "signed-in" ? status.probe : void 0;
261
- const key = status?.status === "signed-in" ? status.probeKey : void 0;
262
- const result = status === void 0 ? void 0 : resultFor(status, model);
263
- const visible = probe?.candidates.includes(model) === true || result !== void 0;
264
- (0, react.useEffect)(() => {
265
- setConfirming(false);
266
- setNote(void 0);
267
- }, [model]);
268
- const detect = async () => {
269
- if (key === void 0 || inFlight.current || probe?.running === true) return;
270
- inFlight.current = true;
271
- setNote(void 0);
272
- setConfirming(false);
273
- setBusy(true);
274
- setFailed(false);
275
- try {
276
- const response = await fetch(WORKBUDDY_PROBE_PATH, {
277
- method: "POST",
278
- credentials: "same-origin",
279
- headers: {
280
- "Content-Type": "application/json",
281
- "X-WorkBuddy-Probe-Key": key
282
- },
283
- body: JSON.stringify({
284
- action: "probe",
285
- model
286
- })
287
- });
288
- const body = await response.json();
289
- if (!response.ok || body.state !== "ok" || body.validation !== "validating" && body.validation !== "non-validating" || !Array.isArray(body.efforts) || !body.efforts.every((effort) => typeof effort === "string")) throw new Error("probe failed");
290
- if (mounted.current) {
291
- const completed = {
292
- id: model,
293
- name: model,
294
- validation: body.validation,
295
- efforts: body.efforts,
296
- probedAt: Date.now()
297
- };
298
- setNote(completed);
299
- }
300
- refresh().catch(() => {});
301
- } catch {
302
- if (mounted.current) setFailed(true);
303
- } finally {
304
- inFlight.current = false;
305
- if (mounted.current) setBusy(false);
306
- }
307
- };
308
- if (!visible) return null;
309
- const text = tooltipText(t, model, {
310
- busy,
311
- result,
312
- failed
313
- });
314
- const disabled = busy || probe?.running === true || key === void 0;
315
- const showTooltip = tooltipVisible && !confirming && note === void 0;
316
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
317
- style: wrapperStyle,
318
- onMouseEnter: () => {
319
- setTooltipVisible(true);
320
- },
321
- onMouseLeave: () => {
322
- setTooltipVisible(false);
323
- },
324
- children: [
325
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
326
- type: "button",
327
- "aria-label": text,
328
- "aria-describedby": showTooltip ? tooltipId : void 0,
329
- "aria-busy": busy,
330
- "aria-expanded": confirming,
331
- disabled,
332
- onClick: () => {
333
- setConfirming(true);
334
- },
335
- onFocus: () => {
336
- setTooltipVisible(true);
337
- },
338
- onBlur: () => {
339
- setTooltipVisible(false);
340
- },
341
- style: {
342
- ...buttonStyle$1,
343
- opacity: disabled && !confirming ? .6 : 1,
344
- cursor: disabled ? "default" : "pointer"
345
- },
346
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
347
- width: "16",
348
- height: "16",
349
- viewBox: "0 0 24 24",
350
- fill: "none",
351
- stroke: "currentColor",
352
- strokeWidth: "1.6",
353
- "aria-hidden": "true",
354
- focusable: "false",
355
- children: [
356
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
357
- cx: "12",
358
- cy: "12",
359
- r: "9"
360
- }),
361
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
362
- cx: "12",
363
- cy: "12",
364
- r: "4"
365
- }),
366
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 12 20 4" }),
367
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
368
- cx: "12",
369
- cy: "12",
370
- r: "1"
371
- })
372
- ]
373
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
374
- style: labelStyle,
375
- children: label
376
- })]
377
- }),
378
- showTooltip && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
379
- id: tooltipId,
380
- role: "tooltip",
381
- style: tooltipStyle,
382
- children: text
383
- }),
384
- confirming && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
385
- style: confirmStyle,
386
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("probeBubbleBody") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
387
- style: confirmRowStyle$1,
388
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
389
- type: "button",
390
- style: confirmButtonStyle,
391
- onClick: () => {
392
- setConfirming(false);
393
- },
394
- children: t("cancel")
395
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
396
- type: "button",
397
- style: primaryButtonStyle$1,
398
- onClick: () => {
399
- detect();
400
- },
401
- children: t("probeConfirmAction")
402
- })]
403
- })]
404
- }),
405
- note === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
406
- role: "status",
407
- "aria-live": "polite",
408
- style: noteStyle,
409
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: noteText(t, note) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
410
- type: "button",
411
- style: noteDismissStyle,
412
- onClick: () => {
413
- setNote(void 0);
414
- },
415
- children: t("probeNoteDismiss")
416
- })]
417
- })
418
- ]
419
- });
420
- }
421
- /** Compose the one-line outcome string the note bubble shows. */
422
- function noteText(t, result) {
423
- if (result.validation === "validating" && result.efforts.length > 0) return t("probeNoteVerified", { levels: result.efforts.join(" / ") });
424
- if (result.validation === "non-validating") return t("probeNoteNotValidating");
425
- return t("probeNoteUnknown");
426
- }
427
- //#endregion
428
- //#region src/client/WorkBuddyPluginCard.tsx
429
- /** WorkBuddy status card contributed to Harness Plugin configuration. */
430
- const POLL_INTERVAL_MS = 6e4;
431
- const cardStyle = {
432
- overflow: "hidden",
215
+ const confirmBoxStyle = {
216
+ display: "flex",
217
+ flexDirection: "column",
218
+ gap: 10,
219
+ padding: "10px 12px",
433
220
  border: "1px solid var(--dsw-alias-border-l2)",
434
- borderRadius: 10,
435
- background: "var(--dsw-alias-bg-module-platform)"
221
+ borderRadius: 8,
222
+ background: "var(--dsw-alias-bg-layer-1)"
436
223
  };
437
- const headerStyle = {
438
- boxSizing: "border-box",
439
- width: "100%",
440
- display: "flex",
441
- alignItems: "center",
442
- justifyContent: "space-between",
443
- gap: 16,
444
- border: 0,
445
- padding: "13px 14px",
446
- background: "transparent",
447
- color: "var(--dsw-alias-label-primary)",
448
- font: "inherit",
449
- textAlign: "left",
450
- cursor: "pointer"
451
- };
452
- const headTextStyle = {
453
- display: "flex",
454
- minWidth: 0,
455
- flexDirection: "column",
456
- gap: 3
457
- };
458
- const nameStyle = {
459
- fontSize: 14,
460
- lineHeight: "20px",
461
- fontWeight: 600
462
- };
463
- const descriptionStyle = {
464
- fontSize: 13,
465
- lineHeight: "18px",
466
- color: "var(--dsw-alias-label-tertiary)"
467
- };
468
- const chevronStyle = {
469
- flex: "0 0 auto",
470
- fontSize: 18,
471
- lineHeight: 1,
472
- transition: "transform 120ms ease"
473
- };
474
- const cardBodyStyle = {
475
- borderTop: "1px solid var(--dsw-alias-border-l2)",
476
- padding: "16px 14px 18px"
477
- };
478
- const bodyStyle = {
479
- margin: 0,
480
- fontSize: 14,
481
- lineHeight: "22px",
482
- color: "var(--dsw-alias-label-secondary)"
483
- };
484
- const rowStyle = {
485
- display: "flex",
486
- alignItems: "center",
487
- justifyContent: "space-between",
488
- flexWrap: "wrap",
489
- gap: 12
490
- };
491
- const statusStyle = {
492
- display: "flex",
493
- alignItems: "center",
494
- gap: 9,
495
- fontSize: 15,
496
- fontWeight: 500,
497
- color: "var(--dsw-alias-label-primary)"
498
- };
499
- const buttonStyle = {
500
- boxSizing: "border-box",
501
- minHeight: 34,
502
- padding: "6px 14px",
503
- border: "1px solid var(--dsw-alias-border-l2)",
504
- borderRadius: 18,
505
- background: "var(--dsw-alias-bg-layer-1)",
506
- color: "var(--dsw-alias-label-primary)",
507
- font: "inherit",
508
- fontSize: 14,
509
- cursor: "pointer"
510
- };
511
- const errorStyle = {
512
- ...bodyStyle,
513
- color: "var(--dsw-alias-state-error-primary)"
514
- };
515
- const quotaListStyle = {
516
- display: "flex",
517
- flexDirection: "column",
518
- gap: 18,
519
- paddingTop: 2
520
- };
521
- const quotaGroupStyle = {
522
- display: "flex",
523
- flexDirection: "column",
524
- gap: 10
525
- };
526
- const quotaTitleStyle = {
527
- margin: 0,
528
- fontSize: 14,
529
- lineHeight: "20px",
530
- fontWeight: 600,
531
- color: "var(--dsw-alias-label-primary)"
532
- };
533
- const quotaLabelStyle = {
534
- display: "flex",
535
- justifyContent: "space-between",
536
- gap: 12,
537
- fontSize: 13,
538
- lineHeight: "20px",
539
- color: "var(--dsw-alias-label-secondary)"
540
- };
541
- const modelBadgeStyle = {
542
- display: "flex",
543
- alignItems: "center",
544
- gap: 6,
545
- flexWrap: "wrap"
546
- };
547
- const modelOfferStyle = {
548
- display: "flex",
549
- flexDirection: "column",
550
- gap: 2
551
- };
552
- const modelRateStyle = {
553
- fontSize: 12,
554
- lineHeight: "18px",
555
- color: "var(--dsw-alias-label-tertiary)"
556
- };
557
- const modelBadgeChipStyle = {
558
- padding: "1px 8px",
559
- borderRadius: 999,
560
- fontSize: 11,
561
- lineHeight: "18px",
562
- background: "var(--dsw-alias-state-success-subtle, rgba(34, 160, 107, 0.12))",
563
- color: "var(--dsw-alias-state-success-primary, #22a06b)"
564
- };
565
- /** Localize an upstream promotional badge label, with an unknown-badge fallback. */
566
- function modelBadgeLabel(badge, t) {
567
- if (badge === "限时免费") return t("badgeLimitedFree");
568
- if (badge === "夜间折扣") return t("badgeNightDiscount");
569
- return badge;
570
- }
571
- const progressTrackStyle = {
572
- height: 8,
573
- overflow: "hidden",
574
- borderRadius: 999,
575
- background: "var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.08))"
576
- };
577
- /**
578
- * Inline confirmation box for a paid detection. Replaces the previous
579
- * `window.confirm`: the decision is one line plus two buttons, and a modal
580
- * alert for that is heavier than the action it guards.
581
- */
582
- const confirmBoxStyle = {
583
- display: "flex",
584
- flexDirection: "column",
585
- gap: 10,
586
- padding: "10px 12px",
587
- border: "1px solid var(--dsw-alias-border-l2)",
588
- borderRadius: 8,
589
- background: "var(--dsw-alias-bg-layer-1)"
590
- };
591
- const confirmRowStyle = {
592
- display: "flex",
593
- justifyContent: "flex-end",
594
- gap: 8
595
- };
596
- /** One probeable model's row: name on the left, state and action on the right. */
597
- const probeRowStyle = {
224
+ const confirmRowStyle$1 = {
225
+ display: "flex",
226
+ justifyContent: "flex-end",
227
+ gap: 8
228
+ };
229
+ /** One probeable model's row: name on the left, state and action on the right. */
230
+ const probeRowStyle = {
598
231
  display: "flex",
599
232
  alignItems: "center",
600
233
  justifyContent: "space-between",
@@ -644,8 +277,8 @@ window.__ModuleLoader__.load({
644
277
  * theme as a pair: `brand-primary` is a light accent here, so pairing it with a
645
278
  * hardcoded white would render white-on-white.
646
279
  */
647
- const primaryButtonStyle = {
648
- ...buttonStyle,
280
+ const primaryButtonStyle$1 = {
281
+ ...buttonStyle$1,
649
282
  border: "1px solid var(--dsw-alias-button-primary-fill)",
650
283
  background: "var(--dsw-alias-button-primary-fill)",
651
284
  color: "var(--dsw-alias-label-primary-foreground)"
@@ -708,474 +341,970 @@ window.__ModuleLoader__.load({
708
341
  });
709
342
  }
710
343
  /**
711
- * One model offer row: name, promotional badges, and the billing rate.
344
+ * One model offer row: name, promotional badges, and the billing rate.
345
+ *
346
+ * The rate sits under the name rather than beside it because the row already
347
+ * spends its horizontal budget on badges; stacking keeps long model names and
348
+ * several badges from squeezing the rate into an ellipsis.
349
+ */
350
+ function ModelOfferRow({ model, t }) {
351
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
352
+ style: modelOfferStyle,
353
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
354
+ style: quotaLabelStyle,
355
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
356
+ style: modelBadgeStyle,
357
+ children: [model.badges?.map((badge) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
358
+ style: modelBadgeChipStyle,
359
+ children: modelBadgeLabel(badge, t)
360
+ }, badge)), model.free === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
361
+ style: modelBadgeChipStyle,
362
+ children: t("freeModel")
363
+ }) : null]
364
+ })]
365
+ }), model.credits === void 0 ? model.rateUnknown === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
366
+ style: modelRateStyle,
367
+ children: t("rateUnknown")
368
+ }) : null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
369
+ style: modelRateStyle,
370
+ children: t("rate", { rate: model.credits })
371
+ })]
372
+ });
373
+ }
374
+ /**
375
+ * Context capacity, listed in full.
376
+ *
377
+ * Every model the upstream reports a capacity for, largest first. A one-line
378
+ * summary with the exceptions on hover was tried and rejected: capacity is
379
+ * reference data you scan by model, and hiding most of it behind a hover made
380
+ * the common case (a model you already have in mind) the hard one to look up.
381
+ *
382
+ * Purely a report of the upstream's own numbers. The plugin offers no tier
383
+ * picker: the CN catalog declares one capacity per model and publishes no
384
+ * alternatives, so a menu there would mean inventing client-side policy. The
385
+ * international document does declare alternatives (`supportedLengths`), and
386
+ * they are shown as a secondary figure rather than merged into one number —
387
+ * the default is the budget actually requested, while the larger value is a
388
+ * ceiling the upstream would accept.
389
+ */
390
+ function ContextTable({ models, t }) {
391
+ const known = (models ?? []).filter((model) => model.contextWindow !== void 0).sort((a, b) => b.contextWindow - a.contextWindow);
392
+ if (known.length === 0) return null;
393
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
394
+ style: quotaListStyle,
395
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
396
+ style: quotaTitleStyle,
397
+ children: t("contextHeading")
398
+ }), known.map((model) => {
399
+ const capacity = model.contextWindow;
400
+ const alternative = model.maxContextWindow !== void 0 && model.maxContextWindow > capacity ? model.maxContextWindow : void 0;
401
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
402
+ style: quotaLabelStyle,
403
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
404
+ style: modelOfferStyle,
405
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
406
+ style: { textAlign: "right" },
407
+ children: formatTokens(capacity)
408
+ }), alternative === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
409
+ style: modelRateStyle,
410
+ children: t("contextUpTo", { size: formatTokens(alternative) })
411
+ })]
412
+ })]
413
+ }, model.id);
414
+ })]
415
+ });
416
+ }
417
+ /**
418
+ * Compact token count for display: the catalog's own round numbers (`200000`,
419
+ * `1000000`) read better as `200K` / `1M`, and no precision is lost because
420
+ * these values are always whole thousands.
421
+ */
422
+ function formatTokens(tokens) {
423
+ if (tokens >= 1e6 && tokens % 1e6 === 0) return `${tokens / 1e6}M`;
424
+ if (tokens >= 1e3 && tokens % 1e3 === 0) return `${tokens / 1e3}K`;
425
+ return String(tokens);
426
+ }
427
+ /**
428
+ * Reasoning-effort detection section: consent switches, per-model detection,
429
+ * and the recorded observations.
430
+ *
431
+ * Two deliberate UX rules from the plan (§3.1, §3.2):
432
+ * - the confirmation is shown *before* any request, and its copy states the
433
+ * credit caveat;
434
+ * - a `non-validating` result is presented as an observation about the
435
+ * parameter ("this model does not check it"), never as a statement that a
436
+ * level is unsupported.
437
+ */
438
+ function ProbeSection({ probe, t, onDetect, onClear, busy }) {
439
+ const [pending, setPending] = (0, react.useState)();
440
+ const [runningModel, setRunningModel] = (0, react.useState)();
441
+ (0, react.useEffect)(() => {
442
+ if (pending !== void 0 && !probe.candidates.includes(pending)) setPending(void 0);
443
+ }, [pending, probe.candidates]);
444
+ const runningArmed = (0, react.useRef)(false);
445
+ (0, react.useEffect)(() => {
446
+ if (runningModel === void 0) return;
447
+ if (busy || probe.running) {
448
+ runningArmed.current = true;
449
+ return;
450
+ }
451
+ if (!runningArmed.current) return;
452
+ runningArmed.current = false;
453
+ setRunningModel(void 0);
454
+ }, [
455
+ runningModel,
456
+ busy,
457
+ probe.running
458
+ ]);
459
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
460
+ style: quotaListStyle,
461
+ children: [
462
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
463
+ style: quotaTitleStyle,
464
+ children: t("probeHeading")
465
+ }),
466
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
467
+ style: bodyStyle,
468
+ children: t("probeIntro")
469
+ }),
470
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
471
+ style: bodyStyle,
472
+ children: t("probeConsentHint")
473
+ }),
474
+ probe.running ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
475
+ style: bodyStyle,
476
+ children: t("probeRunningGeneric")
477
+ }) : null,
478
+ probe.candidates.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
479
+ style: bodyStyle,
480
+ children: t("probeResultEmpty")
481
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
482
+ style: quotaGroupStyle,
483
+ children: probe.candidates.map((id) => {
484
+ const result = probe.results.find((entry) => entry.id === id);
485
+ const name = result?.name ?? id;
486
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
487
+ style: modelOfferStyle,
488
+ children: [
489
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
490
+ style: probeRowStyle,
491
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
492
+ style: probeRowEndStyle,
493
+ children: [result === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
494
+ style: modelBadgeChipStyle,
495
+ children: result.validation === "validating" && result.efforts.length > 0 ? result.efforts.join(" / ") : t(result.validation === "non-validating" ? "probeResultNotValidating" : "probeResultUnknown")
496
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
497
+ type: "button",
498
+ style: buttonStyle$1,
499
+ disabled: probe.running || busy,
500
+ onClick: () => {
501
+ setPending(id);
502
+ },
503
+ children: runningModel === id ? t("probeRunning", { model: id }) : t(result === void 0 ? "probeStart" : "probeRedetect")
504
+ })]
505
+ })]
506
+ }),
507
+ result === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
508
+ style: modelRateStyle,
509
+ children: t("probeResultAt", { time: formatTime(result.probedAt) })
510
+ }),
511
+ pending === id ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
512
+ style: confirmBoxStyle,
513
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
514
+ style: bodyStyle,
515
+ children: t("probeConfirmBody", { model: name })
516
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
517
+ style: confirmRowStyle$1,
518
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
519
+ type: "button",
520
+ style: buttonStyle$1,
521
+ onClick: () => {
522
+ setPending(void 0);
523
+ },
524
+ children: t("cancel")
525
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
526
+ type: "button",
527
+ style: primaryButtonStyle$1,
528
+ disabled: probe.running || busy,
529
+ onClick: () => {
530
+ setRunningModel(id);
531
+ setPending(void 0);
532
+ onDetect(id);
533
+ },
534
+ children: t("probeConfirmAction")
535
+ })]
536
+ })]
537
+ }) : null
538
+ ]
539
+ }, id);
540
+ })
541
+ }),
542
+ probe.results.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
543
+ type: "button",
544
+ style: buttonStyle$1,
545
+ disabled: busy,
546
+ onClick: () => {
547
+ onClear();
548
+ },
549
+ children: t("probeClear")
550
+ })
551
+ ]
552
+ });
553
+ }
554
+ /** Render WorkBuddy sign-in state and credit as one expandable card. */
555
+ function WorkBuddyPluginCard({ t, variant = CN_CARD_VARIANT }) {
556
+ if (t === void 0) throw new Error("WorkBuddy plugin card requires its translation function");
557
+ const [open, setOpen] = (0, react.useState)(false);
558
+ const [status, setStatus] = (0, react.useState)({ status: "signed-out" });
559
+ const [busy, setBusy] = (0, react.useState)(false);
560
+ const [tab, setTab] = (0, react.useState)("status");
561
+ const mounted = (0, react.useRef)(true);
562
+ (0, react.useEffect)(() => {
563
+ mounted.current = true;
564
+ return () => {
565
+ mounted.current = false;
566
+ };
567
+ }, []);
568
+ const refresh = (0, react.useCallback)(async (signal) => {
569
+ try {
570
+ const response = await fetch(variant.statusPath, {
571
+ headers: { accept: "application/json" },
572
+ credentials: "same-origin",
573
+ ...signal === void 0 ? {} : { signal }
574
+ });
575
+ const value = await response.json().catch(() => void 0);
576
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
577
+ if (mounted.current && signal?.aborted !== true) setStatus(value);
578
+ } catch (error) {
579
+ if (mounted.current && signal?.aborted !== true) setStatus({
580
+ status: "error",
581
+ message: error instanceof Error ? error.message : t("requestFailed")
582
+ });
583
+ }
584
+ }, [t, variant.statusPath]);
585
+ (0, react.useEffect)(() => {
586
+ if (!open) return;
587
+ const controller = new AbortController();
588
+ refresh(controller.signal);
589
+ return () => {
590
+ controller.abort();
591
+ };
592
+ }, [open, refresh]);
593
+ (0, react.useEffect)(() => {
594
+ if (!open || status.status !== "signed-in") return;
595
+ const controller = new AbortController();
596
+ const timer = window.setInterval(() => {
597
+ refresh(controller.signal);
598
+ }, POLL_INTERVAL_MS);
599
+ return () => {
600
+ window.clearInterval(timer);
601
+ controller.abort();
602
+ };
603
+ }, [
604
+ open,
605
+ refresh,
606
+ status.status
607
+ ]);
608
+ const manualRefresh = async () => {
609
+ setBusy(true);
610
+ try {
611
+ await refresh();
612
+ } finally {
613
+ if (mounted.current) setBusy(false);
614
+ }
615
+ };
616
+ /**
617
+ * Ask the host to re-read the credential and re-fetch this variant's catalog.
618
+ *
619
+ * Shares the probe route's key and guards: it is a write that spends an
620
+ * upstream request, so it does not belong on the read-only status GET. A
621
+ * failure is surfaced through the refreshed document's `catalog.error` rather
622
+ * than thrown away, so the reason survives the round trip.
623
+ */
624
+ const refreshModels = (0, react.useCallback)(async () => {
625
+ const key = status.status === "signed-in" ? status.probeKey : void 0;
626
+ if (key === void 0) return;
627
+ setBusy(true);
628
+ try {
629
+ const response = await fetch(variant.probePath, {
630
+ method: "POST",
631
+ headers: {
632
+ "Content-Type": "application/json",
633
+ "X-WorkBuddy-Probe-Key": key
634
+ },
635
+ credentials: "same-origin",
636
+ body: JSON.stringify({ action: "refresh" })
637
+ });
638
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
639
+ } catch (error) {
640
+ if (mounted.current) setStatus((previous) => ({
641
+ status: "error",
642
+ message: error instanceof Error ? error.message : t("requestFailed")
643
+ }));
644
+ return;
645
+ } finally {
646
+ if (mounted.current) setBusy(false);
647
+ }
648
+ await refresh();
649
+ }, [
650
+ refresh,
651
+ status,
652
+ t,
653
+ variant.probePath
654
+ ]);
655
+ /**
656
+ * Run one control action and refresh the card's state afterwards.
657
+ *
658
+ * The key travels in a header, not the body: it authorizes the write, and
659
+ * the host never accepts a prompt, a sentinel, or a model outside its own
660
+ * catalog from here.
661
+ */
662
+ const control = (0, react.useCallback)(async (action) => {
663
+ const key = status.status === "signed-in" ? status.probeKey : void 0;
664
+ if (key === void 0) return;
665
+ setBusy(true);
666
+ try {
667
+ const response = await fetch(variant.probePath, {
668
+ method: "POST",
669
+ headers: {
670
+ "Content-Type": "application/json",
671
+ "X-WorkBuddy-Probe-Key": key
672
+ },
673
+ credentials: "same-origin",
674
+ body: JSON.stringify(action)
675
+ });
676
+ const value = await response.json().catch(() => void 0);
677
+ if (!response.ok) {
678
+ const message = typeof value === "object" && value !== null && "error" in value ? String(value["error"]) : `HTTP ${response.status}`;
679
+ throw new Error(message);
680
+ }
681
+ await refresh();
682
+ } catch (error) {
683
+ if (mounted.current) setStatus((previous) => ({
684
+ status: "error",
685
+ message: error instanceof Error ? error.message : t("requestFailed")
686
+ }));
687
+ } finally {
688
+ if (mounted.current) setBusy(false);
689
+ }
690
+ }, [
691
+ refresh,
692
+ status,
693
+ t,
694
+ variant.probePath
695
+ ]);
696
+ /**
697
+ * Start a detection. Confirmation happens inline in the section, so this is
698
+ * only ever called after the user has already agreed.
699
+ */
700
+ const confirmDetect = (0, react.useCallback)((modelId) => {
701
+ control({
702
+ action: "probe",
703
+ model: modelId
704
+ });
705
+ }, [control]);
706
+ const title = t(variant.titleKey);
707
+ const label = status.status === "signed-in" ? status.nickname === void 0 ? t("signedInAs", { nickname: "" }).trimEnd().replace(/[::]$/, "") : t("signedInAs", { nickname: status.nickname }) : status.status === "error" ? t("requestFailed") : t("signedOut");
708
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
709
+ style: cardStyle,
710
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
711
+ type: "button",
712
+ style: headerStyle,
713
+ "aria-expanded": open,
714
+ "aria-label": `${t(open ? "collapse" : "expand")}: ${title}`,
715
+ onClick: () => {
716
+ setOpen(!open);
717
+ },
718
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
719
+ style: headTextStyle,
720
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
721
+ style: nameStyle,
722
+ children: title
723
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
724
+ style: descriptionStyle,
725
+ children: t(variant.introKey)
726
+ })]
727
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
728
+ "aria-hidden": "true",
729
+ style: {
730
+ ...chevronStyle,
731
+ transform: open ? "rotate(180deg)" : "none"
732
+ },
733
+ children: "⌄"
734
+ })]
735
+ }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
736
+ style: cardBodyStyle,
737
+ children: [
738
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
739
+ style: quotaTitleStyle,
740
+ children: t("accountHeading")
741
+ }),
742
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
743
+ style: rowStyle,
744
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
745
+ style: statusStyle,
746
+ role: "status",
747
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
748
+ "aria-hidden": "true",
749
+ style: dotStyle(status.status)
750
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })]
751
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
752
+ type: "button",
753
+ style: buttonStyle$1,
754
+ disabled: busy,
755
+ onClick: () => {
756
+ manualRefresh();
757
+ },
758
+ children: busy ? t("refreshing") : t("refresh")
759
+ })]
760
+ }),
761
+ status.status === "signed-in" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
762
+ status.expiresAt === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
763
+ style: bodyStyle,
764
+ children: t("accessTokenExpires", { time: formatTime(status.expiresAt) })
765
+ }),
766
+ status.catalog === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
767
+ style: rowStyle,
768
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
769
+ style: bodyStyle,
770
+ children: [status.catalog.source === "live" && status.catalog.fetchedAt !== void 0 ? t("catalogLive", { time: formatTime(status.catalog.fetchedAt) }) : status.catalog.source === "saved" && status.catalog.fetchedAt !== void 0 ? t("catalogSaved", { time: formatTime(status.catalog.fetchedAt) }) : t("catalogFallback"), status.catalog.appVersion === void 0 ? "" : ` · ${t("catalogAppVersion", { version: status.catalog.appVersion })}`]
771
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
772
+ type: "button",
773
+ style: buttonStyle$1,
774
+ disabled: busy,
775
+ onClick: () => {
776
+ refreshModels();
777
+ },
778
+ children: busy ? t("refreshingModels") : t("refreshModels")
779
+ })]
780
+ }),
781
+ status.catalog?.error === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
782
+ style: errorStyle,
783
+ children: t("catalogError", { message: status.catalog.error })
784
+ }),
785
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
786
+ role: "tablist",
787
+ style: tabBarStyle,
788
+ children: [
789
+ "status",
790
+ "context",
791
+ "details"
792
+ ].map((id) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
793
+ type: "button",
794
+ role: "tab",
795
+ "aria-selected": tab === id,
796
+ onClick: () => {
797
+ setTab(id);
798
+ },
799
+ style: {
800
+ ...tabStyle,
801
+ ...tab === id ? tabActiveStyle : {}
802
+ },
803
+ children: t(id === "status" ? "tabStatus" : id === "context" ? "tabContext" : "tabDetails")
804
+ }, id))
805
+ }),
806
+ tab === "status" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
807
+ style: tabPanelStyle,
808
+ children: [
809
+ status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
810
+ style: quotaListStyle,
811
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
812
+ style: rowStyle,
813
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
814
+ style: quotaTitleStyle,
815
+ children: t("creditsHeading")
816
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
817
+ style: bodyStyle,
818
+ children: t("creditsTotal", { total: formatNumber(status.credits.total) })
819
+ })]
820
+ })
821
+ }),
822
+ status.creditsError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
823
+ style: errorStyle,
824
+ children: t("creditsError", { message: status.creditsError })
825
+ }),
826
+ status.probe === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProbeSection, {
827
+ probe: status.probe,
828
+ t,
829
+ busy,
830
+ onDetect: confirmDetect,
831
+ onClear: () => {
832
+ control({ action: "clear" });
833
+ }
834
+ })
835
+ ]
836
+ }) : tab === "context" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
837
+ style: tabPanelStyle,
838
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContextTable, {
839
+ models: status.models,
840
+ t
841
+ })
842
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
843
+ style: tabPanelStyle,
844
+ children: [status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
845
+ style: quotaListStyle,
846
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
847
+ style: quotaTitleStyle,
848
+ children: t("creditsDetailHeading")
849
+ }), status.credits.accounts.filter((account) => account.remain > 0).map((account, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CreditBar, {
850
+ label: account.packageName,
851
+ remain: account.remain,
852
+ size: account.size,
853
+ t
854
+ }, `${account.packageName}-${String(index)}`))]
855
+ }), status.models === void 0 || status.models.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
856
+ style: quotaListStyle,
857
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
858
+ style: quotaTitleStyle,
859
+ children: t("modelsHeading")
860
+ }), status.models.filter((model) => model.free === true || (model.badges?.length ?? 0) > 0).map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelOfferRow, {
861
+ model,
862
+ t
863
+ }, model.id))]
864
+ })]
865
+ })
866
+ ] }) : null,
867
+ status.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
868
+ style: status.reason === void 0 ? bodyStyle : errorStyle,
869
+ children: status.reason ?? t(variant.signedOutKey)
870
+ }) : null,
871
+ status.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
872
+ style: errorStyle,
873
+ children: status.message
874
+ }) : null
875
+ ]
876
+ }) : null]
877
+ });
878
+ }
879
+ //#endregion
880
+ //#region src/client/WorkBuddyProbeControl.tsx
881
+ /**
882
+ * Per-model reasoning-effort entry beside the Composer's model selector.
712
883
  *
713
- * The rate sits under the name rather than beside it because the row already
714
- * spends its horizontal budget on badges; stacking keeps long model names and
715
- * several badges from squeezing the rate into an ellipsis.
716
- */
717
- function ModelOfferRow({ model, t }) {
718
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
719
- style: modelOfferStyle,
720
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
721
- style: quotaLabelStyle,
722
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
723
- style: modelBadgeStyle,
724
- children: [model.badges?.map((badge) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
725
- style: modelBadgeChipStyle,
726
- children: modelBadgeLabel(badge, t)
727
- }, badge)), model.free === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
728
- style: modelBadgeChipStyle,
729
- children: t("freeModel")
730
- }) : null]
731
- })]
732
- }), model.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
733
- style: modelRateStyle,
734
- children: t("rate", { rate: model.credits })
735
- })]
736
- });
737
- }
738
- /**
739
- * Context capacity, listed in full.
884
+ * Interaction follows the Fast Mode control `dsh-codex-connect` ships in this
885
+ * same seat, which is the established shape for composer chrome here:
740
886
  *
741
- * Every model the upstream reports a capacity for, largest first. A one-line
742
- * summary with the exceptions on hover was tried and rejected: capacity is
743
- * reference data you scan by model, and hiding most of it behind a hover made
744
- * the common case (a model you already have in mind) the hard one to look up.
887
+ * - a **static inline label** next to the icon names the feature ("Reasoning
888
+ * levels"), set smaller and dimmer than the surrounding chrome so it reads as
889
+ * an annotation on the icon. It never carries state: the verified levels
890
+ * already appear in the model dropdown (the adapter exposes them as
891
+ * selectable efforts), so repeating them here would duplicate the real answer
892
+ * and make the label's width jump as results change.
893
+ * - a **hover/focus tooltip** carries the state and the click's purpose, the way
894
+ * Fast Mode's tooltip explains its current speed.
895
+ * - the **confirmation** is a small bubble anchored to the control, not a
896
+ * `window.confirm`. Probing spends real credit, so a confirmation stays — but
897
+ * it belongs next to the thing it acts on, sized to one line plus two small
898
+ * buttons.
745
899
  *
746
- * Purely a report of the upstream's own number. The plugin offers no tier
747
- * picker: the catalog declares one capacity per model and publishes no
748
- * alternatives, so a menu would mean inventing client-side policy. (The
749
- * desktop app's "300K / 1M" selector appears nowhere in the API.)
900
+ * @module dsh-workbuddy-connect/client/probe-control
750
901
  */
751
- function ContextTable({ models, t }) {
752
- const known = (models ?? []).filter((model) => model.contextWindow !== void 0).sort((a, b) => b.contextWindow - a.contextWindow);
753
- if (known.length === 0) return null;
754
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
755
- style: quotaListStyle,
756
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
757
- style: quotaTitleStyle,
758
- children: t("contextHeading")
759
- }), known.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
760
- style: quotaLabelStyle,
761
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatTokens(model.contextWindow) })]
762
- }, model.id))]
763
- });
764
- }
765
902
  /**
766
- * Compact token count for display: the catalog's own round numbers (`200000`,
767
- * `1000000`) read better as `200K` / `1M`, and no precision is lost because
768
- * these values are always whole thousands.
903
+ * The card (and therefore the routes) a selected provider belongs to.
904
+ *
905
+ * The control serves both WorkBuddy providers from one seat, so the provider id
906
+ * is what selects the status and probe endpoints. Returning `undefined` for any
907
+ * other provider is what keeps the icon off every non-WorkBuddy model.
769
908
  */
770
- function formatTokens(tokens) {
771
- if (tokens >= 1e6 && tokens % 1e6 === 0) return `${tokens / 1e6}M`;
772
- if (tokens >= 1e3 && tokens % 1e3 === 0) return `${tokens / 1e3}K`;
773
- return String(tokens);
909
+ function cardVariantFor(provider) {
910
+ return CARD_VARIANTS.find((card) => card.id === provider);
774
911
  }
912
+ /** How often the control re-checks state when the window regains focus. */
913
+ const RECONCILE_MS = 6e4;
914
+ const wrapperStyle = {
915
+ display: "inline-flex",
916
+ position: "relative",
917
+ alignItems: "center",
918
+ transform: "translateY(2px)",
919
+ marginRight: -8
920
+ };
921
+ const buttonStyle = {
922
+ display: "inline-flex",
923
+ alignItems: "center",
924
+ justifyContent: "center",
925
+ gap: 2,
926
+ height: 30,
927
+ padding: "0 6px",
928
+ border: 0,
929
+ borderRadius: 8,
930
+ background: "transparent",
931
+ color: "var(--dsw-alias-label-secondary)",
932
+ font: "inherit",
933
+ whiteSpace: "nowrap",
934
+ cursor: "pointer"
935
+ };
775
936
  /**
776
- * Reasoning-effort detection section: consent switches, per-model detection,
777
- * and the recorded observations.
778
- *
779
- * Two deliberate UX rules from the plan (§3.1, §3.2):
780
- * - the confirmation is shown *before* any request, and its copy states the
781
- * credit caveat;
782
- * - a `non-validating` result is presented as an observation about the
783
- * parameter ("this model does not check it"), never as a statement that a
784
- * level is unsupported.
937
+ * The inline label. Smaller and dimmer than the surrounding chrome on purpose:
938
+ * it names the feature, so it should read as an annotation attached to the icon
939
+ * rather than compete with the adjacent model selector.
785
940
  */
786
- function ProbeSection({ probe, t, onDetect, onClear, busy }) {
787
- const [pending, setPending] = (0, react.useState)();
788
- const [runningModel, setRunningModel] = (0, react.useState)();
789
- (0, react.useEffect)(() => {
790
- if (pending !== void 0 && !probe.candidates.includes(pending)) setPending(void 0);
791
- }, [pending, probe.candidates]);
792
- const runningArmed = (0, react.useRef)(false);
793
- (0, react.useEffect)(() => {
794
- if (runningModel === void 0) return;
795
- if (busy || probe.running) {
796
- runningArmed.current = true;
797
- return;
798
- }
799
- if (!runningArmed.current) return;
800
- runningArmed.current = false;
801
- setRunningModel(void 0);
802
- }, [
803
- runningModel,
804
- busy,
805
- probe.running
806
- ]);
807
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
808
- style: quotaListStyle,
809
- children: [
810
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
811
- style: quotaTitleStyle,
812
- children: t("probeHeading")
813
- }),
814
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
815
- style: bodyStyle,
816
- children: t("probeIntro")
817
- }),
818
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
819
- style: bodyStyle,
820
- children: t("probeConsentHint")
821
- }),
822
- probe.running ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
823
- style: bodyStyle,
824
- children: t("probeRunningGeneric")
825
- }) : null,
826
- probe.candidates.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
827
- style: bodyStyle,
828
- children: t("probeResultEmpty")
829
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
830
- style: quotaGroupStyle,
831
- children: probe.candidates.map((id) => {
832
- const result = probe.results.find((entry) => entry.id === id);
833
- const name = result?.name ?? id;
834
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
835
- style: modelOfferStyle,
836
- children: [
837
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
838
- style: probeRowStyle,
839
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
840
- style: probeRowEndStyle,
841
- children: [result === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
842
- style: modelBadgeChipStyle,
843
- children: result.validation === "validating" && result.efforts.length > 0 ? result.efforts.join(" / ") : t(result.validation === "non-validating" ? "probeResultNotValidating" : "probeResultUnknown")
844
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
845
- type: "button",
846
- style: buttonStyle,
847
- disabled: probe.running || busy,
848
- onClick: () => {
849
- setPending(id);
850
- },
851
- children: runningModel === id ? t("probeRunning", { model: id }) : t(result === void 0 ? "probeStart" : "probeRedetect")
852
- })]
853
- })]
854
- }),
855
- result === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
856
- style: modelRateStyle,
857
- children: t("probeResultAt", { time: formatTime(result.probedAt) })
858
- }),
859
- pending === id ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
860
- style: confirmBoxStyle,
861
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
862
- style: bodyStyle,
863
- children: t("probeConfirmBody", { model: name })
864
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
865
- style: confirmRowStyle,
866
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
867
- type: "button",
868
- style: buttonStyle,
869
- onClick: () => {
870
- setPending(void 0);
871
- },
872
- children: t("cancel")
873
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
874
- type: "button",
875
- style: primaryButtonStyle,
876
- disabled: probe.running || busy,
877
- onClick: () => {
878
- setRunningModel(id);
879
- setPending(void 0);
880
- onDetect(id);
881
- },
882
- children: t("probeConfirmAction")
883
- })]
884
- })]
885
- }) : null
886
- ]
887
- }, id);
888
- })
889
- }),
890
- probe.results.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
891
- type: "button",
892
- style: buttonStyle,
893
- disabled: busy,
894
- onClick: () => {
895
- onClear();
896
- },
897
- children: t("probeClear")
898
- })
899
- ]
900
- });
941
+ const labelStyle = {
942
+ fontSize: 11,
943
+ lineHeight: "16px",
944
+ color: "var(--dsw-alias-label-tertiary)"
945
+ };
946
+ /** Tooltip bubble: the Fast Mode shape (nowrap, one line, above the control). */
947
+ const tooltipStyle = {
948
+ position: "absolute",
949
+ left: "50%",
950
+ bottom: "calc(100% + 8px)",
951
+ zIndex: 1e3,
952
+ transform: "translateX(-50%)",
953
+ padding: "4px 8px",
954
+ borderRadius: 6,
955
+ background: "var(--dsw-specific-tip, #1f2329)",
956
+ boxShadow: "var(--dsw-shadow-lv2)",
957
+ color: "var(--dsw-alias-label-primary, #fff)",
958
+ fontSize: 12,
959
+ lineHeight: "18px",
960
+ whiteSpace: "nowrap",
961
+ pointerEvents: "none"
962
+ };
963
+ /** Confirmation bubble: same anchor, but interactive and allowed to wrap. */
964
+ const confirmStyle = {
965
+ position: "absolute",
966
+ right: 0,
967
+ bottom: "calc(100% + 8px)",
968
+ zIndex: 1001,
969
+ display: "flex",
970
+ flexDirection: "column",
971
+ gap: 8,
972
+ width: 260,
973
+ padding: "10px 12px",
974
+ border: "1px solid var(--dsw-alias-border-l2)",
975
+ borderRadius: 8,
976
+ background: "var(--dsw-alias-bg-layer-1, #fff)",
977
+ boxShadow: "var(--dsw-shadow-lv2)",
978
+ color: "var(--dsw-alias-label-primary)",
979
+ fontSize: 12,
980
+ lineHeight: "18px"
981
+ };
982
+ const confirmRowStyle = {
983
+ display: "flex",
984
+ justifyContent: "flex-end",
985
+ gap: 8
986
+ };
987
+ const confirmButtonStyle = {
988
+ padding: "3px 10px",
989
+ border: "1px solid var(--dsw-alias-border-l2)",
990
+ borderRadius: 6,
991
+ background: "transparent",
992
+ color: "inherit",
993
+ font: "inherit",
994
+ fontSize: 12,
995
+ cursor: "pointer"
996
+ };
997
+ /**
998
+ * Primary action inside the confirmation bubble.
999
+ *
1000
+ * The fill and its text colour must come as a pair: `brand-primary` resolves to
1001
+ * a light accent in this theme, so hardcoding `color: #fff` on top of it renders
1002
+ * white-on-white. `button-primary-fill` + `label-primary-foreground` is the
1003
+ * theme's own pair for exactly this, and is what `dsh-codex-connect` uses for
1004
+ * the same job.
1005
+ */
1006
+ const primaryButtonStyle = {
1007
+ ...confirmButtonStyle,
1008
+ border: "1px solid var(--dsw-alias-button-primary-fill)",
1009
+ background: "var(--dsw-alias-button-primary-fill)",
1010
+ color: "var(--dsw-alias-label-primary-foreground)"
1011
+ };
1012
+ /**
1013
+ * Result note: a single line + a dismiss button, anchored to the control's
1014
+ * right side. Smaller than the confirmation bubble because it carries an
1015
+ * *outcome*, not a *decision* — the work is done, the user only has to read
1016
+ * and dismiss.
1017
+ */
1018
+ const noteStyle = {
1019
+ position: "absolute",
1020
+ right: 0,
1021
+ bottom: "calc(100% + 8px)",
1022
+ zIndex: 1001,
1023
+ display: "flex",
1024
+ alignItems: "center",
1025
+ gap: 12,
1026
+ padding: "6px 10px",
1027
+ border: "1px solid var(--dsw-alias-border-l2)",
1028
+ borderRadius: 8,
1029
+ background: "var(--dsw-alias-bg-layer-1)",
1030
+ boxShadow: "var(--dsw-shadow-lv2)",
1031
+ color: "var(--dsw-alias-label-primary)",
1032
+ fontSize: 12,
1033
+ lineHeight: "18px",
1034
+ whiteSpace: "nowrap"
1035
+ };
1036
+ /**
1037
+ * The note's dismiss action. Outlined rather than bare text: inside an already
1038
+ * bordered bubble, an unbordered word does not read as something you can click.
1039
+ * Matches the outlined pill convention the plugin's other secondary actions use.
1040
+ */
1041
+ const noteDismissStyle = {
1042
+ padding: "2px 8px",
1043
+ border: "1px solid var(--dsw-alias-border-l2)",
1044
+ borderRadius: 6,
1045
+ background: "transparent",
1046
+ color: "var(--dsw-alias-label-secondary)",
1047
+ font: "inherit",
1048
+ fontSize: 12,
1049
+ lineHeight: "18px",
1050
+ cursor: "pointer"
1051
+ };
1052
+ /**
1053
+ * The feature's static inline label. Deliberately not a state readout — see the
1054
+ * module comment.
1055
+ */
1056
+ function useLabel(t) {
1057
+ return t("probeLabel");
901
1058
  }
902
- /** Render WorkBuddy sign-in state and credit as one expandable card. */
903
- function WorkBuddyPluginCard({ t }) {
904
- if (t === void 0) throw new Error("WorkBuddy plugin card requires its translation function");
905
- const [open, setOpen] = (0, react.useState)(false);
906
- const [status, setStatus] = (0, react.useState)({ status: "signed-out" });
1059
+ /** Pick the model's recorded observation out of the probe section. */
1060
+ function resultFor(status, model) {
1061
+ if (status.status !== "signed-in") return void 0;
1062
+ return status.probe?.results.find((result) => result.id === model);
1063
+ }
1064
+ /**
1065
+ * The one-line tooltip: current state first, then what a click does — the same
1066
+ * two-part shape Fast Mode uses.
1067
+ */
1068
+ function tooltipText(t, model, state) {
1069
+ if (state.busy) return t("probeRunning", { model });
1070
+ if (state.failed) return t("probeTooltipRetry");
1071
+ const result = state.result;
1072
+ if (result === void 0) return t("probeTooltipIdle", { model });
1073
+ if (result.validation === "validating" && result.efforts.length > 0) return t("probeTooltipVerified", { levels: result.efforts.join(" / ") });
1074
+ if (result.validation === "non-validating") return t("probeTooltipNotValidating");
1075
+ return t("probeTooltipRetry");
1076
+ }
1077
+ /** Model-independent shell: resolves the selection, then delegates per model. */
1078
+ function WorkBuddyProbeControl({ directory, t }) {
1079
+ const subscribe = (0, react.useCallback)((listener) => directory.subscribe(listener), [directory]);
1080
+ const snapshot = (0, react.useCallback)(() => directory.getSnapshot(), [directory]);
1081
+ const selection = (0, react.useSyncExternalStore)(subscribe, snapshot, snapshot).current;
1082
+ const card = selection === void 0 ? void 0 : cardVariantFor(selection.provider);
1083
+ const key = card === void 0 || selection === void 0 ? void 0 : `${card.id}:${selection.model}`;
1084
+ return card === void 0 || selection === void 0 || key === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelProbe, {
1085
+ model: selection.model,
1086
+ card,
1087
+ label: useLabel(t),
1088
+ t
1089
+ }, key);
1090
+ }
1091
+ function ModelProbe({ model, card, label, t }) {
1092
+ const [status, setStatus] = (0, react.useState)();
907
1093
  const [busy, setBusy] = (0, react.useState)(false);
908
- const [tab, setTab] = (0, react.useState)("status");
909
- const mounted = (0, react.useRef)(true);
910
- (0, react.useEffect)(() => {
911
- mounted.current = true;
912
- return () => {
913
- mounted.current = false;
914
- };
915
- }, []);
1094
+ const [confirming, setConfirming] = (0, react.useState)(false);
1095
+ const [tooltipVisible, setTooltipVisible] = (0, react.useState)(false);
1096
+ const [failed, setFailed] = (0, react.useState)(false);
1097
+ const [note, setNote] = (0, react.useState)();
1098
+ const inFlight = (0, react.useRef)(false);
1099
+ const mounted = (0, react.useRef)(false);
1100
+ const tooltipId = (0, react.useId)();
916
1101
  const refresh = (0, react.useCallback)(async (signal) => {
917
- try {
918
- const response = await fetch(WORKBUDDY_STATUS_PATH, {
919
- headers: { accept: "application/json" },
920
- credentials: "same-origin",
921
- ...signal === void 0 ? {} : { signal }
922
- });
923
- const value = await response.json().catch(() => void 0);
924
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
925
- if (mounted.current && signal?.aborted !== true) setStatus(value);
926
- } catch (error) {
927
- if (mounted.current && signal?.aborted !== true) setStatus({
928
- status: "error",
929
- message: error instanceof Error ? error.message : t("requestFailed")
930
- });
931
- }
932
- }, [t]);
1102
+ const response = await fetch(card.statusPath, {
1103
+ credentials: "same-origin",
1104
+ headers: { accept: "application/json" },
1105
+ ...signal === void 0 ? {} : { signal }
1106
+ });
1107
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1108
+ const value = await response.json();
1109
+ if (mounted.current && !signal?.aborted) setStatus(value);
1110
+ }, [card.statusPath]);
933
1111
  (0, react.useEffect)(() => {
934
- if (!open) return;
1112
+ mounted.current = true;
935
1113
  const controller = new AbortController();
936
- refresh(controller.signal);
937
- return () => {
938
- controller.abort();
1114
+ const load = () => {
1115
+ refresh(controller.signal).catch(() => {});
939
1116
  };
940
- }, [open, refresh]);
941
- (0, react.useEffect)(() => {
942
- if (!open || status.status !== "signed-in") return;
943
- const controller = new AbortController();
944
- const timer = window.setInterval(() => {
945
- refresh(controller.signal);
946
- }, POLL_INTERVAL_MS);
1117
+ load();
1118
+ const timer = window.setInterval(load, RECONCILE_MS);
1119
+ window.addEventListener("focus", load);
947
1120
  return () => {
948
- window.clearInterval(timer);
1121
+ mounted.current = false;
949
1122
  controller.abort();
1123
+ window.clearInterval(timer);
1124
+ window.removeEventListener("focus", load);
950
1125
  };
951
- }, [
952
- open,
953
- refresh,
954
- status.status
955
- ]);
956
- const manualRefresh = async () => {
957
- setBusy(true);
958
- try {
959
- await refresh();
960
- } finally {
961
- if (mounted.current) setBusy(false);
962
- }
963
- };
964
- /**
965
- * Run one control action and refresh the card's state afterwards.
966
- *
967
- * The key travels in a header, not the body: it authorizes the write, and
968
- * the host never accepts a prompt, a sentinel, or a model outside its own
969
- * catalog from here.
970
- */
971
- const control = (0, react.useCallback)(async (action) => {
972
- const key = status.status === "signed-in" ? status.probeKey : void 0;
973
- if (key === void 0) return;
1126
+ }, [refresh]);
1127
+ const probe = status?.status === "signed-in" ? status.probe : void 0;
1128
+ const key = status?.status === "signed-in" ? status.probeKey : void 0;
1129
+ const result = status === void 0 ? void 0 : resultFor(status, model);
1130
+ const visible = probe?.candidates.includes(model) === true || result !== void 0;
1131
+ (0, react.useEffect)(() => {
1132
+ setConfirming(false);
1133
+ setNote(void 0);
1134
+ }, [model]);
1135
+ const detect = async () => {
1136
+ if (key === void 0 || inFlight.current || probe?.running === true) return;
1137
+ inFlight.current = true;
1138
+ setNote(void 0);
1139
+ setConfirming(false);
974
1140
  setBusy(true);
1141
+ setFailed(false);
975
1142
  try {
976
- const response = await fetch(WORKBUDDY_PROBE_PATH, {
1143
+ const response = await fetch(card.probePath, {
977
1144
  method: "POST",
1145
+ credentials: "same-origin",
978
1146
  headers: {
979
1147
  "Content-Type": "application/json",
980
1148
  "X-WorkBuddy-Probe-Key": key
981
1149
  },
982
- credentials: "same-origin",
983
- body: JSON.stringify(action)
984
- });
985
- const value = await response.json().catch(() => void 0);
986
- if (!response.ok) {
987
- const message = typeof value === "object" && value !== null && "error" in value ? String(value["error"]) : `HTTP ${response.status}`;
988
- throw new Error(message);
989
- }
990
- await refresh();
991
- } catch (error) {
992
- if (mounted.current) setStatus((previous) => ({
993
- status: "error",
994
- message: error instanceof Error ? error.message : t("requestFailed")
995
- }));
996
- } finally {
997
- if (mounted.current) setBusy(false);
998
- }
999
- }, [
1000
- refresh,
1001
- status,
1002
- t
1003
- ]);
1004
- /**
1005
- * Start a detection. Confirmation happens inline in the section, so this is
1006
- * only ever called after the user has already agreed.
1007
- */
1008
- const confirmDetect = (0, react.useCallback)((modelId) => {
1009
- control({
1010
- action: "probe",
1011
- model: modelId
1012
- });
1013
- }, [control]);
1014
- const title = t("title");
1015
- const label = status.status === "signed-in" ? status.nickname === void 0 ? t("signedInAs", { nickname: "" }).replace(/[::]\s*$/, "") : t("signedInAs", { nickname: status.nickname }) : status.status === "error" ? t("requestFailed") : t("signedOut");
1016
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1017
- style: cardStyle,
1018
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1019
- type: "button",
1020
- style: headerStyle,
1021
- "aria-expanded": open,
1022
- "aria-label": `${t(open ? "collapse" : "expand")}: ${title}`,
1023
- onClick: () => {
1024
- setOpen(!open);
1025
- },
1026
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1027
- style: headTextStyle,
1028
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1029
- style: nameStyle,
1030
- children: title
1031
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1032
- style: descriptionStyle,
1033
- children: t("intro")
1034
- })]
1035
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1036
- "aria-hidden": "true",
1150
+ body: JSON.stringify({
1151
+ action: "probe",
1152
+ model
1153
+ })
1154
+ });
1155
+ const body = await response.json();
1156
+ if (!response.ok || body.state !== "ok" || body.validation !== "validating" && body.validation !== "non-validating" || !Array.isArray(body.efforts) || !body.efforts.every((effort) => typeof effort === "string")) throw new Error("probe failed");
1157
+ if (mounted.current) {
1158
+ const completed = {
1159
+ id: model,
1160
+ name: model,
1161
+ validation: body.validation,
1162
+ efforts: body.efforts,
1163
+ probedAt: Date.now()
1164
+ };
1165
+ setNote(completed);
1166
+ }
1167
+ refresh().catch(() => {});
1168
+ } catch {
1169
+ if (mounted.current) setFailed(true);
1170
+ } finally {
1171
+ inFlight.current = false;
1172
+ if (mounted.current) setBusy(false);
1173
+ }
1174
+ };
1175
+ if (!visible) return null;
1176
+ const text = tooltipText(t, model, {
1177
+ busy,
1178
+ result,
1179
+ failed
1180
+ });
1181
+ const disabled = busy || probe?.running === true || key === void 0;
1182
+ const showTooltip = tooltipVisible && !confirming && note === void 0;
1183
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1184
+ style: wrapperStyle,
1185
+ onMouseEnter: () => {
1186
+ setTooltipVisible(true);
1187
+ },
1188
+ onMouseLeave: () => {
1189
+ setTooltipVisible(false);
1190
+ },
1191
+ children: [
1192
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1193
+ type: "button",
1194
+ "aria-label": text,
1195
+ "aria-describedby": showTooltip ? tooltipId : void 0,
1196
+ "aria-busy": busy,
1197
+ "aria-expanded": confirming,
1198
+ disabled,
1199
+ onClick: () => {
1200
+ setConfirming(true);
1201
+ },
1202
+ onFocus: () => {
1203
+ setTooltipVisible(true);
1204
+ },
1205
+ onBlur: () => {
1206
+ setTooltipVisible(false);
1207
+ },
1037
1208
  style: {
1038
- ...chevronStyle,
1039
- transform: open ? "rotate(180deg)" : "none"
1209
+ ...buttonStyle,
1210
+ opacity: disabled && !confirming ? .6 : 1,
1211
+ cursor: disabled ? "default" : "pointer"
1040
1212
  },
1041
- children: "⌄"
1042
- })]
1043
- }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1044
- style: cardBodyStyle,
1045
- children: [
1046
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1047
- style: quotaTitleStyle,
1048
- children: t("accountHeading")
1049
- }),
1050
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1051
- style: rowStyle,
1052
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1053
- style: statusStyle,
1054
- role: "status",
1055
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1056
- "aria-hidden": "true",
1057
- style: dotStyle(status.status)
1058
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })]
1213
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1214
+ width: "16",
1215
+ height: "16",
1216
+ viewBox: "0 0 24 24",
1217
+ fill: "none",
1218
+ stroke: "currentColor",
1219
+ strokeWidth: "1.6",
1220
+ "aria-hidden": "true",
1221
+ focusable: "false",
1222
+ children: [
1223
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1224
+ cx: "12",
1225
+ cy: "12",
1226
+ r: "9"
1227
+ }),
1228
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1229
+ cx: "12",
1230
+ cy: "12",
1231
+ r: "4"
1232
+ }),
1233
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 12 20 4" }),
1234
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1235
+ cx: "12",
1236
+ cy: "12",
1237
+ r: "1"
1238
+ })
1239
+ ]
1240
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1241
+ style: labelStyle,
1242
+ children: label
1243
+ })]
1244
+ }),
1245
+ showTooltip && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1246
+ id: tooltipId,
1247
+ role: "tooltip",
1248
+ style: tooltipStyle,
1249
+ children: text
1250
+ }),
1251
+ confirming && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1252
+ style: confirmStyle,
1253
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("probeBubbleBody") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1254
+ style: confirmRowStyle,
1255
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1256
+ type: "button",
1257
+ style: confirmButtonStyle,
1258
+ onClick: () => {
1259
+ setConfirming(false);
1260
+ },
1261
+ children: t("cancel")
1059
1262
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1060
1263
  type: "button",
1061
- style: buttonStyle,
1062
- disabled: busy,
1264
+ style: primaryButtonStyle,
1063
1265
  onClick: () => {
1064
- manualRefresh();
1266
+ detect();
1065
1267
  },
1066
- children: busy ? t("refreshing") : t("refresh")
1268
+ children: t("probeConfirmAction")
1067
1269
  })]
1068
- }),
1069
- status.status === "signed-in" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1070
- status.expiresAt === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1071
- style: bodyStyle,
1072
- children: t("accessTokenExpires", { time: formatTime(status.expiresAt) })
1073
- }),
1074
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1075
- role: "tablist",
1076
- style: tabBarStyle,
1077
- children: [
1078
- "status",
1079
- "context",
1080
- "details"
1081
- ].map((id) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1082
- type: "button",
1083
- role: "tab",
1084
- "aria-selected": tab === id,
1085
- onClick: () => {
1086
- setTab(id);
1087
- },
1088
- style: {
1089
- ...tabStyle,
1090
- ...tab === id ? tabActiveStyle : {}
1091
- },
1092
- children: t(id === "status" ? "tabStatus" : id === "context" ? "tabContext" : "tabDetails")
1093
- }, id))
1094
- }),
1095
- tab === "status" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1096
- style: tabPanelStyle,
1097
- children: [
1098
- status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1099
- style: quotaListStyle,
1100
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1101
- style: rowStyle,
1102
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1103
- style: quotaTitleStyle,
1104
- children: t("creditsHeading")
1105
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1106
- style: bodyStyle,
1107
- children: t("creditsTotal", { total: formatNumber(status.credits.total) })
1108
- })]
1109
- })
1110
- }),
1111
- status.creditsError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1112
- style: errorStyle,
1113
- children: t("creditsError", { message: status.creditsError })
1114
- }),
1115
- status.probe === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProbeSection, {
1116
- probe: status.probe,
1117
- t,
1118
- busy,
1119
- onDetect: confirmDetect,
1120
- onClear: () => {
1121
- control({ action: "clear" });
1122
- }
1123
- })
1124
- ]
1125
- }) : tab === "context" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1126
- style: tabPanelStyle,
1127
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContextTable, {
1128
- models: status.models,
1129
- t
1130
- })
1131
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1132
- style: tabPanelStyle,
1133
- children: [status.credits === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1134
- style: quotaListStyle,
1135
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1136
- style: quotaTitleStyle,
1137
- children: t("creditsDetailHeading")
1138
- }), status.credits.accounts.filter((account) => account.remain > 0).map((account, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CreditBar, {
1139
- label: account.packageName,
1140
- remain: account.remain,
1141
- size: account.size,
1142
- t
1143
- }, `${account.packageName}-${String(index)}`))]
1144
- }), status.models === void 0 || status.models.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1145
- style: quotaListStyle,
1146
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1147
- style: quotaTitleStyle,
1148
- children: t("modelsHeading")
1149
- }), status.models.filter((model) => model.free === true || (model.badges?.length ?? 0) > 0).map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelOfferRow, {
1150
- model,
1151
- t
1152
- }, model.id))]
1153
- })]
1154
- })
1155
- ] }) : null,
1156
- status.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1157
- style: bodyStyle,
1158
- children: t("signedOutHint")
1159
- }) : null,
1160
- status.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1161
- style: errorStyle,
1162
- children: status.message
1163
- }) : null
1164
- ]
1165
- }) : null]
1270
+ })]
1271
+ }),
1272
+ note === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1273
+ role: "status",
1274
+ "aria-live": "polite",
1275
+ style: noteStyle,
1276
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: noteText(t, note) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1277
+ type: "button",
1278
+ style: noteDismissStyle,
1279
+ onClick: () => {
1280
+ setNote(void 0);
1281
+ },
1282
+ children: t("probeNoteDismiss")
1283
+ })]
1284
+ })
1285
+ ]
1166
1286
  });
1167
1287
  }
1288
+ /** Compose the one-line outcome string the note bubble shows. */
1289
+ function noteText(t, result) {
1290
+ if (result.validation === "validating" && result.efforts.length > 0) return t("probeNoteVerified", { levels: result.efforts.join(" / ") });
1291
+ if (result.validation === "non-validating") return t("probeNoteNotValidating");
1292
+ return t("probeNoteUnknown");
1293
+ }
1168
1294
  //#endregion
1169
1295
  //#region src/client/locales.ts
1170
1296
  /** Plugin-card copy registered under the settings.workbuddy locale namespace. */
1171
1297
  const en = {
1172
1298
  title: "DSH WorkBuddy Connect",
1173
1299
  intro: "Use the models in the WorkBuddy desktop app directly in DSH — zero configuration, ready out of the box.",
1300
+ titleAI: "DSH WorkBuddy AI Connect",
1301
+ introAI: "Use the models in the WorkBuddy AI international desktop app directly in DSH — zero configuration, ready out of the box.",
1174
1302
  expand: "Expand",
1175
1303
  collapse: "Collapse",
1176
1304
  loading: "Loading account…",
1177
1305
  signedOut: "Not signed in",
1178
1306
  signedOutHint: "Sign in once in the WorkBuddy desktop app; this plugin follows that sign-in automatically.",
1307
+ signedOutHintAI: "Sign in once in the WorkBuddy AI desktop app; this plugin follows that sign-in automatically.",
1179
1308
  signedInAs: "Signed in as {nickname}",
1180
1309
  accessTokenExpires: "Access token expires {time} (refresh is automatic)",
1181
1310
  creditsHeading: "Remaining credit",
@@ -1190,14 +1319,24 @@ window.__ModuleLoader__.load({
1190
1319
  creditsError: "Credit unavailable: {message}",
1191
1320
  refresh: "Refresh",
1192
1321
  refreshing: "Refreshing…",
1322
+ refreshModels: "Refresh model list",
1323
+ refreshingModels: "Refreshing models…",
1324
+ catalogLive: "Model list updated {time}",
1325
+ catalogSaved: "Showing the saved model list from {time}",
1326
+ catalogFallback: "Showing the built-in model list (not yet updated from WorkBuddy)",
1327
+ catalogError: "Last update failed: {message}",
1328
+ catalogAppVersion: "App version {version}",
1193
1329
  requestFailed: "Request failed",
1194
1330
  accountHeading: "Account",
1195
1331
  modelsHeading: "Model offers",
1196
1332
  contextHeading: "Context window",
1333
+ contextUpTo: "up to {size}",
1197
1334
  freeModel: "Free",
1198
1335
  badgeLimitedFree: "Limited-time free",
1199
1336
  badgeNightDiscount: "Night discount",
1337
+ badgeFreeNow: "Free now",
1200
1338
  rate: "{rate} credits per message",
1339
+ rateUnknown: "Price unavailable — refresh to update",
1201
1340
  probeLabel: "Reasoning levels",
1202
1341
  probeTooltipIdle: "Detect the reasoning levels {model} accepts",
1203
1342
  probeTooltipVerified: "Accepted levels: {levels} · click to detect again",
@@ -1212,7 +1351,6 @@ window.__ModuleLoader__.load({
1212
1351
  probeHeading: "Reasoning effort detection",
1213
1352
  probeResultNoLevels: "No tested levels were accepted.",
1214
1353
  probeIntro: "Some models reason but declare no selectable effort levels. Detecting which levels a model accepts sends a few real requests that may consume credit.",
1215
- probeConsent: "Authorize detection",
1216
1354
  probeConsentHint: "Each detection sends test requests to one model to confirm its available reasoning levels, and may consume a small amount of credit.",
1217
1355
  probeStart: "Detect",
1218
1356
  probeRedetect: "Detect again",
@@ -1232,11 +1370,14 @@ window.__ModuleLoader__.load({
1232
1370
  const zh = {
1233
1371
  title: "DSH WorkBuddy Connect",
1234
1372
  intro: "在 DSH 中直接使用 WorkBuddy 桌面 App 包含的模型,开箱即用,无需额外配置。",
1373
+ titleAI: "DSH WorkBuddy AI Connect",
1374
+ introAI: "在 DSH 中直接使用 WorkBuddy AI 国际版桌面 App 包含的模型,开箱即用,无需额外配置。",
1235
1375
  expand: "展开",
1236
1376
  collapse: "收起",
1237
1377
  loading: "正在读取账号…",
1238
1378
  signedOut: "未登录",
1239
1379
  signedOutHint: "在 WorkBuddy 桌面 App 里登录一次即可,插件会自动跟随当前登录的账号。",
1380
+ signedOutHintAI: "在 WorkBuddy AI 国际版桌面 App 里登录一次即可,插件会自动跟随当前登录的账号。",
1240
1381
  signedInAs: "已登录:{nickname}",
1241
1382
  accessTokenExpires: "访问令牌 {time} 过期(自动续期)",
1242
1383
  creditsHeading: "剩余积分",
@@ -1251,14 +1392,24 @@ window.__ModuleLoader__.load({
1251
1392
  creditsError: "积分查询失败:{message}",
1252
1393
  refresh: "刷新",
1253
1394
  refreshing: "正在刷新…",
1395
+ refreshModels: "刷新模型列表",
1396
+ refreshingModels: "正在刷新模型…",
1397
+ catalogLive: "模型列表更新于 {time}",
1398
+ catalogSaved: "当前显示已保存的模型列表,更新于 {time}",
1399
+ catalogFallback: "当前显示内置模型列表(尚未从 WorkBuddy 更新)",
1400
+ catalogError: "上次更新失败:{message}",
1401
+ catalogAppVersion: "App 版本 {version}",
1254
1402
  requestFailed: "请求失败",
1255
1403
  accountHeading: "账号",
1256
1404
  modelsHeading: "模型优惠",
1257
1405
  contextHeading: "上下文窗口",
1406
+ contextUpTo: "最高 {size}",
1258
1407
  freeModel: "免费",
1259
1408
  badgeLimitedFree: "限时免费",
1260
1409
  badgeNightDiscount: "夜间折扣",
1410
+ badgeFreeNow: "限时免费",
1261
1411
  rate: "{rate} 积分/次",
1412
+ rateUnknown: "价格未知 — 刷新后更新",
1262
1413
  probeLabel: "推理等级",
1263
1414
  probeTooltipIdle: "检测 {model} 可用的推理档位",
1264
1415
  probeTooltipVerified: "已接受:{levels} · 点击可重新检测",
@@ -1273,7 +1424,6 @@ window.__ModuleLoader__.load({
1273
1424
  probeHeading: "推理档位检测",
1274
1425
  probeResultNoLevels: "本次测试的档位均未被接受。",
1275
1426
  probeIntro: "部分模型具备思考能力,但没有声明可选档位。检测会发送少量真实请求,可能消耗积分。",
1276
- probeConsent: "授权检测",
1277
1427
  probeConsentHint: "每次检测会向该模型发送探测请求,以确认可用推理档位,可能消耗少量积分。",
1278
1428
  probeStart: "开始检测",
1279
1429
  probeRedetect: "重新检测",
@@ -1306,7 +1456,12 @@ window.__ModuleLoader__.load({
1306
1456
  * named in the package's `dsh.client.inject` list, so cordis has activated
1307
1457
  * them before this plugin's fiber starts.
1308
1458
  */
1309
- const inject = ["slots", "locale"];
1459
+ const inject = [
1460
+ "slots",
1461
+ "locale",
1462
+ "remote",
1463
+ "remote.session"
1464
+ ];
1310
1465
  /**
1311
1466
  * Register card copy and the WorkBuddy card under Plugin configuration.
1312
1467
  *
@@ -1333,11 +1488,14 @@ window.__ModuleLoader__.load({
1333
1488
  en
1334
1489
  }), "dsh-workbuddy-connect: settings copy");
1335
1490
  const t = ctx.locale.bind(namespace);
1336
- ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
1491
+ for (const [index, variant] of CARD_VARIANTS.entries()) ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
1337
1492
  name: "settings.plugin.item",
1338
- key: "workbuddy",
1339
- priority: 30,
1340
- inject: () => ({ t })
1493
+ key: variant.id,
1494
+ priority: 30 - index,
1495
+ inject: () => ({
1496
+ t,
1497
+ variant
1498
+ })
1341
1499
  }, WorkBuddyPluginCard));
1342
1500
  ctx.inject(["modelDirectories"], (scope) => {
1343
1501
  scope.slots.inject("conversation.input.right", () => scope.slots.register({