@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.
- package/lib/client.js +158 -88
- package/lib/client.js.map +1 -1
- package/lib/index.js +70 -47
- package/lib/types/index.d.ts +7 -6
- package/lib/types/server/service.d.ts +27 -4
- package/lib/types/shared/config.d.ts +2 -0
- package/package.json +8 -8
- package/src/client/api.ts +5 -0
- package/src/client/icons.tsx +97 -0
- package/src/client/index.tsx +45 -28
- package/src/client/locales.ts +3 -7
- package/src/client/model-seat.tsx +10 -15
- package/src/client/{settings-card.css → settings-section.css} +19 -59
- package/src/client/settings-section.tsx +431 -0
- package/src/index.ts +11 -8
- package/src/server/service.ts +67 -57
- package/src/shared/config.ts +32 -2
- package/src/client/settings-card.tsx +0 -469
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 ? <
|
|
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 ? <
|
|
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 ? <
|
|
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={<
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
/* ──
|
|
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,
|
|
7
|
-
* per-model provider order/active config in the
|
|
8
|
-
*
|
|
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
|
|
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
|
-
|
|
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()
|