@fonlan/dsh-model-router 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/lib/client.js +286 -9
- package/lib/client.js.map +1 -1
- package/lib/index.js +50 -11
- package/lib/types/server/service.d.ts +4 -0
- package/lib/types/shared/config.d.ts +6 -0
- package/package.json +8 -2
- package/src/client/api.ts +4 -0
- package/src/client/index.tsx +7 -4
- package/src/client/locales.ts +20 -0
- package/src/client/route-switcher.css +136 -0
- package/src/client/route-switcher.tsx +252 -0
- package/src/client/settings-section.css +35 -0
- package/src/client/settings-section.tsx +29 -0
- package/src/server/rpc.ts +8 -0
- package/src/server/service.ts +19 -3
- package/src/shared/config.ts +12 -4
package/lib/index.js
CHANGED
|
@@ -160,9 +160,17 @@ function setOrder(config, modelId, order, catalogProviderIds) {
|
|
|
160
160
|
/** Shape-validate an unknown value as a RouterConfigShape (lenient). */
|
|
161
161
|
function normalizeConfig(value) {
|
|
162
162
|
const models = {};
|
|
163
|
-
if (typeof value !== "object" || value === null) return {
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
if (typeof value !== "object" || value === null) return {
|
|
164
|
+
models,
|
|
165
|
+
showQuickSwitch: true
|
|
166
|
+
};
|
|
167
|
+
const root = value;
|
|
168
|
+
const showQuickSwitch = root.showQuickSwitch !== false;
|
|
169
|
+
const raw = root.models;
|
|
170
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
171
|
+
models,
|
|
172
|
+
showQuickSwitch
|
|
173
|
+
};
|
|
166
174
|
for (const [id, entry] of Object.entries(raw)) {
|
|
167
175
|
if (typeof entry !== "object" || entry === null) continue;
|
|
168
176
|
const e = entry;
|
|
@@ -174,7 +182,10 @@ function normalizeConfig(value) {
|
|
|
174
182
|
active
|
|
175
183
|
};
|
|
176
184
|
}
|
|
177
|
-
return {
|
|
185
|
+
return {
|
|
186
|
+
models,
|
|
187
|
+
showQuickSwitch
|
|
188
|
+
};
|
|
178
189
|
}
|
|
179
190
|
//#endregion
|
|
180
191
|
//#region src/server/catalog.ts
|
|
@@ -332,11 +343,17 @@ var ModelRouterAdapter = class extends LlmAdapter {
|
|
|
332
343
|
//#region src/server/service.ts
|
|
333
344
|
const NS = settingsNamespace(ROUTER_SETTINGS_NS);
|
|
334
345
|
/** Settings document schema: one entry per model id. */
|
|
335
|
-
const RouterConfigSchema = z.object({
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
346
|
+
const RouterConfigSchema = z.object({
|
|
347
|
+
models: z.dict(z.object({
|
|
348
|
+
order: z.array(z.string()),
|
|
349
|
+
active: z.string()
|
|
350
|
+
})),
|
|
351
|
+
showQuickSwitch: z.boolean()
|
|
352
|
+
});
|
|
353
|
+
const EMPTY_CONFIG = {
|
|
354
|
+
models: {},
|
|
355
|
+
showQuickSwitch: true
|
|
356
|
+
};
|
|
340
357
|
var ModelRouterService = class {
|
|
341
358
|
ctx;
|
|
342
359
|
cached = {
|
|
@@ -422,7 +439,10 @@ var ModelRouterService = class {
|
|
|
422
439
|
}
|
|
423
440
|
if (!changed) return;
|
|
424
441
|
try {
|
|
425
|
-
await this.scope.replace({
|
|
442
|
+
await this.scope.replace({
|
|
443
|
+
models: next,
|
|
444
|
+
showQuickSwitch: this.config().showQuickSwitch
|
|
445
|
+
});
|
|
426
446
|
} catch (error) {
|
|
427
447
|
this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
428
448
|
}
|
|
@@ -440,6 +460,7 @@ var ModelRouterService = class {
|
|
|
440
460
|
const names = catalog.providerNames;
|
|
441
461
|
const creds = catalog.credentialConfigured;
|
|
442
462
|
return {
|
|
463
|
+
showQuickSwitch: config.showQuickSwitch,
|
|
443
464
|
providers: catalog.providers.map((provider) => ({
|
|
444
465
|
id: provider.id,
|
|
445
466
|
name: names.get(provider.id) ?? provider.id,
|
|
@@ -482,9 +503,22 @@ var ModelRouterService = class {
|
|
|
482
503
|
if (!changed) return;
|
|
483
504
|
await this.persist(models);
|
|
484
505
|
}
|
|
506
|
+
/** Toggle the composer quick route-switcher button (global, persisted). */
|
|
507
|
+
async setShowQuickSwitch(value) {
|
|
508
|
+
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
509
|
+
const current = this.config();
|
|
510
|
+
if (current.showQuickSwitch === value) return;
|
|
511
|
+
await this.scope.replace({
|
|
512
|
+
models: current.models,
|
|
513
|
+
showQuickSwitch: value
|
|
514
|
+
});
|
|
515
|
+
}
|
|
485
516
|
async persist(models) {
|
|
486
517
|
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
487
|
-
await this.scope.replace({
|
|
518
|
+
await this.scope.replace({
|
|
519
|
+
models,
|
|
520
|
+
showQuickSwitch: this.config().showQuickSwitch
|
|
521
|
+
});
|
|
488
522
|
}
|
|
489
523
|
};
|
|
490
524
|
//#endregion
|
|
@@ -599,6 +633,11 @@ function registerApiRoutes(ctx, service) {
|
|
|
599
633
|
await service.refreshAndReconcile();
|
|
600
634
|
return service.state();
|
|
601
635
|
});
|
|
636
|
+
route("set-show-quick-switch", async (p) => {
|
|
637
|
+
if (typeof p.value !== "boolean") throw new Error("value 必须是布尔值");
|
|
638
|
+
await service.setShowQuickSwitch(p.value);
|
|
639
|
+
return service.state();
|
|
640
|
+
});
|
|
602
641
|
return () => {
|
|
603
642
|
for (const dispose of disposers) dispose();
|
|
604
643
|
};
|
|
@@ -13,6 +13,8 @@ export declare const NS: import("@deepseek-ai/dsh-settings").SettingsNamespace;
|
|
|
13
13
|
export declare const RouterConfigSchema: z<RouterConfigShape>;
|
|
14
14
|
/** The settings-page state view served by the API. */
|
|
15
15
|
export interface ModelRouterState {
|
|
16
|
+
/** Whether the composer quick route-switcher button is enabled. */
|
|
17
|
+
showQuickSwitch: boolean;
|
|
16
18
|
providers: Array<{
|
|
17
19
|
id: string;
|
|
18
20
|
name: string;
|
|
@@ -62,5 +64,7 @@ export declare class ModelRouterService implements RouterFacts {
|
|
|
62
64
|
switchActive(modelId: string, providerId: string): Promise<void>;
|
|
63
65
|
/** Replace the preference order for one model (active left untouched). */
|
|
64
66
|
reorder(modelId: string, order: string[]): Promise<void>;
|
|
67
|
+
/** Toggle the composer quick route-switcher button (global, persisted). */
|
|
68
|
+
setShowQuickSwitch(value: boolean): Promise<void>;
|
|
65
69
|
private persist;
|
|
66
70
|
}
|
|
@@ -51,6 +51,12 @@ export interface RouterModelConfig {
|
|
|
51
51
|
/** The whole router config document. */
|
|
52
52
|
export interface RouterConfigShape {
|
|
53
53
|
models: Record<string, RouterModelConfig>;
|
|
54
|
+
/**
|
|
55
|
+
* Whether the quick route-switcher button is shown beside the composer
|
|
56
|
+
* model selector (settings → 模型路由 → 显示快速路由切换按钮). Stored
|
|
57
|
+
* globally; absent/unknown documents normalize to `true`.
|
|
58
|
+
*/
|
|
59
|
+
showQuickSwitch: boolean;
|
|
54
60
|
}
|
|
55
61
|
export declare const ROUTER_PROVIDER_ID = "model-router";
|
|
56
62
|
export declare const ROUTER_SETTINGS_NS = "model-router";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonlan/dsh-model-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "DSH plugin: a virtual model-router provider that aggregates every configured provider and model, routes each request to the model's currently active provider, and ships a settings page for per-model provider ordering and switching.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -61,7 +61,9 @@
|
|
|
61
61
|
"@deepseek-ai/dsh-client-locale",
|
|
62
62
|
"@deepseek-ai/dsh-client-ui-slots",
|
|
63
63
|
"@deepseek-ai/dsh-client-ui-primitives",
|
|
64
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
66
|
+
"@deepseek-ai/dsh-client-ui-model-selection"
|
|
65
67
|
]
|
|
66
68
|
}
|
|
67
69
|
},
|
|
@@ -75,6 +77,8 @@
|
|
|
75
77
|
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
76
78
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
77
79
|
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
|
80
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
|
81
|
+
"@deepseek-ai/dsh-client-ui-model-selection": "^0.1.0-rc.6",
|
|
78
82
|
"react": "^18.2.0",
|
|
79
83
|
"react-dom": "^18.2.0"
|
|
80
84
|
},
|
|
@@ -93,6 +97,8 @@
|
|
|
93
97
|
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
94
98
|
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
|
95
99
|
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
|
100
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
|
101
|
+
"@deepseek-ai/dsh-client-ui-model-selection": "^0.1.0-rc.6",
|
|
96
102
|
"vitest": "^3.2.0"
|
|
97
103
|
},
|
|
98
104
|
"packageManager": "pnpm@11.21.0"
|
package/src/client/api.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface RouterModelView {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export interface ModelRouterState {
|
|
17
|
+
/** Whether the composer quick route-switcher button is enabled. */
|
|
18
|
+
showQuickSwitch: boolean
|
|
17
19
|
providers: RouterProviderView[]
|
|
18
20
|
models: RouterModelView[]
|
|
19
21
|
}
|
|
@@ -53,4 +55,6 @@ export const api = {
|
|
|
53
55
|
reorder: (modelId: string, order: string[]) =>
|
|
54
56
|
call<ModelRouterState>('reorder', { modelId, order }),
|
|
55
57
|
refresh: () => call<ModelRouterState>('refresh'),
|
|
58
|
+
setShowQuickSwitch: (value: boolean) =>
|
|
59
|
+
call<ModelRouterState>('set-show-quick-switch', { value }),
|
|
56
60
|
}
|
package/src/client/index.tsx
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fonlan/dsh-model-router client half: the Model Router settings page plus
|
|
3
|
-
*
|
|
4
|
-
* injection — the host catalog already renders the `model-router` group and
|
|
5
|
-
* each model's description carries the active provider.
|
|
3
|
+
* a quick provider switcher beside DSH's composer model selector.
|
|
6
4
|
*/
|
|
7
5
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
8
6
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
9
7
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
10
8
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
9
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
10
|
+
import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
11
11
|
import { LOCALE_NS, zh, en } from './locales'
|
|
12
12
|
import { makeSettingsSection } from './settings-section'
|
|
13
|
+
import { registerRouteSwitcher } from './route-switcher'
|
|
13
14
|
|
|
14
15
|
/** Services required before mounting (provided by the client runtime). */
|
|
15
|
-
export const inject = ['slots', 'locale']
|
|
16
|
+
export const inject = ['slots', 'locale', 'modelDirectories', 'sessions']
|
|
16
17
|
|
|
17
18
|
/** Client plugin body. */
|
|
18
19
|
export function apply(ctx: ClientContext): void {
|
|
@@ -32,4 +33,6 @@ export function apply(ctx: ClientContext): void {
|
|
|
32
33
|
locale: LOCALE_NS,
|
|
33
34
|
}, SettingsSection as never),
|
|
34
35
|
)
|
|
36
|
+
|
|
37
|
+
registerRouteSwitcher(ctx)
|
|
35
38
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -19,6 +19,16 @@ export const zh = {
|
|
|
19
19
|
switchFailed: '切换失败:{message}',
|
|
20
20
|
reorderFailed: '排序失败:{message}',
|
|
21
21
|
providerCount: '{count} 个提供商',
|
|
22
|
+
routeButtonLabel: '快速切换模型路由',
|
|
23
|
+
routeButtonTitle: '切换当前模型的提供商',
|
|
24
|
+
routeButtonAria: '切换当前模型提供商,当前为 {provider}',
|
|
25
|
+
routeMenuTitle: '{model} · 选择提供商',
|
|
26
|
+
routeLoading: '加载路由…',
|
|
27
|
+
unknownProvider: '未知提供商',
|
|
28
|
+
noCredentialShort: '未配置凭据',
|
|
29
|
+
globalRouteHint: '切换后对所有会话的后续请求生效',
|
|
30
|
+
showQuickSwitchLabel: '在输入栏显示快速路由切换按钮',
|
|
31
|
+
showQuickSwitchDescription: '开启后,当当前模型是 model-router 路由时,在输入框模型选择器右侧显示 provider 快速切换按钮。',
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
export const en = {
|
|
@@ -36,4 +46,14 @@ export const en = {
|
|
|
36
46
|
switchFailed: 'Switch failed: {message}',
|
|
37
47
|
reorderFailed: 'Reorder failed: {message}',
|
|
38
48
|
providerCount: '{count} providers',
|
|
49
|
+
routeButtonLabel: 'Quick model route switcher',
|
|
50
|
+
routeButtonTitle: 'Switch the current model provider',
|
|
51
|
+
routeButtonAria: 'Switch current model provider, currently {provider}',
|
|
52
|
+
routeMenuTitle: '{model} · Choose provider',
|
|
53
|
+
routeLoading: 'Loading routes…',
|
|
54
|
+
unknownProvider: 'Unknown provider',
|
|
55
|
+
noCredentialShort: 'No credentials',
|
|
56
|
+
globalRouteHint: 'Changes apply to subsequent requests in all sessions',
|
|
57
|
+
showQuickSwitchLabel: 'Show quick route switcher in the composer',
|
|
58
|
+
showQuickSwitchDescription: 'When enabled, a provider quick-switch button appears next to the model selector when the current model is a model-router route.',
|
|
39
59
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/* Quick provider route control beside DSH's composer model seat. */
|
|
2
|
+
.mr-route-anchor {
|
|
3
|
+
/* Menu's wrapper span: never let the row crush the trigger. */
|
|
4
|
+
flex: none;
|
|
5
|
+
min-width: 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.mr-route-trigger {
|
|
9
|
+
min-width: 0;
|
|
10
|
+
max-width: 180px;
|
|
11
|
+
height: 28px;
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
gap: 2px;
|
|
15
|
+
padding: 0 6px;
|
|
16
|
+
border: none;
|
|
17
|
+
border-radius: 24px;
|
|
18
|
+
color: var(--dsw-alias-label-secondary);
|
|
19
|
+
background: transparent;
|
|
20
|
+
cursor: pointer;
|
|
21
|
+
font: inherit;
|
|
22
|
+
font-size: 12px;
|
|
23
|
+
font-weight: 500;
|
|
24
|
+
line-height: 20px;
|
|
25
|
+
outline: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.mr-route-trigger:hover:not(:disabled) {
|
|
29
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.mr-route-trigger:focus-visible {
|
|
33
|
+
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.mr-route-trigger:disabled {
|
|
37
|
+
color: var(--dsw-alias-label-dimmed);
|
|
38
|
+
cursor: default;
|
|
39
|
+
opacity: 0.7;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.mr-route-trigger-label {
|
|
43
|
+
min-width: 0;
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
text-overflow: ellipsis;
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.mr-route-trigger-chevron {
|
|
50
|
+
color: var(--dsw-alias-label-caption);
|
|
51
|
+
flex: none;
|
|
52
|
+
transition: transform 0.12s;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.mr-route-trigger[aria-expanded="true"] .mr-route-trigger-chevron {
|
|
56
|
+
transform: rotate(180deg);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.mr-route-item-copy {
|
|
60
|
+
min-width: 0;
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: baseline;
|
|
63
|
+
gap: 6px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.mr-route-item-name {
|
|
67
|
+
min-width: 0;
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
text-overflow: ellipsis;
|
|
70
|
+
white-space: nowrap;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.mr-route-item-note {
|
|
74
|
+
flex: none;
|
|
75
|
+
color: var(--dsw-alias-label-tertiary);
|
|
76
|
+
font-size: 11px;
|
|
77
|
+
line-height: 16px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
* Mobile portrait: the composer row is an inline-size container. Keep the
|
|
82
|
+
* fixed control row intact — attachment, permission, provider (icon-only),
|
|
83
|
+
* context meter, send — and let the model seat absorb the remaining width.
|
|
84
|
+
* The breakpoint matches DSH's own narrow-composer rule (<=460px).
|
|
85
|
+
*/
|
|
86
|
+
@container (width <= 460px) {
|
|
87
|
+
.mr-route-anchor {
|
|
88
|
+
flex: 0 0 auto;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.mr-route-trigger {
|
|
92
|
+
/* The trigger now carries globe + chevron, so let it size naturally
|
|
93
|
+
instead of forcing a 28px circle that could overflow the row. */
|
|
94
|
+
height: 28px;
|
|
95
|
+
padding: 4px 6px;
|
|
96
|
+
justify-content: center;
|
|
97
|
+
border-radius: 999px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
* Make the composer's trailing group (the parent of our [data-slot] right
|
|
102
|
+
* wrapper) shrinkable on narrow rows so the globe/chevron trigger never
|
|
103
|
+
* spills left over the permission control. The model seat inside it
|
|
104
|
+
* absorbs the compression (flex 1 1 0, min-width 0 below).
|
|
105
|
+
*/
|
|
106
|
+
div:has(> [data-slot="conversation.input.right"]) {
|
|
107
|
+
flex: 1 1 auto;
|
|
108
|
+
min-width: 0;
|
|
109
|
+
justify-content: flex-end;
|
|
110
|
+
/* Mirror the trailing group's internal gap (12px) so the provider
|
|
111
|
+
trigger has equal spacing to the permission button on its left and
|
|
112
|
+
the model seat on its right. */
|
|
113
|
+
margin-left: 12px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.mr-route-trigger-label {
|
|
117
|
+
display: none;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* The model seat: each slot is wrapped in a display:contents
|
|
122
|
+
* [data-slot] div, so the ModelSelect root div (the real flex item of the
|
|
123
|
+
* composer row) is [data-slot="conversation.input.model"] > *. Give it
|
|
124
|
+
* the remaining row width; the other controls keep fixed sizes.
|
|
125
|
+
*/
|
|
126
|
+
[data-slot="conversation.input.right"] + [data-slot="conversation.input.model"] > * {
|
|
127
|
+
flex: 1 1 0;
|
|
128
|
+
min-width: 0;
|
|
129
|
+
max-width: 100%;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
[data-slot="conversation.input.right"] + [data-slot="conversation.input.model"] [aria-haspopup="menu"] {
|
|
133
|
+
width: 100%;
|
|
134
|
+
max-width: 100%;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
3
|
+
import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
4
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
5
|
+
import { IconChevronDownOutline14, IconGlobeOutline14, Menu, type MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
6
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
8
|
+
import { api, type ModelRouterState, type RouterModelView } from './api'
|
|
9
|
+
import { LOCALE_NS } from './locales'
|
|
10
|
+
import './route-switcher.css'
|
|
11
|
+
|
|
12
|
+
const ROUTER_PROVIDER_ID = 'model-router'
|
|
13
|
+
|
|
14
|
+
interface RouteSwitcherInjected {
|
|
15
|
+
available: boolean
|
|
16
|
+
directory: SnapshotStore<ModelDirectoryState>
|
|
17
|
+
load: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RouteSwitcherProps = PropsRuntime<'conversation.input.right'>
|
|
21
|
+
& PropsLocale<typeof LOCALE_NS>
|
|
22
|
+
& RouteSwitcherInjected
|
|
23
|
+
|
|
24
|
+
type RouteProvider = RouterModelView['providers'][number]
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The quick route switcher lives beside DSH's model seat in the composer row.
|
|
28
|
+
* It reads the same per-session model directory as the host model picker, but
|
|
29
|
+
* only appears when the selected model is the virtual model-router route.
|
|
30
|
+
*/
|
|
31
|
+
export function RouteSwitcher({
|
|
32
|
+
session,
|
|
33
|
+
available,
|
|
34
|
+
directory,
|
|
35
|
+
load,
|
|
36
|
+
t,
|
|
37
|
+
}: RouteSwitcherProps): JSX.Element | null {
|
|
38
|
+
const directoryState = useSyncExternalStore(
|
|
39
|
+
onChange => directory.subscribe(onChange),
|
|
40
|
+
() => directory.getSnapshot(),
|
|
41
|
+
() => directory.getSnapshot(),
|
|
42
|
+
)
|
|
43
|
+
const current = directoryState.current
|
|
44
|
+
const [routerState, setRouterState] = useState<ModelRouterState | null>(null)
|
|
45
|
+
const [routerLoading, setRouterLoading] = useState(false)
|
|
46
|
+
const [routerError, setRouterError] = useState<string | null>(null)
|
|
47
|
+
const [open, setOpen] = useState(false)
|
|
48
|
+
const [busyProvider, setBusyProvider] = useState<string | null>(null)
|
|
49
|
+
|
|
50
|
+
const isRouterModel = available && current?.provider === ROUTER_PROVIDER_ID
|
|
51
|
+
const modelId = isRouterModel ? current.model : null
|
|
52
|
+
|
|
53
|
+
const loadRouterState = useCallback(async () => {
|
|
54
|
+
setRouterLoading(true)
|
|
55
|
+
try {
|
|
56
|
+
setRouterState(await api.state())
|
|
57
|
+
setRouterError(null)
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
setRouterError(cause instanceof Error ? cause.message : String(cause))
|
|
60
|
+
} finally {
|
|
61
|
+
setRouterLoading(false)
|
|
62
|
+
}
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!available) return
|
|
67
|
+
load()
|
|
68
|
+
}, [available, load])
|
|
69
|
+
|
|
70
|
+
// Fetch the global toggle (and the model's provider list) on mount too, so a
|
|
71
|
+
// "hide quick switcher" setting applies even before the model directory
|
|
72
|
+
// settles. The button stays visible while the value is unknown (default on).
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!available) return
|
|
75
|
+
void loadRouterState()
|
|
76
|
+
}, [available])
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
setOpen(false)
|
|
80
|
+
setBusyProvider(null)
|
|
81
|
+
setRouterState(null)
|
|
82
|
+
setRouterError(null)
|
|
83
|
+
}, [modelId])
|
|
84
|
+
|
|
85
|
+
// The model-selection directory is refreshed when another session changes
|
|
86
|
+
// the router settings document. Re-read the global router state after that
|
|
87
|
+
// refresh so this button never shows a stale active provider. Include the
|
|
88
|
+
// model id: switching between two router models can leave the directory in
|
|
89
|
+
// the same ready state while changing the API row we need to display.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!isRouterModel || modelId === null || directoryState.status !== 'ready') return
|
|
92
|
+
void loadRouterState()
|
|
93
|
+
}, [directoryState.status, isRouterModel, loadRouterState, modelId])
|
|
94
|
+
|
|
95
|
+
const routeModel = useMemo(
|
|
96
|
+
() => modelId === null
|
|
97
|
+
? undefined
|
|
98
|
+
: routerState?.models.find(model => model.id === modelId),
|
|
99
|
+
[modelId, routerState],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const providers = useMemo(() => {
|
|
103
|
+
if (routeModel === undefined) return []
|
|
104
|
+
const byId = new Map(routeModel.providers.map(provider => [provider.id, provider]))
|
|
105
|
+
const ordered = routeModel.order
|
|
106
|
+
.map(id => byId.get(id))
|
|
107
|
+
.filter((provider): provider is RouteProvider => provider !== undefined)
|
|
108
|
+
const seen = new Set(ordered.map(provider => provider.id))
|
|
109
|
+
return [...ordered, ...routeModel.providers.filter(provider => !seen.has(provider.id))]
|
|
110
|
+
}, [routeModel])
|
|
111
|
+
|
|
112
|
+
const switchProvider = useCallback(async (providerId: string) => {
|
|
113
|
+
if (modelId === null || routeModel === undefined || routeModel.active === providerId) {
|
|
114
|
+
setOpen(false)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
setBusyProvider(providerId)
|
|
118
|
+
try {
|
|
119
|
+
const next = await api.switchActive(modelId, providerId)
|
|
120
|
+
setRouterState(next)
|
|
121
|
+
setRouterError(null)
|
|
122
|
+
setOpen(false)
|
|
123
|
+
// The model directory also listens for settings updates, but an explicit
|
|
124
|
+
// refresh keeps the host model picker description in sync immediately.
|
|
125
|
+
load()
|
|
126
|
+
} catch (cause) {
|
|
127
|
+
setRouterError(t('switchFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
128
|
+
} finally {
|
|
129
|
+
setBusyProvider(null)
|
|
130
|
+
}
|
|
131
|
+
}, [load, modelId, routeModel, t])
|
|
132
|
+
|
|
133
|
+
if (session.removed || !isRouterModel) return null
|
|
134
|
+
if (routerState?.showQuickSwitch === false) return null
|
|
135
|
+
|
|
136
|
+
const activeProvider = providers.find(provider => provider.id === routeModel?.active)
|
|
137
|
+
const activeName = activeProvider?.name ?? routeModel?.active ?? t('routeLoading')
|
|
138
|
+
if (routeModel !== undefined && providers.length < 2) return null
|
|
139
|
+
|
|
140
|
+
const menuItems: MenuEntry[] = routeModel === undefined
|
|
141
|
+
? [{
|
|
142
|
+
type: 'label',
|
|
143
|
+
id: 'route-status',
|
|
144
|
+
text: routerError ?? t('routeLoading'),
|
|
145
|
+
}]
|
|
146
|
+
: [
|
|
147
|
+
{
|
|
148
|
+
type: 'label',
|
|
149
|
+
id: 'route-model',
|
|
150
|
+
text: t('routeMenuTitle', { model: modelId }),
|
|
151
|
+
},
|
|
152
|
+
...providers.map(provider => ({
|
|
153
|
+
id: provider.id,
|
|
154
|
+
label: (
|
|
155
|
+
<span className="mr-route-item-copy">
|
|
156
|
+
<span className="mr-route-item-name">{provider.name}</span>
|
|
157
|
+
{!provider.credentialConfigured && (
|
|
158
|
+
<span className="mr-route-item-note">{t('noCredentialShort')}</span>
|
|
159
|
+
)}
|
|
160
|
+
</span>
|
|
161
|
+
),
|
|
162
|
+
disabled: !provider.credentialConfigured || busyProvider !== null,
|
|
163
|
+
})),
|
|
164
|
+
...(routerError === null ? [] : [{
|
|
165
|
+
id: 'route-error',
|
|
166
|
+
label: routerError,
|
|
167
|
+
disabled: true,
|
|
168
|
+
danger: true,
|
|
169
|
+
}]),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<Menu
|
|
174
|
+
className="mr-route-anchor"
|
|
175
|
+
open={open}
|
|
176
|
+
side="top"
|
|
177
|
+
align="end"
|
|
178
|
+
portal
|
|
179
|
+
compact
|
|
180
|
+
dense
|
|
181
|
+
items={menuItems}
|
|
182
|
+
selectedId={routeModel?.active ?? undefined}
|
|
183
|
+
onSelect={id => {
|
|
184
|
+
if (id === 'route-error' || routeModel === undefined) {
|
|
185
|
+
void loadRouterState()
|
|
186
|
+
load()
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
void switchProvider(id)
|
|
190
|
+
}}
|
|
191
|
+
onClose={() => setOpen(false)}
|
|
192
|
+
footer={[
|
|
193
|
+
{
|
|
194
|
+
type: 'label',
|
|
195
|
+
id: 'route-global-hint',
|
|
196
|
+
text: t('globalRouteHint'),
|
|
197
|
+
},
|
|
198
|
+
]}
|
|
199
|
+
anchor={(
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
className="mr-route-trigger"
|
|
203
|
+
aria-label={t('routeButtonAria', { provider: activeName })}
|
|
204
|
+
aria-haspopup="menu"
|
|
205
|
+
aria-expanded={open}
|
|
206
|
+
disabled={routerLoading || busyProvider !== null}
|
|
207
|
+
onClick={() => {
|
|
208
|
+
setOpen(value => !value)
|
|
209
|
+
if (!open) {
|
|
210
|
+
void loadRouterState()
|
|
211
|
+
load()
|
|
212
|
+
}
|
|
213
|
+
}}
|
|
214
|
+
title={t('routeButtonTitle')}
|
|
215
|
+
>
|
|
216
|
+
<IconGlobeOutline14 />
|
|
217
|
+
<span className="mr-route-trigger-label">{activeName}</span>
|
|
218
|
+
<span className="mr-route-trigger-chevron" aria-hidden>
|
|
219
|
+
<IconChevronDownOutline14 />
|
|
220
|
+
</span>
|
|
221
|
+
</button>
|
|
222
|
+
)}
|
|
223
|
+
/>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Register the quick route switcher into the composer right-side tool row. */
|
|
228
|
+
export function registerRouteSwitcher(ctx: ClientContext): void {
|
|
229
|
+
const t = ctx.locale.bind(LOCALE_NS)
|
|
230
|
+
ctx.inject(['slots', 'modelDirectories'], scope => {
|
|
231
|
+
const models = scope.modelDirectories
|
|
232
|
+
const sessions = scope.sessions
|
|
233
|
+
scope.slots.inject('conversation.input.right', () => scope.slots.register({
|
|
234
|
+
name: 'conversation.input.right',
|
|
235
|
+
id: 'model-router-route-switcher',
|
|
236
|
+
order: 100,
|
|
237
|
+
label: () => t('routeButtonLabel'),
|
|
238
|
+
locale: LOCALE_NS,
|
|
239
|
+
inject: (sessionId) => {
|
|
240
|
+
const directory = models.directoryFor(sessionId)
|
|
241
|
+
const available = sessions.subagentAddress(sessionId) === undefined
|
|
242
|
+
return {
|
|
243
|
+
available,
|
|
244
|
+
directory: directory.store,
|
|
245
|
+
load: () => {
|
|
246
|
+
if (available) directory.load().catch(() => {})
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
}, RouteSwitcher as never))
|
|
251
|
+
})
|
|
252
|
+
}
|
|
@@ -13,6 +13,41 @@
|
|
|
13
13
|
line-height: 20px;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
.mr-quick-switch {
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: flex-start;
|
|
19
|
+
gap: 8px;
|
|
20
|
+
padding: 8px 10px;
|
|
21
|
+
border: 1px solid var(--dsw-alias-border-inverted);
|
|
22
|
+
border-radius: 10px;
|
|
23
|
+
background: var(--dsw-alias-bg-module-platform);
|
|
24
|
+
cursor: pointer;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.mr-quick-switch input[type="checkbox"] {
|
|
28
|
+
margin-top: 2px;
|
|
29
|
+
accent-color: var(--dsw-alias-accent);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.mr-quick-switch-copy {
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
gap: 2px;
|
|
36
|
+
min-width: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.mr-quick-switch-label {
|
|
40
|
+
color: var(--dsw-alias-label-primary);
|
|
41
|
+
font-size: 13px;
|
|
42
|
+
line-height: 20px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.mr-quick-switch-desc {
|
|
46
|
+
color: var(--dsw-alias-label-tertiary);
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
line-height: 18px;
|
|
49
|
+
}
|
|
50
|
+
|
|
16
51
|
.mr-toolbar {
|
|
17
52
|
display: flex;
|
|
18
53
|
justify-content: space-between;
|