@fonlan/dsh-model-router 0.3.5 → 0.3.7

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.
@@ -15,15 +15,10 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
15
15
  type ClientContext = Context
16
16
  import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
17
17
  import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
18
- import {
19
- IconCheckOutline16,
20
- IconChevronDownOutline14,
21
- IconChevronRightOutline14,
22
- IconWarningOutline16,
23
- Toast,
24
- } from '@deepseek-ai/dsh-client-ui-primitives'
18
+ import { Toast } from '@deepseek-ai/dsh-client-ui-primitives'
25
19
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
26
20
  import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
21
+ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, WarningIcon } from './icons'
27
22
  import { api, type ModelRouterState, type RouterModelView } from './api'
28
23
  import { LOCALE_NS } from './locales'
29
24
  import './model-seat.css'
@@ -392,7 +387,7 @@ export function ModelRouterSeat({
392
387
  <span className="mr-seat-triggerEffort">{effortLabel}</span>
393
388
  )}
394
389
  <span className={`mr-seat-chevron${open ? ' mr-seat-chevronOpen' : ''}`} aria-hidden>
395
- <IconChevronDownOutline14 />
390
+ <ChevronDownIcon />
396
391
  </span>
397
392
  </button>
398
393
  {open && (
@@ -415,7 +410,7 @@ export function ModelRouterSeat({
415
410
  <span className="mr-seat-cellLabel">{t('menuModel')}</span>
416
411
  <span className="mr-seat-cellValue">{modelLabel}</span>
417
412
  <span className="mr-seat-cellChevron" aria-hidden>
418
- <IconChevronRightOutline14 />
413
+ <ChevronRightIcon />
419
414
  </span>
420
415
  </button>
421
416
  {providerRowVisible && (
@@ -429,7 +424,7 @@ export function ModelRouterSeat({
429
424
  <span className="mr-seat-cellLabel">{t('menuProvider')}</span>
430
425
  <span className="mr-seat-cellValue">{activeProviderName}</span>
431
426
  <span className="mr-seat-cellChevron" aria-hidden>
432
- <IconChevronRightOutline14 />
427
+ <ChevronRightIcon />
433
428
  </span>
434
429
  </button>
435
430
  )}
@@ -444,7 +439,7 @@ export function ModelRouterSeat({
444
439
  <span className="mr-seat-cellLabel">{t('menuEffort')}</span>
445
440
  <span className="mr-seat-cellValue">{effortLabel}</span>
446
441
  <span className="mr-seat-cellChevron" aria-hidden>
447
- <IconChevronRightOutline14 />
442
+ <ChevronRightIcon />
448
443
  </span>
449
444
  </button>
450
445
  )}
@@ -505,7 +500,7 @@ export function ModelRouterSeat({
505
500
  )}
506
501
  </span>
507
502
  <span className="mr-seat-check" aria-hidden>
508
- {selected ? <IconCheckOutline16 /> : null}
503
+ {selected ? <CheckIcon /> : null}
509
504
  </span>
510
505
  </button>
511
506
  )
@@ -562,7 +557,7 @@ export function ModelRouterSeat({
562
557
  )}
563
558
  </span>
564
559
  <span className="mr-seat-check" aria-hidden>
565
- {selected ? <IconCheckOutline16 /> : null}
560
+ {selected ? <CheckIcon /> : null}
566
561
  </span>
567
562
  </button>
568
563
  )
@@ -603,7 +598,7 @@ export function ModelRouterSeat({
603
598
  )}
604
599
  </span>
605
600
  <span className="mr-seat-check" aria-hidden>
606
- {effectiveEffort === level.effort ? <IconCheckOutline16 /> : null}
601
+ {effectiveEffort === level.effort ? <CheckIcon /> : null}
607
602
  </span>
608
603
  </button>
609
604
  ))}
@@ -615,7 +610,7 @@ export function ModelRouterSeat({
615
610
  <Toast
616
611
  key={toast.seq}
617
612
  text={toast.text}
618
- icon={<IconWarningOutline16 />}
613
+ icon={<WarningIcon />}
619
614
  anchor={rootRef.current?.closest('[data-composer-card]') ?? null}
620
615
  onDone={() => setToast(null)}
621
616
  />
@@ -1,81 +1,41 @@
1
- /* Model Router settings card — chrome aligned with the built-in plugin cards
2
- (设置 → 插件 → 插件配置), theme-consistent via DSW alias variables. */
3
- .mr-settings-card {
4
- border: 1px solid var(--dsw-alias-border-l2, #2a3346);
5
- background: var(--dsw-alias-bg-layer-3, #0d1017);
6
- border-radius: 12px;
7
- list-style: none;
8
- transition: border-color .16s, background .16s;
9
- }
1
+ /* Model Router settings page — the page owns one entry in the settings
2
+ sidebar and renders its content in the panel's content column. Styles are
3
+ theme-consistent via DSW alias variables; the page draws its own static
4
+ header (title plus a one-line summary) over the always-visible controls. */
10
5
 
11
- .mr-settings-card:hover {
12
- border-color: var(--dsw-alias-label-dimmed, #4a5468);
13
- }
14
-
15
- .mr-settings-card[data-open] {
16
- background: var(--dsw-alias-bg-layer-2, #141a26);
17
- border-color: var(--dsw-alias-label-dimmed, #4a5468);
18
- }
6
+ /* page layout */
19
7
 
20
- .mr-settings-head {
21
- appearance: none;
22
- width: 100%;
23
- font: inherit;
24
- color: inherit;
25
- text-align: left;
26
- cursor: pointer;
27
- background: transparent;
28
- border: 0;
29
- border-radius: 12px;
30
- align-items: center;
31
- gap: 12px;
32
- padding: 14px 16px;
8
+ .mr-page {
33
9
  display: flex;
10
+ flex-direction: column;
11
+ gap: 16px;
12
+ max-width: 720px;
34
13
  }
35
14
 
36
- .mr-settings-head:focus-visible {
37
- outline: 2px solid var(--dsw-alias-brand-primary, #4f8cff);
38
- outline-offset: -2px;
39
- }
40
-
41
- .mr-settings-head-text {
42
- flex-direction: column;
43
- flex: 1;
44
- gap: 4px;
45
- min-width: 0;
15
+ .mr-page-head {
46
16
  display: flex;
17
+ flex-direction: column;
18
+ gap: 6px;
47
19
  }
48
20
 
49
- .mr-settings-title {
21
+ .mr-page-title {
22
+ margin: 0;
50
23
  color: var(--dsw-alias-label-primary, #e6e6e6);
51
24
  font-size: 15px;
52
25
  font-weight: 600;
53
26
  line-height: 1.4;
54
27
  }
55
28
 
56
- .mr-settings-sub {
29
+ .mr-page-sub {
30
+ margin: 0;
57
31
  color: var(--dsw-alias-label-tertiary, #8b93a7);
58
32
  font-size: 13px;
59
33
  line-height: 1.5;
60
34
  }
61
35
 
62
- .mr-settings-chevron {
63
- color: var(--dsw-alias-label-tertiary, #8b93a7);
64
- flex: none;
65
- transition: transform .16s;
66
- display: flex;
67
- align-items: center;
68
- justify-content: center;
69
- }
70
-
71
- .mr-settings-chevron[data-open] {
72
- transform: rotate(180deg);
73
- }
36
+ /* body layout */
74
37
 
75
- .mr-settings-body {
76
- border-top: 1px solid var(--dsw-alias-border-l2, #2a3346);
77
- margin: 0 16px;
78
- padding: 12px 0 8px;
38
+ .mr-body {
79
39
  display: flex;
80
40
  flex-direction: column;
81
41
  gap: 12px;
@@ -88,7 +48,7 @@
88
48
  line-height: 1.5;
89
49
  }
90
50
 
91
- /* ── card body: the router controls ─────────────────────────────────────── */
51
+ /* ── page body: the router controls ─────────────────────────────────────── */
92
52
 
93
53
  .mr-root {
94
54
  display: flex;
@@ -0,0 +1,431 @@
1
+ /**
2
+ * The Model Router settings page (设置 → 侧栏「模型路由」).
3
+ *
4
+ * Registers into the settings.section slot, so the page owns one entry in the
5
+ * settings sidebar and renders its content in the panel's content column. The
6
+ * page draws its own static header (title plus a one-line summary) over the
7
+ * router controls: every routed model with its providers, their order and the
8
+ * provider the model currently routes to. All reads/mutations go through the
9
+ * plugin's fenced API; the server persists into this plugin's loader-entry
10
+ * config (dsh >= 0.1.7), so a switch here is live for the next request
11
+ * globally.
12
+ *
13
+ * The page needs no settings service: whether the deployment can persist is
14
+ * reported by the API itself (`state.writable`), so the page registers and
15
+ * mounts everywhere the host half serves its API. A read-only deployment
16
+ * shows the banner and disables the mutation controls.
17
+ */
18
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
19
+ import type { Context } from '@deepseek-ai/cordis'
20
+ type ClientContext = Context
21
+ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
22
+ import { LOCALE_NS } from './locales'
23
+ import { api, type ModelRouterState, type ModelSortMode, type RouterModelView } from './api'
24
+ import './settings-section.css'
25
+
26
+ function arrayMove<T>(list: readonly T[], from: number, to: number): T[] {
27
+ const next = [...list]
28
+ const [item] = next.splice(from, 1)
29
+ next.splice(to, 0, item)
30
+ return next
31
+ }
32
+
33
+ function useLocaleRevision(ctx: ClientContext): number {
34
+ const subscribe = useCallback(
35
+ (onChange: () => void) => {
36
+ try {
37
+ return ctx.locale.subscribe(onChange)
38
+ } catch {
39
+ return () => {}
40
+ }
41
+ },
42
+ [ctx],
43
+ )
44
+ const getSnapshot = useCallback(() => {
45
+ try {
46
+ return ctx.locale.getLocale().revision
47
+ } catch {
48
+ return 0
49
+ }
50
+ }, [ctx])
51
+ return useSyncExternalStore(subscribe, getSnapshot)
52
+ }
53
+
54
+ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
55
+ // Bound translation is namespace-typed; the page's props use the plain
56
+ // Translate face (string keys), which the dict satisfies structurally.
57
+ const t: Translate = (() => {
58
+ try {
59
+ return ctx.locale.bind(LOCALE_NS) as unknown as Translate
60
+ } catch {
61
+ return (key: string) => key
62
+ }
63
+ })()
64
+
65
+ return function ModelRouterSettingsSection(): JSX.Element {
66
+ useLocaleRevision(ctx)
67
+ const [state, setState] = useState<ModelRouterState | null>(null)
68
+ const [error, setError] = useState<string | null>(null)
69
+ const [loading, setLoading] = useState(true)
70
+ const [busyModel, setBusyModel] = useState<string | null>(null)
71
+ const [drag, setDrag] = useState<{ modelId: string; index: number } | null>(null)
72
+ const [quickSwitchBusy, setQuickSwitchBusy] = useState(false)
73
+ const [ignorePrefixBusy, setIgnorePrefixBusy] = useState(false)
74
+ const [sortBusy, setSortBusy] = useState(false)
75
+ const [modelDrag, setModelDrag] = useState<{ index: number } | null>(null)
76
+
77
+ const load = useCallback(async () => {
78
+ setLoading(true)
79
+ try {
80
+ const next = await api.state()
81
+ setState(next)
82
+ setError(null)
83
+ } catch (cause) {
84
+ setError(cause instanceof Error ? cause.message : String(cause))
85
+ } finally {
86
+ setLoading(false)
87
+ }
88
+ }, [])
89
+
90
+ useEffect(() => {
91
+ void load()
92
+ }, [load])
93
+
94
+ const switchActive = useCallback(async (model: RouterModelView, providerId: string) => {
95
+ if (model.active === providerId) return
96
+ setBusyModel(model.id)
97
+ try {
98
+ setState(await api.switchActive(model.id, providerId))
99
+ setError(null)
100
+ } catch (cause) {
101
+ setError(t('switchFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
102
+ } finally {
103
+ setBusyModel(null)
104
+ }
105
+ }, [t])
106
+
107
+ const reorder = useCallback(async (model: RouterModelView, from: number, to: number) => {
108
+ if (from === to) return
109
+ const order = arrayMove(model.order, from, to)
110
+ setState(prev => prev === null ? prev : {
111
+ ...prev,
112
+ models: prev.models.map(m => (m.id === model.id ? { ...m, order } : m)),
113
+ })
114
+ setBusyModel(model.id)
115
+ try {
116
+ setState(await api.reorder(model.id, order))
117
+ setError(null)
118
+ } catch (cause) {
119
+ setError(t('reorderFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
120
+ setState(prev => prev === null ? prev : {
121
+ ...prev,
122
+ models: prev.models.map(m => (m.id === model.id ? { ...m, order: model.order } : m)),
123
+ })
124
+ } finally {
125
+ setBusyModel(null)
126
+ }
127
+ }, [t])
128
+
129
+ const toggleQuickSwitch = useCallback(async (value: boolean) => {
130
+ if (state === null) return
131
+ const previous = state.showQuickSwitch
132
+ setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: value })
133
+ setQuickSwitchBusy(true)
134
+ try {
135
+ setState(await api.setShowQuickSwitch(value))
136
+ setError(null)
137
+ } catch (cause) {
138
+ setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: previous })
139
+ setError(cause instanceof Error ? cause.message : String(cause))
140
+ } finally {
141
+ setQuickSwitchBusy(false)
142
+ }
143
+ }, [state])
144
+
145
+ const toggleIgnorePrefix = useCallback(async (value: boolean) => {
146
+ if (state === null) return
147
+ const previous = state.ignoreModelIdPrefix
148
+ setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: value })
149
+ setIgnorePrefixBusy(true)
150
+ try {
151
+ setState(await api.setIgnoreModelIdPrefix(value))
152
+ setError(null)
153
+ } catch (cause) {
154
+ setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: previous })
155
+ setError(cause instanceof Error ? cause.message : String(cause))
156
+ } finally {
157
+ setIgnorePrefixBusy(false)
158
+ }
159
+ }, [state])
160
+
161
+ const changeModelSort = useCallback(async (mode: ModelSortMode) => {
162
+ if (state === null || state.modelSort === mode) return
163
+ const previous = state.modelSort
164
+ setState(prev => prev === null ? prev : { ...prev, modelSort: mode })
165
+ setSortBusy(true)
166
+ try {
167
+ setState(await api.setModelSort(mode))
168
+ setError(null)
169
+ } catch (cause) {
170
+ setState(prev => prev === null ? prev : { ...prev, modelSort: previous })
171
+ setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
172
+ } finally {
173
+ setSortBusy(false)
174
+ }
175
+ }, [state, t])
176
+
177
+ const moveModel = useCallback(async (from: number, to: number) => {
178
+ if (state === null || from === to) return
179
+ // The drag list shows the full display order (modelOrder + catalog
180
+ // tail); persist the whole reordered id list as the new custom order.
181
+ const ids = state.models.map(model => model.id)
182
+ const order = arrayMove(ids, from, to)
183
+ setState(prev => prev === null ? prev : { ...prev, modelOrder: order })
184
+ setSortBusy(true)
185
+ try {
186
+ setState(await api.setModelOrder(order))
187
+ setError(null)
188
+ } catch (cause) {
189
+ setState(prev => prev === null ? prev : { ...prev, modelOrder: state.modelOrder })
190
+ setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
191
+ } finally {
192
+ setSortBusy(false)
193
+ }
194
+ }, [state, t])
195
+
196
+ // Persistence capability is reported by the host half through the API
197
+ // itself. A host that predates the field reads as writable: the page must
198
+ // not block edits (with a wrong "read-only" banner) merely because the
199
+ // host half and the client bundle are briefly out of step.
200
+ const writable = state?.writable ?? true
201
+
202
+ const models = useMemo(() => state?.models ?? [], [state])
203
+
204
+ return (
205
+ <div className="mr-page">
206
+ <header className="mr-page-head">
207
+ <h3 className="mr-page-title">{t('settingsTitle')}</h3>
208
+ <p className="mr-page-sub">{t('sectionSub')}</p>
209
+ </header>
210
+ <div className="mr-body">
211
+ {!loading && state !== null && !writable && (
212
+ <p className="mr-settings-readonly" role="status">{t('readOnly')}</p>
213
+ )}
214
+ <div className="mr-root">
215
+ <label className="mr-quick-switch">
216
+ <input
217
+ type="checkbox"
218
+ checked={state?.showQuickSwitch ?? true}
219
+ disabled={loading || quickSwitchBusy || !writable}
220
+ onChange={(event) => void toggleQuickSwitch(event.target.checked)}
221
+ />
222
+ <span className="mr-quick-switch-copy">
223
+ <span className="mr-quick-switch-label">{t('showQuickSwitchLabel')}</span>
224
+ <span className="mr-quick-switch-desc">{t('showQuickSwitchDescription')}</span>
225
+ </span>
226
+ </label>
227
+ <label className="mr-quick-switch">
228
+ <input
229
+ type="checkbox"
230
+ checked={state?.ignoreModelIdPrefix ?? true}
231
+ disabled={loading || ignorePrefixBusy || !writable}
232
+ onChange={(event) => void toggleIgnorePrefix(event.target.checked)}
233
+ />
234
+ <span className="mr-quick-switch-copy">
235
+ <span className="mr-quick-switch-label">{t('ignorePrefixLabel')}</span>
236
+ <span className="mr-quick-switch-desc">{t('ignorePrefixDescription')}</span>
237
+ </span>
238
+ </label>
239
+ <div className="mr-sort">
240
+ <div className="mr-sort-head">
241
+ <span className="mr-sort-title">{t('sortTitle')}</span>
242
+ <span className="mr-sort-desc">{t('sortDescription')}</span>
243
+ </div>
244
+ <div className="mr-sort-modes" role="radiogroup" aria-label={t('sortTitle')}>
245
+ {(['custom', 'name', 'recent'] as const).map(mode => (
246
+ <label key={mode} className="mr-sort-mode">
247
+ <input
248
+ type="radio"
249
+ name="mr-model-sort"
250
+ checked={state?.modelSort === mode}
251
+ disabled={loading || sortBusy || !writable}
252
+ onChange={() => void changeModelSort(mode)}
253
+ />
254
+ <span className="mr-sort-mode-copy">
255
+ <span className="mr-sort-mode-label">{t(`sortMode${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
256
+ <span className="mr-sort-mode-desc">{t(`sortModeDesc${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
257
+ </span>
258
+ </label>
259
+ ))}
260
+ </div>
261
+ {state?.modelSort === 'custom' && (
262
+ <div className="mr-sort-custom">
263
+ <p className="mr-sort-hint">{t('sortCustomHint')}</p>
264
+ <ol className="mr-sort-list">
265
+ {models.map((model, index) => {
266
+ const dropTarget = modelDrag !== null && modelDrag.index !== index
267
+ return (
268
+ <li
269
+ key={model.id}
270
+ className={[
271
+ 'mr-sort-item',
272
+ dropTarget ? 'mr-sort-item-drop' : '',
273
+ sortBusy ? 'mr-sort-item-busy' : '',
274
+ ].filter(Boolean).join(' ')}
275
+ draggable={!sortBusy && writable}
276
+ onDragStart={(event) => {
277
+ setModelDrag({ index })
278
+ event.dataTransfer.effectAllowed = 'move'
279
+ try {
280
+ event.dataTransfer.setData('text/plain', model.id)
281
+ } catch {
282
+ // drag data is cosmetic for our own handler
283
+ }
284
+ }}
285
+ onDragOver={(event) => {
286
+ if (modelDrag !== null && modelDrag.index !== index) {
287
+ event.preventDefault()
288
+ event.dataTransfer.dropEffect = 'move'
289
+ }
290
+ }}
291
+ onDrop={(event) => {
292
+ if (modelDrag !== null && modelDrag.index !== index) {
293
+ event.preventDefault()
294
+ moveModel(modelDrag.index, index)
295
+ }
296
+ setModelDrag(null)
297
+ }}
298
+ onDragEnd={() => setModelDrag(null)}
299
+ >
300
+ <span className="mr-grip" aria-hidden="true">⠿</span>
301
+ <span className="mr-sort-model-name">{model.name}</span>
302
+ <span className="mr-sort-model-id">{model.id}</span>
303
+ </li>
304
+ )
305
+ })}
306
+ </ol>
307
+ </div>
308
+ )}
309
+ </div>
310
+ <div className="mr-toolbar">
311
+ <span className="mr-count">{t('modelsCount', { count: models.length })}</span>
312
+ <button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>
313
+ {t('refresh')}
314
+ </button>
315
+ </div>
316
+ {error !== null && (
317
+ <div className="mr-error" role="alert">{error}</div>
318
+ )}
319
+ {loading && models.length === 0 && (
320
+ <div className="mr-empty">{t('loading')}</div>
321
+ )}
322
+ {!loading && models.length === 0 && (
323
+ <div className="mr-empty">{t('empty')}</div>
324
+ )}
325
+ <div className="mr-models">
326
+ {models.map(model => (
327
+ <ModelCard
328
+ key={model.id}
329
+ model={model}
330
+ t={t}
331
+ readOnly={!writable}
332
+ busy={busyModel === model.id}
333
+ dragging={drag}
334
+ onDragStart={index => setDrag({ modelId: model.id, index })}
335
+ onDragEnd={() => setDrag(null)}
336
+ onDrop={index => {
337
+ if (drag !== null && drag.modelId === model.id) {
338
+ void reorder(model, drag.index, index)
339
+ }
340
+ setDrag(null)
341
+ }}
342
+ onSwitch={providerId => void switchActive(model, providerId)}
343
+ />
344
+ ))}
345
+ </div>
346
+ </div>
347
+ </div>
348
+ </div>
349
+ )
350
+ }
351
+ }
352
+
353
+ interface ModelCardProps {
354
+ model: RouterModelView
355
+ t: Translate
356
+ readOnly: boolean
357
+ busy: boolean
358
+ dragging: { modelId: string; index: number } | null
359
+ onDragStart: (index: number) => void
360
+ onDragEnd: () => void
361
+ onDrop: (index: number) => void
362
+ onSwitch: (providerId: string) => void
363
+ }
364
+
365
+ function ModelCard(props: ModelCardProps): JSX.Element {
366
+ const { model, t, readOnly, busy, dragging } = props
367
+ return (
368
+ <section className={`mr-card${busy ? ' mr-card-busy' : ''}`}>
369
+ <header className="mr-card-header">
370
+ <span className="mr-model-name">{model.name}</span>
371
+ <span className="mr-model-id">{model.id}</span>
372
+ <span className="mr-provider-count">{t('providerCount', { count: model.providers.length })}</span>
373
+ </header>
374
+ <p className="mr-drag-hint">{t('dragHint')}</p>
375
+ <ul className="mr-providers">
376
+ {model.providers.map((provider, index) => {
377
+ const active = provider.id === model.active
378
+ const selectable = provider.credentialConfigured
379
+ const dropTarget = dragging !== null && dragging.modelId === model.id && dragging.index !== index
380
+ return (
381
+ <li
382
+ key={provider.id}
383
+ className={[
384
+ 'mr-provider',
385
+ active ? 'mr-provider-active' : '',
386
+ !selectable ? 'mr-provider-nocred' : '',
387
+ dropTarget ? 'mr-provider-drop' : '',
388
+ ].filter(Boolean).join(' ')}
389
+ draggable={!busy && !readOnly && model.providers.length > 1}
390
+ title={selectable ? undefined : t('noCredential')}
391
+ onDragStart={(event) => {
392
+ props.onDragStart(index)
393
+ event.dataTransfer.effectAllowed = 'move'
394
+ try {
395
+ event.dataTransfer.setData('text/plain', model.id)
396
+ } catch {
397
+ // drag data is cosmetic for our own handler
398
+ }
399
+ }}
400
+ onDragOver={(event) => {
401
+ if (dragging !== null && dragging.modelId === model.id && dragging.index !== index) {
402
+ event.preventDefault()
403
+ event.dataTransfer.dropEffect = 'move'
404
+ }
405
+ }}
406
+ onDrop={(event) => {
407
+ if (dragging !== null && dragging.modelId === model.id) {
408
+ event.preventDefault()
409
+ props.onDrop(index)
410
+ }
411
+ }}
412
+ onDragEnd={() => props.onDragEnd()}
413
+ >
414
+ <span className="mr-grip" aria-hidden="true">⠿</span>
415
+ <button
416
+ type="button"
417
+ className="mr-provider-button"
418
+ disabled={!selectable || busy || active || readOnly}
419
+ onClick={() => props.onSwitch(provider.id)}
420
+ >
421
+ <span className="mr-provider-name">{provider.name}</span>
422
+ {!selectable && <span className="mr-provider-nocred-label">· {t('noCredential')}</span>}
423
+ </button>
424
+ {active && <span className="mr-active-badge">{t('activeBadge')}</span>}
425
+ </li>
426
+ )
427
+ })}
428
+ </ul>
429
+ </section>
430
+ )
431
+ }
package/src/index.ts CHANGED
@@ -3,14 +3,15 @@
3
3
  *
4
4
  * Registers one `model-router` LLM adapter that aggregates every configured
5
5
  * provider and model (strict model-id merge), routes each request to the
6
- * model's currently active provider by in-process delegation, persists the
7
- * per-model provider order/active config in the `model-router` settings
8
- * namespace, and serves the fenced JSON API the web settings page calls.
6
+ * model's currently active provider by in-process delegation, keeps the
7
+ * per-model provider order/active config in the entry config of this bundle
8
+ * (dsh >= 0.1.7: the `Config` schema below is the settings document), and
9
+ * serves the fenced JSON API the web settings page calls.
9
10
  */
10
11
  import type { Context } from '@deepseek-ai/cordis'
11
- import z from '@deepseek-ai/schemastery'
12
- import { ModelRouterService } from './server/service.js'
12
+ import { ModelRouterService, RouterConfigSchema } from './server/service.js'
13
13
  import { registerApiRoutes } from './server/rpc.js'
14
+ import type { RouterConfigShape } from './shared/config.js'
14
15
 
15
16
  export const name = '@fonlan/dsh-model-router'
16
17
 
@@ -20,10 +21,12 @@ export const name = '@fonlan/dsh-model-router'
20
21
  // no settings page).
21
22
  export const inject = ['llm']
22
23
 
23
- export const Config = z.object({})
24
+ // The entry config IS the router document: the settings plane writes it
25
+ // (settings page / settings.replace) and a changed config restarts this entry.
26
+ export const Config = RouterConfigSchema
24
27
 
25
- export function apply(ctx: Context): void {
26
- const service = new ModelRouterService(ctx)
28
+ export function apply(ctx: Context, config: RouterConfigShape): void {
29
+ const service = new ModelRouterService(ctx, config)
27
30
 
28
31
  ctx.effect(() => {
29
32
  service.start()