@fonlan/dsh-model-router 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -11,7 +11,10 @@ window.__ModuleLoader__.load({
11
11
  const LOCALE_NS = "model-router";
12
12
  const zh = {
13
13
  settingsTitle: "模型路由",
14
- settingsDescription: "每个模型按 model id 聚合所有提供商;点击提供商切换该模型当前使用的路由,拖拽调整优先级顺序(供后续自动切换使用)。",
14
+ settingsCardDescription: "模型聚合、提供商切换与优先级顺序",
15
+ expand: "展开",
16
+ collapse: "收起",
17
+ readOnly: "本部署的设置为只读。",
15
18
  loading: "加载中…",
16
19
  loadFailed: "加载失败:{message}",
17
20
  retry: "重试",
@@ -24,22 +27,47 @@ window.__ModuleLoader__.load({
24
27
  switchFailed: "切换失败:{message}",
25
28
  reorderFailed: "排序失败:{message}",
26
29
  providerCount: "{count} 个提供商",
27
- routeButtonLabel: "快速切换模型路由",
28
- routeButtonTitle: "切换当前模型的提供商",
29
- routeButtonAria: "切换当前模型提供商,当前为 {provider}",
30
+ seatLabel: "模型选择(含提供商切换)",
31
+ menuAria: "模型、提供商与推理等级",
32
+ menuModel: "模型",
33
+ menuProvider: "提供商",
34
+ menuEffort: "推理等级",
35
+ triggerFallback: "选择模型",
36
+ triggerSelectAria: "选择模型",
37
+ triggerAria: "选择模型,当前 {model}",
38
+ triggerAriaEffort: "选择模型,当前 {model},推理等级 {effort}",
39
+ effortProviderDefault: "Default",
40
+ statusLoading: "正在刷新模型列表…",
41
+ errorAction: "模型操作失败:{message}",
42
+ actionReload: "重新加载",
43
+ warningGroupLoad: "{name} 加载失败:{message}",
44
+ emptyModels: "没有可用的模型。",
45
+ emptyEfforts: "当前模型未提供推理等级。",
30
46
  routeMenuTitle: "{model} · 选择提供商",
31
47
  routeLoading: "加载路由…",
32
- unknownProvider: "未知提供商",
33
48
  noCredentialShort: "未配置凭据",
34
49
  globalRouteHint: "切换后对所有会话的后续请求生效",
35
- showQuickSwitchLabel: "在输入栏显示快速路由切换按钮",
36
- showQuickSwitchDescription: "开启后,当当前模型是 model-router 路由时,在输入框模型选择器右侧显示 provider 快速切换按钮。",
50
+ showQuickSwitchLabel: "在模型选择菜单中显示提供商切换",
51
+ showQuickSwitchDescription: "开启后,当当前模型是 model-router 路由时,模型选择弹出菜单会显示提供商列表入口,可直接切换该模型的激活提供商。",
37
52
  ignorePrefixLabel: "匹配时忽略模型 ID 前缀",
38
- ignorePrefixDescription: "开启后,deepseek/deepseek-v4-flash 与 deepseek-v4-flash 视为同一模型,统一显示不带前缀的模型 id(默认开启)。"
53
+ ignorePrefixDescription: "开启后,deepseek/deepseek-v4-flash 与 deepseek-v4-flash 视为同一模型,统一显示不带前缀的模型 id(默认开启)。",
54
+ sortTitle: "模型显示顺序",
55
+ sortDescription: "模型选择列表中 model-router 分组内模型的排列方式。",
56
+ sortModeCustom: "自定义",
57
+ sortModeName: "按名称",
58
+ sortModeRecent: "最近使用",
59
+ sortModeDescCustom: "按下方拖拽的手动顺序排列",
60
+ sortModeDescName: "按模型名称排序(数字感知)",
61
+ sortModeDescRecent: "最近用过的模型排在最前",
62
+ sortCustomHint: "拖拽调整模型在模型选择列表中的显示顺序",
63
+ sortFailed: "排序设置失败:{message}"
39
64
  };
40
65
  const en = {
41
66
  settingsTitle: "Model Router",
42
- settingsDescription: "Each model aggregates its providers by exact model id. Click a provider to switch where this model currently routes; drag to adjust the preference order (used by automatic failover later).",
67
+ settingsCardDescription: "Model aggregation, provider switching and preference order",
68
+ expand: "Expand",
69
+ collapse: "Collapse",
70
+ readOnly: "This deployment stores settings read-only.",
43
71
  loading: "Loading…",
44
72
  loadFailed: "Load failed: {message}",
45
73
  retry: "Retry",
@@ -52,18 +80,40 @@ window.__ModuleLoader__.load({
52
80
  switchFailed: "Switch failed: {message}",
53
81
  reorderFailed: "Reorder failed: {message}",
54
82
  providerCount: "{count} providers",
55
- routeButtonLabel: "Quick model route switcher",
56
- routeButtonTitle: "Switch the current model provider",
57
- routeButtonAria: "Switch current model provider, currently {provider}",
83
+ seatLabel: "Model selection (with provider switching)",
84
+ menuAria: "Model, provider and reasoning effort",
85
+ menuModel: "Model",
86
+ menuProvider: "Provider",
87
+ menuEffort: "Effort",
88
+ triggerFallback: "Select model",
89
+ triggerSelectAria: "Select model",
90
+ triggerAria: "Select model, current {model}",
91
+ triggerAriaEffort: "Select model, current {model}, reasoning effort {effort}",
92
+ effortProviderDefault: "Default",
93
+ statusLoading: "Refreshing model list…",
94
+ errorAction: "Model operation failed: {message}",
95
+ actionReload: "Reload",
96
+ warningGroupLoad: "{name} failed to load: {message}",
97
+ emptyModels: "No models available.",
98
+ emptyEfforts: "This model provides no reasoning effort levels.",
58
99
  routeMenuTitle: "{model} · Choose provider",
59
100
  routeLoading: "Loading routes…",
60
- unknownProvider: "Unknown provider",
61
101
  noCredentialShort: "No credentials",
62
102
  globalRouteHint: "Changes apply to subsequent requests in all sessions",
63
- showQuickSwitchLabel: "Show quick route switcher in the composer",
64
- showQuickSwitchDescription: "When enabled, a provider quick-switch button appears next to the model selector when the current model is a model-router route.",
103
+ showQuickSwitchLabel: "Show provider switching in the model picker",
104
+ showQuickSwitchDescription: "When enabled, the model picker menu shows a provider list entry that switches the active provider, for model-router routes.",
65
105
  ignorePrefixLabel: "Ignore model ID prefix when matching",
66
- ignorePrefixDescription: "When enabled, deepseek/deepseek-v4-flash and deepseek-v4-flash are treated as the same model and shown without the prefix (on by default)."
106
+ ignorePrefixDescription: "When enabled, deepseek/deepseek-v4-flash and deepseek-v4-flash are treated as the same model and shown without the prefix (on by default).",
107
+ sortTitle: "Model display order",
108
+ sortDescription: "How models inside the model-router group are ordered in the model picker.",
109
+ sortModeCustom: "Custom",
110
+ sortModeName: "By name",
111
+ sortModeRecent: "Recently used",
112
+ sortModeDescCustom: "Manual order via drag below",
113
+ sortModeDescName: "Sort by model name (numeric-aware)",
114
+ sortModeDescRecent: "Most recently used models first",
115
+ sortCustomHint: "Drag to reorder models in the model picker",
116
+ sortFailed: "Sort setting failed: {message}"
67
117
  };
68
118
  //#endregion
69
119
  //#region src/client/api.ts
@@ -101,12 +151,14 @@ window.__ModuleLoader__.load({
101
151
  }),
102
152
  refresh: () => call("refresh"),
103
153
  setShowQuickSwitch: (value) => call("set-show-quick-switch", { value }),
104
- setIgnoreModelIdPrefix: (value) => call("set-ignore-model-id-prefix", { value })
154
+ setIgnoreModelIdPrefix: (value) => call("set-ignore-model-id-prefix", { value }),
155
+ setModelSort: (mode) => call("set-model-sort", { mode }),
156
+ setModelOrder: (order) => call("set-model-order", { order })
105
157
  };
106
158
  //#endregion
107
- //#region \0dsh-css:/Users/fonlan/Repos/dsh-model-router/src/client/settings-section.css.mjs
108
- const css$1 = "/* Model Router settings page — theme-consistent via DSW alias variables. */\n.mr-root {\n display: flex;\n flex-direction: column;\n gap: 12px;\n padding: 4px 0 24px;\n}\n\n.mr-description {\n margin: 0;\n color: var(--dsw-alias-label-secondary);\n font-size: 13px;\n line-height: 20px;\n}\n\n.mr-quick-switch {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n padding: 8px 10px;\n border: 1px solid var(--dsw-alias-border-inverted);\n border-radius: 10px;\n background: var(--dsw-alias-bg-module-platform);\n cursor: pointer;\n}\n\n.mr-quick-switch input[type=\"checkbox\"] {\n margin-top: 2px;\n accent-color: var(--dsw-alias-accent);\n}\n\n.mr-quick-switch-copy {\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n}\n\n.mr-quick-switch-label {\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n line-height: 20px;\n}\n\n.mr-quick-switch-desc {\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n}\n\n.mr-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.mr-count {\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n}\n\n.mr-button {\n color: var(--dsw-alias-label-primary);\n cursor: pointer;\n background: var(--dsw-alias-interactive-bg-hover);\n border: 1px solid var(--dsw-alias-border-l3);\n border-radius: 8px;\n font-size: 12px;\n line-height: 18px;\n padding: 3px 10px;\n}\n\n.mr-button:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n filter: brightness(1.08);\n}\n\n.mr-button:disabled {\n opacity: 0.5;\n cursor: default;\n}\n\n.mr-error {\n background: var(--dsw-alias-interactive-bg-hover-danger);\n color: var(--dsw-alias-state-error-primary);\n border-radius: 8px;\n padding: 7px 8px;\n font-size: 12px;\n line-height: 18px;\n}\n\n.mr-empty {\n color: var(--dsw-alias-label-tertiary);\n padding: 16px 0;\n font-size: 13px;\n line-height: 20px;\n}\n\n.mr-models {\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.mr-card {\n border: 1px solid var(--dsw-alias-border-inverted);\n border-radius: 12px;\n padding: 10px 12px;\n background: var(--dsw-alias-bg-module-platform);\n transition: opacity 0.12s;\n}\n\n.mr-card-busy {\n opacity: 0.6;\n pointer-events: none;\n}\n\n.mr-card-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n}\n\n.mr-model-name {\n color: var(--dsw-alias-label-primary);\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n\n.mr-model-id {\n color: var(--dsw-alias-label-tertiary);\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n line-height: 18px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n min-width: 0;\n flex: 1;\n}\n\n.mr-provider-count {\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n flex: none;\n}\n\n.mr-drag-hint {\n margin: 2px 0 6px;\n color: var(--dsw-alias-label-caption);\n font-size: 11px;\n line-height: 16px;\n}\n\n.mr-providers {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.mr-provider {\n display: flex;\n align-items: center;\n gap: 6px;\n border: 1px solid transparent;\n border-radius: 10px;\n padding: 2px 8px 2px 4px;\n}\n\n.mr-provider-active {\n background: var(--dsw-alias-interactive-bg-hover);\n border-color: var(--dsw-alias-border-l3);\n}\n\n.mr-provider-nocred {\n opacity: 0.55;\n}\n\n.mr-provider-drop {\n border-color: var(--dsw-alias-border-brand);\n}\n\n.mr-grip {\n color: var(--dsw-alias-label-caption);\n cursor: grab;\n font-size: 12px;\n line-height: 20px;\n flex: none;\n user-select: none;\n}\n\n.mr-grip:active {\n cursor: grabbing;\n}\n\n.mr-provider-button {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: baseline;\n gap: 6px;\n color: var(--dsw-alias-label-primary);\n background: none;\n border: none;\n text-align: left;\n cursor: pointer;\n font: inherit;\n padding: 4px 0;\n}\n\n.mr-provider-button:disabled {\n cursor: default;\n}\n\n.mr-provider-name {\n font-size: 13px;\n line-height: 20px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n}\n\n.mr-provider-nocred-label {\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n flex: none;\n}\n\n.mr-active-badge {\n flex: none;\n color: var(--dsw-alias-label-inverted, #fff);\n background: var(--dsw-alias-border-brand, #4d6bfe);\n border-radius: 999px;\n font-size: 11px;\n line-height: 16px;\n padding: 0 8px;\n}\n";
109
- const tagId$1 = "@fonlan/dsh-model-router/settings-section.css";
159
+ //#region \0dsh-css:/home/runner/work/dsh-model-router/dsh-model-router/src/client/settings-card.css.mjs
160
+ const css$1 = "/* Model Router settings card — chrome aligned with the built-in plugin cards\n (设置 → 插件 → 插件配置), theme-consistent via DSW alias variables. */\n.mr-settings-card {\n border: 1px solid var(--dsw-alias-border-l2, #2a3346);\n background: var(--dsw-alias-bg-layer-3, #0d1017);\n border-radius: 12px;\n list-style: none;\n transition: border-color .16s, background .16s;\n}\n\n.mr-settings-card:hover {\n border-color: var(--dsw-alias-label-dimmed, #4a5468);\n}\n\n.mr-settings-card[data-open] {\n background: var(--dsw-alias-bg-layer-2, #141a26);\n border-color: var(--dsw-alias-label-dimmed, #4a5468);\n}\n\n.mr-settings-head {\n appearance: none;\n width: 100%;\n font: inherit;\n color: inherit;\n text-align: left;\n cursor: pointer;\n background: transparent;\n border: 0;\n border-radius: 12px;\n align-items: center;\n gap: 12px;\n padding: 14px 16px;\n display: flex;\n}\n\n.mr-settings-head:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary, #4f8cff);\n outline-offset: -2px;\n}\n\n.mr-settings-head-text {\n flex-direction: column;\n flex: 1;\n gap: 4px;\n min-width: 0;\n display: flex;\n}\n\n.mr-settings-title {\n color: var(--dsw-alias-label-primary, #e6e6e6);\n font-size: 15px;\n font-weight: 600;\n line-height: 1.4;\n}\n\n.mr-settings-sub {\n color: var(--dsw-alias-label-tertiary, #8b93a7);\n font-size: 13px;\n line-height: 1.5;\n}\n\n.mr-settings-chevron {\n color: var(--dsw-alias-label-tertiary, #8b93a7);\n flex: none;\n transition: transform .16s;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.mr-settings-chevron[data-open] {\n transform: rotate(180deg);\n}\n\n.mr-settings-body {\n border-top: 1px solid var(--dsw-alias-border-l2, #2a3346);\n margin: 0 16px;\n padding: 12px 0 8px;\n display: flex;\n flex-direction: column;\n gap: 12px;\n}\n\n.mr-settings-readonly {\n color: var(--dsw-alias-label-tertiary, #8b93a7);\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n}\n\n/* ── card body: the router controls ─────────────────────────────────────── */\n\n.mr-root {\n display: flex;\n flex-direction: column;\n gap: 12px;\n padding: 0 0 4px;\n}\n\n.mr-quick-switch {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n padding: 8px 10px;\n border: 1px solid var(--dsw-alias-border-inverted);\n border-radius: 10px;\n background: var(--dsw-alias-bg-module-platform);\n cursor: pointer;\n}\n\n.mr-quick-switch input[type=\"checkbox\"] {\n margin-top: 2px;\n accent-color: var(--dsw-alias-accent);\n}\n\n.mr-quick-switch-copy {\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n}\n\n.mr-quick-switch-label {\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n line-height: 20px;\n}\n\n.mr-quick-switch-desc {\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n}\n\n.mr-toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.mr-count {\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n}\n\n.mr-button {\n color: var(--dsw-alias-label-primary);\n cursor: pointer;\n background: var(--dsw-alias-interactive-bg-hover);\n border: 1px solid var(--dsw-alias-border-l3);\n border-radius: 8px;\n font-size: 12px;\n line-height: 18px;\n padding: 3px 10px;\n}\n\n.mr-button:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n filter: brightness(1.08);\n}\n\n.mr-button:disabled {\n opacity: 0.5;\n cursor: default;\n}\n\n.mr-error {\n background: var(--dsw-alias-interactive-bg-hover-danger);\n color: var(--dsw-alias-state-error-primary);\n border-radius: 8px;\n padding: 7px 8px;\n font-size: 12px;\n line-height: 18px;\n}\n\n.mr-empty {\n color: var(--dsw-alias-label-tertiary);\n padding: 16px 0;\n font-size: 13px;\n line-height: 20px;\n}\n\n.mr-models {\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.mr-card {\n border: 1px solid var(--dsw-alias-border-inverted);\n border-radius: 12px;\n padding: 10px 12px;\n background: var(--dsw-alias-bg-module-platform);\n transition: opacity 0.12s;\n}\n\n.mr-card-busy {\n opacity: 0.6;\n pointer-events: none;\n}\n\n.mr-card-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n}\n\n.mr-model-name {\n color: var(--dsw-alias-label-primary);\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n\n.mr-model-id {\n color: var(--dsw-alias-label-tertiary);\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n line-height: 18px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n min-width: 0;\n flex: 1;\n}\n\n.mr-provider-count {\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n flex: none;\n}\n\n.mr-drag-hint {\n margin: 2px 0 6px;\n color: var(--dsw-alias-label-caption);\n font-size: 11px;\n line-height: 16px;\n}\n\n.mr-providers {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.mr-provider {\n display: flex;\n align-items: center;\n gap: 6px;\n border: 1px solid transparent;\n border-radius: 10px;\n padding: 2px 8px 2px 4px;\n}\n\n.mr-provider-active {\n background: var(--dsw-alias-interactive-bg-hover);\n border-color: var(--dsw-alias-border-l3);\n}\n\n.mr-provider-nocred {\n opacity: 0.55;\n}\n\n.mr-provider-drop {\n border-color: var(--dsw-alias-border-brand);\n}\n\n.mr-grip {\n color: var(--dsw-alias-label-caption);\n cursor: grab;\n font-size: 12px;\n line-height: 20px;\n flex: none;\n user-select: none;\n}\n\n.mr-grip:active {\n cursor: grabbing;\n}\n\n.mr-provider-button {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: baseline;\n gap: 6px;\n color: var(--dsw-alias-label-primary);\n background: none;\n border: none;\n text-align: left;\n cursor: pointer;\n font: inherit;\n padding: 4px 0;\n}\n\n.mr-provider-button:disabled {\n cursor: default;\n}\n\n.mr-provider-name {\n font-size: 13px;\n line-height: 20px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n}\n\n.mr-provider-nocred-label {\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n flex: none;\n}\n\n.mr-active-badge {\n flex: none;\n color: var(--dsw-alias-label-inverted, #fff);\n background: var(--dsw-alias-border-brand, #4d6bfe);\n border-radius: 999px;\n font-size: 11px;\n line-height: 16px;\n padding: 0 8px;\n}\n\n/* ── model display order ────────────────────────────────────────────────── */\n\n.mr-sort {\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding: 8px 10px;\n border: 1px solid var(--dsw-alias-border-inverted);\n border-radius: 10px;\n background: var(--dsw-alias-bg-module-platform);\n}\n\n.mr-sort-head {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.mr-sort-title {\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n line-height: 20px;\n}\n\n.mr-sort-desc {\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n}\n\n.mr-sort-modes {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.mr-sort-mode {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n border: 1px solid transparent;\n border-radius: 8px;\n padding: 5px 6px;\n cursor: pointer;\n}\n\n.mr-sort-mode:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.mr-sort-mode input[type=\"radio\"] {\n margin-top: 2px;\n accent-color: var(--dsw-alias-accent);\n}\n\n.mr-sort-mode-copy {\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 0;\n}\n\n.mr-sort-mode-label {\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n line-height: 20px;\n}\n\n.mr-sort-mode-desc {\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n}\n\n.mr-sort-custom {\n display: flex;\n flex-direction: column;\n gap: 6px;\n border-top: 1px solid var(--dsw-alias-border-l2, #2a3346);\n padding-top: 8px;\n}\n\n.mr-sort-hint {\n color: var(--dsw-alias-label-caption);\n margin: 0;\n font-size: 11px;\n line-height: 16px;\n}\n\n.mr-sort-list {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n max-height: 280px;\n overflow-y: auto;\n}\n\n.mr-sort-item {\n display: flex;\n align-items: center;\n gap: 6px;\n border: 1px solid var(--dsw-alias-border-l2, #2a3346);\n border-radius: 8px;\n padding: 4px 8px 4px 4px;\n background: var(--dsw-alias-bg-layer-1, #0d1017);\n}\n\n.mr-sort-item-drop {\n border-color: var(--dsw-alias-border-brand);\n}\n\n.mr-sort-item-busy {\n opacity: 0.6;\n pointer-events: none;\n}\n\n.mr-sort-model-name {\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n line-height: 20px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n min-width: 0;\n}\n\n.mr-sort-model-id {\n color: var(--dsw-alias-label-tertiary);\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 11px;\n line-height: 16px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n flex: 1;\n text-align: right;\n}\n";
161
+ const tagId$1 = "@fonlan/dsh-model-router/settings-card.css";
110
162
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
111
163
  const tag = document.createElement("style");
112
164
  tag.dataset.plugin = "@fonlan/dsh-model-router";
@@ -115,13 +167,23 @@ window.__ModuleLoader__.load({
115
167
  document.head.appendChild(tag);
116
168
  }
117
169
  //#endregion
118
- //#region src/client/settings-section.tsx
170
+ //#region src/client/settings-card.tsx
119
171
  /**
120
- * The Model Router settings page: one card per merged model, provider chips
121
- * that switch the active route on click and reorder by native HTML5 drag &
122
- * drop (preference order for future automatic failover). All reads/mutations
123
- * go through the plugin's fenced API; the server persists through the
124
- * settings service, so a switch here is live for the next request globally.
172
+ * The Model Router Settings Card (设置 → 插件 → 插件配置 → 模型路由).
173
+ *
174
+ * Renders its own expandable chrome aligned with the built-in plugin cards
175
+ * (external plugins cannot import PluginCard): a header naming the plugin and
176
+ * what its settings govern, disclosing the router controls in place. All
177
+ * reads/mutations go through the plugin's fenced API; the server persists
178
+ * through the `model-router` settings namespace, so a switch here is live for
179
+ * the next request globally.
180
+ *
181
+ * The bound settings scope (the same `model-router` namespace the host half
182
+ * registers) supplies the card's dispatch state: while the namespace is merely
183
+ * loading the card stays mounted, and when it is unavailable (deployment
184
+ * without the host half) nothing renders — matching the built-in cards. A
185
+ * read-only deployment shows the built-in card's banner and disables the
186
+ * mutation controls.
125
187
  */
126
188
  function arrayMove(list, from, to) {
127
189
  const next = [...list];
@@ -146,7 +208,7 @@ window.__ModuleLoader__.load({
146
208
  }, [ctx]);
147
209
  return (0, react.useSyncExternalStore)(subscribe, getSnapshot);
148
210
  }
149
- function makeSettingsSection(ctx) {
211
+ function makeSettingsCard(ctx) {
150
212
  const t = (() => {
151
213
  try {
152
214
  return ctx.locale.bind(LOCALE_NS);
@@ -154,8 +216,11 @@ window.__ModuleLoader__.load({
154
216
  return (key) => key;
155
217
  }
156
218
  })();
157
- return function ModelRouterSettingsSection() {
219
+ return function ModelRouterSettingsCard(props) {
220
+ const { scope } = props;
221
+ const snapshot = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
158
222
  useLocaleRevision(ctx);
223
+ const [open, setOpen] = (0, react.useState)(false);
159
224
  const [state, setState] = (0, react.useState)(null);
160
225
  const [error, setError] = (0, react.useState)(null);
161
226
  const [loading, setLoading] = (0, react.useState)(true);
@@ -163,6 +228,8 @@ window.__ModuleLoader__.load({
163
228
  const [drag, setDrag] = (0, react.useState)(null);
164
229
  const [quickSwitchBusy, setQuickSwitchBusy] = (0, react.useState)(false);
165
230
  const [ignorePrefixBusy, setIgnorePrefixBusy] = (0, react.useState)(false);
231
+ const [sortBusy, setSortBusy] = (0, react.useState)(false);
232
+ const [modelDrag, setModelDrag] = (0, react.useState)(null);
166
233
  const load = (0, react.useCallback)(async () => {
167
234
  setLoading(true);
168
235
  try {
@@ -258,101 +325,285 @@ window.__ModuleLoader__.load({
258
325
  setIgnorePrefixBusy(false);
259
326
  }
260
327
  }, [state]);
328
+ const changeModelSort = (0, react.useCallback)(async (mode) => {
329
+ if (state === null || state.modelSort === mode) return;
330
+ const previous = state.modelSort;
331
+ setState((prev) => prev === null ? prev : {
332
+ ...prev,
333
+ modelSort: mode
334
+ });
335
+ setSortBusy(true);
336
+ try {
337
+ setState(await api.setModelSort(mode));
338
+ setError(null);
339
+ } catch (cause) {
340
+ setState((prev) => prev === null ? prev : {
341
+ ...prev,
342
+ modelSort: previous
343
+ });
344
+ setError(t("sortFailed", { message: cause instanceof Error ? cause.message : String(cause) }));
345
+ } finally {
346
+ setSortBusy(false);
347
+ }
348
+ }, [state, t]);
349
+ const moveModel = (0, react.useCallback)(async (from, to) => {
350
+ if (state === null || from === to) return;
351
+ const order = arrayMove(state.models.map((model) => model.id), from, to);
352
+ setState((prev) => prev === null ? prev : {
353
+ ...prev,
354
+ modelOrder: order
355
+ });
356
+ setSortBusy(true);
357
+ try {
358
+ setState(await api.setModelOrder(order));
359
+ setError(null);
360
+ } catch (cause) {
361
+ setState((prev) => prev === null ? prev : {
362
+ ...prev,
363
+ modelOrder: state.modelOrder
364
+ });
365
+ setError(t("sortFailed", { message: cause instanceof Error ? cause.message : String(cause) }));
366
+ } finally {
367
+ setSortBusy(false);
368
+ }
369
+ }, [state, t]);
370
+ if (snapshot.status === "unavailable") return null;
371
+ const writable = snapshot.writable;
261
372
  const models = (0, react.useMemo)(() => state?.models ?? [], [state]);
262
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
263
- className: "mr-root",
264
- children: [
265
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
266
- className: "mr-description",
267
- children: t("settingsDescription")
268
- }),
269
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
270
- className: "mr-quick-switch",
271
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
272
- type: "checkbox",
273
- checked: state?.showQuickSwitch ?? true,
274
- disabled: loading || quickSwitchBusy,
275
- onChange: (event) => void toggleQuickSwitch(event.target.checked)
276
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
277
- className: "mr-quick-switch-copy",
278
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
279
- className: "mr-quick-switch-label",
280
- children: t("showQuickSwitchLabel")
281
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
282
- className: "mr-quick-switch-desc",
283
- children: t("showQuickSwitchDescription")
284
- })]
285
- })]
286
- }),
287
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
288
- className: "mr-quick-switch",
289
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
290
- type: "checkbox",
291
- checked: state?.ignoreModelIdPrefix ?? true,
292
- disabled: loading || ignorePrefixBusy,
293
- onChange: (event) => void toggleIgnorePrefix(event.target.checked)
294
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
295
- className: "mr-quick-switch-copy",
296
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
297
- className: "mr-quick-switch-label",
298
- children: t("ignorePrefixLabel")
299
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
300
- className: "mr-quick-switch-desc",
301
- children: t("ignorePrefixDescription")
302
- })]
303
- })]
304
- }),
305
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
306
- className: "mr-toolbar",
373
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
374
+ className: "mr-settings-card",
375
+ "data-open": open ? "" : void 0,
376
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
377
+ type: "button",
378
+ className: "mr-settings-head",
379
+ "aria-expanded": open,
380
+ "aria-label": (open ? t("collapse") : t("expand")) + ":" + t("settingsTitle"),
381
+ onClick: () => setOpen(!open),
382
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
383
+ className: "mr-settings-head-text",
307
384
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
308
- className: "mr-count",
309
- children: t("modelsCount", { count: models.length })
310
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
311
- type: "button",
312
- className: "mr-button",
313
- onClick: () => void load(),
314
- disabled: loading,
315
- children: t("refresh")
385
+ className: "mr-settings-title",
386
+ children: t("settingsTitle")
387
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
388
+ className: "mr-settings-sub",
389
+ children: t("settingsCardDescription")
316
390
  })]
