@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.
@@ -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
- }