dsh-llm-fallbacks 0.2.0 → 0.2.2

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.
@@ -63,7 +63,8 @@ window.__ModuleLoader__.load({
63
63
  revertPolicy: "cooldown-expiry",
64
64
  maxSwitchesPerStep: 8,
65
65
  alwaysModeRetryCap: 5,
66
- presets: "bundled"
66
+ presets: "bundled",
67
+ roleAutoMatch: true
67
68
  };
68
69
  /**
69
70
  * Reserved role id: legal as a rule target (`roles.rules[].role`) and as
@@ -192,6 +193,8 @@ window.__ModuleLoader__.load({
192
193
  if (presets !== void 0 && presets !== "bundled" && presets !== "none") throw new TypeError("fallbacks descriptor presets must be bundled|none");
193
194
  const enabled = value.enabled;
194
195
  if (enabled !== void 0 && typeof enabled !== "boolean") throw new TypeError("fallbacks descriptor enabled must be a boolean");
196
+ const roleAutoMatch = value.roleAutoMatch;
197
+ if (roleAutoMatch !== void 0 && typeof roleAutoMatch !== "boolean") throw new TypeError("fallbacks descriptor roleAutoMatch must be a boolean");
195
198
  return {
196
199
  enabled: enabled ?? defaultFallbacksConfig.enabled,
197
200
  triggerCodes: triggerCodes ?? [...defaultFallbacksConfig.triggerCodes],
@@ -204,7 +207,8 @@ window.__ModuleLoader__.load({
204
207
  revertPolicy: revertPolicy ?? defaultFallbacksConfig.revertPolicy,
205
208
  maxSwitchesPerStep: maxSwitchesPerStep ?? defaultFallbacksConfig.maxSwitchesPerStep,
206
209
  alwaysModeRetryCap: alwaysModeRetryCap ?? defaultFallbacksConfig.alwaysModeRetryCap,
207
- presets: presets ?? defaultFallbacksConfig.presets
210
+ presets: presets ?? defaultFallbacksConfig.presets,
211
+ roleAutoMatch: roleAutoMatch ?? defaultFallbacksConfig.roleAutoMatch
208
212
  };
209
213
  }
210
214
  /** The raw selector string a selection serializes to ('' when empty). */
@@ -262,46 +266,6 @@ window.__ModuleLoader__.load({
262
266
  switches.sort((a, b) => b.seq - a.seq);
263
267
  return switches.slice(0, limit);
264
268
  }
265
- /** The config's primary target: the rootChain's first entry (D-6 ③). */
266
- function configPrimaryTarget(config) {
267
- const firstEntry = config.rootChain[0];
268
- if (firstEntry === void 0) return null;
269
- try {
270
- const selector = parseSelector(firstEntry);
271
- return {
272
- provider: selector.provider,
273
- model: selector.model ?? "*"
274
- };
275
- } catch {
276
- return {
277
- provider: firstEntry,
278
- model: "*"
279
- };
280
- }
281
- }
282
- /**
283
- * Derive the status block's "current effective model" (spec §2.5 D-6): ①
284
- * disabled / empty rootChain → unavailable; ② a recent switch exists → the
285
- * latest one's `to`; ③ otherwise → the config's primary target. A **display
286
- * value** — never a live route probe (the section appends the non-probing
287
- * note inline right after the derived value, available case only; the
288
- * unavailable 空态 renders its own copy without the note).
289
- */
290
- function deriveEffectiveModel(config, switches) {
291
- if (!config.enabled || config.rootChain.length === 0) return { kind: "unavailable" };
292
- const latest = switches[0];
293
- if (latest !== void 0) return {
294
- kind: "switched",
295
- provider: latest.to.provider,
296
- model: latest.to.model
297
- };
298
- const target = configPrimaryTarget(config);
299
- if (target === null) return { kind: "unavailable" };
300
- return {
301
- kind: "config",
302
- ...target
303
- };
304
- }
305
269
  /** Serialize one selector row to its wire string (`provider/model` | `provider/*`). */
306
270
  function selectorRowToRaw(row) {
307
271
  const provider = selectionToRaw(row.provider);
@@ -850,8 +814,12 @@ window.__ModuleLoader__.load({
850
814
  "advanced.label": "高级选项",
851
815
  "advanced.expand": "展开高级选项",
852
816
  "advanced.collapse": "收起高级选项",
817
+ "roleAutoMatch.label": "启用角色自动匹配",
818
+ "roleAutoMatch.hint": "规则未命中时,由模型自选最贴近的已声明角色",
819
+ "roleAutoMatch.tooltip": "规则未命中时,模型会自动从已声明角色(id + persona)中选择最匹配者并注入该角色的链;关闭后未命中规则时按现状回落(inherit / rootChain)。",
853
820
  "rootChain.label": "root 主代理降级链",
854
- "rootChain.hint": "未配置 = root 不降级",
821
+ "rootChain.firstLine": "仅当当前会话所选模型失败后才启用降级——不抢占会话模型",
822
+ "rootChain.hint": "优先使用当前会话的模型;失败后才按此链降级",
855
823
  "rootChain.tooltip": "root 主代理失败时按此有序选择器列表依次降级;未配置时行为与未安装插件一致。",
856
824
  "rootChain.selector.add": "添加选择器",
857
825
  "chains.selector.remove": "删除该选择器",
@@ -911,26 +879,27 @@ window.__ModuleLoader__.load({
911
879
  "catalog.outside.short": " (目录外)",
912
880
  "catalog.unconfigured.short": " (未配置)",
913
881
  "status.title": "运行状态(只读)",
914
- "status.effectiveModel.label": "当前生效模型:",
915
- "status.effectiveModel.unavailable": "fallbacks 未启用(或 rootChain 未配置)",
916
- "status.effectiveModel.note": "配置 + 最近切换推导,非实时路由探测",
917
882
  "status.switches.label": "最近切换:",
918
883
  "status.switches.empty": "本会话暂无 fallback 切换。",
919
884
  "status.switches.error": "切换历史读取失败:{message}",
920
885
  "status.switches.compact": "最近 {count} 次 · {from} → {to}({role} · {reason})",
886
+ "status.switches.compact.roleInject": "最近 {count} 次 · {role} → {to}({reason})",
921
887
  "status.switches.reason.trigger-code": "触发失败码",
922
888
  "status.switches.reason.always-cap": "always 模式上限",
923
- "status.selectionNote": "说明:web 前端手动选择的模型可能在切换后重新套用(标记载体已随本地 patch 移除)。",
889
+ "status.switches.reason.role-inject": "角色注入",
924
890
  "general.title": "模型故障降级",
925
891
  "general.enabled": "已启用",
926
892
  "general.disabled": "未启用",
927
893
  "general.unknown": "未知",
928
894
  "general.unavailable": "状态通道暂不可达",
929
895
  "general.switch": "最近切换:{from} → {to}({role} · {reason})",
896
+ "general.switch.roleInject": "最近切换:{role} → {to}({reason})",
930
897
  "general.switch.empty": "本会话暂无切换",
931
898
  "general.error": "状态读取失败:{message}",
932
899
  "chat.switch.title": "模型已降级",
933
- "chat.switch.summary": "{from} → {to}({role} · {reason})",
900
+ "chat.switch.summary": "{from} → {to}({reason})",
901
+ "chat.switch.summary.roleInject": "({reason})",
902
+ "chat.switch.roleMap": "{role} → {model}",
934
903
  "defaults.prefix": "默认值",
935
904
  "save": "保存",
936
905
  "save.saving": "保存中…",
@@ -984,8 +953,12 @@ window.__ModuleLoader__.load({
984
953
  "advanced.label": "Advanced options",
985
954
  "advanced.expand": "Show advanced options",
986
955
  "advanced.collapse": "Hide advanced options",
956
+ "roleAutoMatch.label": "Enable role auto-match",
957
+ "roleAutoMatch.hint": "On rules-miss, the model picks the closest declared role",
958
+ "roleAutoMatch.tooltip": "When no rule matches, the model auto-selects the best-fit declared role (id + persona) and uses its chain; turn off to keep today's fallback (inherit / rootChain) on a rules-miss.",
987
959
  "rootChain.label": "Root agent fallback chain",
988
- "rootChain.hint": "Unset = root does not fall back",
960
+ "rootChain.firstLine": "Engages only after the current session's selected model fails — it never preempts the session model",
961
+ "rootChain.hint": "Prefer the current session's model; degrade down this chain only after it fails",
989
962
  "rootChain.tooltip": "When the root agent fails it falls back down this ordered selector list; unset behaves like an uninstalled plugin.",
990
963
  "rootChain.selector.add": "Add selector",
991
964
  "chains.selector.remove": "Remove this selector",
@@ -1045,26 +1018,27 @@ window.__ModuleLoader__.load({
1045
1018
  "catalog.outside.short": " (outside catalog)",
1046
1019
  "catalog.unconfigured.short": " (not configured)",
1047
1020
  "status.title": "Runtime status (read-only)",
1048
- "status.effectiveModel.label": "Current effective model: ",
1049
- "status.effectiveModel.unavailable": "Fallbacks disabled (or rootChain not configured)",
1050
- "status.effectiveModel.note": "Derived from configuration and recent switches; not real-time route probing",
1051
1021
  "status.switches.label": "Recent switches: ",
1052
1022
  "status.switches.empty": "No fallback switches in this session yet.",
1053
1023
  "status.switches.error": "Switch history read failed: {message}",
1054
1024
  "status.switches.compact": "last {count} · {from} → {to} ({role} · {reason})",
1025
+ "status.switches.compact.roleInject": "last {count} · {role} → {to} ({reason})",
1055
1026
  "status.switches.reason.trigger-code": "trigger code",
1056
1027
  "status.switches.reason.always-cap": "always-mode cap",
1057
- "status.selectionNote": "Note: a model manually selected in the web front end may be re-applied after a switch (the marker coordination shipped with the local patch has been removed).",
1028
+ "status.switches.reason.role-inject": "role inject",
1058
1029
  "general.title": "Model failover",
1059
1030
  "general.enabled": "Enabled",
1060
1031
  "general.disabled": "Disabled",
1061
1032
  "general.unknown": "Unknown",
1062
1033
  "general.unavailable": "Status channel unavailable",
1063
1034
  "general.switch": "Last switch: {from} → {to} ({role} · {reason})",
1035
+ "general.switch.roleInject": "Last switch: {role} → {to} ({reason})",
1064
1036
  "general.switch.empty": "No switches this session",
1065
1037
  "general.error": "Status read failed: {message}",
1066
1038
  "chat.switch.title": "Model downgraded",
1067
- "chat.switch.summary": "{from} → {to} ({role} · {reason})",
1039
+ "chat.switch.summary": "{from} → {to} ({reason})",
1040
+ "chat.switch.summary.roleInject": "({reason})",
1041
+ "chat.switch.roleMap": "{role} → {model}",
1068
1042
  "defaults.prefix": "Default",
1069
1043
  "save": "Save",
1070
1044
  "save.saving": "Saving…",
@@ -1084,13 +1058,17 @@ window.__ModuleLoader__.load({
1084
1058
  const NS = "fallbacks";
1085
1059
  /**
1086
1060
  * Reason → locale key map for switch summaries (S-c; shared by the card's
1087
- * status block and the General page status row). The session log is durable
1088
- * and forward-compatible: a reason value outside the current union (a newer
1089
- * plugin wrote it) renders raw instead of falling into a binary else branch.
1061
+ * status block, the General page status row, and the conversation node). All
1062
+ * reasons resolve from ONE key family (`status.switches.reason.*`) — the
1063
+ * shared reason vocabulary must not mix dictionary families (qc1 F-004). The
1064
+ * session log is durable and forward-compatible: a reason value outside the
1065
+ * current union (a newer plugin wrote it) renders raw instead of falling
1066
+ * into a binary else branch.
1090
1067
  */
1091
1068
  const SWITCH_REASON_KEYS = {
1092
1069
  "trigger-code": "status.switches.reason.trigger-code",
1093
- "always-cap": "status.switches.reason.always-cap"
1070
+ "always-cap": "status.switches.reason.always-cap",
1071
+ "role-inject": "status.switches.reason.role-inject"
1094
1072
  };
1095
1073
  /** Human-readable trigger-code labels (spec §4 用户直观性). */
1096
1074
  const TRIGGER_CODE_LABELS = {
@@ -1186,9 +1164,10 @@ window.__ModuleLoader__.load({
1186
1164
  //#region src/client/FallbacksCard.tsx
1187
1165
  /**
1188
1166
  * Fallbacks settings card — the `fallbacks` plugin card on the web settings
1189
- * "插件配置" page (spec §4). Registered into the `settings.plugin.item` slot
1190
- * (id `fallbacks`, order 30, alongside the upstream bash/agent-loop/web-search
1191
- * cards and the advisor card); owner props are empty and all data flows
1167
+ * "插件配置" page (spec §4). Registered into the `settings.plugin.item` keyed
1168
+ * slot (key `fallbacks`, the settings namespace the card edits, alongside
1169
+ * the upstream bash/agent-loop/web-search cards and the advisor card, in
1170
+ * registration order); owner props are empty and all data flows
1192
1171
  * through {@link FallbacksSettingsController}.
1193
1172
  *
1194
1173
  * The card chrome replicates the upstream `PluginCard` contract (self-drawn:
@@ -1257,7 +1236,8 @@ window.__ModuleLoader__.load({
1257
1236
  cooldownMs: config.cooldownMs,
1258
1237
  revertPolicy: config.revertPolicy,
1259
1238
  maxSwitchesPerStep: config.maxSwitchesPerStep,
1260
- alwaysModeRetryCap: config.alwaysModeRetryCap
1239
+ alwaysModeRetryCap: config.alwaysModeRetryCap,
1240
+ roleAutoMatch: config.roleAutoMatch
1261
1241
  };
1262
1242
  }
1263
1243
  /**
@@ -1269,8 +1249,14 @@ window.__ModuleLoader__.load({
1269
1249
  * the top level: no presets UI this iteration (R-001 re-defer), so the
1270
1250
  * draft carries the accepted value through untouched — a clean draft stays
1271
1251
  * equal to the accepted config and a save never drops the key.
1252
+ * `roleAutoMatch` follows the same rule (config-model mirror of `presets`):
1253
+ * the draft carries the scalar's value through untouched. The scalar is
1254
+ * ALWAYS defined — the gateway composition resolves the schema default
1255
+ * `true` even for a legacy config that never declared the key — so the
1256
+ * toggle always renders (default on) and a save persists the resolved value
1257
+ * (AC-7 re-scope, PM decision 2026-08-17 Option A).
1272
1258
  */
1273
- function assembleConfig(scalars, rootChainRows, roleRows, ruleRows, originalRoles, presets) {
1259
+ function assembleConfig(scalars, rootChainRows, roleRows, ruleRows, originalRoles, presets, roleAutoMatch) {
1274
1260
  const list = mergeRoleExtras(roleRows, originalRoles);
1275
1261
  return {
1276
1262
  enabled: scalars.enabled,
@@ -1284,7 +1270,8 @@ window.__ModuleLoader__.load({
1284
1270
  revertPolicy: scalars.revertPolicy,
1285
1271
  maxSwitchesPerStep: scalars.maxSwitchesPerStep,
1286
1272
  alwaysModeRetryCap: scalars.alwaysModeRetryCap,
1287
- ...presets === void 0 ? {} : { presets }
1273
+ ...presets === void 0 ? {} : { presets },
1274
+ roleAutoMatch
1288
1275
  };
1289
1276
  }
1290
1277
  /**
@@ -1660,18 +1647,17 @@ window.__ModuleLoader__.load({
1660
1647
  return next;
1661
1648
  });
1662
1649
  };
1663
- const draft = assembleConfig(scalars, rootChainRows, roleRows, ruleRows, state.config.roles.list, state.config.presets);
1650
+ const draft = assembleConfig(scalars, rootChainRows, roleRows, ruleRows, state.config.roles.list, state.config.presets, scalars.roleAutoMatch);
1664
1651
  const hasEmptyRuleRows = ruleRows.some((row) => row.role === "");
1665
1652
  const dirty = JSON.stringify(draft) !== JSON.stringify(state.config) || hasEmptyRuleRows;
1666
1653
  const saving = state.status === "saving";
1667
1654
  const writable = state.writable;
1668
1655
  const unknownCodes = scalars.triggerCodes.filter((code) => !KNOWN_TRIGGER_CODES.includes(code));
1656
+ const rootChainConfigured = rowsToRootChain(rootChainRows).length > 0;
1669
1657
  const roleOptions = ruleRoleOptions({ list: roleRows });
1670
1658
  const invalidRoleIds = validationAttempted ? collectInvalidRoleIds(roleRows) : null;
1671
1659
  const seededIds = /* @__PURE__ */ new Map();
1672
1660
  for (const seed of state.seeds) seededIds.set(seed.id.trim(), seed.overridden);
1673
- const effectiveModel = deriveEffectiveModel(state.config, state.switches);
1674
- const effectiveModelLine = effectiveModel.kind === "unavailable" ? t("status.effectiveModel.unavailable") : `${effectiveModel.provider}/${effectiveModel.model} · ${t("status.effectiveModel.note")}`;
1675
1661
  const latestSwitch = state.switches[0];
1676
1662
  let switchesLine;
1677
1663
  if (state.switchesStatus === "error") switchesLine = t("status.switches.error", { message: state.switchesError });
@@ -1679,13 +1665,14 @@ window.__ModuleLoader__.load({
1679
1665
  else if (latestSwitch === void 0) switchesLine = t("status.switches.empty");
1680
1666
  else {
1681
1667
  const reasonKey = SWITCH_REASON_KEYS[latestSwitch.reason];
1682
- switchesLine = t("status.switches.compact", {
1668
+ const params = {
1683
1669
  count: String(state.switches.length),
1684
1670
  from: `${latestSwitch.from.provider}/${latestSwitch.from.model}`,
1685
1671
  to: `${latestSwitch.to.provider}/${latestSwitch.to.model}`,
1686
1672
  role: latestSwitch.role,
1687
1673
  reason: reasonKey === void 0 ? latestSwitch.reason : t(reasonKey)
1688
- });
1674
+ };
1675
+ switchesLine = latestSwitch.reason === "role-inject" ? t("status.switches.compact.roleInject", params) : t("status.switches.compact", params);
1689
1676
  }
1690
1677
  const catalogSeededEpoch = (0, react.useRef)(null);
1691
1678
  (0, react.useEffect)(() => {
@@ -1878,6 +1865,10 @@ window.__ModuleLoader__.load({
1878
1865
  })]
1879
1866
  }),
1880
1867
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1868
+ className: FallbacksCard_module_css_default.hint,
1869
+ children: t("rootChain.firstLine")
1870
+ }),
1871
+ scalars.enabled && rootChainConfigured && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1881
1872
  className: FallbacksCard_module_css_default.hint,
1882
1873
  children: t("rootChain.hint")
1883
1874
  }),
@@ -2330,6 +2321,36 @@ window.__ModuleLoader__.load({
2330
2321
  }), advancedVisible && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2331
2322
  id: "fallbacks-advanced-body",
2332
2323
  children: [
2324
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2325
+ className: FallbacksCard_module_css_default.checkboxRow,
2326
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2327
+ className: FallbacksCard_module_css_default.checkLabel,
2328
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2329
+ className: FallbacksCard_module_css_default.checkLabelTitle,
2330
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
2331
+ htmlFor: "fallbacks-role-automatch",
2332
+ children: t("roleAutoMatch.label")
2333
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InfoHint, {
2334
+ label: t("roleAutoMatch.tooltip"),
2335
+ disabled: !writable
2336
+ })]
2337
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2338
+ className: FallbacksCard_module_css_default.checkLabelDesc,
2339
+ children: t("roleAutoMatch.hint")
2340
+ })]
2341
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2342
+ id: "fallbacks-role-automatch",
2343
+ type: "checkbox",
2344
+ className: FallbacksCard_module_css_default.checkbox,
2345
+ checked: scalars.roleAutoMatch,
2346
+ disabled: !writable,
2347
+ onChange: (event) => {
2348
+ updateScalars((draft) => {
2349
+ draft.roleAutoMatch = event.target.checked;
2350
+ });
2351
+ }
2352
+ })]
2353
+ }),
2333
2354
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2334
2355
  className: FallbacksCard_module_css_default.field,
2335
2356
  role: "group",
@@ -2544,31 +2565,17 @@ window.__ModuleLoader__.load({
2544
2565
  }),
2545
2566
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2546
2567
  className: FallbacksCard_module_css_default.statusBlock,
2547
- children: [
2548
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2549
- className: FallbacksCard_module_css_default.statusTitle,
2550
- children: t("status.title")
2551
- }),
2552
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2553
- className: FallbacksCard_module_css_default.statusLine,
2554
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2555
- className: FallbacksCard_module_css_default.statusLineLabel,
2556
- children: t("status.effectiveModel.label")
2557
- }), effectiveModelLine]
2558
- }),
2559
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2560
- className: FallbacksCard_module_css_default.statusLine,
2561
- role: state.switchesStatus === "error" ? "alert" : void 0,
2562
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2563
- className: FallbacksCard_module_css_default.statusLineLabel,
2564
- children: t("status.switches.label")
2565
- }), switchesLine]
2566
- }),
2567
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2568
- className: FallbacksCard_module_css_default.statusLine,
2569
- children: t("status.selectionNote")
2570
- })
2571
- ]
2568
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2569
+ className: FallbacksCard_module_css_default.statusTitle,
2570
+ children: t("status.title")
2571
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2572
+ className: FallbacksCard_module_css_default.statusLine,
2573
+ role: state.switchesStatus === "error" ? "alert" : void 0,
2574
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2575
+ className: FallbacksCard_module_css_default.statusLineLabel,
2576
+ children: t("status.switches.label")
2577
+ }), switchesLine]
2578
+ })]
2572
2579
  }),