317
- }),
318
- error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
319
- className: "mr-error",
320
- role: "alert",
321
- children: error
322
- }),
323
- loading && models.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
324
- className: "mr-empty",
325
- children: t("loading")
326
- }),
327
- !loading && models.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
328
- className: "mr-empty",
329
- children: t("empty")
330
- }),
331
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
332
- className: "mr-models",
333
- children: models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelCard, {
334
- model,
335
- t,
336
- busy: busyModel === model.id,
337
- dragging: drag,
338
- onDragStart: (index) => setDrag({
339
- modelId: model.id,
340
- index
391
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
392
+ className: "mr-settings-chevron",
393
+ "data-open": open ? "" : void 0,
394
+ "aria-hidden": "true",
395
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
396
+ width: "14",
397
+ height: "14",
398
+ viewBox: "0 0 14 14",
399
+ fill: "none",
400
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
401
+ d: "M3.5 5.25L7 8.75L10.5 5.25",
402
+ stroke: "currentColor",
403
+ strokeWidth: "1.5",
404
+ strokeLinecap: "round",
405
+ strokeLinejoin: "round"
406
+ })
407
+ })
408
+ })]
409
+ }), open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
410
+ className: "mr-settings-body",
411
+ children: [!writable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
412
+ className: "mr-settings-readonly",
413
+ role: "status",
414
+ children: t("readOnly")
415
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
416
+ className: "mr-root",
417
+ children: [
418
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
419
+ className: "mr-quick-switch",
420
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
421
+ type: "checkbox",
422
+ checked: state?.showQuickSwitch ?? true,
423
+ disabled: loading || quickSwitchBusy || !writable,
424
+ onChange: (event) => void toggleQuickSwitch(event.target.checked)
425
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
426
+ className: "mr-quick-switch-copy",
427
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
428
+ className: "mr-quick-switch-label",
429
+ children: t("showQuickSwitchLabel")
430
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
431
+ className: "mr-quick-switch-desc",
432
+ children: t("showQuickSwitchDescription")
433
+ })]
434
+ })]
341
435
  }),
