dsh-connect-workbuddy 5.9.0 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -50,6 +50,18 @@ window.__ModuleLoader__.load({
50
50
  const WORKBUDDY_BENEFIT_CLAIM_PATH = "/plugins/dsh-connect-workbuddy/benefit/claim";
51
51
  /** Plugin-owned live model refresh endpoint. */
52
52
  const WORKBUDDY_MODELS_REFRESH_PATH = "/plugins/dsh-connect-workbuddy/models/refresh";
53
+ /**
54
+ * Plugin-owned MEDIA model list endpoint (image + video, read from the region's
55
+ * `/v3/config`).
56
+ *
57
+ * Separate from {@link WORKBUDDY_MODELS_REFRESH_PATH} because it answers a
58
+ * different question from a different document projection: the refresh route
59
+ * returns the CHAT roster, from which `selectCliModels` drops every media
60
+ * entry, while this one keeps the `text-to-image`/`image-to-image`/
61
+ * `text-to-video`/`image-to-video` rows the card lists. Read-only and free —
62
+ * it is the same GET the image tool already performs.
63
+ */
64
+ const WORKBUDDY_MEDIA_MODELS_PATH = "/plugins/dsh-connect-workbuddy/media-models";
53
65
  /** Plugin-owned local account rescan endpoint. */
54
66
  const WORKBUDDY_ACCOUNTS_REFRESH_PATH = "/plugins/dsh-connect-workbuddy/accounts/refresh";
55
67
  /**
@@ -466,6 +478,44 @@ window.__ModuleLoader__.load({
466
478
  function regionImageToolEnabledOf(value, region) {
467
479
  return regionSlotOf(value, region)["imageToolEnabled"] === true;
468
480
  }
481
+ function regionVideoToolEnabledOf(value, region) {
482
+ return regionSlotOf(value, region)["videoToolEnabled"] === true;
483
+ }
484
+ /**
485
+ * The video model pinned per tag in one region, or an empty record.
486
+ *
487
+ * A PIN, not an authorisation: reading it grants nothing, and an absent entry
488
+ * merely means the tool takes that tag group's first model. Values are model ids
489
+ * from the same region's catalog. A stale id is tolerated on purpose — a catalog
490
+ * can drop a model the user had pinned, and that must not turn into a failed
491
+ * generation (the tool falls back to the group's first model).
492
+ */
493
+ function regionPreferredVideoModelsOf(value, region) {
494
+ const raw = regionSlotOf(value, region)["preferredVideoModels"];
495
+ if (typeof raw !== "object" || raw === null) return {};
496
+ const out = {};
497
+ for (const [tag, id] of Object.entries(raw)) if (typeof id === "string" && id !== "") out[tag] = id;
498
+ return out;
499
+ }
500
+ /**
501
+ * Build the next `regions` settings value for one pinned video model.
502
+ *
503
+ * `modelId` of `undefined` or `''` CLEARS that tag's pin, which is why this is
504
+ * a three-state write rather than a boolean: the card's picker has a "no pin"
505
+ * row, and deleting the key is what makes the absence mean "nothing pinned"
506
+ * again (an empty string would be a value the tool would have to special-case).
507
+ *
508
+ * Same merge rule as the switches above: only this region's field changes.
509
+ */
510
+ function nextRegionPreferredVideoModels(value, region, tag, modelId) {
511
+ const pinned = { ...regionPreferredVideoModelsOf(value, region) };
512
+ if (modelId === void 0 || modelId === "") delete pinned[tag];
513
+ else pinned[tag] = modelId;
514
+ return nextRegionSlots(regionsMapOf(value), region, {
515
+ ...regionSlotOf(value, region),
516
+ preferredVideoModels: pinned
517
+ });
518
+ }
469
519
  /** The seat a region's readout uses, defaulting to the composer row. */
470
520
  function regionCreditPositionOf(value, region) {
471
521
  return regionSlotOf(value, region)["creditPosition"] === "stats" ? "stats" : "composer";
@@ -508,6 +558,12 @@ window.__ModuleLoader__.load({
508
558
  imageToolEnabled: enabled
509
559
  });
510
560
  }
561
+ function nextRegionVideoToolEnabled(value, region, enabled) {
562
+ return nextRegionSlots(regionsMapOf(value), region, {
563
+ ...regionSlotOf(value, region),
564
+ videoToolEnabled: enabled
565
+ });
566
+ }
511
567
  /**
512
568
  * Project one card row into its persisted `lastCatalog` shape: the native
513
569
  * context window becomes the stored `contextWindow`, and the card-only
@@ -1150,6 +1206,33 @@ window.__ModuleLoader__.load({
1150
1206
  return stored !== null && stored !== void 0 && stored["imageToolEnabled"] === enabled;
1151
1207
  });
1152
1208
  }
1209
+ async function writeRegionVideoToolEnabled(scope, region, enabled, slot) {
1210
+ await writeField(scope, "regions", region, slot, (readBack) => {
1211
+ const stored = readBack;
1212
+ return stored !== null && stored !== void 0 && stored["videoToolEnabled"] === enabled;
1213
+ });
1214
+ }
1215
+ /**
1216
+ * Pin (or clear) one region's video model for ONE mode tag, verifying it landed.
1217
+ *
1218
+ * `modelId` of `undefined`/`''` clears the pin, so the landed check accepts an
1219
+ * ABSENT key as success for that case — writing an empty string instead would
1220
+ * leave a value the tool has to keep special-casing forever.
1221
+ *
1222
+ * Routed through `writeField` like every other settings write (Host endpoint
1223
+ * first, then the landed read-back): on the affected 0.1.7 deployments a direct
1224
+ * `scope.set()` settles without storing anything, and a picker that silently
1225
+ * reverted would be worse than no picker at all.
1226
+ */
1227
+ async function writeRegionPreferredVideoModels(scope, region, tag, modelId, slot) {
1228
+ await writeField(scope, "regions", region, slot, (readBack) => {
1229
+ const stored = readBack;
1230
+ if (stored === null || stored === void 0) return false;
1231
+ const pinned = stored["preferredVideoModels"];
1232
+ const current = typeof pinned === "object" && pinned !== null ? pinned[tag] : void 0;
1233
+ return modelId === void 0 || modelId === "" ? current === void 0 || current === "" : current === modelId;
1234
+ });
1235
+ }
1153
1236
  async function writeRegionCreditsShown(scope, region, shown, slot) {
1154
1237
  await writeField(scope, "regions", region, slot, (readBack) => {
1155
1238
  const stored = readBack;
@@ -4958,7 +5041,7 @@ window.__ModuleLoader__.load({
4958
5041
  CONTRAST colour, so a ticked box painted itself as a heavy black pill — the
4959
5042
  loudest object in a section made of plain 13px text. */
4960
5043
  .dsm-workbuddy-pref input{flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
4961
- .dsm-workbuddy-pref-check,.dsm-workbuddy-image-tool-check,.dsm-workbuddy-cli-bridge-check,.dsm-workbuddy-upstream-check{inline-size:15px;block-size:15px;margin:3px 0 0}
5044
+ .dsm-workbuddy-pref-check,.dsm-workbuddy-image-tool-check,.dsm-workbuddy-video-tool-check,.dsm-workbuddy-cli-bridge-check,.dsm-workbuddy-upstream-check{inline-size:15px;block-size:15px;margin:3px 0 0}
4962
5045
  /* The upstream-identity switches are their own group, so they get a divider
4963
5046
  above them instead of relying on spacing alone: they change the request the
4964
5047
  whole plugin sends, not a per-region display choice, and reading them as the
@@ -4981,6 +5064,31 @@ window.__ModuleLoader__.load({
4981
5064
  .dsm-workbuddy-pref-lead{display:flex;align-items:center;justify-content:flex-start;gap:10px;min-width:0}
4982
5065
  .dsm-workbuddy-pref-lead .dsm-workbuddy-pref-label{flex:none}
4983
5066
  .dsm-workbuddy-pref-hint{color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:17px}
5067
+ /* The media roster: a READ-ONLY list, so it is drawn as quiet rows rather than
5068
+ as a table — it answers "what can this tab draw with" in passing, and must
5069
+ not compete with the switches above it for attention. The id is monospace
5070
+ and dimmer than the name because it is the string a tool call has to pass,
5071
+ not the thing a human reads; the tag chips carry the only colour, one hue
5072
+ per direction (text→image vs image→image), so the grouping is visible
5073
+ without a legend. */
5074
+ /* Aligned with the indented rows above it, but under its OWN class: the
5075
+ credit position picker owns .dsm-workbuddy-pref-indent, and the card suite
5076
+ resolves that selector expecting the picker. This block is a block (lead
5077
+ row + hint + list), not a single indented line. */
5078
+ .dsm-workbuddy-media{padding-left:25px}
5079
+ .dsm-workbuddy-media-list{margin:2px 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px}
5080
+ .dsm-workbuddy-media-row{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px;min-width:0}
5081
+ .dsm-workbuddy-media-name{color:var(--dsw-alias-label-secondary,#b8bcc4);font-size:12px}
5082
+ .dsm-workbuddy-media-id{color:var(--dsw-alias-label-tertiary,#8b8f98);font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;overflow-wrap:anywhere}
5083
+ .dsm-workbuddy-media-tags{display:inline-flex;flex-wrap:wrap;gap:4px}
5084
+ .dsm-workbuddy-media-tag{border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:0 6px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#8b8f98)}
5085
+ /* The per-mode video-model picker. It reuses .dsm-workbuddy-media-row so the
5086
+ tag chip and the select sit on one baseline, like a roster row does. */
5087
+ .dsm-workbuddy-video-model-select{font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary,#b8bcc4);background:var(--dsw-alias-bg-l1,transparent);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:1px 6px;max-width:100%;min-width:0}
5088
+ .dsm-workbuddy-video-model-select:disabled{opacity:.6}
5089
+ .dsm-workbuddy-media-empty,.dsm-workbuddy-media-error{font-size:12px;line-height:17px}
5090
+ .dsm-workbuddy-media-empty{color:var(--dsw-alias-label-tertiary,#8b8f98)}
5091
+ .dsm-workbuddy-media-error{color:var(--dsw-alias-label-error,#e5675f)}
4984
5092
  /* The supplement to the host's fixed quota sentence (issue #63). The plugin no
4985
5093
  longer claims the host's shell.quota-notice chain seat, so this no longer
4986
5094
  paints into shell.overlay — that seat is the reason the previous build's
@@ -6062,6 +6170,32 @@ window.__ModuleLoader__.load({
6062
6170
  * text-only model and an unverified one look identically broken next to their
6063
6171
  * checked siblings.
6064
6172
  */
6173
+ /**
6174
+ * The media roster from the Host's JSON, tolerantly.
6175
+ *
6176
+ * Anything that is not a row with a string id and a name is DROPPED, never
6177
+ * rendered: this is a read-only list, and a half-shaped entry is not worth an
6178
+ * exception that would take down the whole card. A Host older than the route
6179
+ * (or any other answer without `models`) yields an empty list, which the block
6180
+ * shows as "this tab publishes none" instead of as a failure.
6181
+ */
6182
+ function readMediaRoster(value) {
6183
+ const models = value?.models;
6184
+ if (!Array.isArray(models)) return void 0;
6185
+ const rows = [];
6186
+ for (const entry of models) {
6187
+ const record = entry;
6188
+ if (record === null || typeof record !== "object") continue;
6189
+ if (typeof record.id !== "string" || record.id === "") continue;
6190
+ const tags = Array.isArray(record.tags) ? record.tags.filter((tag) => typeof tag === "string") : [];
6191
+ rows.push({
6192
+ id: record.id,
6193
+ name: typeof record.name === "string" ? record.name : record.id,
6194
+ tags
6195
+ });
6196
+ }
6197
+ return rows;
6198
+ }
6065
6199
  function imageCheckboxHint(model, t) {
6066
6200
  switch (nativeModalityOf(model.id)) {
6067
6201
  case "multimodal": return t("row.modelImage");
@@ -6443,6 +6577,18 @@ window.__ModuleLoader__.load({
6443
6577
  const [saveError, setSaveError] = (0, react.useState)(void 0);
6444
6578
  const [switchingAccount, setSwitchingAccount] = (0, react.useState)(false);
6445
6579
  /**
6580
+ * The region's MEDIA model roster (read-only), keyed by region.
6581
+ *
6582
+ * NOT part of `statusByRegion`: the usage document is the editable model
6583
+ * directory, and folding media rows into it would put them in the saved
6584
+ * `lastCatalog` — where the adapter would offer an image model as a chat
6585
+ * model. It is a separate, cheap, read-only read that only the capability
6586
+ * panel needs.
6587
+ */
6588
+ const [mediaByRegion, setMediaByRegion] = (0, react.useState)({});
6589
+ const [mediaError, setMediaError] = (0, react.useState)(void 0);
6590
+ const [mediaBusy, setMediaBusy] = (0, react.useState)(false);
6591
+ /**
6446
6592
  * Whether the account pool's save is in flight. Both sections write into one
6447
6593
  * region slot and the Host merges per-region, so two concurrent saves can
6448
6594
  * interleave on a stale base and revert each other while both report success
@@ -6462,6 +6608,15 @@ window.__ModuleLoader__.load({
6462
6608
  */
6463
6609
  const [togglingCredits, setTogglingCredits] = (0, react.useState)(void 0);
6464
6610
  /**
6611
+ * Which video-model pin is being written right now: the tag string, or
6612
+ * undefined.
6613
+ *
6614
+ * A SEPARATE state from {@link togglingCredits} for the same reason those two
6615
+ * switches keep separate busy flags: the pin lives in a different field of the
6616
+ * same region slot, so one in-flight write must not disable the other control.
6617
+ */
6618
+ const [pinningVideoModel, setPinningVideoModel] = (0, react.useState)(void 0);
6619
+ /**
6465
6620
  * The OPTIONAL CodeBuddy CLI route switch (global: one switch, one budget).
6466
6621
  *
6467
6622
  * `undefined` until the first Host answer arrives, so the box renders the
@@ -6843,6 +6998,35 @@ window.__ModuleLoader__.load({
6843
6998
  return regionImageToolEnabledOf(settingsScope?.getSnapshot().value, item);
6844
6999
  };
6845
7000
  /**
7001
+ * Whether the model may call the video tool in this region. Mirrors
7002
+ * imageToolOn: read off the Host answer first, so a stale mirror cannot
7003
+ * leave the spending capability on. Absent is NOT consent.
7004
+ */
7005
+ const videoToolOn = (item) => {
7006
+ const usage = statusByRegion[item];
7007
+ if (typeof usage?.videoToolEnabled === "boolean") return usage.videoToolEnabled;
7008
+ return regionVideoToolEnabledOf(settingsScope?.getSnapshot().value, item);
7009
+ };
7010
+ /**
7011
+ * The video model pinned for one mode tag in this region, or `''` for "no pin".
7012
+ *
7013
+ * Same read order as {@link videoToolOn} and for the same measured reason: the
7014
+ * Host's answer first, because this namespace's browser mirror is stale on the
7015
+ * affected 0.1.7 deployments — a picker reading the mirror alone would keep
7016
+ * showing a pin the tool no longer uses.
7017
+ *
7018
+ * `''` rather than `undefined` because that is what the `<select>` needs for its
7019
+ * "default" row; the write path turns it back into "remove the key".
7020
+ */
7021
+ const preferredVideoModelOf = (item, tag) => {
7022
+ const fromHost = statusByRegion[item]?.preferredVideoModels;
7023
+ if (typeof fromHost === "object" && fromHost !== null) {
7024
+ const pinned = fromHost[tag];
7025
+ if (typeof pinned === "string") return pinned;
7026
+ }
7027
+ return regionPreferredVideoModelsOf(settingsScope?.getSnapshot().value, item)[tag] ?? "";
7028
+ };
7029
+ /**
6846
7030
  * Switch one region's provider off or on. The write carries the region's
6847
7031
  * whole slot through untouched — only `enabled` changes — so the user's
6848
7032
  * directory, model picks, image opt-ins and budgets survive a round trip.
@@ -7112,6 +7296,107 @@ window.__ModuleLoader__.load({
7112
7296
  if (mounted.current) setTogglingCredits(void 0);
7113
7297
  }
7114
7298
  };
7299
+ const toggleVideoTool = async (item, enabled) => {
7300
+ if (settingsScope === void 0) return;
7301
+ setTogglingCredits(item);
7302
+ try {
7303
+ const slot = nextRegionVideoToolEnabled(settingsScope.getSnapshot().value, item, enabled)[item];
7304
+ await writeRegionVideoToolEnabled(settingsScope, item, enabled, typeof slot === "object" && slot !== null ? slot : {});
7305
+ await refreshUsage(item);
7306
+ } catch (error) {
7307
+ if (mounted.current) setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
7308
+ } finally {
7309
+ if (mounted.current) setTogglingCredits(void 0);
7310
+ }
7311
+ };
7312
+ /**
7313
+ * Pin (or clear) one region's video model for one mode tag.
7314
+ *
7315
+ * `undefined` means "no pin": the picker's default row, which the tool reads as
7316
+ * "take the first model of that group". Both directions go through the same
7317
+ * landed-checked write as every other settings control, then re-read the Host —
7318
+ * a pin that reverted silently would leave the user believing the tool would
7319
+ * use a model it never picks.
7320
+ */
7321
+ const pinVideoModel = async (item, tag, modelId) => {
7322
+ if (settingsScope === void 0) return;
7323
+ setPinningVideoModel(tag);
7324
+ try {
7325
+ const slot = nextRegionPreferredVideoModels(settingsScope.getSnapshot().value, item, tag, modelId)[item];
7326
+ await writeRegionPreferredVideoModels(settingsScope, item, tag, modelId, typeof slot === "object" && slot !== null ? slot : {});
7327
+ await refreshUsage(item);
7328
+ } catch (error) {
7329
+ if (mounted.current) setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
7330
+ } finally {
7331
+ if (mounted.current) setPinningVideoModel(void 0);
7332
+ }
7333
+ };
7334
+ /**
7335
+ * Read the active region's media roster. Read-only and free (the same GET the
7336
+ * image tool performs), so it is safe to run whenever the capability panel is
7337
+ * shown; `refresh` bypasses the Host's 5-minute cache for the explicit
7338
+ * button, because a user who just enabled an image model upstream should not
7339
+ * have to wait out a TTL to see it.
7340
+ *
7341
+ * A failure lands in `mediaError` INSIDE the block and nowhere else: it must
7342
+ * not touch `statusByRegion`, or one unreachable roster would replace the
7343
+ * whole panel with an error screen.
7344
+ */
7345
+ const loadMediaModels = (0, react.useCallback)(async (region, refresh, signal) => {
7346
+ try {
7347
+ const url = refresh ? `${withWorkBuddyRegion(WORKBUDDY_MEDIA_MODELS_PATH, region)}&refresh=1` : withWorkBuddyRegion(WORKBUDDY_MEDIA_MODELS_PATH, region);
7348
+ const response = await fetch(url, {
7349
+ method: "POST",
7350
+ headers: { accept: "application/json" },
7351
+ credentials: "same-origin",
7352
+ ...signal === void 0 ? {} : { signal }
7353
+ });
7354
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
7355
+ const models = readMediaRoster(await response.json().catch(() => void 0));
7356
+ if (models === void 0) throw new Error(`HTTP ${response.status}`);
7357
+ if (!mounted.current || signal?.aborted === true) return;
7358
+ setMediaByRegion((prev) => ({
7359
+ ...prev,
7360
+ [region]: models
7361
+ }));
7362
+ setMediaError(void 0);
7363
+ } catch (error) {
7364
+ if (!mounted.current || signal?.aborted === true) return;
7365
+ setMediaError(error instanceof Error ? error.message : t("row.requestFailed"));
7366
+ }
7367
+ }, [t]);
7368
+ /**
7369
+ * Regions whose media roster has already been asked for, so showing the
7370
+ * capability panel does not re-read the roster on every render — and so a
7371
+ * FAILED read is not retried in a loop. The explicit "re-read" button is the
7372
+ * only thing that asks twice, because a user who just enabled a model
7373
+ * upstream is the one case where a fresh answer is the point.
7374
+ */
7375
+ const mediaRequested = (0, react.useRef)(/* @__PURE__ */ new Set());
7376
+ /**
7377
+ * Read the media roster once per region, when the capability panel is opened.
7378
+ *
7379
+ * Lazy rather than alongside the usage fetch: the roster is only visible in
7380
+ * this one panel, and the usage fetch runs for BOTH regions on every open. If
7381
+ * the secret store happens to hold a credential for the region the user is not
7382
+ * looking at, an eager read would spend a request nobody asked for.
7383
+ */
7384
+ (0, react.useEffect)(() => {
7385
+ if (!open || panel !== "capabilities" || !hasAccounts) return;
7386
+ if (mediaRequested.current.has(activeRegion)) return;
7387
+ mediaRequested.current.add(activeRegion);
7388
+ const controller = new AbortController();
7389
+ loadMediaModels(activeRegion, false, controller.signal);
7390
+ return () => {
7391
+ controller.abort();
7392
+ };
7393
+ }, [
7394
+ open,
7395
+ panel,
7396
+ activeRegion,
7397
+ hasAccounts,
7398
+ loadMediaModels
7399
+ ]);
7115
7400
  const refreshModels = async () => {
7116
7401
  setBusy(true);
7117
7402
  try {
@@ -7601,12 +7886,14 @@ window.__ModuleLoader__.load({
7601
7886
  *
7602
7887
  * It sits first because it is the only preference here that authorizes SPEND
7603
7888
  * rather than display: the credit line is a readout, this one lets the model
7604
- * buy an image (about 5.71 credits) without asking again. CN-only, because the
7605
- * upstream publishes a `text-to-image` model on the CN gateway and none at all
7606
- * internationally — showing the box on the global tab would offer a control
7607
- * that can never do anything.
7889
+ * buy an image without asking again. Shown on BOTH tabs: each gateway now
7890
+ * publishes its own `text-to-image` model (CN `hunyuan-image-alpha`,
7891
+ * international `hy-image-3.5`, measured 2026-10-25), so the global box is no
7892
+ * longer a control that can never do anything. The flag is per-region and the
7893
+ * tool uses whichever region is BOTH enabled and switched on (CN first), so
7894
+ * ticking it here only ever authorizes spending on THIS tab.
7608
7895
  */
7609
- const imageToolPreference = activeRegion === "cn" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
7896
+ const imageToolPreference = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
7610
7897
  className: "dsm-workbuddy-pref",
7611
7898
  title: t("row.imageToolHint"),
7612
7899
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -7627,7 +7914,158 @@ window.__ModuleLoader__.load({
7627
7914
  children: t("row.imageToolHint")
7628
7915
  })]
7629
7916
  })]
7630
- }) : null;
7917
+ });
7918
+ /**
7919
+ * The video-tool opt-in. Same shape and same stakes as imageToolPreference:
7920
+ * per-region, off means the tool is NOT registered at all (not "registered
7921
+ * but refusing"), so a tick here only ever authorizes spending on THIS tab.
7922
+ * The tool is an async task that runs tens of seconds to minutes.
7923
+ */
7924
+ const videoToolPreference = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
7925
+ className: "dsm-workbuddy-pref",
7926
+ title: t("row.videoToolHint"),
7927
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
7928
+ type: "checkbox",
7929
+ className: "dsm-workbuddy-video-tool-check",
7930
+ checked: activeRegionOn ? videoToolOn(activeRegion) : false,
7931
+ disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
7932
+ onChange: (event) => {
7933
+ toggleVideoTool(activeRegion, event.target.checked);
7934
+ }
7935
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7936
+ className: "dsm-workbuddy-pref-copy",
7937
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7938
+ className: "dsm-workbuddy-pref-label",
7939
+ children: t("row.videoToolLabel")
7940
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7941
+ className: "dsm-workbuddy-pref-hint",
7942
+ children: t("row.videoToolHint")
7943
+ })]
7944
+ })]
7945
+ });
7946
+ /**
7947
+ * What this region can actually draw. The IMAGE rows stay read-only; the
7948
+ * VIDEO rows are pinnable in `videoModelPreference` below.
7949
+ *
7950
+ * The roster used to be read-only because every tag group held exactly one
7951
+ * model in both regions (measured 2026-10-25), so a dropdown would have been a
7952
+ * choice of one. That stopped being true for VIDEO: CN publishes two models
7953
+ * per video tag (`kling-video-v3` and `minimax-video-h3`) — the very
7954
+ * "两个模型能不能挑" question. The pin therefore lives in its own block below,
7955
+ * and the roster keeps answering "can this tab draw at all, and with what".
7956
+ *
7957
+ * Indented like the position picker, because it is a property of the switch
7958
+ * above it rather than a fourth independent setting.
7959
+ */
7960
+ const mediaTagLabels = {
7961
+ "text-to-image": t("row.mediaTagTextToImage"),
7962
+ "image-to-image": t("row.mediaTagImageToImage"),
7963
+ "text-to-video": t("row.mediaTagTextToVideo"),
7964
+ "image-to-video": t("row.mediaTagImageToVideo")
7965
+ };
7966
+ const activeMedia = mediaByRegion[activeRegion];
7967
+ const videoModelPreference = !(activeRegionOn && videoToolOn(activeRegion)) || activeMedia === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7968
+ className: "dsm-workbuddy-pref dsm-workbuddy-media",
7969
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7970
+ className: "dsm-workbuddy-pref-copy",
7971
+ children: [
7972
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7973
+ className: "dsm-workbuddy-pref-label",
7974
+ children: t("row.videoModelLabel")
7975
+ }),
7976
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7977
+ className: "dsm-workbuddy-pref-hint",
7978
+ children: t("row.videoModelHint")
7979
+ }),
7980
+ ["text-to-video", "image-to-video"].map((tag) => {
7981
+ const group = activeMedia.filter((model) => model.tags.includes(tag));
7982
+ if (group.length === 0) return null;
7983
+ const pinned = preferredVideoModelOf(activeRegion, tag);
7984
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
7985
+ className: "dsm-workbuddy-media-row",
7986
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7987
+ className: "dsm-workbuddy-media-tag",
7988
+ children: mediaTagLabels[tag] ?? tag
7989
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
7990
+ className: "dsm-workbuddy-video-model-select",
7991
+ value: group.some((model) => model.id === pinned) ? pinned : "",
7992
+ disabled: !canWrite || saving || poolBusy || pinningVideoModel !== void 0,
7993
+ onChange: (event) => {
7994
+ const next = event.target.value;
7995
+ pinVideoModel(activeRegion, tag, next === "" ? void 0 : next);
7996
+ },
7997
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
7998
+ value: "",
7999
+ children: t("row.videoModelDefault")
8000
+ }), group.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
8001
+ value: model.id,
8002
+ children: model.name
8003
+ }, model.id))]
8004
+ })]
8005
+ }, tag);
8006
+ })
8007
+ ]
8008
+ })
8009
+ });
8010
+ const mediaModelsPreference = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
8011
+ className: "dsm-workbuddy-pref dsm-workbuddy-media",
8012
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
8013
+ className: "dsm-workbuddy-pref-copy",
8014
+ children: [
8015
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
8016
+ className: "dsm-workbuddy-pref-lead",
8017
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8018
+ className: "dsm-workbuddy-pref-label",
8019
+ children: t("row.mediaModelsLabel")
8020
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8021
+ type: "button",
8022
+ className: "dsm-btn dsm-btn-outline",
8023
+ disabled: mediaBusy || !activeRegionOn,
8024
+ onClick: () => {
8025
+ setMediaBusy(true);
8026
+ loadMediaModels(activeRegion, true).finally(() => {
8027
+ if (mounted.current) setMediaBusy(false);
8028
+ });
8029
+ },
8030
+ children: mediaBusy ? t("row.mediaModelsRefreshing") : t("row.mediaModelsRefresh")
8031
+ })]
8032
+ }),
8033
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8034
+ className: "dsm-workbuddy-pref-hint",
8035
+ children: t("row.mediaModelsHint")
8036
+ }),
8037
+ mediaError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8038
+ className: "dsm-workbuddy-media-error",
8039
+ children: t("row.mediaModelsError", { message: mediaError })
8040
+ }) : activeMedia === void 0 ? null : activeMedia.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8041
+ className: "dsm-workbuddy-media-empty",
8042
+ children: t("row.mediaModelsEmpty")
8043
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
8044
+ className: "dsm-workbuddy-media-list",
8045
+ children: activeMedia.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
8046
+ className: "dsm-workbuddy-media-row",
8047
+ children: [
8048
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8049
+ className: "dsm-workbuddy-media-name",
8050
+ children: model.name
8051
+ }),
8052
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8053
+ className: "dsm-workbuddy-media-id",
8054
+ children: model.id
8055
+ }),
8056
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8057
+ className: "dsm-workbuddy-media-tags",
8058
+ children: model.tags.map((tag) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8059
+ className: "dsm-workbuddy-media-tag",
8060
+ children: mediaTagLabels[tag] ?? tag
8061
+ }, tag))
8062
+ })
8063
+ ]
8064
+ }, model.id))
8065
+ })
8066
+ ]
8067
+ })
8068
+ });
7631
8069
  /**
7632
8070
  * The CLI-route switch.
7633
8071
  *
@@ -7870,6 +8308,9 @@ window.__ModuleLoader__.load({
7870
8308
  cliBridgePreference,
7871
8309
  cliBridgeSwitchedOn() ? cliBridgePickers : null,
7872
8310
  imageToolPreference,
8311
+ videoToolPreference,
8312
+ videoModelPreference,
8313
+ mediaModelsPreference,
7873
8314
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
7874
8315
  className: "dsm-workbuddy-pref",
7875
8316
  title: activeRegion === "cn" ? t("row.showCreditsCnHint") : t("row.showCreditsGlobalHint"),
@@ -10616,7 +11057,22 @@ window.__ModuleLoader__.load({
10616
11057
  "row.cliBridgeRegionOrderGlobalOnly": "Global only",
10617
11058
  "row.cliBridgePickersHint": "All three pickers write the global cliBridge block; \"CLI default\" on model and effort is an explicit empty value, not \"unset\" — the CLI then uses its own defaults; the region picker writes cliBridge.regionOrder.",
10618
11059
  "row.imageToolLabel": "Let the model draw images",
10619
- "row.imageToolHint": "Adds the workbuddy_draw_image tool, which the MODEL may call on its own — about 5.71 credits per image, with no per-call confirmation. Off means the tool is not registered at all.",
11060
+ "row.imageToolHint": "Adds the workbuddy_draw_image tool, which the MODEL may call on its own — about 5.71 credits per image on the CN gateway and about 0.03 on the international one, with no per-call confirmation. Ticking it here only authorizes THIS tab's region. Off everywhere means the tool is not registered at all.",
11061
+ "row.videoToolLabel": "Let the model generate videos",
11062
+ "row.videoToolHint": "Adds the workbuddy_draw_video tool, which the MODEL may call on its own — a video is an async task (submit, poll, then download) that costs WorkBuddy credits and runs tens of seconds to minutes, with no per-call confirmation. Ticking it here only authorizes THIS tab's region. Off everywhere means the tool is not registered at all.",
11063
+ "row.videoModelLabel": "Video model per mode",
11064
+ "row.videoModelHint": "Pinned separately for text-to-video and image-to-video, from THIS region's own catalog — a model id does not carry across regions. A call that names no model uses the pin; with nothing pinned the tool takes the first model of that group. A pin the catalog no longer carries quietly falls back to the first model instead of failing.",
11065
+ "row.videoModelDefault": "Default (first in the group)",
11066
+ "row.mediaModelsLabel": "Image models in this region",
11067
+ "row.mediaModelsHint": "Read from this account's product configuration. The draw tools pick their models from here: a prompt without an image uses a text-to-image model, one WITH an image uses an image-to-image model. Image rows are read-only (pass a model to the tool to change it); the VIDEO model is pinnable in the picker above.",
11068
+ "row.mediaModelsEmpty": "This region publishes no image or video model.",
11069
+ "row.mediaModelsError": "Could not read the media model list: {message}",
11070
+ "row.mediaModelsRefresh": "Re-read",
11071
+ "row.mediaModelsRefreshing": "Reading…",
11072
+ "row.mediaTagTextToImage": "text → image",
11073
+ "row.mediaTagImageToImage": "image → image",
11074
+ "row.mediaTagTextToVideo": "text → video",
11075
+ "row.mediaTagImageToVideo": "image → video",
10620
11076
  "row.showCreditsCn": "Show credits in the composer toolbar",
10621
11077
  "row.showCreditsGlobal": "Show credits in the composer toolbar",
10622
11078
  "row.showCreditsCnHint": "Show the domestic (WB CN) credit balance in the composer toolbar, but only while this session uses a WB CN model. Click it for per-account balances and a one-click test of the checked accounts (the test spends real credits). The readout refreshes by itself every 5 minutes; the per-account table every 60 seconds while it is open.",
@@ -11031,7 +11487,22 @@ window.__ModuleLoader__.load({
11031
11487
  "row.cliBridgeRegionOrderGlobalOnly": "仅国际",
11032
11488
  "row.cliBridgePickersHint": "三个选择都写入全局 cliBridge 块;模型/档位的\"CLI 默认\"是显式的空值而非未设置,届时 CLI 使用自己的默认;区域选择写入 cliBridge.regionOrder。",
11033
11489
  "row.imageToolLabel": "允许模型自主生图",
11034
- "row.imageToolHint": "开启后模型可以自己调用 workbuddy_draw_image 画图——约 5.71 积分/张,没有逐次确认。关闭时该工具根本不会注册。",
11490
+ "row.imageToolHint": "开启后模型可以自己调用 workbuddy_draw_image 画图——国内区约 5.71、国际区约 0.03 积分/张,没有逐次确认。在这里勾选只授权当前这一区的账号。两区都关着时该工具根本不会注册。",
11491
+ "row.videoToolLabel": "允许模型自主生视频",
11492
+ "row.videoToolHint": "开启后模型可以自己调用 workbuddy_draw_video 生成短视频——视频是异步任务(提交、轮询到完成、再下载到本地),要几十秒到几分钟,没有逐次确认。在这里勾选只授权当前这一区的账号。两区都关着时该工具根本不会注册。",
11493
+ "row.videoModelLabel": "按模式指定视频模型",
11494
+ "row.videoModelHint": "文生视频与图生视频各钉一个,选项只来自当前这一区自己的目录——模型 id 不跨区。调用时不填 model 就用这里钉的;什么都没钉就用该组第一个模型。钉住的模型如果目录里已经没有了,会安静地回落到第一个,而不是让这次生成失败。",
11495
+ "row.videoModelDefault": "默认(组内第一个)",
11496
+ "row.mediaModelsLabel": "本区可用的图像模型",
11497
+ "row.mediaModelsHint": "读自当前账号的产品配置。生图工具就按这里挑模型:不填图片走「文生图」那一组,填了图片走「图生图」那一组。「图像」那几行是只读的(想换就在调用工具时指定模型);「视频」模型可以在上面的选择器里钉住。",
11498
+ "row.mediaModelsEmpty": "这一区的产品配置里没有图像或视频模型。",
11499
+ "row.mediaModelsError": "读取媒体模型清单失败:{message}",
11500
+ "row.mediaModelsRefresh": "重新读取",
11501
+ "row.mediaModelsRefreshing": "正在读取…",
11502
+ "row.mediaTagTextToImage": "文生图",
11503
+ "row.mediaTagImageToImage": "图生图",
11504
+ "row.mediaTagTextToVideo": "文生视频",
11505
+ "row.mediaTagImageToVideo": "图生视频",
11035
11506
  "row.showCreditsCn": "在输入框工具栏显示积分读数",
11036
11507
  "row.showCreditsGlobal": "在输入框工具栏显示积分读数",
11037
11508
  "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并一键测试勾选的账号(测试会真实消耗积分)。读数每 5 分钟自动刷新;各账号明细在面板打开时每 60 秒刷新。",