2573
2580
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2574
2581
  className: FallbacksCard_module_css_default.footer,
@@ -2700,12 +2707,13 @@ window.__ModuleLoader__.load({
2700
2707
  else if (latestSwitch === void 0) summary = t("general.switch.empty");
2701
2708
  else {
2702
2709
  const reasonKey = SWITCH_REASON_KEYS[latestSwitch.reason];
2703
- summary = t("general.switch", {
2710
+ const params = {
2704
2711
  from: `${latestSwitch.from.provider}/${latestSwitch.from.model}`,
2705
2712
  to: `${latestSwitch.to.provider}/${latestSwitch.to.model}`,
2706
2713
  role: latestSwitch.role,
2707
2714
  reason: reasonKey === void 0 ? latestSwitch.reason : t(reasonKey)
2708
- });
2715
+ };
2716
+ summary = latestSwitch.reason === "role-inject" ? t("general.switch.roleInject", params) : t("general.switch", params);
2709
2717
  }
2710
2718
  const alert = state.status === "error" || state.switchesStatus === "error";
2711
2719
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2754,7 +2762,7 @@ window.__ModuleLoader__.load({
2754
2762
  }
2755
2763
  //#endregion
2756
2764
  //#region \0dsh-css:/home/runner/work/dsh-llm-fallbacks/dsh-llm-fallbacks/src/client/ConversationFallbackSwitch.module.css.mjs
2757
- const css = "\n\n._ea99bbef_switchRow {\n display: flex;\n align-items: center;\n padding: 2px 0;\n font-size: 14px;\n line-height: 24px;\n}\n\n\n._02580bfd_switchTitle {\n flex: none;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n._88b2d18b_switchSep {\n flex: none;\n width: 2px;\n height: 2px;\n margin: 0 8px;\n border-radius: 1px;\n background: var(--dsw-alias-label-caption);\n}\n\n\n._7c8c1b6f_switchSummary {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--dsw-alias-label-tertiary);\n}\n";
2765
+ const css = "\n\n._ea99bbef_switchRow {\n display: flex;\n align-items: center;\n padding: 2px 0;\n font-size: 14px;\n line-height: 24px;\n}\n\n\n._02580bfd_switchTitle {\n flex: none;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n._88b2d18b_switchSep {\n flex: none;\n width: 2px;\n height: 2px;\n margin: 0 8px;\n border-radius: 1px;\n background: var(--dsw-alias-label-caption);\n}\n\n\n._7c8c1b6f_switchSummary {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--dsw-alias-label-tertiary);\n}\n\n\n._fdc977bc_roleBadge {\n flex: none;\n max-width: 96px;\n padding: 0 8px;\n border-radius: 999px;\n font-size: 12px;\n line-height: 20px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n background: var(--dsw-alias-bg-module-platform);\n color: var(--dsw-alias-label-primary);\n}\n\n\n._dce8ba28_roleModelMap {\n flex: 0 1 auto;\n min-width: 0;\n margin-left: 8px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--dsw-alias-label-secondary);\n}\n";
2758
2766
  const tagId = "dsh-llm-fallbacks/ConversationFallbackSwitch.module.css";
2759
2767
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
2760
2768
  const tag = document.createElement("style");
@@ -2767,7 +2775,9 @@ window.__ModuleLoader__.load({
2767
2775
  "switchRow": "_ea99bbef_switchRow",
2768
2776
  "switchTitle": "_02580bfd_switchTitle",
2769
2777
  "switchSep": "_88b2d18b_switchSep",
2770
- "switchSummary": "_7c8c1b6f_switchSummary"
2778
+ "switchSummary": "_7c8c1b6f_switchSummary",
2779
+ "roleBadge": "_fdc977bc_roleBadge",
2780
+ "roleModelMap": "_dce8ba28_roleModelMap"
2771
2781
  };
2772
2782
  //#endregion
2773
2783
  //#region src/client/ConversationFallbackSwitch.tsx
@@ -2843,12 +2853,8 @@ window.__ModuleLoader__.load({
2843
2853
  })
2844
2854
  });
2845
2855
  const reasonKey = SWITCH_REASON_KEYS[data.reason];
2846
- const summary = t("chat.switch.summary", {
2847
- from: `${data.from.provider}/${data.from.model}`,
2848
- to: `${data.to.provider}/${data.to.model}`,
2849
- role: data.role,
2850
- reason: reasonKey === void 0 ? data.reason : t(reasonKey)
2851
- });
2856
+ const reason = reasonKey === void 0 ? data.reason : t(reasonKey);
2857
+ const isRoleMapped = data.role !== "inherit";
2852
2858
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2853
2859
  className: ConversationFallbackSwitch_module_css_default.switchRow,
2854
2860
  role: "status",
@@ -2861,9 +2867,34 @@ window.__ModuleLoader__.load({
2861
2867
  className: ConversationFallbackSwitch_module_css_default.switchSep,
2862
2868
  "aria-hidden": "true"
2863
2869
  }),
2864
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2870
+ isRoleMapped ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
2871
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2872
+ className: ConversationFallbackSwitch_module_css_default.roleBadge,
2873
+ title: data.role,
2874
+ children: data.role
2875
+ }),
2876
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2877
+ className: ConversationFallbackSwitch_module_css_default.roleModelMap,
2878
+ children: t("chat.switch.roleMap", {
2879
+ role: data.role,
2880
+ model: `${data.to.provider}/${data.to.model}`
2881
+ })
2882
+ }),
2883
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2884
+ className: ConversationFallbackSwitch_module_css_default.switchSep,
2885
+ "aria-hidden": "true"
2886
+ }),
2887
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2888
+ className: ConversationFallbackSwitch_module_css_default.switchSummary,
2889
+ children: t("chat.switch.summary.roleInject", { reason })
2890
+ })
2891
+ ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2865
2892
  className: ConversationFallbackSwitch_module_css_default.switchSummary,
