@fonlan/dsh-model-router 0.3.0 → 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 +7 -7
- package/lib/client.js +197 -117
- package/lib/client.js.map +1 -1
- package/lib/index.js +20 -4
- package/lib/types/server/service.d.ts +17 -3
- package/package.json +3 -1
- package/src/client/index.tsx +37 -14
- package/src/client/locales.ts +8 -2
- package/src/client/route-switcher.tsx +31 -6
- package/src/client/{settings-section.css → settings-card.css} +91 -7
- package/src/client/{settings-section.tsx → settings-card.tsx} +138 -71
- package/src/server/service.ts +23 -4
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
|
-
-
|
|
12
|
+
- **设置卡片**(设置 → 插件 → 插件配置 → 模型路由,可展开式卡片):每个模型下列出其全部提供商——点击切换当前使用;拖拽调整优先级顺序(供后续自动切换策略使用);未配置 API 密钥的提供商标灰不可选。
|
|
13
13
|
- **自动配置维护**:新增/删除提供商或模型后配置自动同步(消失的提供商从排序中清理、激活自动回落到剩余第一位、新提供商追加到排序末尾);未配置过的模型自动选择排序第一位。
|
|
14
14
|
- **失败即报错**:当前提供商请求失败直接返回错误(自动切换为后续规划功能)。
|
|
15
|
-
-
|
|
16
|
-
- **通用运行时**:核心路由逻辑与 UI 解耦——web profile
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
108
|
-
const css$1 = "/* Model Router settings
|
|
109
|
-
const tagId$1 = "@fonlan/dsh-model-router/settings-
|
|
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-
|
|
124
|
+
//#region src/client/settings-card.tsx
|
|
119
125
|
/**
|
|
120
|
-
* The Model Router
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* settings
|
|
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
|
|
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
|
|
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)("
|
|
263
|
-
className: "mr-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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-
|
|
309
|
-
children: t("
|
|
310
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
children:
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
},
|
|
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
|