@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
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Model Router settings page: one card per merged model, provider chips
|
|
3
|
-
* that switch the active route on click and reorder by native HTML5 drag &
|
|
4
|
-
* drop (preference order for future automatic failover). All reads/mutations
|
|
5
|
-
* go through the plugin's fenced API; the server persists through the
|
|
6
|
-
* settings service, so a switch here is live for the next request globally.
|
|
7
|
-
*/
|
|
8
|
-
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
9
|
-
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
10
|
-
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
|
11
|
-
import { LOCALE_NS } from './locales'
|
|
12
|
-
import { api, type ModelRouterState, type RouterModelView } from './api'
|
|
13
|
-
import './settings-section.css'
|
|
14
|
-
|
|
15
|
-
function arrayMove<T>(list: readonly T[], from: number, to: number): T[] {
|
|
16
|
-
const next = [...list]
|
|
17
|
-
const [item] = next.splice(from, 1)
|
|
18
|
-
next.splice(to, 0, item)
|
|
19
|
-
return next
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function useLocaleRevision(ctx: ClientContext): number {
|
|
23
|
-
const subscribe = useCallback(
|
|
24
|
-
(onChange: () => void) => {
|
|
25
|
-
try {
|
|
26
|
-
return ctx.locale.subscribe(onChange)
|
|
27
|
-
} catch {
|
|
28
|
-
return () => {}
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
|
-
[ctx],
|
|
32
|
-
)
|
|
33
|
-
const getSnapshot = useCallback(() => {
|
|
34
|
-
try {
|
|
35
|
-
return ctx.locale.getLocale().revision
|
|
36
|
-
} catch {
|
|
37
|
-
return 0
|
|
38
|
-
}
|
|
39
|
-
}, [ctx])
|
|
40
|
-
return useSyncExternalStore(subscribe, getSnapshot)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
|
|
44
|
-
// Bound translation is namespace-typed; the section's props use the plain
|
|
45
|
-
// Translate face (string keys), which the dict satisfies structurally.
|
|
46
|
-
const t: Translate = (() => {
|
|
47
|
-
try {
|
|
48
|
-
return ctx.locale.bind(LOCALE_NS) as unknown as Translate
|
|
49
|
-
} catch {
|
|
50
|
-
return (key: string) => key
|
|
51
|
-
}
|
|
52
|
-
})()
|
|
53
|
-
|
|
54
|
-
return function ModelRouterSettingsSection(): JSX.Element {
|
|
55
|
-
useLocaleRevision(ctx)
|
|
56
|
-
const [state, setState] = useState<ModelRouterState | null>(null)
|
|
57
|
-
const [error, setError] = useState<string | null>(null)
|
|
58
|
-
const [loading, setLoading] = useState(true)
|
|
59
|
-
const [busyModel, setBusyModel] = useState<string | null>(null)
|
|
60
|
-
const [drag, setDrag] = useState<{ modelId: string; index: number } | null>(null)
|
|
61
|
-
const [quickSwitchBusy, setQuickSwitchBusy] = useState(false)
|
|
62
|
-
const [ignorePrefixBusy, setIgnorePrefixBusy] = useState(false)
|
|
63
|
-
|
|
64
|
-
const load = useCallback(async () => {
|
|
65
|
-
setLoading(true)
|
|
66
|
-
try {
|
|
67
|
-
setState(await api.state())
|
|
68
|
-
setError(null)
|
|
69
|
-
} catch (cause) {
|
|
70
|
-
setError(cause instanceof Error ? cause.message : String(cause))
|
|
71
|
-
} finally {
|
|
72
|
-
setLoading(false)
|
|
73
|
-
}
|
|
74
|
-
}, [])
|
|
75
|
-
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
void load()
|
|
78
|
-
}, [load])
|
|
79
|
-
|
|
80
|
-
const switchActive = useCallback(async (model: RouterModelView, providerId: string) => {
|
|
81
|
-
if (model.active === providerId) return
|
|
82
|
-
setBusyModel(model.id)
|
|
83
|
-
try {
|
|
84
|
-
setState(await api.switchActive(model.id, providerId))
|
|
85
|
-
setError(null)
|
|
86
|
-
} catch (cause) {
|
|
87
|
-
setError(t('switchFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
88
|
-
} finally {
|
|
89
|
-
setBusyModel(null)
|
|
90
|
-
}
|
|
91
|
-
}, [t])
|
|
92
|
-
|
|
93
|
-
const reorder = useCallback(async (model: RouterModelView, from: number, to: number) => {
|
|
94
|
-
if (from === to) return
|
|
95
|
-
const order = arrayMove(model.order, from, to)
|
|
96
|
-
setState(prev => prev === null ? prev : {
|
|
97
|
-
...prev,
|
|
98
|
-
models: prev.models.map(m => (m.id === model.id ? { ...m, order } : m)),
|
|
99
|
-
})
|
|
100
|
-
setBusyModel(model.id)
|
|
101
|
-
try {
|
|
102
|
-
setState(await api.reorder(model.id, order))
|
|
103
|
-
setError(null)
|
|
104
|
-
} catch (cause) {
|
|
105
|
-
setError(t('reorderFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
106
|
-
setState(prev => prev === null ? prev : {
|
|
107
|
-
...prev,
|
|
108
|
-
models: prev.models.map(m => (m.id === model.id ? { ...m, order: model.order } : m)),
|
|
109
|
-
})
|
|
110
|
-
} finally {
|
|
111
|
-
setBusyModel(null)
|
|
112
|
-
}
|
|
113
|
-
}, [t])
|
|
114
|
-
|
|
115
|
-
const toggleQuickSwitch = useCallback(async (value: boolean) => {
|
|
116
|
-
if (state === null) return
|
|
117
|
-
const previous = state.showQuickSwitch
|
|
118
|
-
setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: value })
|
|
119
|
-
setQuickSwitchBusy(true)
|
|
120
|
-
try {
|
|
121
|
-
setState(await api.setShowQuickSwitch(value))
|
|
122
|
-
setError(null)
|
|
123
|
-
} catch (cause) {
|
|
124
|
-
setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: previous })
|
|
125
|
-
setError(cause instanceof Error ? cause.message : String(cause))
|
|
126
|
-
} finally {
|
|
127
|
-
setQuickSwitchBusy(false)
|
|
128
|
-
}
|
|
129
|
-
}, [state])
|
|
130
|
-
|
|
131
|
-
const toggleIgnorePrefix = useCallback(async (value: boolean) => {
|
|
132
|
-
if (state === null) return
|
|
133
|
-
const previous = state.ignoreModelIdPrefix
|
|
134
|
-
setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: value })
|
|
135
|
-
setIgnorePrefixBusy(true)
|
|
136
|
-
try {
|
|
137
|
-
setState(await api.setIgnoreModelIdPrefix(value))
|
|
138
|
-
setError(null)
|
|
139
|
-
} catch (cause) {
|
|
140
|
-
setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: previous })
|
|
141
|
-
setError(cause instanceof Error ? cause.message : String(cause))
|
|
142
|
-
} finally {
|
|
143
|
-
setIgnorePrefixBusy(false)
|
|
144
|
-
}
|
|
145
|
-
}, [state])
|
|
146
|
-
|
|
147
|
-
const models = useMemo(() => state?.models ?? [], [state])
|
|
148
|
-
|
|
149
|
-
return (
|
|
150
|
-
<div className="mr-root">
|
|
151
|
-
<p className="mr-description">{t('settingsDescription')}</p>
|
|
152
|
-
<label className="mr-quick-switch">
|
|
153
|
-
<input
|
|
154
|
-
type="checkbox"
|
|
155
|
-
checked={state?.showQuickSwitch ?? true}
|
|
156
|
-
disabled={loading || quickSwitchBusy}
|
|
157
|
-
onChange={(event) => void toggleQuickSwitch(event.target.checked)}
|
|
158
|
-
/>
|
|
159
|
-
<span className="mr-quick-switch-copy">
|
|
160
|
-
<span className="mr-quick-switch-label">{t('showQuickSwitchLabel')}</span>
|
|
161
|
-
<span className="mr-quick-switch-desc">{t('showQuickSwitchDescription')}</span>
|
|
162
|
-
</span>
|
|
163
|
-
</label>
|
|
164
|
-
<label className="mr-quick-switch">
|
|
165
|
-
<input
|
|
166
|
-
type="checkbox"
|
|
167
|
-
checked={state?.ignoreModelIdPrefix ?? true}
|
|
168
|
-
disabled={loading || ignorePrefixBusy}
|
|
169
|
-
onChange={(event) => void toggleIgnorePrefix(event.target.checked)}
|
|
170
|
-
/>
|
|
171
|
-
<span className="mr-quick-switch-copy">
|
|
172
|
-
<span className="mr-quick-switch-label">{t('ignorePrefixLabel')}</span>
|
|
173
|
-
<span className="mr-quick-switch-desc">{t('ignorePrefixDescription')}</span>
|
|
174
|
-
</span>
|
|
175
|
-
</label>
|
|
176
|
-
<div className="mr-toolbar">
|
|
177
|
-
<span className="mr-count">{t('modelsCount', { count: models.length })}</span>
|
|
178
|
-
<button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>
|
|
179
|
-
{t('refresh')}
|
|
180
|
-
</button>
|
|
181
|
-
</div>
|
|
182
|
-
{error !== null && (
|
|
183
|
-
<div className="mr-error" role="alert">{error}</div>
|
|
184
|
-
)}
|
|
185
|
-
{loading && models.length === 0 && (
|
|
186
|
-
<div className="mr-empty">{t('loading')}</div>
|
|
187
|
-
)}
|
|
188
|
-
{!loading && models.length === 0 && (
|
|
189
|
-
<div className="mr-empty">{t('empty')}</div>
|
|
190
|
-
)}
|
|
191
|
-
<div className="mr-models">
|
|
192
|
-
{models.map(model => (
|
|
193
|
-
<ModelCard
|
|
194
|
-
key={model.id}
|
|
195
|
-
model={model}
|
|
196
|
-
t={t}
|
|
197
|
-
busy={busyModel === model.id}
|
|
198
|
-
dragging={drag}
|
|
199
|
-
onDragStart={index => setDrag({ modelId: model.id, index })}
|
|
200
|
-
onDragEnd={() => setDrag(null)}
|
|
201
|
-
onDrop={index => {
|
|
202
|
-
if (drag !== null && drag.modelId === model.id) {
|
|
203
|
-
void reorder(model, drag.index, index)
|
|
204
|
-
}
|
|
205
|
-
setDrag(null)
|
|
206
|
-
}}
|
|
207
|
-
onSwitch={providerId => void switchActive(model, providerId)}
|
|
208
|
-
/>
|
|
209
|
-
))}
|
|
210
|
-
</div>
|
|
211
|
-
</div>
|
|
212
|
-
)
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
interface ModelCardProps {
|
|
217
|
-
model: RouterModelView
|
|
218
|
-
t: Translate
|
|
219
|
-
busy: boolean
|
|
220
|
-
dragging: { modelId: string; index: number } | null
|
|
221
|
-
onDragStart: (index: number) => void
|
|
222
|
-
onDragEnd: () => void
|
|
223
|
-
onDrop: (index: number) => void
|
|
224
|
-
onSwitch: (providerId: string) => void
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function ModelCard(props: ModelCardProps): JSX.Element {
|
|
228
|
-
const { model, t, busy, dragging } = props
|
|
229
|
-
return (
|
|
230
|
-
<section className={`mr-card${busy ? ' mr-card-busy' : ''}`}>
|
|
231
|
-
<header className="mr-card-header">
|
|
232
|
-
<span className="mr-model-name">{model.name}</span>
|
|
233
|
-
<span className="mr-model-id">{model.id}</span>
|
|
234
|
-
<span className="mr-provider-count">{t('providerCount', { count: model.providers.length })}</span>
|
|
235
|
-
</header>
|
|
236
|
-
<p className="mr-drag-hint">{t('dragHint')}</p>
|
|
237
|
-
<ul className="mr-providers">
|
|
238
|
-
{model.providers.map((provider, index) => {
|
|
239
|
-
const active = provider.id === model.active
|
|
240
|
-
const selectable = provider.credentialConfigured
|
|
241
|
-
const dropTarget = dragging !== null && dragging.modelId === model.id && dragging.index !== index
|
|
242
|
-
return (
|
|
243
|
-
<li
|
|
244
|
-
key={provider.id}
|
|
245
|
-
className={[
|
|
246
|
-
'mr-provider',
|
|
247
|
-
active ? 'mr-provider-active' : '',
|
|
248
|
-
!selectable ? 'mr-provider-nocred' : '',
|
|
249
|
-
dropTarget ? 'mr-provider-drop' : '',
|
|
250
|
-
].filter(Boolean).join(' ')}
|
|
251
|
-
draggable={!busy && model.providers.length > 1}
|
|
252
|
-
title={selectable ? undefined : t('noCredential')}
|
|
253
|
-
onDragStart={(event) => {
|
|
254
|
-
props.onDragStart(index)
|
|
255
|
-
event.dataTransfer.effectAllowed = 'move'
|
|
256
|
-
try {
|
|
257
|
-
event.dataTransfer.setData('text/plain', model.id)
|
|
258
|
-
} catch {
|
|
259
|
-
// drag data is cosmetic for our own handler
|
|
260
|
-
}
|
|
261
|
-
}}
|
|
262
|
-
onDragOver={(event) => {
|
|
263
|
-
if (dragging !== null && dragging.modelId === model.id && dragging.index !== index) {
|
|
264
|
-
event.preventDefault()
|
|
265
|
-
event.dataTransfer.dropEffect = 'move'
|
|
266
|
-
}
|
|
267
|
-
}}
|
|
268
|
-
onDrop={(event) => {
|
|
269
|
-
if (dragging !== null && dragging.modelId === model.id) {
|
|
270
|
-
event.preventDefault()
|
|
271
|
-
props.onDrop(index)
|
|
272
|
-
}
|
|
273
|
-
}}
|
|
274
|
-
onDragEnd={() => props.onDragEnd()}
|
|
275
|
-
>
|
|
276
|
-
<span className="mr-grip" aria-hidden="true">⠿</span>
|
|
277
|
-
<button
|
|
278
|
-
type="button"
|
|
279
|
-
className="mr-provider-button"
|
|
280
|
-
disabled={!selectable || busy || active}
|
|
281
|
-
onClick={() => props.onSwitch(provider.id)}
|
|
282
|
-
>
|
|
283
|
-
<span className="mr-provider-name">{provider.name}</span>
|
|
284
|
-
{!selectable && <span className="mr-provider-nocred-label">· {t('noCredential')}</span>}
|
|
285
|
-
</button>
|
|
286
|
-
{active && <span className="mr-active-badge">{t('activeBadge')}</span>}
|
|
287
|
-
</li>
|
|
288
|
-
)
|
|
289
|
-
})}
|
|
290
|
-
</ul>
|
|
291
|
-
</section>
|
|
292
|
-
)
|
|
293
|
-
}
|