@fonlan/dsh-model-router 0.3.0 → 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 +17 -10
- package/lib/client.js +844 -239
- package/lib/client.js.map +1 -1
- package/lib/index.js +257 -30
- package/lib/types/server/adapter.d.ts +27 -1
- package/lib/types/server/service.d.ts +45 -5
- package/lib/types/shared/config.d.ts +54 -2
- package/package.json +33 -29
- package/src/client/api.ts +11 -1
- package/src/client/index.tsx +39 -16
- package/src/client/locales.ts +64 -14
- package/src/client/model-seat.css +289 -0
- package/src/client/model-seat.tsx +623 -0
- package/src/client/settings-card.css +470 -0
- package/src/client/settings-card.tsx +468 -0
- package/src/server/adapter.ts +62 -13
- package/src/server/rpc.ts +16 -0
- package/src/server/service.ts +135 -25
- package/src/shared/config.ts +142 -8
- package/src/client/route-switcher.css +0 -136
- package/src/client/route-switcher.tsx +0 -252
- package/src/client/settings-section.css +0 -245
- package/src/client/settings-section.tsx +0 -293
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useId,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
type FocusEvent as ReactFocusEvent,
|
|
10
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
11
|
+
} from 'react'
|
|
12
|
+
import type { ClientContext, SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
13
|
+
import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
14
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
15
|
+
import {
|
|
16
|
+
IconCheckOutline16,
|
|
17
|
+
IconChevronDownOutline14,
|
|
18
|
+
IconChevronRightOutline14,
|
|
19
|
+
IconWarningOutline16,
|
|
20
|
+
Toast,
|
|
21
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
22
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
24
|
+
import { api, type ModelRouterState, type RouterModelView } from './api'
|
|
25
|
+
import { LOCALE_NS } from './locales'
|
|
26
|
+
import './model-seat.css'
|
|
27
|
+
|
|
28
|
+
const ROUTER_PROVIDER_ID = 'model-router'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Minimal structural ModelSelection (wire shape: provider / model /
|
|
32
|
+
* reasoningEffort). Declared locally to avoid pulling a new peer dependency
|
|
33
|
+
* for a type-only surface; structurally identical to the host wire type.
|
|
34
|
+
*/
|
|
35
|
+
interface ModelSelection {
|
|
36
|
+
provider: string
|
|
37
|
+
model: string
|
|
38
|
+
reasoningEffort?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The injected business face of the composer model seat (same share the host ModelSelect receives). */
|
|
42
|
+
interface ModelSeatInjected {
|
|
43
|
+
available: boolean
|
|
44
|
+
directory: SnapshotStore<ModelDirectoryState>
|
|
45
|
+
load: () => void
|
|
46
|
+
select: (selection: ModelSelection) => Promise<boolean>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type ModelSeatProps = PropsRuntime<'conversation.input.model'>
|
|
50
|
+
& ModelSeatInjected
|
|
51
|
+
& PropsLocale<typeof LOCALE_NS>
|
|
52
|
+
|
|
53
|
+
type RouteProvider = RouterModelView['providers'][number]
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The composer model seat, shadowing the host ModelSelect at a lower slot
|
|
57
|
+
* priority. It renders the same two-level Model / Effort menu as the host
|
|
58
|
+
* (model list grouped by provider, reasoning effort levels), plus a third
|
|
59
|
+
* Provider row that opens the model-router provider list — the former quick
|
|
60
|
+
* route-switcher button moved into the model picker, on par with the model
|
|
61
|
+
* and effort menus.
|
|
62
|
+
*
|
|
63
|
+
* Shadowing contract: the host ui-model-selection registers the
|
|
64
|
+
* `conversation.input.model` occupant at priority 0; this entry registers at
|
|
65
|
+
* priority -1, so the renderer projects this component as the single-slot
|
|
66
|
+
* winner while the host entry stays as the abdication fallback. Both entries
|
|
67
|
+
* share the same per-session ModelDirectory, so selections stay consistent
|
|
68
|
+
* with the /model popup and the host picker.
|
|
69
|
+
*/
|
|
70
|
+
export function ModelRouterSeat({
|
|
71
|
+
locked,
|
|
72
|
+
available,
|
|
73
|
+
directory,
|
|
74
|
+
load,
|
|
75
|
+
select,
|
|
76
|
+
t,
|
|
77
|
+
}: ModelSeatProps): JSX.Element | null {
|
|
78
|
+
const directoryState = useSyncExternalStore(
|
|
79
|
+
onChange => directory.subscribe(onChange),
|
|
80
|
+
() => directory.getSnapshot(),
|
|
81
|
+
() => directory.getSnapshot(),
|
|
82
|
+
)
|
|
83
|
+
const current = directoryState.current
|
|
84
|
+
|
|
85
|
+
const [open, setOpen] = useState(false)
|
|
86
|
+
const [pane, setPane] = useState<'root' | 'model' | 'provider' | 'effort'>('root')
|
|
87
|
+
const lastActionRef = useRef<'load' | 'select'>('load')
|
|
88
|
+
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
|
89
|
+
const toastSeq = useRef(0)
|
|
90
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
91
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
92
|
+
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
|
93
|
+
const id = useId()
|
|
94
|
+
|
|
95
|
+
// ---- model-router state (moved from the quick route switcher) ----
|
|
96
|
+
const [routerState, setRouterState] = useState<ModelRouterState | null>(null)
|
|
97
|
+
const [routerLoading, setRouterLoading] = useState(false)
|
|
98
|
+
const [routerError, setRouterError] = useState<string | null>(null)
|
|
99
|
+
const [busyProvider, setBusyProvider] = useState<string | null>(null)
|
|
100
|
+
|
|
101
|
+
const isRouterModel = available && current?.provider === ROUTER_PROVIDER_ID
|
|
102
|
+
const modelId = isRouterModel ? current.model : null
|
|
103
|
+
|
|
104
|
+
const loadRouterState = useCallback(async (silent = false) => {
|
|
105
|
+
if (!silent) setRouterLoading(true)
|
|
106
|
+
try {
|
|
107
|
+
setRouterState(await api.state())
|
|
108
|
+
setRouterError(null)
|
|
109
|
+
} catch (cause) {
|
|
110
|
+
setRouterError(cause instanceof Error ? cause.message : String(cause))
|
|
111
|
+
} finally {
|
|
112
|
+
if (!silent) setRouterLoading(false)
|
|
113
|
+
}
|
|
114
|
+
}, [])
|
|
115
|
+
|
|
116
|
+
// Load the advisory directory on mount (host behavior).
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!available) return
|
|
119
|
+
load()
|
|
120
|
+
}, [available, load])
|
|
121
|
+
|
|
122
|
+
// Fetch the router state (and thus the global toggle) on mount too, so the
|
|
123
|
+
// "hide provider switching" setting applies as soon as it resolves.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!available) return
|
|
126
|
+
void loadRouterState()
|
|
127
|
+
}, [available, loadRouterState])
|
|
128
|
+
|
|
129
|
+
// Reset the open pane when the selected model changes (a different model may
|
|
130
|
+
// no longer be a model-router route).
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
setPane('root')
|
|
133
|
+
setBusyProvider(null)
|
|
134
|
+
setRouterError(null)
|
|
135
|
+
}, [modelId])
|
|
136
|
+
|
|
137
|
+
// Silent debounced refresh of the router state after directory refreshes
|
|
138
|
+
// (settings edits, adapter updates), so the provider row never shows a stale
|
|
139
|
+
// active provider.
|
|
140
|
+
const refreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!isRouterModel || modelId === null || directoryState.status !== 'ready') return
|
|
143
|
+
if (refreshTimer.current !== null) clearTimeout(refreshTimer.current)
|
|
144
|
+
refreshTimer.current = setTimeout(() => {
|
|
145
|
+
refreshTimer.current = null
|
|
146
|
+
void loadRouterState(true)
|
|
147
|
+
}, 300)
|
|
148
|
+
return () => {
|
|
149
|
+
if (refreshTimer.current !== null) clearTimeout(refreshTimer.current)
|
|
150
|
+
refreshTimer.current = null
|
|
151
|
+
}
|
|
152
|
+
}, [directoryState.status, isRouterModel, loadRouterState, modelId])
|
|
153
|
+
|
|
154
|
+
const routeModel = useMemo(
|
|
155
|
+
() => modelId === null
|
|
156
|
+
? undefined
|
|
157
|
+
: routerState?.models.find(model => model.id === modelId),
|
|
158
|
+
[modelId, routerState],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const providers = useMemo(() => {
|
|
162
|
+
if (routeModel === undefined) return []
|
|
163
|
+
const byId = new Map(routeModel.providers.map(provider => [provider.id, provider]))
|
|
164
|
+
const ordered = routeModel.order
|
|
165
|
+
.map(id => byId.get(id))
|
|
166
|
+
.filter((provider): provider is RouteProvider => provider !== undefined)
|
|
167
|
+
const seen = new Set(ordered.map(provider => provider.id))
|
|
168
|
+
return [...ordered, ...routeModel.providers.filter(provider => !seen.has(provider.id))]
|
|
169
|
+
}, [routeModel])
|
|
170
|
+
|
|
171
|
+
const switchProvider = useCallback(async (providerId: string) => {
|
|
172
|
+
if (modelId === null || routeModel === undefined || routeModel.active === providerId) {
|
|
173
|
+
setOpen(false)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
setBusyProvider(providerId)
|
|
177
|
+
try {
|
|
178
|
+
const next = await api.switchActive(modelId, providerId)
|
|
179
|
+
setRouterState(next)
|
|
180
|
+
setRouterError(null)
|
|
181
|
+
setOpen(false)
|
|
182
|
+
// Keep the host model picker description in sync immediately.
|
|
183
|
+
load()
|
|
184
|
+
} catch (cause) {
|
|
185
|
+
setRouterError(t('switchFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
186
|
+
} finally {
|
|
187
|
+
setBusyProvider(null)
|
|
188
|
+
}
|
|
189
|
+
}, [load, modelId, routeModel, t])
|
|
190
|
+
|
|
191
|
+
// ---- host ModelSelect logic (faithful copy) ----
|
|
192
|
+
const choices = useMemo(() => directoryState.groups.flatMap(group => group.models.map(model => ({
|
|
193
|
+
group,
|
|
194
|
+
model,
|
|
195
|
+
selection: {
|
|
196
|
+
provider: group.id,
|
|
197
|
+
model: model.id,
|
|
198
|
+
...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort },
|
|
199
|
+
},
|
|
200
|
+
}))), [directoryState.groups])
|
|
201
|
+
const currentChoice = choices[current === null ? -1 : choices.findIndex(c =>
|
|
202
|
+
c.selection.provider === current?.provider && c.selection.model === current.model)]
|
|
203
|
+
const reasoning = currentChoice?.model.reasoning
|
|
204
|
+
const effectiveEffort = current?.reasoningEffort ?? reasoning?.defaultEffort
|
|
205
|
+
const effortLabel = reasoning === undefined ? undefined
|
|
206
|
+
: effectiveEffort === undefined ? t('effortProviderDefault')
|
|
207
|
+
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
|
|
208
|
+
const effortChoices = useMemo<Array<{ key: string; effort: string | undefined; label: string; description?: string }>>(() => reasoning === undefined ? [] : [
|
|
209
|
+
...(reasoning.defaultEffort === undefined ? [{
|
|
210
|
+
key: 'provider-default',
|
|
211
|
+
effort: undefined as string | undefined,
|
|
212
|
+
label: t('effortProviderDefault'),
|
|
213
|
+
}] : []),
|
|
214
|
+
...reasoning.efforts.map(effort => ({
|
|
215
|
+
key: `effort:${effort.id}`,
|
|
216
|
+
effort: effort.id,
|
|
217
|
+
label: effort.name,
|
|
218
|
+
...effort.description === undefined ? {} : { description: effort.description },
|
|
219
|
+
})),
|
|
220
|
+
], [reasoning, t])
|
|
221
|
+
|
|
222
|
+
const busy = directoryState.status === 'selecting'
|
|
223
|
+
const reload = () => {
|
|
224
|
+
lastActionRef.current = 'load'
|
|
225
|
+
load()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Close on outside mousedown (host behavior).
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (!open) return
|
|
231
|
+
const closeOutside = (event: MouseEvent) => {
|
|
232
|
+
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
|
|
233
|
+
}
|
|
234
|
+
document.addEventListener('mousedown', closeOutside)
|
|
235
|
+
return () => {
|
|
236
|
+
document.removeEventListener('mousedown', closeOutside)
|
|
237
|
+
}
|
|
238
|
+
}, [open])
|
|
239
|
+
|
|
240
|
+
if (!available) return null
|
|
241
|
+
|
|
242
|
+
const show = () => {
|
|
243
|
+
setPane('root')
|
|
244
|
+
setOpen(true)
|
|
245
|
+
reload()
|
|
246
|
+
void loadRouterState()
|
|
247
|
+
}
|
|
248
|
+
const close = (restoreFocus = false) => {
|
|
249
|
+
setOpen(false)
|
|
250
|
+
setPane('root')
|
|
251
|
+
if (restoreFocus) queueMicrotask(() => {
|
|
252
|
+
triggerRef.current?.focus()
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
const moveFocus = (offset: number) => {
|
|
256
|
+
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null)
|
|
257
|
+
if (items.length === 0) return
|
|
258
|
+
const active = items.findIndex(item => item === document.activeElement)
|
|
259
|
+
items[(Math.max(active, 0) + offset + items.length) % items.length]?.focus()
|
|
260
|
+
}
|
|
261
|
+
const onRootKeyDown = (event: ReactKeyboardEvent) => {
|
|
262
|
+
if (event.key === 'Escape' && open) {
|
|
263
|
+
event.preventDefault()
|
|
264
|
+
if (pane !== 'root') setPane('root')
|
|
265
|
+
else close(true)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
if (!open) return
|
|
269
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
270
|
+
event.preventDefault()
|
|
271
|
+
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const onBlur = (event: ReactFocusEvent) => {
|
|
275
|
+
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
|
|
276
|
+
close()
|
|
277
|
+
}
|
|
278
|
+
const settleSelection = (accepted: boolean) => {
|
|
279
|
+
if (accepted) {
|
|
280
|
+
if (rootRef.current !== null) close(true)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
const message = directory.getSnapshot().error
|
|
284
|
+
if (message !== null) {
|
|
285
|
+
toastSeq.current += 1
|
|
286
|
+
setToast({ seq: toastSeq.current, text: t('errorAction', { message }) })
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const choose = (selection: ModelSelection) => {
|
|
290
|
+
if (current !== null && current.provider === selection.provider && current.model === selection.model) {
|
|
291
|
+
close(true)
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
lastActionRef.current = 'select'
|
|
295
|
+
select(selection).then(settleSelection)
|
|
296
|
+
}
|
|
297
|
+
const chooseEffort = (effort: string | undefined) => {
|
|
298
|
+
if (current === null) return
|
|
299
|
+
if (effectiveEffort === effort) {
|
|
300
|
+
close(true)
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
const selection: ModelSelection = {
|
|
304
|
+
provider: current.provider,
|
|
305
|
+
model: current.model,
|
|
306
|
+
...effort === undefined ? {} : { reasoningEffort: effort },
|
|
307
|
+
}
|
|
308
|
+
lastActionRef.current = 'select'
|
|
309
|
+
select(selection).then(settleSelection)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const modelLabel = currentChoice?.model.name ?? t('triggerFallback')
|
|
313
|
+
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
|
314
|
+
const triggerAria = currentChoice === undefined ? t('triggerSelectAria')
|
|
315
|
+
: effortLabel === undefined ? t('triggerAria', { model: modelLabel })
|
|
316
|
+
: t('triggerAriaEffort', { model: modelLabel, effort: effortLabel })
|
|
317
|
+
|
|
318
|
+
// ---- provider row visibility (the former quick-switch button conditions) ----
|
|
319
|
+
const activeProvider = providers.find(provider => provider.id === routeModel?.active)
|
|
320
|
+
const activeProviderName = activeProvider?.name ?? routeModel?.active ?? t('routeLoading')
|
|
321
|
+
const providerRowVisible = isRouterModel
|
|
322
|
+
&& routerState !== null
|
|
323
|
+
&& routerState.showQuickSwitch !== false
|
|
324
|
+
&& routerState.providers.length >= 2
|
|
325
|
+
&& routeModel !== undefined
|
|
326
|
+
&& providers.length >= 2
|
|
327
|
+
|
|
328
|
+
itemRefs.current = []
|
|
329
|
+
let itemIndex = 0
|
|
330
|
+
const itemRef = () => {
|
|
331
|
+
const at = itemIndex++
|
|
332
|
+
return (node: HTMLButtonElement | null) => {
|
|
333
|
+
itemRefs.current[at] = node
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div
|
|
339
|
+
ref={rootRef}
|
|
340
|
+
className="mr-seat-root"
|
|
341
|
+
onKeyDown={onRootKeyDown}
|
|
342
|
+
onBlur={onBlur}
|
|
343
|
+
>
|
|
344
|
+
<button
|
|
345
|
+
ref={triggerRef}
|
|
346
|
+
type="button"
|
|
347
|
+
className="mr-seat-trigger"
|
|
348
|
+
aria-label={triggerAria}
|
|
349
|
+
aria-haspopup="menu"
|
|
350
|
+
aria-expanded={open}
|
|
351
|
+
aria-controls={open ? `${id}-menu` : undefined}
|
|
352
|
+
title={triggerLabel}
|
|
353
|
+
disabled={locked}
|
|
354
|
+
onClick={() => {
|
|
355
|
+
if (open) close()
|
|
356
|
+
else show()
|
|
357
|
+
}}
|
|
358
|
+
>
|
|
359
|
+
<span className="mr-seat-triggerLabel">{modelLabel}</span>
|
|
360
|
+
{effortLabel !== undefined && (
|
|
361
|
+
<span className="mr-seat-triggerEffort">{effortLabel}</span>
|
|
362
|
+
)}
|
|
363
|
+
<span className={`mr-seat-chevron${open ? ' mr-seat-chevronOpen' : ''}`} aria-hidden>
|
|
364
|
+
<IconChevronDownOutline14 />
|
|
365
|
+
</span>
|
|
366
|
+
</button>
|
|
367
|
+
{open && (
|
|
368
|
+
<div
|
|
369
|
+
id={`${id}-menu`}
|
|
370
|
+
className="mr-seat-menu"
|
|
371
|
+
role="menu"
|
|
372
|
+
aria-label={t('menuAria')}
|
|
373
|
+
aria-busy={directoryState.status === 'loading' || busy}
|
|
374
|
+
>
|
|
375
|
+
{pane === 'root' && (
|
|
376
|
+
<>
|
|
377
|
+
<button
|
|
378
|
+
ref={itemRef()}
|
|
379
|
+
type="button"
|
|
380
|
+
role="menuitem"
|
|
381
|
+
className="mr-seat-cell"
|
|
382
|
+
onClick={() => setPane('model')}
|
|
383
|
+
>
|
|
384
|
+
<span className="mr-seat-cellLabel">{t('menuModel')}</span>
|
|
385
|
+
<span className="mr-seat-cellValue">{modelLabel}</span>
|
|
386
|
+
<span className="mr-seat-cellChevron" aria-hidden>
|
|
387
|
+
<IconChevronRightOutline14 />
|
|
388
|
+
</span>
|
|
389
|
+
</button>
|
|
390
|
+
{providerRowVisible && (
|
|
391
|
+
<button
|
|
392
|
+
ref={itemRef()}
|
|
393
|
+
type="button"
|
|
394
|
+
role="menuitem"
|
|
395
|
+
className="mr-seat-cell"
|
|
396
|
+
onClick={() => setPane('provider')}
|
|
397
|
+
>
|
|
398
|
+
<span className="mr-seat-cellLabel">{t('menuProvider')}</span>
|
|
399
|
+
<span className="mr-seat-cellValue">{activeProviderName}</span>
|
|
400
|
+
<span className="mr-seat-cellChevron" aria-hidden>
|
|
401
|
+
<IconChevronRightOutline14 />
|
|
402
|
+
</span>
|
|
403
|
+
</button>
|
|
404
|
+
)}
|
|
405
|
+
{reasoning !== undefined && (
|
|
406
|
+
<button
|
|
407
|
+
ref={itemRef()}
|
|
408
|
+
type="button"
|
|
409
|
+
role="menuitem"
|
|
410
|
+
className="mr-seat-cell"
|
|
411
|
+
onClick={() => setPane('effort')}
|
|
412
|
+
>
|
|
413
|
+
<span className="mr-seat-cellLabel">{t('menuEffort')}</span>
|
|
414
|
+
<span className="mr-seat-cellValue">{effortLabel}</span>
|
|
415
|
+
<span className="mr-seat-cellChevron" aria-hidden>
|
|
416
|
+
<IconChevronRightOutline14 />
|
|
417
|
+
</span>
|
|
418
|
+
</button>
|
|
419
|
+
)}
|
|
420
|
+
</>
|
|
421
|
+
)}
|
|
422
|
+
{pane === 'model' && (
|
|
423
|
+
<>
|
|
424
|
+
{directoryState.status === 'loading' && (
|
|
425
|
+
<div className="mr-seat-status">{t('statusLoading')}</div>
|
|
426
|
+
)}
|
|
427
|
+
{directoryState.error !== null && lastActionRef.current === 'load' && (
|
|
428
|
+
<div className="mr-seat-error">
|
|
429
|
+
<span>{t('errorAction', { message: directoryState.error })}</span>
|
|
430
|
+
<button type="button" className="mr-seat-retry" onClick={reload}>
|
|
431
|
+
{t('retry')}
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
)}
|
|
435
|
+
{directoryState.failures.map(failure => (
|
|
436
|
+
<div key={failure.id} className="mr-seat-warning">
|
|
437
|
+
<span>{t('warningGroupLoad', { name: failure.name, message: failure.message })}</span>
|
|
438
|
+
<button type="button" className="mr-seat-retry" onClick={reload}>
|
|
439
|
+
{t('retry')}
|
|
440
|
+
</button>
|
|
441
|
+
</div>
|
|
442
|
+
))}
|
|
443
|
+
<div className="mr-seat-groups">
|
|
444
|
+
{directoryState.groups.map(group => {
|
|
445
|
+
const headingId = `${id}-${group.id}`
|
|
446
|
+
return (
|
|
447
|
+
<section
|
|
448
|
+
key={group.id}
|
|
449
|
+
role="group"
|
|
450
|
+
aria-labelledby={headingId}
|
|
451
|
+
className="mr-seat-group"
|
|
452
|
+
>
|
|
453
|
+
<div className="mr-seat-groupTitle" id={headingId}>
|
|
454
|
+
{group.name}
|
|
455
|
+
</div>
|
|
456
|
+
{group.models.map(model => {
|
|
457
|
+
const selected = current?.provider === group.id && current.model === model.id
|
|
458
|
+
return (
|
|
459
|
+
<button
|
|
460
|
+
key={model.id}
|
|
461
|
+
ref={itemRef()}
|
|
462
|
+
type="button"
|
|
463
|
+
role="menuitemradio"
|
|
464
|
+
aria-checked={selected}
|
|
465
|
+
className={`mr-seat-option${selected ? ' mr-seat-selected' : ''}`}
|
|
466
|
+
title={model.name}
|
|
467
|
+
disabled={busy}
|
|
468
|
+
onClick={() => choose({ provider: group.id, model: model.id })}
|
|
469
|
+
>
|
|
470
|
+
<span className="mr-seat-optionCopy">
|
|
471
|
+
<span className="mr-seat-modelName">{model.name}</span>
|
|
472
|
+
{model.description !== undefined && (
|
|
473
|
+
<span className="mr-seat-description">{model.description}</span>
|
|
474
|
+
)}
|
|
475
|
+
</span>
|
|
476
|
+
<span className="mr-seat-check" aria-hidden>
|
|
477
|
+
{selected ? <IconCheckOutline16 /> : null}
|
|
478
|
+
</span>
|
|
479
|
+
</button>
|
|
480
|
+
)
|
|
481
|
+
})}
|
|
482
|
+
</section>
|
|
483
|
+
)
|
|
484
|
+
})}
|
|
485
|
+
</div>
|
|
486
|
+
{directoryState.status === 'ready' && choices.length === 0 && (
|
|
487
|
+
<div className="mr-seat-empty">{t('emptyModels')}</div>
|
|
488
|
+
)}
|
|
489
|
+
</>
|
|
490
|
+
)}
|
|
491
|
+
{pane === 'provider' && (
|
|
492
|
+
<>
|
|
493
|
+
{routerError !== null && (
|
|
494
|
+
<div className="mr-seat-error">
|
|
495
|
+
<span>{routerError}</span>
|
|
496
|
+
<button
|
|
497
|
+
type="button"
|
|
498
|
+
className="mr-seat-retry"
|
|
499
|
+
onClick={() => {
|
|
500
|
+
void loadRouterState()
|
|
501
|
+
load()
|
|
502
|
+
}}
|
|
503
|
+
>
|
|
504
|
+
{t('retry')}
|
|
505
|
+
</button>
|
|
506
|
+
</div>
|
|
507
|
+
)}
|
|
508
|
+
{routeModel === undefined ? (
|
|
509
|
+
<div className="mr-seat-status">{t('routeLoading')}</div>
|
|
510
|
+
) : (
|
|
511
|
+
<>
|
|
512
|
+
<div className="mr-seat-title">{t('routeMenuTitle', { model: modelId ?? '' })}</div>
|
|
513
|
+
<div className="mr-seat-groups">
|
|
514
|
+
{providers.map(provider => {
|
|
515
|
+
const selected = routeModel.active === provider.id
|
|
516
|
+
return (
|
|
517
|
+
<button
|
|
518
|
+
key={provider.id}
|
|
519
|
+
ref={itemRef()}
|
|
520
|
+
type="button"
|
|
521
|
+
role="menuitemradio"
|
|
522
|
+
aria-checked={selected}
|
|
523
|
+
className={`mr-seat-option${selected ? ' mr-seat-selected' : ''}`}
|
|
524
|
+
disabled={!provider.credentialConfigured || busyProvider !== null}
|
|
525
|
+
onClick={() => void switchProvider(provider.id)}
|
|
526
|
+
>
|
|
527
|
+
<span className="mr-seat-optionCopy">
|
|
528
|
+
<span className="mr-seat-modelName">{provider.name}</span>
|
|
529
|
+
{!provider.credentialConfigured && (
|
|
530
|
+
<span className="mr-seat-description">{t('noCredentialShort')}</span>
|
|
531
|
+
)}
|
|
532
|
+
</span>
|
|
533
|
+
<span className="mr-seat-check" aria-hidden>
|
|
534
|
+
{selected ? <IconCheckOutline16 /> : null}
|
|
535
|
+
</span>
|
|
536
|
+
</button>
|
|
537
|
+
)
|
|
538
|
+
})}
|
|
539
|
+
</div>
|
|
540
|
+
<div className="mr-seat-hint">{t('globalRouteHint')}</div>
|
|
541
|
+
</>
|
|
542
|
+
)}
|
|
543
|
+
</>
|
|
544
|
+
)}
|
|
545
|
+
{pane === 'effort' && (
|
|
546
|
+
<>
|
|
547
|
+
{directoryState.error !== null && lastActionRef.current === 'load' && (
|
|
548
|
+
<div className="mr-seat-error">
|
|
549
|
+
<span>{t('errorAction', { message: directoryState.error })}</span>
|
|
550
|
+
<button type="button" className="mr-seat-retry" onClick={reload}>
|
|
551
|
+
{t('actionReload')}
|
|
552
|
+
</button>
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
{effortChoices.length === 0 ? (
|
|
556
|
+
<div className="mr-seat-empty">{t('emptyEfforts')}</div>
|
|
557
|
+
) : effortChoices.map(level => (
|
|
558
|
+
<button
|
|
559
|
+
key={level.key}
|
|
560
|
+
ref={itemRef()}
|
|
561
|
+
type="button"
|
|
562
|
+
role="menuitemradio"
|
|
563
|
+
aria-checked={effectiveEffort === level.effort}
|
|
564
|
+
className={`mr-seat-option${effectiveEffort === level.effort ? ' mr-seat-selected' : ''}`}
|
|
565
|
+
disabled={busy}
|
|
566
|
+
onClick={() => chooseEffort(level.effort)}
|
|
567
|
+
>
|
|
568
|
+
<span className="mr-seat-optionCopy">
|
|
569
|
+
<span className="mr-seat-modelName">{level.label}</span>
|
|
570
|
+
{level.description !== undefined && (
|
|
571
|
+
<span className="mr-seat-description">{level.description}</span>
|
|
572
|
+
)}
|
|
573
|
+
</span>
|
|
574
|
+
<span className="mr-seat-check" aria-hidden>
|
|
575
|
+
{effectiveEffort === level.effort ? <IconCheckOutline16 /> : null}
|
|
576
|
+
</span>
|
|
577
|
+
</button>
|
|
578
|
+
))}
|
|
579
|
+
</>
|
|
580
|
+
)}
|
|
581
|
+
</div>
|
|
582
|
+
)}
|
|
583
|
+
{toast !== null && (
|
|
584
|
+
<Toast
|
|
585
|
+
key={toast.seq}
|
|
586
|
+
text={toast.text}
|
|
587
|
+
icon={<IconWarningOutline16 />}
|
|
588
|
+
anchor={rootRef.current?.closest('[data-composer-card]') ?? null}
|
|
589
|
+
onDone={() => setToast(null)}
|
|
590
|
+
/>
|
|
591
|
+
)}
|
|
592
|
+
</div>
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Register the model seat onto the composer's named model slot (priority -1 shadows the host seat). */
|
|
597
|
+
export function registerModelRouterSeat(ctx: ClientContext): void {
|
|
598
|
+
const t = ctx.locale.bind(LOCALE_NS)
|
|
599
|
+
ctx.inject(['slots', 'modelDirectories'], scope => {
|
|
600
|
+
const models = scope.modelDirectories
|
|
601
|
+
const sessions = scope.sessions
|
|
602
|
+
scope.slots.inject('conversation.input.model', () => scope.slots.register({
|
|
603
|
+
name: 'conversation.input.model',
|
|
604
|
+
id: 'model-router-model-seat',
|
|
605
|
+
priority: -1,
|
|
606
|
+
label: () => t('seatLabel'),
|
|
607
|
+
locale: LOCALE_NS,
|
|
608
|
+
inject: (sessionId: SessionId) => {
|
|
609
|
+
const directory = models.directoryFor(sessionId)
|
|
610
|
+
const available = sessions.subagentAddress(sessionId) === undefined
|
|
611
|
+
return {
|
|
612
|
+
available,
|
|
613
|
+
directory: directory.store,
|
|
614
|
+
load: () => {
|
|
615
|
+
if (available) directory.load().catch(() => {})
|
|
616
|
+
},
|
|
617
|
+
select: (selection: ModelSelection) =>
|
|
618
|
+
available ? directory.select(selection).then(() => true, () => false) : Promise.resolve(false),
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
} as never, ModelRouterSeat as never))
|
|
622
|
+
})
|
|
623
|
+
}
|