@fonlan/dsh-model-router 0.3.1 → 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/README.md +12 -5
- package/lib/client.js +655 -130
- package/lib/client.js.map +1 -1
- package/lib/index.js +237 -26
- package/lib/types/server/adapter.d.ts +27 -1
- package/lib/types/server/service.d.ts +28 -2
- package/lib/types/shared/config.d.ts +54 -2
- package/package.json +31 -29
- package/src/client/api.ts +11 -1
- package/src/client/index.tsx +4 -4
- package/src/client/locales.ts +56 -12
- package/src/client/model-seat.css +289 -0
- package/src/client/model-seat.tsx +623 -0
- package/src/client/settings-card.css +141 -0
- package/src/client/settings-card.tsx +109 -1
- package/src/server/adapter.ts +62 -13
- package/src/server/rpc.ts +16 -0
- package/src/server/service.ts +112 -21
- package/src/shared/config.ts +142 -8
- package/src/client/route-switcher.css +0 -136
- package/src/client/route-switcher.tsx +0 -277
package/src/shared/config.ts
CHANGED
|
@@ -64,12 +64,28 @@ export interface RouterModelConfig {
|
|
|
64
64
|
active: string
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* How the router orders the model list it advertises to the host model
|
|
69
|
+
* picker (the `model-router` group in DSH's model selection box).
|
|
70
|
+
*
|
|
71
|
+
* - `custom`: the explicit `modelOrder` array wins; models absent from it are
|
|
72
|
+
* appended in catalog (first-seen) order.
|
|
73
|
+
* - `name`: models sorted by their display name (active provider's name),
|
|
74
|
+
* falling back to the merged id.
|
|
75
|
+
* - `recent`: models sorted by most-recently-used first (timestamp desc);
|
|
76
|
+
* never-used models come last in catalog order.
|
|
77
|
+
*
|
|
78
|
+
* Absent/unknown documents normalize to `custom` (the pre-existing catalog
|
|
79
|
+
* order, so upgrading is a no-op until the user changes it).
|
|
80
|
+
*/
|
|
81
|
+
export type ModelSortMode = 'custom' | 'name' | 'recent'
|
|
82
|
+
|
|
67
83
|
/** The whole router config document. */
|
|
68
84
|
export interface RouterConfigShape {
|
|
69
85
|
models: Record<string, RouterModelConfig>
|
|
70
86
|
/**
|
|
71
|
-
* Whether the
|
|
72
|
-
*
|
|
87
|
+
* Whether the provider switching entry is shown inside the composer model
|
|
88
|
+
* picker menu (settings → 模型路由 → 在模型选择菜单中显示提供商切换). Stored
|
|
73
89
|
* globally; absent/unknown documents normalize to `true`.
|
|
74
90
|
*/
|
|
75
91
|
showQuickSwitch: boolean
|
|
@@ -81,6 +97,23 @@ export interface RouterConfigShape {
|
|
|
81
97
|
* `true`.
|
|
82
98
|
*/
|
|
83
99
|
ignoreModelIdPrefix: boolean
|
|
100
|
+
/**
|
|
101
|
+
* The model-list display order mode (settings → 模型路由 → 模型显示顺序).
|
|
102
|
+
* Absent/unknown documents normalize to `custom`.
|
|
103
|
+
*/
|
|
104
|
+
modelSort: ModelSortMode
|
|
105
|
+
/**
|
|
106
|
+
* Explicit model id order for `custom` mode, in display order. Kept
|
|
107
|
+
* reconciled against the catalog (vanished ids pruned, new ids appended).
|
|
108
|
+
* Absent/unknown documents normalize to `[]`.
|
|
109
|
+
*/
|
|
110
|
+
modelOrder: string[]
|
|
111
|
+
/**
|
|
112
|
+
* Merged model id → last-use epoch millis, for `recent` mode. Written by
|
|
113
|
+
* the router adapter whenever a model request streams. Absent/unknown
|
|
114
|
+
* documents normalize to `{}`.
|
|
115
|
+
*/
|
|
116
|
+
recentlyUsed: Record<string, number>
|
|
84
117
|
}
|
|
85
118
|
|
|
86
119
|
export const ROUTER_PROVIDER_ID = 'model-router'
|
|
@@ -152,6 +185,59 @@ export function displayProvider(
|
|
|
152
185
|
return merged.providers.find(p => p.provider === active) ?? merged.providers[0]
|
|
153
186
|
}
|
|
154
187
|
|
|
188
|
+
/** The display name a merged model advertises (active provider's name). */
|
|
189
|
+
export function displayNameOf(
|
|
190
|
+
model: MergedModel,
|
|
191
|
+
config: RouterConfigShape,
|
|
192
|
+
): string {
|
|
193
|
+
const ids = model.providers.map(provider => provider.provider)
|
|
194
|
+
const active = resolveActive(config, model.id, ids)
|
|
195
|
+
return displayProvider(model, active ?? undefined)?.name ?? model.id
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Order the merged catalog the way the model picker should list it, per the
|
|
200
|
+
* configured `modelSort` mode. Always returns a permutation of `models`
|
|
201
|
+
* (never drops or duplicates entries).
|
|
202
|
+
*
|
|
203
|
+
* - `custom`: `config.modelOrder` first (in stored order), then any model not
|
|
204
|
+
* present in it, in catalog order.
|
|
205
|
+
* - `name`: display-name ascending (numeric-aware, case-insensitive), ties
|
|
206
|
+
* keep catalog order (stable sort).
|
|
207
|
+
* - `recent`: most-recently-used first; never-used models sink to the tail in
|
|
208
|
+
* catalog order. Ties (equal timestamps, or both unused) keep catalog
|
|
209
|
+
* order (stable sort).
|
|
210
|
+
*/
|
|
211
|
+
export function sortModelsForList(
|
|
212
|
+
models: readonly MergedModel[],
|
|
213
|
+
config: RouterConfigShape,
|
|
214
|
+
): MergedModel[] {
|
|
215
|
+
if (config.modelSort === 'name') {
|
|
216
|
+
const byName = new Map(models.map(model => [model, displayNameOf(model, config)]))
|
|
217
|
+
return [...models].sort((left, right) =>
|
|
218
|
+
byName.get(left)!.localeCompare(byName.get(right)!, undefined, { numeric: true, sensitivity: 'base' }),
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
if (config.modelSort === 'recent') {
|
|
222
|
+
const recent = config.recentlyUsed ?? {}
|
|
223
|
+
return [...models].sort((left, right) =>
|
|
224
|
+
(recent[right.id] ?? -Infinity) - (recent[left.id] ?? -Infinity),
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
// custom: explicit order first (deduped), then the catalog-order tail
|
|
228
|
+
const listed: MergedModel[] = []
|
|
229
|
+
const seen = new Set<string>()
|
|
230
|
+
for (const id of config.modelOrder ?? []) {
|
|
231
|
+
if (seen.has(id)) continue
|
|
232
|
+
const model = models.find(candidate => candidate.id === id)
|
|
233
|
+
if (model === undefined) continue
|
|
234
|
+
seen.add(id)
|
|
235
|
+
listed.push(model)
|
|
236
|
+
}
|
|
237
|
+
const tail = models.filter(model => !seen.has(model.id))
|
|
238
|
+
return [...listed, ...tail]
|
|
239
|
+
}
|
|
240
|
+
|
|
155
241
|
/** Initial config for a model the user never configured: first in order wins. */
|
|
156
242
|
export function initialConfigFor(providerIds: readonly string[]): RouterModelConfig | null {
|
|
157
243
|
if (providerIds.length === 0) return null
|
|
@@ -256,11 +342,14 @@ function sameModels(a: Record<string, RouterModelConfig>, b: Record<string, Rout
|
|
|
256
342
|
* for `active`, and `order` becomes the deduped union in exact-key-first
|
|
257
343
|
* order. Stored keys no longer mapping to any merged model are dropped (this
|
|
258
344
|
* also covers turning the flag off — split models re-initialize fresh).
|
|
345
|
+
*
|
|
346
|
+
* `modelOrder` (custom display order) is reconciled the same way: vanished
|
|
347
|
+
* ids are pruned, newly discovered models are appended in catalog order.
|
|
259
348
|
*/
|
|
260
349
|
export function reconcileConfig(
|
|
261
350
|
config: RouterConfigShape,
|
|
262
351
|
models: readonly MergedModel[],
|
|
263
|
-
): { models: Record<string, RouterModelConfig>; changed: boolean } {
|
|
352
|
+
): { models: Record<string, RouterModelConfig>; modelOrder: string[] | undefined; changed: boolean } {
|
|
264
353
|
// stored key → merged id it belongs to (exact key wins; prefixed raw keys
|
|
265
354
|
// map to their stripped merged id only while the flag is on)
|
|
266
355
|
const keyToMerged = new Map<string, string>()
|
|
@@ -306,7 +395,31 @@ export function reconcileConfig(
|
|
|
306
395
|
if (entry !== undefined) next[model.id] = entry
|
|
307
396
|
}
|
|
308
397
|
|
|
309
|
-
|
|
398
|
+
// Reconcile the custom display order against the live catalog: prune
|
|
399
|
+
// vanished ids, append newly discovered models in catalog order. Documents
|
|
400
|
+
// that predate the field (modelOrder undefined) keep it absent — the
|
|
401
|
+
// service's normalizeConfig materializes it on the next write, and an old
|
|
402
|
+
// document must not report drift merely because the field is missing.
|
|
403
|
+
const storedOrder = config.modelOrder
|
|
404
|
+
let modelOrder: string[] | undefined
|
|
405
|
+
let orderChanged = false
|
|
406
|
+
if (storedOrder !== undefined) {
|
|
407
|
+
const catalogIds = new Set(models.map(model => model.id))
|
|
408
|
+
modelOrder = storedOrder.filter(id => catalogIds.has(id))
|
|
409
|
+
orderChanged = modelOrder.length !== storedOrder.length
|
|
410
|
+
for (const model of models) {
|
|
411
|
+
if (!modelOrder.includes(model.id)) {
|
|
412
|
+
modelOrder.push(model.id)
|
|
413
|
+
orderChanged = true
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
models: next,
|
|
420
|
+
modelOrder,
|
|
421
|
+
changed: !sameModels(next, config.models) || orderChanged,
|
|
422
|
+
}
|
|
310
423
|
}
|
|
311
424
|
|
|
312
425
|
/** Switch a model's active provider (no-op when already active). */
|
|
@@ -354,13 +467,34 @@ export function setOrder(
|
|
|
354
467
|
export function normalizeConfig(value: unknown): RouterConfigShape {
|
|
355
468
|
const models: Record<string, RouterModelConfig> = {}
|
|
356
469
|
if (typeof value !== 'object' || value === null) {
|
|
357
|
-
return { models, showQuickSwitch: true, ignoreModelIdPrefix: true }
|
|
470
|
+
return { models, showQuickSwitch: true, ignoreModelIdPrefix: true, modelSort: 'custom', modelOrder: [], recentlyUsed: {} }
|
|
471
|
+
}
|
|
472
|
+
const root = value as {
|
|
473
|
+
models?: unknown
|
|
474
|
+
showQuickSwitch?: unknown
|
|
475
|
+
ignoreModelIdPrefix?: unknown
|
|
476
|
+
modelSort?: unknown
|
|
477
|
+
modelOrder?: unknown
|
|
478
|
+
recentlyUsed?: unknown
|
|
358
479
|
}
|
|
359
|
-
const root = value as { models?: unknown; showQuickSwitch?: unknown; ignoreModelIdPrefix?: unknown }
|
|
360
480
|
const showQuickSwitch = root.showQuickSwitch !== false
|
|
361
481
|
const ignoreModelIdPrefix = root.ignoreModelIdPrefix !== false
|
|
482
|
+
const modelSort: ModelSortMode = root.modelSort === 'name' || root.modelSort === 'recent'
|
|
483
|
+
? root.modelSort
|
|
484
|
+
: 'custom'
|
|
485
|
+
const modelOrder = Array.isArray(root.modelOrder)
|
|
486
|
+
? root.modelOrder.filter((x): x is string => typeof x === 'string')
|
|
487
|
+
: []
|
|
488
|
+
const recentlyUsed: Record<string, number> = {}
|
|
489
|
+
if (typeof root.recentlyUsed === 'object' && root.recentlyUsed !== null) {
|
|
490
|
+
for (const [id, ts] of Object.entries(root.recentlyUsed as Record<string, unknown>)) {
|
|
491
|
+
if (typeof ts === 'number' && Number.isFinite(ts)) recentlyUsed[id] = ts
|
|
492
|
+
}
|
|
493
|
+
}
|
|
362
494
|
const raw = root.models
|
|
363
|
-
if (typeof raw !== 'object' || raw === null)
|
|
495
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
496
|
+
return { models, showQuickSwitch, ignoreModelIdPrefix, modelSort, modelOrder, recentlyUsed }
|
|
497
|
+
}
|
|
364
498
|
for (const [id, entry] of Object.entries(raw as Record<string, unknown>)) {
|
|
365
499
|
if (typeof entry !== 'object' || entry === null) continue
|
|
366
500
|
const e = entry as { order?: unknown; active?: unknown }
|
|
@@ -369,5 +503,5 @@ export function normalizeConfig(value: unknown): RouterConfigShape {
|
|
|
369
503
|
if (active === '') continue
|
|
370
504
|
models[id] = { order, active }
|
|
371
505
|
}
|
|
372
|
-
return { models, showQuickSwitch, ignoreModelIdPrefix }
|
|
506
|
+
return { models, showQuickSwitch, ignoreModelIdPrefix, modelSort, modelOrder, recentlyUsed }
|
|
373
507
|
}
|
|
@@ -1,136 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,277 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, 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
|
-
/**
|
|
54
|
-
* Fetch the router state. `silent` skips the busy state: background
|
|
55
|
-
* refreshes (directory-ready, model switch) should never gray out the
|
|
56
|
-
* button — only the initial load and explicit user actions do.
|
|
57
|
-
*/
|
|
58
|
-
const loadRouterState = useCallback(async (silent = false) => {
|
|
59
|
-
if (!silent) setRouterLoading(true)
|
|
60
|
-
try {
|
|
61
|
-
setRouterState(await api.state())
|
|
62
|
-
setRouterError(null)
|
|
63
|
-
} catch (cause) {
|
|
64
|
-
setRouterError(cause instanceof Error ? cause.message : String(cause))
|
|
65
|
-
} finally {
|
|
66
|
-
if (!silent) setRouterLoading(false)
|
|
67
|
-
}
|
|
68
|
-
}, [])
|
|
69
|
-
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
if (!available) return
|
|
72
|
-
load()
|
|
73
|
-
}, [available, load])
|
|
74
|
-
|
|
75
|
-
// Fetch the global toggle (and the model's provider list) on mount too, so a
|
|
76
|
-
// "hide quick switcher" setting applies even before the model directory
|
|
77
|
-
// settles. The button stays visible while the value is unknown (default on).
|
|
78
|
-
useEffect(() => {
|
|
79
|
-
if (!available) return
|
|
80
|
-
void loadRouterState()
|
|
81
|
-
}, [available])
|
|
82
|
-
|
|
83
|
-
useEffect(() => {
|
|
84
|
-
setOpen(false)
|
|
85
|
-
setBusyProvider(null)
|
|
86
|
-
// Keep the last-known router state across model switches: the global
|
|
87
|
-
// provider count stays valid while the per-model row refetches, so a
|
|
88
|
-
// single-provider router hides the button without a loading flash.
|
|
89
|
-
setRouterError(null)
|
|
90
|
-
}, [modelId])
|
|
91
|
-
|
|
92
|
-
// The model-selection directory is refreshed when another session changes
|
|
93
|
-
// the router settings document. Re-read the global router state after that
|
|
94
|
-
// refresh so this button never shows a stale active provider. Include the
|
|
95
|
-
// model id: switching between two router models can leave the directory in
|
|
96
|
-
// the same ready state while changing the API row we need to display.
|
|
97
|
-
//
|
|
98
|
-
// This refresh is debounced and silent: directory refreshes fire in bursts
|
|
99
|
-
// (each settings change re-loads it), and the whole point of the silent
|
|
100
|
-
// mode is that background refreshes never gray out the button.
|
|
101
|
-
const refreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
102
|
-
useEffect(() => {
|
|
103
|
-
if (!isRouterModel || modelId === null || directoryState.status !== 'ready') return
|
|
104
|
-
if (refreshTimer.current !== null) clearTimeout(refreshTimer.current)
|
|
105
|
-
refreshTimer.current = setTimeout(() => {
|
|
106
|
-
refreshTimer.current = null
|
|
107
|
-
void loadRouterState(true)
|
|
108
|
-
}, 300)
|
|
109
|
-
return () => {
|
|
110
|
-
if (refreshTimer.current !== null) clearTimeout(refreshTimer.current)
|
|
111
|
-
refreshTimer.current = null
|
|
112
|
-
}
|
|
113
|
-
}, [directoryState.status, isRouterModel, loadRouterState, modelId])
|
|
114
|
-
|
|
115
|
-
const routeModel = useMemo(
|
|
116
|
-
() => modelId === null
|
|
117
|
-
? undefined
|
|
118
|
-
: routerState?.models.find(model => model.id === modelId),
|
|
119
|
-
[modelId, routerState],
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
const providers = useMemo(() => {
|
|
123
|
-
if (routeModel === undefined) return []
|
|
124
|
-
const byId = new Map(routeModel.providers.map(provider => [provider.id, provider]))
|
|
125
|
-
const ordered = routeModel.order
|
|
126
|
-
.map(id => byId.get(id))
|
|
127
|
-
.filter((provider): provider is RouteProvider => provider !== undefined)
|
|
128
|
-
const seen = new Set(ordered.map(provider => provider.id))
|
|
129
|
-
return [...ordered, ...routeModel.providers.filter(provider => !seen.has(provider.id))]
|
|
130
|
-
}, [routeModel])
|
|
131
|
-
|
|
132
|
-
const switchProvider = useCallback(async (providerId: string) => {
|
|
133
|
-
if (modelId === null || routeModel === undefined || routeModel.active === providerId) {
|
|
134
|
-
setOpen(false)
|
|
135
|
-
return
|
|
136
|
-
}
|
|
137
|
-
setBusyProvider(providerId)
|
|
138
|
-
try {
|
|
139
|
-
const next = await api.switchActive(modelId, providerId)
|
|
140
|
-
setRouterState(next)
|
|
141
|
-
setRouterError(null)
|
|
142
|
-
setOpen(false)
|
|
143
|
-
// The model directory also listens for settings updates, but an explicit
|
|
144
|
-
// refresh keeps the host model picker description in sync immediately.
|
|
145
|
-
load()
|
|
146
|
-
} catch (cause) {
|
|
147
|
-
setRouterError(t('switchFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
148
|
-
} finally {
|
|
149
|
-
setBusyProvider(null)
|
|
150
|
-
}
|
|
151
|
-
}, [load, modelId, routeModel, t])
|
|
152
|
-
|
|
153
|
-
if (session.removed || !isRouterModel) return null
|
|
154
|
-
if (routerState?.showQuickSwitch === false) return null
|
|
155
|
-
|
|
156
|
-
const activeProvider = providers.find(provider => provider.id === routeModel?.active)
|
|
157
|
-
const activeName = activeProvider?.name ?? routeModel?.active ?? t('routeLoading')
|
|
158
|
-
// Nothing to switch between: hide when the router serves fewer than two
|
|
159
|
-
// providers in total (known as soon as the global state resolves, even
|
|
160
|
-
// while the per-model row is still loading), or when the current model
|
|
161
|
-
// is served by a single provider.
|
|
162
|
-
if (routerState !== null && routerState.providers.length < 2) return null
|
|
163
|
-
if (routeModel !== undefined && providers.length < 2) return null
|
|
164
|
-
|
|
165
|
-
const menuItems: MenuEntry[] = routeModel === undefined
|
|
166
|
-
? [{
|
|
167
|
-
type: 'label',
|
|
168
|
-
id: 'route-status',
|
|
169
|
-
text: routerError ?? t('routeLoading'),
|
|
170
|
-
}]
|
|
171
|
-
: [
|
|
172
|
-
{
|
|
173
|
-
type: 'label',
|
|
174
|
-
id: 'route-model',
|
|
175
|
-
text: t('routeMenuTitle', { model: modelId }),
|
|
176
|
-
},
|
|
177
|
-
...providers.map(provider => ({
|
|
178
|
-
id: provider.id,
|
|
179
|
-
label: (
|
|
180
|
-
<span className="mr-route-item-copy">
|
|
181
|
-
<span className="mr-route-item-name">{provider.name}</span>
|
|
182
|
-
{!provider.credentialConfigured && (
|
|
183
|
-
<span className="mr-route-item-note">{t('noCredentialShort')}</span>
|
|
184
|
-
)}
|
|
185
|
-
</span>
|
|
186
|
-
),
|
|
187
|
-
disabled: !provider.credentialConfigured || busyProvider !== null,
|
|
188
|
-
})),
|
|
189
|
-
...(routerError === null ? [] : [{
|
|
190
|
-
id: 'route-error',
|
|
191
|
-
label: routerError,
|
|
192
|
-
disabled: true,
|
|
193
|
-
danger: true,
|
|
194
|
-
}]),
|
|
195
|
-
]
|
|
196
|
-
|
|
197
|
-
return (
|
|
198
|
-
<Menu
|
|
199
|
-
className="mr-route-anchor"
|
|
200
|
-
open={open}
|
|
201
|
-
side="top"
|
|
202
|
-
align="end"
|
|
203
|
-
portal
|
|
204
|
-
compact
|
|
205
|
-
dense
|
|
206
|
-
items={menuItems}
|
|
207
|
-
selectedId={routeModel?.active ?? undefined}
|
|
208
|
-
onSelect={id => {
|
|
209
|
-
if (id === 'route-error' || routeModel === undefined) {
|
|
210
|
-
void loadRouterState()
|
|
211
|
-
load()
|
|
212
|
-
return
|
|
213
|
-
}
|
|
214
|
-
void switchProvider(id)
|
|
215
|
-
}}
|
|
216
|
-
onClose={() => setOpen(false)}
|
|
217
|
-
footer={[
|
|
218
|
-
{
|
|
219
|
-
type: 'label',
|
|
220
|
-
id: 'route-global-hint',
|
|
221
|
-
text: t('globalRouteHint'),
|
|
222
|
-
},
|
|
223
|
-
]}
|
|
224
|
-
anchor={(
|
|
225
|
-
<button
|
|
226
|
-
type="button"
|
|
227
|
-
className="mr-route-trigger"
|
|
228
|
-
aria-label={t('routeButtonAria', { provider: activeName })}
|
|
229
|
-
aria-haspopup="menu"
|
|
230
|
-
aria-expanded={open}
|
|
231
|
-
disabled={routerLoading || busyProvider !== null}
|
|
232
|
-
onClick={() => {
|
|
233
|
-
setOpen(value => !value)
|
|
234
|
-
if (!open) {
|
|
235
|
-
void loadRouterState()
|
|
236
|
-
load()
|
|
237
|
-
}
|
|
238
|
-
}}
|
|
239
|
-
title={t('routeButtonTitle')}
|
|
240
|
-
>
|
|
241
|
-
<IconGlobeOutline14 />
|
|
242
|
-
<span className="mr-route-trigger-label">{activeName}</span>
|
|
243
|
-
<span className="mr-route-trigger-chevron" aria-hidden>
|
|
244
|
-
<IconChevronDownOutline14 />
|
|
245
|
-
</span>
|
|
246
|
-
</button>
|
|
247
|
-
)}
|
|
248
|
-
/>
|
|
249
|
-
)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** Register the quick route switcher into the composer right-side tool row. */
|
|
253
|
-
export function registerRouteSwitcher(ctx: ClientContext): void {
|
|
254
|
-
const t = ctx.locale.bind(LOCALE_NS)
|
|
255
|
-
ctx.inject(['slots', 'modelDirectories'], scope => {
|
|
256
|
-
const models = scope.modelDirectories
|
|
257
|
-
const sessions = scope.sessions
|
|
258
|
-
scope.slots.inject('conversation.input.right', () => scope.slots.register({
|
|
259
|
-
name: 'conversation.input.right',
|
|
260
|
-
id: 'model-router-route-switcher',
|
|
261
|
-
order: 100,
|
|
262
|
-
label: () => t('routeButtonLabel'),
|
|
263
|
-
locale: LOCALE_NS,
|
|
264
|
-
inject: (sessionId) => {
|
|
265
|
-
const directory = models.directoryFor(sessionId)
|
|
266
|
-
const available = sessions.subagentAddress(sessionId) === undefined
|
|
267
|
-
return {
|
|
268
|
-
available,
|
|
269
|
-
directory: directory.store,
|
|
270
|
-
load: () => {
|
|
271
|
-
if (available) directory.load().catch(() => {})
|
|
272
|
-
},
|
|
273
|
-
}
|
|
274
|
-
},
|
|
275
|
-
}, RouteSwitcher as never))
|
|
276
|
-
})
|
|
277
|
-
}
|