342
- onDragEnd: () => setDrag(null),
343
- onDrop: (index) => {
344
- if (drag !== null && drag.modelId === model.id) reorder(model, drag.index, index);
345
- setDrag(null);
346
- },
347
- onSwitch: (providerId) => void switchActive(model, providerId)
348
- }, model.id))
349
- })
350
- ]
436
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
437
+ className: "mr-quick-switch",
438
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
439
+ type: "checkbox",
440
+ checked: state?.ignoreModelIdPrefix ?? true,
441
+ disabled: loading || ignorePrefixBusy || !writable,
442
+ onChange: (event) => void toggleIgnorePrefix(event.target.checked)
443
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
444
+ className: "mr-quick-switch-copy",
445
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
446
+ className: "mr-quick-switch-label",
447
+ children: t("ignorePrefixLabel")
448
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
449
+ className: "mr-quick-switch-desc",
450
+ children: t("ignorePrefixDescription")
451
+ })]
452
+ })]
453
+ }),
454
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
455
+ className: "mr-sort",
456
+ children: [
457
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
458
+ className: "mr-sort-head",
459
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
460
+ className: "mr-sort-title",
461
+ children: t("sortTitle")
462
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
463
+ className: "mr-sort-desc",
464
+ children: t("sortDescription")
465
+ })]
466
+ }),
467
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
468
+ className: "mr-sort-modes",
469
+ role: "radiogroup",
470
+ "aria-label": t("sortTitle"),
471
+ children: [
472
+ "custom",
473
+ "name",
474
+ "recent"
475
+ ].map((mode) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
476
+ className: "mr-sort-mode",
477
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
478
+ type: "radio",
479
+ name: "mr-model-sort",
480
+ checked: state?.modelSort === mode,
481
+ disabled: loading || sortBusy || !writable,
482
+ onChange: () => void changeModelSort(mode)
483
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
484
+ className: "mr-sort-mode-copy",
485
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
486
+ className: "mr-sort-mode-label",
487
+ children: t(`sortMode${mode[0].toUpperCase()}${mode.slice(1)}`)
488
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
489
+ className: "mr-sort-mode-desc",
490
+ children: t(`sortModeDesc${mode[0].toUpperCase()}${mode.slice(1)}`)
491
+ })]
492
+ })]
493
+ }, mode))
494
+ }),
495
+ state?.modelSort === "custom" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
496
+ className: "mr-sort-custom",
497
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
498
+ className: "mr-sort-hint",
499
+ children: t("sortCustomHint")
500
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ol", {
501
+ className: "mr-sort-list",
502
+ children: models.map((model, index) => {
503
+ const dropTarget = modelDrag !== null && modelDrag.index !== index;
504
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
505
+ className: [
506
+ "mr-sort-item",
507
+ dropTarget ? "mr-sort-item-drop" : "",
508
+ sortBusy ? "mr-sort-item-busy" : ""
509
+ ].filter(Boolean).join(" "),
510
+ draggable: !sortBusy && writable,
511
+ onDragStart: (event) => {
512
+ setModelDrag({ index });
513
+ event.dataTransfer.effectAllowed = "move";
514
+ try {
515
+ event.dataTransfer.setData("text/plain", model.id);
516
+ } catch {}
517
+ },
518
+ onDragOver: (event) => {
519
+ if (modelDrag !== null && modelDrag.index !== index) {
520
+ event.preventDefault();
521
+ event.dataTransfer.dropEffect = "move";
522
+ }
523
+ },
524
+ onDrop: (event) => {
525
+ if (modelDrag !== null && modelDrag.index !== index) {
526
+ event.preventDefault();
527
+ moveModel(modelDrag.index, index);
528
+ }
529
+ setModelDrag(null);
530
+ },
531
+ onDragEnd: () => setModelDrag(null),
532
+ children: [
533
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
534
+ className: "mr-grip",
535
+ "aria-hidden": "true",
536
+ children: "⠿"
537
+ }),
538
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
539
+ className: "mr-sort-model-name",
540
+ children: model.name
541
+ }),
542
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
543
+ className: "mr-sort-model-id",
544
+ children: model.id
545
+ })
546
+ ]
547
+ }, model.id);
548
+ })
549
+ })]
550
+ })
551
+ ]
552
+ }),
553
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
554
+ className: "mr-toolbar",
555
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
556
+ className: "mr-count",
557
+ children: t("modelsCount", { count: models.length })
558
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
559
+ type: "button",
560
+ className: "mr-button",
561
+ onClick: () => void load(),
562
+ disabled: loading,
563
+ children: t("refresh")
564
+ })]
565
+ }),
566
+ error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
567
+ className: "mr-error",
568
+ role: "alert",
569
+ children: error
570
+ }),
571
+ loading && models.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
572
+ className: "mr-empty",
573
+ children: t("loading")
574
+ }),
575
+ !loading && models.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
576
+ className: "mr-empty",
577
+ children: t("empty")
578
+ }),
579
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
580
+ className: "mr-models",
581
+ children: models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelCard, {
582
+ model,
583
+ t,
584
+ readOnly: !writable,
585
+ busy: busyModel === model.id,
586
+ dragging: drag,
587
+ onDragStart: (index) => setDrag({
588
+ modelId: model.id,
589
+ index
590
+ }),
591
+ onDragEnd: () => setDrag(null),
592
+ onDrop: (index) => {
593
+ if (drag !== null && drag.modelId === model.id) reorder(model, drag.index, index);
594
+ setDrag(null);
595
+ },
596
+ onSwitch: (providerId) => void switchActive(model, providerId)
597
+ }, model.id))
598
+ })
599
+ ]
600
+ })]
601
+ })]
351
602
  });
