@fonlan/dsh-model-router 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,19 +1,19 @@
1
1
  # dsh-model-router
2
2
 
3
- **dsh-model-router** 是一个 [DSH](https://github.com/deepseek-ai/dsh) 插件,向 harness 注册一个虚拟模型提供商 `model-router`:它汇聚用户已添加的**所有**提供商及其模型(按 model id 严格归并),并在每次 LLM 请求发出时把请求实时路由到该模型**当前激活**的提供商。配套的独立设置页支持按模型对提供商拖拽排序(供后续自动切换使用)与点击切换当前提供商;切换后全局立即生效——下一次发出的该模型请求即走新提供商。
3
+ **dsh-model-router** 是一个 [DSH](https://github.com/deepseek-ai/dsh) 插件,向 harness 注册一个虚拟模型提供商 `model-router`:它汇聚用户已添加的**所有**提供商及其模型(按 model id 严格归并),并在每次 LLM 请求发出时把请求实时路由到该模型**当前激活**的提供商。配套的设置卡片(设置 → 插件 → 插件配置 → 模型路由)支持按模型对提供商拖拽排序(供后续自动切换使用)与点击切换当前提供商;切换后全局立即生效——下一次发出的该模型请求即走新提供商。
4
4
 
5
5
  ## 功能
6
6
 
7
7
  - **虚拟提供商 `model-router`**:出现在输入框下方的模型选择器(含默认模型设置等所有选择入口)中,列出全部已配置提供商的所有模型,模型条目副文本标注当前激活的提供商(如 `via OpenCode Go`)。
8
- - **快速路由切换**:当当前会话选择的是 `model-router` 模型时,输入栏模型选择器右侧显示当前 provider 按钮;点击打开 provider 面板,可直接切换该模型的激活 provider。切换对所有会话的后续请求生效,完整排序和全量管理仍在设置 → 模型路由中完成。可在设置 → 模型路由中通过「在输入栏显示快速路由切换按钮」开关控制该按钮是否显示。
8
+ - **快速路由切换**:当当前会话选择的是 `model-router` 模型时,输入栏模型选择器右侧显示当前 provider 按钮;点击打开 provider 面板,可直接切换该模型的激活 provider。切换对所有会话的后续请求生效,完整排序和全量管理仍在设置 → 插件 → 插件配置 → 模型路由中完成。可在设置 → 插件 → 插件配置 → 模型路由中通过「在输入栏显示快速路由切换按钮」开关控制该按钮是否显示。当 router 全局只有一个提供商、或当前模型仅由一个提供商服务时,按钮自动隐藏(无可切换对象)。
9
9
  - **严格按 model id 归并**:同一模型出现在多个提供商时归并为一条;展示名、上下文窗口、推理档位等参数跟随当前激活的提供商。
10
- - **忽略模型 ID 前缀**(默认开启):`deepseek/deepseek-v4-flash` 与 `deepseek-v4-flash` 视为同一模型,统一显示不带前缀的模型 id;请求时传入带前缀的 id 也能命中同一路由。每个提供商条目保留自己的原始 id,委派时按原始 id 发给真实提供商。可在设置 → 模型路由中通过「匹配时忽略模型 ID 前缀」开关控制。
10
+ - **忽略模型 ID 前缀**(默认开启):`deepseek/deepseek-v4-flash` 与 `deepseek-v4-flash` 视为同一模型,统一显示不带前缀的模型 id;请求时传入带前缀的 id 也能命中同一路由。每个提供商条目保留自己的原始 id,委派时按原始 id 发给真实提供商。可在设置 → 插件 → 插件配置 → 模型路由中通过「匹配时忽略模型 ID 前缀」开关控制。
11
11
  - **请求时实时路由**:会话中始终保存 `provider=model-router`,每次请求发出时进程内解析当前激活提供商并委派给真实 adapter(凭据解析、重试、流式、计量全部走原管线),主 agent、子代理、工具 LLM 调用一视同仁。
12
- - **独立设置页**(设置 → 模型路由):每个模型下列出其全部提供商——点击切换当前使用;拖拽调整优先级顺序(供后续自动切换策略使用);未配置 API 密钥的提供商标灰不可选。
12
+ - **设置卡片**(设置 → 插件 → 插件配置 → 模型路由,可展开式卡片):每个模型下列出其全部提供商——点击切换当前使用;拖拽调整优先级顺序(供后续自动切换策略使用);未配置 API 密钥的提供商标灰不可选。
13
13
  - **自动配置维护**:新增/删除提供商或模型后配置自动同步(消失的提供商从排序中清理、激活自动回落到剩余第一位、新提供商追加到排序末尾);未配置过的模型自动选择排序第一位。
14
14
  - **失败即报错**:当前提供商请求失败直接返回错误(自动切换为后续规划功能)。
15
- - **双语**:设置页文案中英双语,跟随界面语言。
16
- - **通用运行时**:核心路由逻辑与 UI 解耦——web profile 提供设置页与选择器;CLI / headless profile 装上同样按配置路由(可直接手改 `settings.yaml` 的 `model-router:` 段)。
15
+ - **双语**:设置卡片文案中英双语,跟随界面语言。
16
+ - **通用运行时**:核心路由逻辑与 UI 解耦——web profile 提供设置卡片与选择器;CLI / headless profile 装上同样按配置路由(可直接手改 `settings.yaml` 的 `model-router:` 段)。
17
17
 
18
18
  ## 配置
19
19
 
@@ -50,7 +50,7 @@ pnpm build
50
50
  dsh plugin --profile web add .
51
51
  ```
52
52
 
53
- 安装后插件通过 `cordis.patch.yml` 自动挂载到目标 profile:服务端注册 `model-router` 提供商与路由配置,web 端注册 `settings.section` 设置页。重启 profile 后生效(web profile 重启后,输入框下方的模型选择器即出现 Model Router 分组)。
53
+ 安装后插件通过 `cordis.patch.yml` 自动挂载到目标 profile:服务端注册 `model-router` 提供商与路由配置,web 端注册 `settings.plugin.item` 设置卡片(键 = `model-router` 设置命名空间,即设置 → 插件 → 插件配置 → 模型路由)。重启 profile 后生效(web profile 重启后,输入框下方的模型选择器即出现 Model Router 分组)。
54
54
 
55
55
  ## 开发
56
56
 
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: "重试",
@@ -39,7 +42,10 @@ window.__ModuleLoader__.load({
39
42
  };
40
43
  const en = {
41
44
  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).",
45
+ settingsCardDescription: "Model aggregation, provider switching and preference order",
46
+ expand: "Expand",
47
+ collapse: "Collapse",
48
+ readOnly: "This deployment stores settings read-only.",
43
49
  loading: "Loading…",
44
50
  loadFailed: "Load failed: {message}",
45
51
  retry: "Retry",
@@ -104,9 +110,9 @@ window.__ModuleLoader__.load({
104
110
  setIgnoreModelIdPrefix: (value) => call("set-ignore-model-id-prefix", { value })
105
111
  };
106
112
  //#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";
113
+ //#region \0dsh-css:/Users/fonlan/Repos/dsh-model-router/src/client/settings-card.css.mjs
114
+ 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";
115
+ const tagId$1 = "@fonlan/dsh-model-router/settings-card.css";
110
116
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
111
117
  const tag = document.createElement("style");
112
118
  tag.dataset.plugin = "@fonlan/dsh-model-router";
@@ -115,13 +121,23 @@ window.__ModuleLoader__.load({
115
121
  document.head.appendChild(tag);
116
122
  }
117
123
  //#endregion
118
- //#region src/client/settings-section.tsx
124
+ //#region src/client/settings-card.tsx
119
125
  /**
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.
126
+ * The Model Router Settings Card (设置 → 插件 → 插件配置 → 模型路由).
127
+ *
128
+ * Renders its own expandable chrome aligned with the built-in plugin cards
129
+ * (external plugins cannot import PluginCard): a header naming the plugin and
130
+ * what its settings govern, disclosing the router controls in place. All
131
+ * reads/mutations go through the plugin's fenced API; the server persists
132
+ * through the `model-router` settings namespace, so a switch here is live for
133
+ * the next request globally.
134
+ *
135
+ * The bound settings scope (the same `model-router` namespace the host half
136
+ * registers) supplies the card's dispatch state: while the namespace is merely
137
+ * loading the card stays mounted, and when it is unavailable (deployment
138
+ * without the host half) nothing renders — matching the built-in cards. A
139
+ * read-only deployment shows the built-in card's banner and disables the
140
+ * mutation controls.
125
141
  */
126
142
  function arrayMove(list, from, to) {
127
143
  const next = [...list];
@@ -146,7 +162,7 @@ window.__ModuleLoader__.load({
146
162
  }, [ctx]);
147
163
  return (0, react.useSyncExternalStore)(subscribe, getSnapshot);
148
164
  }
149
- function makeSettingsSection(ctx) {
165
+ function makeSettingsCard(ctx) {
150
166
  const t = (() => {
151
167
  try {
152
168
  return ctx.locale.bind(LOCALE_NS);
@@ -154,8 +170,11 @@ window.__ModuleLoader__.load({
154
170
  return (key) => key;
155
171
  }
156
172
  })();
157
- return function ModelRouterSettingsSection() {
173
+ return function ModelRouterSettingsCard(props) {
174
+ const { scope } = props;
175
+ const snapshot = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
158
176
  useLocaleRevision(ctx);
177
+ const [open, setOpen] = (0, react.useState)(false);
159
178
  const [state, setState] = (0, react.useState)(null);
160
179
  const [error, setError] = (0, react.useState)(null);
161
180
  const [loading, setLoading] = (0, react.useState)(true);
@@ -258,101 +277,144 @@ window.__ModuleLoader__.load({
258
277
  setIgnorePrefixBusy(false);
259
278
  }
260
279
  }, [state]);
280
+ if (snapshot.status === "unavailable") return null;
281
+ const writable = snapshot.writable;
261
282
  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",
283
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
284
+ className: "mr-settings-card",
285
+ "data-open": open ? "" : void 0,
286
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
287
+ type: "button",
288
+ className: "mr-settings-head",
289
+ "aria-expanded": open,
290
+ "aria-label": (open ? t("collapse") : t("expand")) + ":" + t("settingsTitle"),
291
+ onClick: () => setOpen(!open),
292
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
293
+ className: "mr-settings-head-text",
307
294
  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")
295
+ className: "mr-settings-title",
296
+ children: t("settingsTitle")
297
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
298
+ className: "mr-settings-sub",
299
+ children: t("settingsCardDescription")
316
300
  })]
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
301
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
302
+ className: "mr-settings-chevron",
303
+ "data-open": open ? "" : void 0,
304
+ "aria-hidden": "true",
305
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
306
+ width: "14",
307
+ height: "14",
308
+ viewBox: "0 0 14 14",
309
+ fill: "none",
310
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
311
+ d: "M3.5 5.25L7 8.75L10.5 5.25",
312
+ stroke: "currentColor",
313
+ strokeWidth: "1.5",
314
+ strokeLinecap: "round",
315
+ strokeLinejoin: "round"
316
+ })
317
+ })
318
+ })]
319
+ }), open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
320
+ className: "mr-settings-body",
321
+ children: [!writable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
322
+ className: "mr-settings-readonly",
323
+ role: "status",
324
+ children: t("readOnly")
325
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
326
+ className: "mr-root",
327
+ children: [
328
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
329
+ className: "mr-quick-switch",
330
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
331
+ type: "checkbox",
332
+ checked: state?.showQuickSwitch ?? true,
333
+ disabled: loading || quickSwitchBusy || !writable,
334
+ onChange: (event) => void toggleQuickSwitch(event.target.checked)
335
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
336
+ className: "mr-quick-switch-copy",
337
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
338
+ className: "mr-quick-switch-label",
339
+ children: t("showQuickSwitchLabel")
340
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
341
+ className: "mr-quick-switch-desc",
342
+ children: t("showQuickSwitchDescription")
343
+ })]
344
+ })]
341
345
  }),
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
- ]
346
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
347
+ className: "mr-quick-switch",
348
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
349
+ type: "checkbox",
350
+ checked: state?.ignoreModelIdPrefix ?? true,
351
+ disabled: loading || ignorePrefixBusy || !writable,
352
+ onChange: (event) => void toggleIgnorePrefix(event.target.checked)
353
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
354
+ className: "mr-quick-switch-copy",
355
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
356
+ className: "mr-quick-switch-label",
357
+ children: t("ignorePrefixLabel")
358
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
359
+ className: "mr-quick-switch-desc",
360
+ children: t("ignorePrefixDescription")
361
+ })]
362
+ })]
363
+ }),
364
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
365
+ className: "mr-toolbar",
366
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
367
+ className: "mr-count",
368
+ children: t("modelsCount", { count: models.length })
369
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
370
+ type: "button",
371
+ className: "mr-button",
372
+ onClick: () => void load(),
373
+ disabled: loading,
374
+ children: t("refresh")
375
+ })]
376
+ }),
377
+ error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
378
+ className: "mr-error",
379
+ role: "alert",
380
+ children: error
381
+ }),
382
+ loading && models.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
383
+ className: "mr-empty",
384
+ children: t("loading")
385
+ }),
386
+ !loading && models.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
387
+ className: "mr-empty",
388
+ children: t("empty")
389
+ }),
390
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
391
+ className: "mr-models",
392
+ children: models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelCard, {
393
+ model,
394
+ t,
395
+ readOnly: !writable,
396
+ busy: busyModel === model.id,
397
+ dragging: drag,
398
+ onDragStart: (index) => setDrag({
399
+ modelId: model.id,
400
+ index
401
+ }),
402
+ onDragEnd: () => setDrag(null),
403
+ onDrop: (index) => {
404
+ if (drag !== null && drag.modelId === model.id) reorder(model, drag.index, index);
405
+ setDrag(null);
406
+ },
407
+ onSwitch: (providerId) => void switchActive(model, providerId)
408
+ }, model.id))
409
+ })
410
+ ]
411
+ })]
412
+ })]
351
413
  });
352
414
  };
353
415
  }
354
416
  function ModelCard(props) {
355
- const { model, t, busy, dragging } = props;
417
+ const { model, t, readOnly, busy, dragging } = props;
356
418
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
357
419
  className: `mr-card${busy ? " mr-card-busy" : ""}`,
358
420
  children: [
@@ -390,7 +452,7 @@ window.__ModuleLoader__.load({
390
452
  !selectable ? "mr-provider-nocred" : "",
391
453
  dropTarget ? "mr-provider-drop" : ""
392
454
  ].filter(Boolean).join(" "),
393
- draggable: !busy && model.providers.length > 1,
455
+ draggable: !busy && !readOnly && model.providers.length > 1,
394
456
  title: selectable ? void 0 : t("noCredential"),
395
457
  onDragStart: (event) => {
396
458
  props.onDragStart(index);
@@ -421,7 +483,7 @@ window.__ModuleLoader__.load({
421
483
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
422
484
  type: "button",
423
485
  className: "mr-provider-button",
424
- disabled: !selectable || busy || active,
486
+ disabled: !selectable || busy || active || readOnly,
425
487
  onClick: () => props.onSwitch(provider.id),
426
488
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
427
489
  className: "mr-provider-name",
@@ -471,15 +533,20 @@ window.__ModuleLoader__.load({
471
533
  const [busyProvider, setBusyProvider] = (0, react.useState)(null);
472
534
  const isRouterModel = available && current?.provider === ROUTER_PROVIDER_ID;
473
535
  const modelId = isRouterModel ? current.model : null;
474
- const loadRouterState = (0, react.useCallback)(async () => {
475
- setRouterLoading(true);
536
+ /**
537
+ * Fetch the router state. `silent` skips the busy state: background
538
+ * refreshes (directory-ready, model switch) should never gray out the
539
+ * button — only the initial load and explicit user actions do.
540
+ */
541
+ const loadRouterState = (0, react.useCallback)(async (silent = false) => {
542
+ if (!silent) setRouterLoading(true);
476
543
  try {
477
544
  setRouterState(await api.state());
478
545
  setRouterError(null);
479
546
  } catch (cause) {
480
547
  setRouterError(cause instanceof Error ? cause.message : String(cause));
481
548
  } finally {
482
- setRouterLoading(false);
549
+ if (!silent) setRouterLoading(false);
483
550
  }
484
551
  }, []);
485
552
  (0, react.useEffect)(() => {
@@ -493,12 +560,20 @@ window.__ModuleLoader__.load({
493
560
  (0, react.useEffect)(() => {
494
561
  setOpen(false);
495
562
  setBusyProvider(null);
496
- setRouterState(null);
497
563
  setRouterError(null);
498
564
  }, [modelId]);
565
+ const refreshTimer = (0, react.useRef)(null);
499
566
  (0, react.useEffect)(() => {
500
567
  if (!isRouterModel || modelId === null || directoryState.status !== "ready") return;
501
- loadRouterState();
568
+ if (refreshTimer.current !== null) clearTimeout(refreshTimer.current);
569
+ refreshTimer.current = setTimeout(() => {
570
+ refreshTimer.current = null;
571
+ loadRouterState(true);
572
+ }, 300);
573
+ return () => {
574
+ if (refreshTimer.current !== null) clearTimeout(refreshTimer.current);
575
+ refreshTimer.current = null;
576
+ };
502
577
  }, [
503
578
  directoryState.status,
504
579
  isRouterModel,
@@ -539,6 +614,7 @@ window.__ModuleLoader__.load({
539
614
  if (session.removed || !isRouterModel) return null;
540
615
  if (routerState?.showQuickSwitch === false) return null;
541
616
  const activeName = providers.find((provider) => provider.id === routeModel?.active)?.name ?? routeModel?.active ?? t("routeLoading");
617
+ if (routerState !== null && routerState.providers.length < 2) return null;
542
618
  if (routeModel !== void 0 && providers.length < 2) return null;
543
619
  const menuItems = routeModel === void 0 ? [{
544
620
  type: "label",
@@ -653,16 +729,20 @@ window.__ModuleLoader__.load({
653
729
  }
654
730
  //#endregion
655
731
  //#region src/client/index.tsx
732
+ /** The settings namespace this card edits (must match the host half). */
733
+ const ROUTER_NS = "model-router";
656
734
  /** Services required before mounting (provided by the client runtime). */
657
735
  const inject = [
658
736
  "slots",
659
737
  "locale",
660
738
  "modelDirectories",
661
- "sessions"
739
+ "sessions",
740
+ "settingsScope",
741
+ "connection",
742
+ "remote"
662
743
  ];
663
744
  /** Client plugin body. */
664
745
  function apply(ctx) {
665
- const t = ctx.locale.bind(LOCALE_NS);
666
746
  ctx.effect(() => {
667
747
  const off = ctx.locale.register(LOCALE_NS, {
668
748
  zh,
@@ -670,14 +750,14 @@ window.__ModuleLoader__.load({
670
750
  });
671
751
  return () => off();
672
752
  }, "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));
753
+ const SettingsCard = makeSettingsCard(ctx);
754
+ const services = ctx;
755
+ const scope = services.settingsScope.bind({ namespace: ROUTER_NS });
756
+ services.slots.inject("settings.plugin.item", () => services.slots.register({
757
+ name: "settings.plugin.item",
758
+ key: ROUTER_NS,
759
+ inject: () => ({ scope })
760
+ }, SettingsCard));
681
761
  registerRouteSwitcher(ctx);
682
762
  }
683
763
  //#endregion