@fonlan/dsh-model-router 0.3.1 → 0.3.4

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.
@@ -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 quick route-switcher button is shown beside the composer
72
- * model selector (settings → 模型路由 → 显示快速路由切换按钮). Stored
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
- return { models: next, changed: !sameModels(next, config.models) }
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) return { models, showQuickSwitch, ignoreModelIdPrefix }
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
- }