352
603
  };
353
604
  }
354
605
  function ModelCard(props) {
355
- const { model, t, busy, dragging } = props;
606
+ const { model, t, readOnly, busy, dragging } = props;
356
607
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
357
608
  className: `mr-card${busy ? " mr-card-busy" : ""}`,
358
609
  children: [
@@ -390,7 +641,7 @@ window.__ModuleLoader__.load({
390
641
  !selectable ? "mr-provider-nocred" : "",
391
642
  dropTarget ? "mr-provider-drop" : ""
392
643
  ].filter(Boolean).join(" "),
393
- draggable: !busy && model.providers.length > 1,
644
+ draggable: !busy && !readOnly && model.providers.length > 1,
394
645
  title: selectable ? void 0 : t("noCredential"),
395
646
  onDragStart: (event) => {
396
647
  props.onDragStart(index);
@@ -421,7 +672,7 @@ window.__ModuleLoader__.load({
421
672
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
422
673
  type: "button",
423
674
  className: "mr-provider-button",
424
- disabled: !selectable || busy || active,
675
+ disabled: !selectable || busy || active || readOnly,
425
676
  onClick: () => props.onSwitch(provider.id),
426
677
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
427
678
  className: "mr-provider-name",
@@ -443,9 +694,9 @@ window.__ModuleLoader__.load({
443
694
  });
444
695
  }
445
696
  //#endregion
446
- //#region \0dsh-css:/Users/fonlan/Repos/dsh-model-router/src/client/route-switcher.css.mjs
447
- const css = "/* Quick provider route control beside DSH's composer model seat. */\n.mr-route-anchor {\n /* Menu's wrapper span: never let the row crush the trigger. */\n flex: none;\n min-width: 0;\n}\n\n.mr-route-trigger {\n min-width: 0;\n max-width: 180px;\n height: 28px;\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 6px;\n border: none;\n border-radius: 24px;\n color: var(--dsw-alias-label-secondary);\n background: transparent;\n cursor: pointer;\n font: inherit;\n font-size: 12px;\n font-weight: 500;\n line-height: 20px;\n outline: none;\n}\n\n.mr-route-trigger:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.mr-route-trigger:focus-visible {\n box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);\n}\n\n.mr-route-trigger:disabled {\n color: var(--dsw-alias-label-dimmed);\n cursor: default;\n opacity: 0.7;\n}\n\n.mr-route-trigger-label {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.mr-route-trigger-chevron {\n color: var(--dsw-alias-label-caption);\n flex: none;\n transition: transform 0.12s;\n}\n\n.mr-route-trigger[aria-expanded=\"true\"] .mr-route-trigger-chevron {\n transform: rotate(180deg);\n}\n\n.mr-route-item-copy {\n min-width: 0;\n display: flex;\n align-items: baseline;\n gap: 6px;\n}\n\n.mr-route-item-name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.mr-route-item-note {\n flex: none;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11px;\n line-height: 16px;\n}\n\n/*\n * Mobile portrait: the composer row is an inline-size container. Keep the\n * fixed control row intact — attachment, permission, provider (icon-only),\n * context meter, send — and let the model seat absorb the remaining width.\n * The breakpoint matches DSH's own narrow-composer rule (<=460px).\n */\n@container (width <= 460px) {\n .mr-route-anchor {\n flex: 0 0 auto;\n }\n\n .mr-route-trigger {\n /* The trigger now carries globe + chevron, so let it size naturally\n instead of forcing a 28px circle that could overflow the row. */\n height: 28px;\n padding: 4px 6px;\n justify-content: center;\n border-radius: 999px;\n }\n\n /*\n * Make the composer's trailing group (the parent of our [data-slot] right\n * wrapper) shrinkable on narrow rows so the globe/chevron trigger never\n * spills left over the permission control. The model seat inside it\n * absorbs the compression (flex 1 1 0, min-width 0 below).\n */\n div:has(> [data-slot=\"conversation.input.right\"]) {\n flex: 1 1 auto;\n min-width: 0;\n justify-content: flex-end;\n /* Mirror the trailing group's internal gap (12px) so the provider\n trigger has equal spacing to the permission button on its left and\n the model seat on its right. */\n margin-left: 12px;\n }\n\n .mr-route-trigger-label {\n display: none;\n }\n\n /*\n * The model seat: each slot is wrapped in a display:contents\n * [data-slot] div, so the ModelSelect root div (the real flex item of the\n * composer row) is [data-slot=\"conversation.input.model\"] > *. Give it\n * the remaining row width; the other controls keep fixed sizes.\n */\n [data-slot=\"conversation.input.right\"] + [data-slot=\"conversation.input.model\"] > * {\n flex: 1 1 0;\n min-width: 0;\n max-width: 100%;\n }\n\n [data-slot=\"conversation.input.right\"] + [data-slot=\"conversation.input.model\"] [aria-haspopup=\"menu\"] {\n width: 100%;\n max-width: 100%;\n }\n}\n";
448
- const tagId = "@fonlan/dsh-model-router/route-switcher.css";
697
+ //#region \0dsh-css:/home/runner/work/dsh-model-router/dsh-model-router/src/client/model-seat.css.mjs
698
+ const css = "/*\n * Composer model seat (shadows the host ModelSelect). Styles mirror the host\n * ModelSelect.module.css exactly (same tokens, sizes and spacing) so the\n * replacement is visually indistinguishable, plus the provider pane's title\n * and hint rows.\n */\n\n.mr-seat-root {\n min-width: 0;\n position: relative;\n}\n\n.mr-seat-trigger {\n min-width: 0;\n max-width: 220px;\n height: 28px;\n color: var(--dsw-alias-label-secondary);\n cursor: pointer;\n background: transparent;\n border: none;\n border-radius: 24px;\n outline: none;\n align-items: center;\n gap: 4px;\n padding: 0 4px 0 8px;\n font-size: 13px;\n font-weight: 500;\n line-height: 20px;\n display: flex;\n}\n\n.mr-seat-trigger:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.mr-seat-trigger:focus-visible {\n box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);\n}\n\n.mr-seat-trigger:disabled {\n color: var(--dsw-alias-label-dimmed);\n cursor: default;\n}\n\n.mr-seat-triggerLabel {\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n overflow: hidden;\n}\n\n.mr-seat-triggerEffort {\n color: var(--dsw-alias-label-caption);\n flex: none;\n}\n\n.mr-seat-chevron {\n color: var(--dsw-alias-label-caption);\n flex: none;\n transition: transform 0.12s;\n}\n\n.mr-seat-chevronOpen {\n transform: rotate(180deg);\n}\n\n.mr-seat-menu {\n z-index: 20;\n border: 1px solid var(--dsw-alias-border-inverted);\n background: var(--dsw-specific-menu);\n width: min(240px, 100vw - 32px);\n max-height: min(360px, 100vh - 96px);\n box-shadow: var(--dsw-shadow-lv3);\n color: var(--dsw-alias-label-primary);\n --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);\n --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);\n border-radius: 12px;\n flex-direction: column;\n padding: 4px;\n display: flex;\n position: absolute;\n bottom: calc(100% + 8px);\n right: 0;\n overflow: hidden;\n}\n\n.mr-seat-status,\n.mr-seat-empty {\n color: var(--dsw-alias-label-tertiary);\n padding: 10px;\n font-size: 13px;\n line-height: 20px;\n}\n\n.mr-seat-error,\n.mr-seat-warning {\n background: var(--dsw-alias-interactive-bg-hover-danger);\n color: var(--dsw-alias-state-error-primary);\n border-radius: 8px;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px;\n margin-bottom: 4px;\n padding: 7px 8px;\n font-size: 12px;\n line-height: 18px;\n display: flex;\n}\n\n.mr-seat-warning {\n background: var(--dsw-alias-bg-module-platform);\n color: var(--dsw-alias-state-warn-label);\n}\n\n.mr-seat-retry {\n color: inherit;\n font: inherit;\n cursor: pointer;\n background: transparent;\n border: none;\n flex: none;\n padding: 0;\n font-weight: 600;\n}\n\n.mr-seat-groups {\n min-height: 0;\n overflow-y: auto;\n}\n\n.mr-seat-group + .mr-seat-group {\n margin-top: 4px;\n}\n\n.mr-seat-groupTitle {\n z-index: 1;\n background: var(--dsw-specific-menu);\n color: var(--dsw-alias-label-tertiary);\n padding: 5px 8px 3px;\n font-size: 12px;\n font-weight: 500;\n line-height: 18px;\n position: sticky;\n top: 0;\n}\n\n/* Provider pane: non-sticky title row above the provider list. */\n.mr-seat-title {\n color: var(--dsw-alias-label-tertiary);\n padding: 5px 8px 3px;\n font-size: 12px;\n font-weight: 500;\n line-height: 18px;\n}\n\n.mr-seat-option {\n width: 100%;\n min-height: 38px;\n color: inherit;\n text-align: left;\n cursor: pointer;\n background: transparent;\n border: none;\n border-radius: 10px;\n outline: none;\n align-items: center;\n gap: 8px;\n padding: 6px 8px;\n display: flex;\n}\n\n.mr-seat-option:hover:not(:disabled),\n.mr-seat-option:focus-visible {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.mr-seat-selected {\n background: transparent;\n}\n\n.mr-seat-option:disabled {\n color: var(--dsw-alias-label-dimmed);\n cursor: default;\n}\n\n.mr-seat-optionCopy {\n flex-direction: column;\n flex: 1;\n min-width: 0;\n display: flex;\n}\n\n.mr-seat-modelName {\n color: inherit;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 14px;\n font-weight: 500;\n line-height: 20px;\n overflow: hidden;\n}\n\n.mr-seat-description {\n color: var(--dsw-alias-label-tertiary);\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 12px;\n line-height: 18px;\n overflow: hidden;\n}\n\n.mr-seat-check {\n color: var(--dsw-alias-label-primary);\n flex: 0 0 18px;\n place-items: center;\n display: grid;\n}\n\n.mr-seat-cell {\n width: 100%;\n height: 40px;\n color: var(--dsw-alias-label-primary);\n cursor: pointer;\n text-align: left;\n background: transparent;\n border: none;\n border-radius: 10px;\n align-items: center;\n gap: 8px;\n padding: 0 10px;\n font-size: 14px;\n line-height: 22px;\n display: flex;\n}\n\n.mr-seat-cell:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.mr-seat-cellLabel {\n text-overflow: ellipsis;\n white-space: nowrap;\n flex: auto;\n min-width: 0;\n overflow: hidden;\n}\n\n.mr-seat-cellValue {\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n color: var(--dsw-alias-label-tertiary);\n flex: 0 auto;\n overflow: hidden;\n}\n\n.mr-seat-cellChevron {\n color: var(--dsw-alias-label-tertiary);\n flex: none;\n}\n\n/* Global hint footer of the provider pane. */\n.mr-seat-hint {\n color: var(--dsw-alias-label-tertiary);\n padding: 5px 8px 3px;\n font-size: 12px;\n line-height: 18px;\n border-top: 1px solid var(--dsw-alias-border-l2);\n margin-top: 4px;\n}\n\n/*\n * Mobile portrait: the composer row is an inline-size container. With the\n * quick route switcher gone from the right tool row, let the model seat\n * absorb the remaining row width (same rule the old switcher enforced).\n * The breakpoint matches DSH's own narrow-composer rule (<=460px).\n */\n@container (width <= 460px) {\n [data-slot=\"conversation.input.model\"] > * {\n flex: 1 1 0;\n min-width: 0;\n max-width: 100%;\n }\n\n [data-slot=\"conversation.input.model\"] [aria-haspopup=\"menu\"] {\n width: 100%;\n max-width: 100%;\n }\n}\n";
699
+ const tagId = "@fonlan/dsh-model-router/model-seat.css";
449
700
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
450
701
  const tag = document.createElement("style");
451
702
  tag.dataset.plugin = "@fonlan/dsh-model-router";
@@ -454,32 +705,50 @@ window.__ModuleLoader__.load({
454
705
  document.head.appendChild(tag);
455
706
  }
456
707
  //#endregion
457
- //#region src/client/route-switcher.tsx
708
+ //#region src/client/model-seat.tsx
458
709
  const ROUTER_PROVIDER_ID = "model-router";
459
710
  /**
460
- * The quick route switcher lives beside DSH's model seat in the composer row.
461
- * It reads the same per-session model directory as the host model picker, but
462
- * only appears when the selected model is the virtual model-router route.
711
+ * The composer model seat, shadowing the host ModelSelect at a lower slot
712
+ * priority. It renders the same two-level Model / Effort menu as the host
713
+ * (model list grouped by provider, reasoning effort levels), plus a third
714
+ * Provider row that opens the model-router provider list — the former quick
715
+ * route-switcher button moved into the model picker, on par with the model
716
+ * and effort menus.
717
+ *
718
+ * Shadowing contract: the host ui-model-selection registers the
719
+ * `conversation.input.model` occupant at priority 0; this entry registers at
720
+ * priority -1, so the renderer projects this component as the single-slot
721
+ * winner while the host entry stays as the abdication fallback. Both entries
722
+ * share the same per-session ModelDirectory, so selections stay consistent
723
+ * with the /model popup and the host picker.
463
724
  */
464
- function RouteSwitcher({ session, available, directory, load, t }) {
725
+ function ModelRouterSeat({ locked, available, directory, load, select, t }) {
465
726
  const directoryState = (0, react.useSyncExternalStore)((onChange) => directory.subscribe(onChange), () => directory.getSnapshot(), () => directory.getSnapshot());
466
727
  const current = directoryState.current;
728
+ const [open, setOpen] = (0, react.useState)(false);
729
+ const [pane, setPane] = (0, react.useState)("root");
730
+ const lastActionRef = (0, react.useRef)("load");
731
+ const [toast, setToast] = (0, react.useState)(null);
732
+ const toastSeq = (0, react.useRef)(0);
733
+ const rootRef = (0, react.useRef)(null);
734
+ const triggerRef = (0, react.useRef)(null);
735
+ const itemRefs = (0, react.useRef)([]);
736
+ const id = (0, react.useId)();
467
737
  const [routerState, setRouterState] = (0, react.useState)(null);
468
738
  const [routerLoading, setRouterLoading] = (0, react.useState)(false);
469
739
  const [routerError, setRouterError] = (0, react.useState)(null);
470
- const [open, setOpen] = (0, react.useState)(false);
471
740
  const [busyProvider, setBusyProvider] = (0, react.useState)(null);
472
741
  const isRouterModel = available && current?.provider === ROUTER_PROVIDER_ID;
473
742
  const modelId = isRouterModel ? current.model : null;
474
- const loadRouterState = (0, react.useCallback)(async () => {
475
- setRouterLoading(true);
743
+ const loadRouterState = (0, react.useCallback)(async (silent = false) => {
744
+ if (!silent) setRouterLoading(true);
476
745
  try {
477
746
  setRouterState(await api.state());
478
747
  setRouterError(null);
479
748
  } catch (cause) {
480
749
  setRouterError(cause instanceof Error ? cause.message : String(cause));
481
750
  } finally {
482
- setRouterLoading(false);
751
+ if (!silent) setRouterLoading(false);
483
752
  }
484
753
  }, []);
485
754
  (0, react.useEffect)(() => {
@@ -489,16 +758,24 @@ window.__ModuleLoader__.load({
489
758
  (0, react.useEffect)(() => {
490
759
  if (!available) return;
491
760
  loadRouterState();
492
- }, [available]);
761
+ }, [available, loadRouterState]);
493
762
  (0, react.useEffect)(() => {
494
- setOpen(false);
763
+ setPane("root");
495
764
  setBusyProvider(null);
496
- setRouterState(null);
497
765
  setRouterError(null);
498
766
  }, [modelId]);
767
+ const refreshTimer = (0, react.useRef)(null);
499
768
  (0, react.useEffect)(() => {
500
769
  if (!isRouterModel || modelId === null || directoryState.status !== "ready") return;
501
- loadRouterState();
770
+ if (refreshTimer.current !== null) clearTimeout(refreshTimer.current);
771
+ refreshTimer.current = setTimeout(() => {
772
+ refreshTimer.current = null;
773
+ loadRouterState(true);
774
+ }, 300);
775
+ return () => {
776
+ if (refreshTimer.current !== null) clearTimeout(refreshTimer.current);
777
+ refreshTimer.current = null;
778
+ };
502
779
  }, [
503
780
  directoryState.status,
504
781
  isRouterModel,
@@ -536,106 +813,429 @@ window.__ModuleLoader__.load({
536
813
  routeModel,
537
814
  t
538
815
  ]);
539
- if (session.removed || !isRouterModel) return null;
540
- if (routerState?.showQuickSwitch === false) return null;
541
- const activeName = providers.find((provider) => provider.id === routeModel?.active)?.name ?? routeModel?.active ?? t("routeLoading");
542
- if (routeModel !== void 0 && providers.length < 2) return null;
543
- const menuItems = routeModel === void 0 ? [{
544
- type: "label",
545
- id: "route-status",
546
- text: routerError ?? t("routeLoading")
547
- }] : [
548
- {
549
- type: "label",
550
- id: "route-model",
551
- text: t("routeMenuTitle", { model: modelId })
552
- },
553
- ...providers.map((provider) => ({
554
- id: provider.id,
555
- label: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
556
- className: "mr-route-item-copy",
557
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
558
- className: "mr-route-item-name",
559
- children: provider.name
560
- }), !provider.credentialConfigured && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
561
- className: "mr-route-item-note",
562
- children: t("noCredentialShort")
563
- })]
816
+ const choices = (0, react.useMemo)(() => directoryState.groups.flatMap((group) => group.models.map((model) => ({
817
+ group,
818
+ model,
819
+ selection: {
820
+ provider: group.id,
821
+ model: model.id,
822
+ ...model.reasoning?.defaultEffort === void 0 ? {} : { reasoningEffort: model.reasoning.defaultEffort }
823
+ }
824
+ }))), [directoryState.groups]);
825
+ const currentChoice = choices[current === null ? -1 : choices.findIndex((c) => c.selection.provider === current?.provider && c.selection.model === current.model)];
826
+ const reasoning = currentChoice?.model.reasoning;
827
+ const effectiveEffort = current?.reasoningEffort ?? reasoning?.defaultEffort;
828
+ const effortLabel = reasoning === void 0 ? void 0 : effectiveEffort === void 0 ? t("effortProviderDefault") : reasoning.efforts.find((level) => level.id === effectiveEffort)?.name ?? effectiveEffort;
829
+ const effortChoices = (0, react.useMemo)(() => reasoning === void 0 ? [] : [...reasoning.defaultEffort === void 0 ? [{
830
+ key: "provider-default",
831
+ effort: void 0,
832
+ label: t("effortProviderDefault")
833
+ }] : [], ...reasoning.efforts.map((effort) => ({
834
+ key: `effort:${effort.id}`,
835
+ effort: effort.id,
836
+ label: effort.name,
837
+ ...effort.description === void 0 ? {} : { description: effort.description }
838
+ }))], [reasoning, t]);
839
+ const busy = directoryState.status === "selecting";
840
+ const reload = () => {
841
+ lastActionRef.current = "load";
842
+ load();
843
+ };
844
+ (0, react.useEffect)(() => {
845
+ if (!open) return;
846
+ const closeOutside = (event) => {
847
+ if (!rootRef.current?.contains(event.target)) setOpen(false);
848
+ };
849
+ document.addEventListener("mousedown", closeOutside);
850
+ return () => {
851
+ document.removeEventListener("mousedown", closeOutside);
852
+ };
853
+ }, [open]);
854
+ if (!available) return null;
855
+ const show = () => {
856
+ setPane("root");
857
+ setOpen(true);
858
+ reload();
859
+ loadRouterState();
860
+ };
861
+ const close = (restoreFocus = false) => {
862
+ setOpen(false);
863
+ setPane("root");
864
+ if (restoreFocus) queueMicrotask(() => {
865
+ triggerRef.current?.focus();
866
+ });
867
+ };
868
+ const moveFocus = (offset) => {
869
+ const items = itemRefs.current.filter((item) => item !== null);
870
+ if (items.length === 0) return;
871
+ const active = items.findIndex((item) => item === document.activeElement);
872
+ items[(Math.max(active, 0) + offset + items.length) % items.length]?.focus();
873
+ };
874
+ const onRootKeyDown = (event) => {
875
+ if (event.key === "Escape" && open) {
876
+ event.preventDefault();
877
+ if (pane !== "root") setPane("root");
878
+ else close(true);
879
+ return;
880
+ }
881
+ if (!open) return;
882
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
883
+ event.preventDefault();
884
+ moveFocus(event.key === "ArrowDown" ? 1 : -1);
885
+ }
886
+ };
887
+ const onBlur = (event) => {
888
+ if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return;
889
+ close();
890
+ };
891
+ const settleSelection = (accepted) => {
892
+ if (accepted) {
893
+ if (rootRef.current !== null) close(true);
894
+ return;
895
+ }
896
+ const message = directory.getSnapshot().error;
897
+ if (message !== null) {
898
+ toastSeq.current += 1;
899
+ setToast({
900
+ seq: toastSeq.current,
901
+ text: t("errorAction", { message })
902
+ });
903
+ }
904
+ };
905
+ const choose = (selection) => {
906
+ if (current !== null && current.provider === selection.provider && current.model === selection.model) {
907
+ close(true);
908
+ return;
909
+ }
910
+ lastActionRef.current = "select";
911
+ select(selection).then(settleSelection);
912
+ };
913
+ const chooseEffort = (effort) => {
914
+ if (current === null) return;
915
+ if (effectiveEffort === effort) {
916
+ close(true);
917
+ return;
918
+ }
919
+ const selection = {
920
+ provider: current.provider,
921
+ model: current.model,
922
+ ...effort === void 0 ? {} : { reasoningEffort: effort }
923
+ };
924
+ lastActionRef.current = "select";
925
+ select(selection).then(settleSelection);
926
+ };
927
+ const modelLabel = currentChoice?.model.name ?? t("triggerFallback");
928
+ const triggerLabel = effortLabel === void 0 ? modelLabel : `${modelLabel} · ${effortLabel}`;
929
+ const triggerAria = currentChoice === void 0 ? t("triggerSelectAria") : effortLabel === void 0 ? t("triggerAria", { model: modelLabel }) : t("triggerAriaEffort", {
930
+ model: modelLabel,
931
+ effort: effortLabel
932
+ });
933
+ const activeProviderName = providers.find((provider) => provider.id === routeModel?.active)?.name ?? routeModel?.active ?? t("routeLoading");
934
+ const providerRowVisible = isRouterModel && routerState !== null && routerState.showQuickSwitch !== false && routerState.providers.length >= 2 && routeModel !== void 0 && providers.length >= 2;
935
+ itemRefs.current = [];
936
+ let itemIndex = 0;
937
+ const itemRef = () => {
938
+ const at = itemIndex++;
939
+ return (node) => {
940
+ itemRefs.current[at] = node;
941
+ };
942
+ };
943
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
944
+ ref: rootRef,
945
+ className: "mr-seat-root",
946
+ onKeyDown: onRootKeyDown,
947
+ onBlur,
948
+ children: [
949
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
950
+ ref: triggerRef,
951
+ type: "button",
952
+ className: "mr-seat-trigger",
953
+ "aria-label": triggerAria,
954
+ "aria-haspopup": "menu",
955
+ "aria-expanded": open,
956
+ "aria-controls": open ? `${id}-menu` : void 0,
957
+ title: triggerLabel,
958
+ disabled: locked,
959
+ onClick: () => {
960
+ if (open) close();
961
+ else show();
962
+ },
963
+ children: [
964
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
965
+ className: "mr-seat-triggerLabel",
966
+ children: modelLabel
967
+ }),
968
+ effortLabel !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
969
+ className: "mr-seat-triggerEffort",
970
+ children: effortLabel
971
+ }),
972
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
973
+ className: `mr-seat-chevron${open ? " mr-seat-chevronOpen" : ""}`,
974
+ "aria-hidden": true,
975
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {})
976
+ })
977
+ ]
564
978
  }),
565
- disabled: !provider.credentialConfigured || busyProvider !== null
566
- })),
567
- ...routerError === null ? [] : [{
568
- id: "route-error",
569
- label: routerError,
570
- disabled: true,
571
- danger: true
572
- }]
573
- ];
574
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Menu, {
575
- className: "mr-route-anchor",
576
- open,
577
- side: "top",
578
- align: "end",
579
- portal: true,
580
- compact: true,
581
- dense: true,
582
- items: menuItems,
583
- selectedId: routeModel?.active ?? void 0,
584
- onSelect: (id) => {
585
- if (id === "route-error" || routeModel === void 0) {
586
- loadRouterState();
587
- load();
588
- return;
589
- }
590
- switchProvider(id);
591
- },
592
- onClose: () => setOpen(false),
593
- footer: [{
594
- type: "label",
595
- id: "route-global-hint",
596
- text: t("globalRouteHint")
597
- }],
598
- anchor: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
599
- type: "button",
600
- className: "mr-route-trigger",
601
- "aria-label": t("routeButtonAria", { provider: activeName }),
602
- "aria-haspopup": "menu",
603
- "aria-expanded": open,
604
- disabled: routerLoading || busyProvider !== null,
605
- onClick: () => {
606
- setOpen((value) => !value);
607
- if (!open) {
608
- loadRouterState();
609
- load();
610
- }
611
- },
612
- title: t("routeButtonTitle"),
613
- children: [
614
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconGlobeOutline14, {}),
615
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
616
- className: "mr-route-trigger-label",
617
- children: activeName
618
- }),
619
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
620
- className: "mr-route-trigger-chevron",
621
- "aria-hidden": true,
622
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {})
623
- })
624
- ]
625
- })
979
+ open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
980
+ id: `${id}-menu`,
981
+ className: "mr-seat-menu",
982
+ role: "menu",
983
+ "aria-label": t("menuAria"),
984
+ "aria-busy": directoryState.status === "loading" || busy,
985
+ children: [
986
+ pane === "root" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
987
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
988
+ ref: itemRef(),
989
+ type: "button",
990
+ role: "menuitem",
991
+ className: "mr-seat-cell",
992
+ onClick: () => setPane("model"),
993
+ children: [
994
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
995
+ className: "mr-seat-cellLabel",
996
+ children: t("menuModel")
997
+ }),
998
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
999
+ className: "mr-seat-cellValue",
1000
+ children: modelLabel
1001
+ }),
1002
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1003
+ className: "mr-seat-cellChevron",
1004
+ "aria-hidden": true,
1005
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutline14, {})
1006
+ })
1007
+ ]
1008
+ }),
1009
+ providerRowVisible && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1010
+ ref: itemRef(),
1011
+ type: "button",
1012
+ role: "menuitem",
1013
+ className: "mr-seat-cell",
1014
+ onClick: () => setPane("provider"),
1015
+ children: [
1016
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1017
+ className: "mr-seat-cellLabel",
1018
+ children: t("menuProvider")
1019
+ }),
1020
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1021
+ className: "mr-seat-cellValue",
1022
+ children: activeProviderName
1023
+ }),
1024
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1025
+ className: "mr-seat-cellChevron",
1026
+ "aria-hidden": true,
1027
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutline14, {})
1028
+ })
1029
+ ]
1030
+ }),
1031
+ reasoning !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1032
+ ref: itemRef(),
1033
+ type: "button",
1034
+ role: "menuitem",
1035
+ className: "mr-seat-cell",
1036
+ onClick: () => setPane("effort"),
1037
+ children: [
1038
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1039
+ className: "mr-seat-cellLabel",
1040
+ children: t("menuEffort")
1041
+ }),
1042
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1043
+ className: "mr-seat-cellValue",
1044
+ children: effortLabel
1045
+ }),
1046
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1047
+ className: "mr-seat-cellChevron",
1048
+ "aria-hidden": true,
1049
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutline14, {})
1050
+ })
1051
+ ]
1052
+ })
1053
+ ] }),
1054
+ pane === "model" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1055
+ directoryState.status === "loading" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1056
+ className: "mr-seat-status",
1057
+ children: t("statusLoading")
1058
+ }),
1059
+ directoryState.error !== null && lastActionRef.current === "load" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1060
+ className: "mr-seat-error",
1061
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("errorAction", { message: directoryState.error }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1062
+ type: "button",
1063
+ className: "mr-seat-retry",
1064
+ onClick: reload,
1065
+ children: t("retry")
1066
+ })]
1067
+ }),
1068
+ directoryState.failures.map((failure) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1069
+ className: "mr-seat-warning",
1070
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("warningGroupLoad", {
1071
+ name: failure.name,
1072
+ message: failure.message
1073
+ }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1074
+ type: "button",
1075
+ className: "mr-seat-retry",
1076
+ onClick: reload,
1077
+ children: t("retry")
1078
+ })]
1079
+ }, failure.id)),
1080
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1081
+ className: "mr-seat-groups",
1082
+ children: directoryState.groups.map((group) => {
1083
+ const headingId = `${id}-${group.id}`;
1084
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1085
+ role: "group",
1086
+ "aria-labelledby": headingId,
1087
+ className: "mr-seat-group",
1088
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1089
+ className: "mr-seat-groupTitle",
1090
+ id: headingId,
1091
+ children: group.name
1092
+ }), group.models.map((model) => {
1093
+ const selected = current?.provider === group.id && current.model === model.id;
1094
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1095
+ ref: itemRef(),
1096
+ type: "button",
1097
+ role: "menuitemradio",
1098
+ "aria-checked": selected,
1099
+ className: `mr-seat-option${selected ? " mr-seat-selected" : ""}`,
1100
+ title: model.name,
1101
+ disabled: busy,
1102
+ onClick: () => choose({
1103
+ provider: group.id,
1104
+ model: model.id
1105
+ }),
1106
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1107
+ className: "mr-seat-optionCopy",
1108
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1109
+ className: "mr-seat-modelName",
1110
+ children: model.name
1111
+ }), model.description !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1112
+ className: "mr-seat-description",
1113
+ children: model.description
1114
+ })]
1115
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1116
+ className: "mr-seat-check",
1117
+ "aria-hidden": true,
1118
+ children: selected ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutline16, {}) : null
1119
+ })]
1120
+ }, model.id);
1121
+ })]
1122
+ }, group.id);
1123
+ })
1124
+ }),
1125
+ directoryState.status === "ready" && choices.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1126
+ className: "mr-seat-empty",
1127
+ children: t("emptyModels")
1128
+ })
1129
+ ] }),
1130
+ pane === "provider" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [routerError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1131
+ className: "mr-seat-error",
1132
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: routerError }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1133
+ type: "button",
1134
+ className: "mr-seat-retry",
1135
+ onClick: () => {
1136
+ loadRouterState();
1137
+ load();
1138
+ },
1139
+ children: t("retry")
1140
+ })]
1141
+ }), routeModel === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1142
+ className: "mr-seat-status",
1143
+ children: t("routeLoading")
1144
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1145
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1146
+ className: "mr-seat-title",
1147
+ children: t("routeMenuTitle", { model: modelId ?? "" })
1148
+ }),
1149
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1150
+ className: "mr-seat-groups",
1151
+ children: providers.map((provider) => {
1152
+ const selected = routeModel.active === provider.id;
1153
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1154
+ ref: itemRef(),
1155
+ type: "button",
1156
+ role: "menuitemradio",
1157
+ "aria-checked": selected,
1158
+ className: `mr-seat-option${selected ? " mr-seat-selected" : ""}`,
1159
+ disabled: !provider.credentialConfigured || busyProvider !== null,
1160
+ onClick: () => void switchProvider(provider.id),
1161
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1162
+ className: "mr-seat-optionCopy",
1163
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1164
+ className: "mr-seat-modelName",
1165
+ children: provider.name
1166
+ }), !provider.credentialConfigured && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1167
+ className: "mr-seat-description",
1168
+ children: t("noCredentialShort")
1169
+ })]
1170
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1171
+ className: "mr-seat-check",
1172
+ "aria-hidden": true,
1173
+ children: selected ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutline16, {}) : null
1174
+ })]
1175
+ }, provider.id);
1176
+ })
1177
+ }),
1178
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1179
+ className: "mr-seat-hint",
1180
+ children: t("globalRouteHint")
1181
+ })
1182
+ ] })] }),
1183
+ pane === "effort" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [directoryState.error !== null && lastActionRef.current === "load" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1184
+ className: "mr-seat-error",
1185
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("errorAction", { message: directoryState.error }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1186
+ type: "button",
1187
+ className: "mr-seat-retry",
1188
+ onClick: reload,
1189
+ children: t("actionReload")
1190
+ })]
1191
+ }), effortChoices.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1192
+ className: "mr-seat-empty",
1193
+ children: t("emptyEfforts")
1194
+ }) : effortChoices.map((level) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1195
+ ref: itemRef(),
1196
+ type: "button",
1197
+ role: "menuitemradio",
1198
+ "aria-checked": effectiveEffort === level.effort,
1199
+ className: `mr-seat-option${effectiveEffort === level.effort ? " mr-seat-selected" : ""}`,
1200
+ disabled: busy,
1201
+ onClick: () => chooseEffort(level.effort),
1202
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1203
+ className: "mr-seat-optionCopy",
1204
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1205
+ className: "mr-seat-modelName",
1206
+ children: level.label
1207
+ }), level.description !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1208
+ className: "mr-seat-description",
1209
+ children: level.description
1210
+ })]
1211
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1212
+ className: "mr-seat-check",
1213
+ "aria-hidden": true,
1214
+ children: effectiveEffort === level.effort ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutline16, {}) : null
1215
+ })]
1216
+ }, level.key))] })
1217
+ ]
1218
+ }),
1219
+ toast !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Toast, {
1220
+ text: toast.text,
1221
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconWarningOutline16, {}),
1222
+ anchor: rootRef.current?.closest("[data-composer-card]") ?? null,
1223
+ onDone: () => setToast(null)
1224
+ }, toast.seq)
1225
+ ]
626
1226
  });