2866
- children: summary
2893
+ children: t("chat.switch.summary", {
2894
+ from: `${data.from.provider}/${data.from.model}`,
2895
+ to: `${data.to.provider}/${data.to.model}`,
2896
+ reason
2897
+ })
2867
2898
  })
2868
2899
  ]
2869
2900
  });
@@ -2945,8 +2976,7 @@ window.__ModuleLoader__.load({
2945
2976
  ctx.slots.inject("settings.plugin.item", function* () {
2946
2977
  yield ctx.slots.register({
2947
2978
  name: "settings.plugin.item",
2948
- id: "fallbacks",
2949
- order: 30,
2979
+ key: "fallbacks",
2950
2980
  locale: NS,
2951
2981
  inject: () => ({
2952
2982
  controller,
@@ -37,7 +37,11 @@ export declare const zh: {
37
37
  'advanced.label': string;
38
38
  'advanced.expand': string;
39
39
  'advanced.collapse': string;
40
+ 'roleAutoMatch.label': string;
41
+ 'roleAutoMatch.hint': string;
42
+ 'roleAutoMatch.tooltip': string;
40
43
  'rootChain.label': string;
44
+ 'rootChain.firstLine': string;
41
45
  'rootChain.hint': string;
42
46
  'rootChain.tooltip': string;
43
47
  'rootChain.selector.add': string;
@@ -98,26 +102,27 @@ export declare const zh: {
98
102
  'catalog.outside.short': string;
99
103
  'catalog.unconfigured.short': string;
100
104
  'status.title': string;
101
- 'status.effectiveModel.label': string;
102
- 'status.effectiveModel.unavailable': string;
103
- 'status.effectiveModel.note': string;
104
105
  'status.switches.label': string;
105
106
  'status.switches.empty': string;
106
107
  'status.switches.error': string;
107
108
  'status.switches.compact': string;
109
+ 'status.switches.compact.roleInject': string;
108
110
  'status.switches.reason.trigger-code': string;
109
111
  'status.switches.reason.always-cap': string;
110
- 'status.selectionNote': string;
112
+ 'status.switches.reason.role-inject': string;
111
113
  'general.title': string;
112
114
  'general.enabled': string;
113
115
  'general.disabled': string;
114
116
  'general.unknown': string;
115
117
  'general.unavailable': string;
116
118
  'general.switch': string;
119
+ 'general.switch.roleInject': string;
117
120
  'general.switch.empty': string;
118
121
  'general.error': string;
119
122
  'chat.switch.title': string;
120
123
  'chat.switch.summary': string;
124
+ 'chat.switch.summary.roleInject': string;
125
+ 'chat.switch.roleMap': string;
121
126
  'defaults.prefix': string;
122
127
  save: string;
123
128
  'save.saving': string;
@@ -173,7 +178,11 @@ export declare const en: {
173
178
  'advanced.label': string;
174
179
  'advanced.expand': string;
175
180
  'advanced.collapse': string;
181
+ 'roleAutoMatch.label': string;
182
+ 'roleAutoMatch.hint': string;
183
+ 'roleAutoMatch.tooltip': string;
176
184
  'rootChain.label': string;
185
+ 'rootChain.firstLine': string;
177
186
  'rootChain.hint': string;
178
187
  'rootChain.tooltip': string;
179
188
  'rootChain.selector.add': string;
@@ -234,26 +243,27 @@ export declare const en: {
234
243
  'catalog.outside.short': string;
235
244
  'catalog.unconfigured.short': string;
236
245
  'status.title': string;
237
- 'status.effectiveModel.label': string;
238
- 'status.effectiveModel.unavailable': string;
239
- 'status.effectiveModel.note': string;
240
246
  'status.switches.label': string;
241
247
  'status.switches.empty': string;
242
248
  'status.switches.error': string;
243
249
  'status.switches.compact': string;
250
+ 'status.switches.compact.roleInject': string;
244
251
  'status.switches.reason.trigger-code': string;
245
252
  'status.switches.reason.always-cap': string;
246
- 'status.selectionNote': string;
253
+ 'status.switches.reason.role-inject': string;
247
254
  'general.title': string;
248
255
  'general.enabled': string;
249
256
  'general.disabled': string;
250
257
  'general.unknown': string;
251
258
  'general.unavailable': string;
252
259
  'general.switch': string;
260
+ 'general.switch.roleInject': string;
253
261
  'general.switch.empty': string;
254
262
  'general.error': string;
255
263
  'chat.switch.title': string;
256
264
  'chat.switch.summary': string;
265
+ 'chat.switch.summary.roleInject': string;
266
+ 'chat.switch.roleMap': string;
257
267
  'defaults.prefix': string;
258
268
  save: string;
259
269
  'save.saving': string;
@@ -279,9 +289,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
279
289
  }
280
290
  /**
281
291
  * Reason → locale key map for switch summaries (S-c; shared by the card's
282
- * status block and the General page status row). The session log is durable
283
- * and forward-compatible: a reason value outside the current union (a newer
284
- * plugin wrote it) renders raw instead of falling into a binary else branch.
292
+ * status block, the General page status row, and the conversation node). All
293
+ * reasons resolve from ONE key family (`status.switches.reason.*`) — the
294
+ * shared reason vocabulary must not mix dictionary families (qc1 F-004). The
295
+ * session log is durable and forward-compatible: a reason value outside the
296
+ * current union (a newer plugin wrote it) renders raw instead of falling
297
+ * into a binary else branch.
285
298
  */
286
299
  export declare const SWITCH_REASON_KEYS: Readonly<Partial<Record<FallbackSwitchReason, FallbacksKey>>>;
287
300
  /** Human-readable trigger-code labels (spec §4 用户直观性). */
@@ -95,6 +95,8 @@ export interface FallbacksConfigSummary {
95
95
  readonly maxSwitchesPerStep: number;
96
96
  readonly alwaysModeRetryCap: number;
97
97
  readonly presets: 'bundled' | 'none';
98
+ /** Dispatch-time LLM role auto-match switch (default true). */
99
+ readonly roleAutoMatch: boolean;
98
100
  }
99
101
  /**
100
102
  * The session-scoped read-only operations the `/fallbacks` handler drives.
@@ -148,6 +150,7 @@ export declare const FALLBACKS_COMMAND_LOCALES: {
148
150
  readonly configMaxSwitches: "单步最大切换";
149
151
  readonly configAlwaysCap: "always 上限";
150
152
  readonly configPresets: "预置";
153
+ readonly configRoleAutoMatch: "角色自动匹配";
151
154
  readonly configEdit: "编辑:~/.dsh/profiles/<profile>/cordis.patch.yml(插件行)或 $DSH_HOME/settings.yaml(fallbacks: 分节)";
152
155
  readonly configEditHint: "TUI 无法修改配置——只能编辑文件";
153
156
  };
@@ -185,6 +188,7 @@ export declare const FALLBACKS_COMMAND_LOCALES: {
185
188
  readonly configMaxSwitches: "Max switches/step";
186
189
  readonly configAlwaysCap: "Always-mode cap";
187
190
  readonly configPresets: "Presets";
191
+ readonly configRoleAutoMatch: "Auto-match";
188
192
  readonly configEdit: "Edit: ~/.dsh/profiles/<profile>/cordis.patch.yml (plugin row) or $DSH_HOME/settings.yaml (fallbacks: section)";
189
193
  readonly configEditHint: "TUI cannot change config — edit files only";
190
194
  };
package/dist/config.d.ts CHANGED
@@ -91,6 +91,19 @@ export interface FallbacksConfig {
91
91
  * every resolved config carries a value via the schema default.
92
92
  */
93
93
  presets?: 'bundled' | 'none';
94
+ /**
95
+ * Dispatch-time LLM role auto-match switch (plan fallbacks-role-automatch
96
+ * Task 1): when `true` (default), a subagent-origin request with no
97
+ * explicit/rules-resolved role may have the best-fit declared role picked
98
+ * by the LLM; `false` reproduces today's behavior exactly. Optional on
99
+ * purpose, mirroring `presets` — a required field would break library
100
+ * consumers that construct `FallbacksConfig` literals with the existing
101
+ * keys (additive, non-breaking). The value domain is guarded by the
102
+ * schema (`z.boolean().default(true)` in `src/schema.ts`), and the
103
+ * runtime reads it defensively as `config.roleAutoMatch ?? true` (safe
104
+ * for direct constructors that omit it).
105
+ */
106
+ roleAutoMatch?: boolean;
94
107
  }
95
108
  /**
96
109
  * Spec §4 defaults — `Config({})` must equal this (no-op install).