627
1227
  }
628
- /** Register the quick route switcher into the composer right-side tool row. */
629
- function registerRouteSwitcher(ctx) {
1228
+ /** Register the model seat onto the composer's named model slot (priority -1 shadows the host seat). */
1229
+ function registerModelRouterSeat(ctx) {
630
1230
  const t = ctx.locale.bind(LOCALE_NS);
631
1231
  ctx.inject(["slots", "modelDirectories"], (scope) => {
632
1232
  const models = scope.modelDirectories;
633
1233
  const sessions = scope.sessions;
634
- scope.slots.inject("conversation.input.right", () => scope.slots.register({
635
- name: "conversation.input.right",
636
- id: "model-router-route-switcher",
637
- order: 100,
638
- label: () => t("routeButtonLabel"),
1234
+ scope.slots.inject("conversation.input.model", () => scope.slots.register({
1235
+ name: "conversation.input.model",
1236
+ id: "model-router-model-seat",
1237
+ priority: -1,
1238
+ label: () => t("seatLabel"),
639
1239
  locale: LOCALE_NS,
640
1240
  inject: (sessionId) => {
641
1241
  const directory = models.directoryFor(sessionId);
@@ -645,24 +1245,29 @@ window.__ModuleLoader__.load({
645
1245
  directory: directory.store,
646
1246
  load: () => {
647
1247
  if (available) directory.load().catch(() => {});
648
- }
1248
+ },
1249
+ select: (selection) => available ? directory.select(selection).then(() => true, () => false) : Promise.resolve(false)
649
1250
  };
650
1251
  }
651
- }, RouteSwitcher));
1252
+ }, ModelRouterSeat));
652
1253
  });
653
1254
  }
654
1255
  //#endregion
655
1256
  //#region src/client/index.tsx
1257
+ /** The settings namespace this card edits (must match the host half). */
1258
+ const ROUTER_NS = "model-router";
656
1259
  /** Services required before mounting (provided by the client runtime). */
657
1260
  const inject = [
658
1261
  "slots",
659
1262
  "locale",
660
1263
  "modelDirectories",
661
- "sessions"
1264
+ "sessions",
1265
+ "settingsScope",
1266
+ "connection",
1267
+ "remote"
662
1268
  ];
663
1269
  /** Client plugin body. */
664
1270
  function apply(ctx) {
665
- const t = ctx.locale.bind(LOCALE_NS);
666
1271
  ctx.effect(() => {
667
1272
  const off = ctx.locale.register(LOCALE_NS, {
668
1273
  zh,
@@ -670,15 +1275,15 @@ window.__ModuleLoader__.load({
670
1275
  });
671
1276
  return () => off();
672
1277
  }, "model-router: dictionaries");
673
- const SettingsSection = makeSettingsSection(ctx);
674
- ctx.slots.inject("settings.section", () => ctx.slots.register({
675
- name: "settings.section",
676
- id: "model-router",
677
- order: 300,
678
- label: () => t("settingsTitle"),
679
- locale: LOCALE_NS
680
- }, SettingsSection));
681
- registerRouteSwitcher(ctx);
1278
+ const SettingsCard = makeSettingsCard(ctx);
1279
+ const services = ctx;
1280
+ const scope = services.settingsScope.bind({ namespace: ROUTER_NS });
1281
+ services.slots.inject("settings.plugin.item", () => services.slots.register({
1282
+ name: "settings.plugin.item",
1283
+ key: ROUTER_NS,
1284
+ inject: () => ({ scope })
1285
+ }, SettingsCard));
1286
+ registerModelRouterSeat(ctx);
682
1287
  }
683
1288
  //#endregion
684
1289
  exports.apply = apply;