@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.
@@ -1,469 +0,0 @@
1
- /**
2
- * The Model Router Settings Card (设置 → 插件 → 插件配置 → 模型路由).
3
- *
4
- * Renders its own expandable chrome aligned with the built-in plugin cards
5
- * (external plugins cannot import PluginCard): a header naming the plugin and
6
- * what its settings govern, disclosing the router controls in place. All
7
- * reads/mutations go through the plugin's fenced API; the server persists
8
- * through the `model-router` settings namespace, so a switch here is live for
9
- * the next request globally.
10
- *
11
- * The bound settings scope (the same `model-router` namespace the host half
12
- * registers) supplies the card's dispatch state: while the namespace is merely
13
- * loading the card stays mounted, and when it is unavailable (deployment
14
- * without the host half) nothing renders — matching the built-in cards. A
15
- * read-only deployment shows the built-in card's banner and disables the
16
- * 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-card.css'
25
-
26
- /** Client settings scope face (subset of the app client modules). */
27
- export interface SettingsScopeFace {
28
- getSnapshot(): {
29
- status: 'loading' | 'ready' | 'unavailable'
30
- writable: boolean
31
- }
32
- subscribe(listener: () => void): () => void
33
- }
34
-
35
- export interface SettingsCardProps {
36
- /** The bound `model-router` settings scope (from the slot entry's inject face). */
37
- scope: SettingsScopeFace
38
- }
39
-
40
- function arrayMove<T>(list: readonly T[], from: number, to: number): T[] {
41
- const next = [...list]
42
- const [item] = next.splice(from, 1)
43
- next.splice(to, 0, item)
44
- return next
45
- }
46
-
47
- function useLocaleRevision(ctx: ClientContext): number {
48
- const subscribe = useCallback(
49
- (onChange: () => void) => {
50
- try {
51
- return ctx.locale.subscribe(onChange)
52
- } catch {
53
- return () => {}
54
- }
55
- },
56
- [ctx],
57
- )
58
- const getSnapshot = useCallback(() => {
59
- try {
60
- return ctx.locale.getLocale().revision
61
- } catch {
62
- return 0
63
- }
64
- }, [ctx])
65
- return useSyncExternalStore(subscribe, getSnapshot)
66
- }
67
-
68
- export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps) => JSX.Element | null {
69
- // Bound translation is namespace-typed; the card's props use the plain
70
- // Translate face (string keys), which the dict satisfies structurally.
71
- const t: Translate = (() => {
72
- try {
73
- return ctx.locale.bind(LOCALE_NS) as unknown as Translate
74
- } catch {
75
- return (key: string) => key
76
- }
77
- })()
78
-
79
- return function ModelRouterSettingsCard(props: SettingsCardProps): JSX.Element | null {
80
- const { scope } = props
81
- // Bind the methods: React invokes getSnapshot/subscribe as bare functions,
82
- // and SettingsScopeController's methods depend on `this`.
83
- const snapshot = useSyncExternalStore(
84
- (listener) => scope.subscribe(listener),
85
- () => scope.getSnapshot(),
86
- )
87
- useLocaleRevision(ctx)
88
- // Card-local disclosure: collapsed by default, like the built-in plugin cards.
89
- const [open, setOpen] = useState(false)
90
- const [state, setState] = useState<ModelRouterState | null>(null)
91
- const [error, setError] = useState<string | null>(null)
92
- const [loading, setLoading] = useState(true)
93
- const [busyModel, setBusyModel] = useState<string | null>(null)
94
- const [drag, setDrag] = useState<{ modelId: string; index: number } | null>(null)
95
- const [quickSwitchBusy, setQuickSwitchBusy] = useState(false)
96
- const [ignorePrefixBusy, setIgnorePrefixBusy] = useState(false)
97
- const [sortBusy, setSortBusy] = useState(false)
98
- const [modelDrag, setModelDrag] = useState<{ index: number } | null>(null)
99
-
100
- const load = useCallback(async () => {
101
- setLoading(true)
102
- try {
103
- setState(await api.state())
104
- setError(null)
105
- } catch (cause) {
106
- setError(cause instanceof Error ? cause.message : String(cause))
107
- } finally {
108
- setLoading(false)
109
- }
110
- }, [])
111
-
112
- useEffect(() => {
113
- void load()
114
- }, [load])
115
-
116
- const switchActive = useCallback(async (model: RouterModelView, providerId: string) => {
117
- if (model.active === providerId) return
118
- setBusyModel(model.id)
119
- try {
120
- setState(await api.switchActive(model.id, providerId))
121
- setError(null)
122
- } catch (cause) {
123
- setError(t('switchFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
124
- } finally {
125
- setBusyModel(null)
126
- }
127
- }, [t])
128
-
129
- const reorder = useCallback(async (model: RouterModelView, from: number, to: number) => {
130
- if (from === to) return
131
- const order = arrayMove(model.order, from, to)
132
- setState(prev => prev === null ? prev : {
133
- ...prev,
134
- models: prev.models.map(m => (m.id === model.id ? { ...m, order } : m)),
135
- })
136
- setBusyModel(model.id)
137
- try {
138
- setState(await api.reorder(model.id, order))
139
- setError(null)
140
- } catch (cause) {
141
- setError(t('reorderFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
142
- setState(prev => prev === null ? prev : {
143
- ...prev,
144
- models: prev.models.map(m => (m.id === model.id ? { ...m, order: model.order } : m)),
145
- })
146
- } finally {
147
- setBusyModel(null)
148
- }
149
- }, [t])
150
-
151
- const toggleQuickSwitch = useCallback(async (value: boolean) => {
152
- if (state === null) return
153
- const previous = state.showQuickSwitch
154
- setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: value })
155
- setQuickSwitchBusy(true)
156
- try {
157
- setState(await api.setShowQuickSwitch(value))
158
- setError(null)
159
- } catch (cause) {
160
- setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: previous })
161
- setError(cause instanceof Error ? cause.message : String(cause))
162
- } finally {
163
- setQuickSwitchBusy(false)
164
- }
165
- }, [state])
166
-
167
- const toggleIgnorePrefix = useCallback(async (value: boolean) => {
168
- if (state === null) return
169
- const previous = state.ignoreModelIdPrefix
170
- setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: value })
171
- setIgnorePrefixBusy(true)
172
- try {
173
- setState(await api.setIgnoreModelIdPrefix(value))
174
- setError(null)
175
- } catch (cause) {
176
- setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: previous })
177
- setError(cause instanceof Error ? cause.message : String(cause))
178
- } finally {
179
- setIgnorePrefixBusy(false)
180
- }
181
- }, [state])
182
-
183
- const changeModelSort = useCallback(async (mode: ModelSortMode) => {
184
- if (state === null || state.modelSort === mode) return
185
- const previous = state.modelSort
186
- setState(prev => prev === null ? prev : { ...prev, modelSort: mode })
187
- setSortBusy(true)
188
- try {
189
- setState(await api.setModelSort(mode))
190
- setError(null)
191
- } catch (cause) {
192
- setState(prev => prev === null ? prev : { ...prev, modelSort: previous })
193
- setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
194
- } finally {
195
- setSortBusy(false)
196
- }
197
- }, [state, t])
198
-
199
- const moveModel = useCallback(async (from: number, to: number) => {
200
- if (state === null || from === to) return
201
- // The drag list shows the full display order (modelOrder + catalog
202
- // tail); persist the whole reordered id list as the new custom order.
203
- const ids = state.models.map(model => model.id)
204
- const order = arrayMove(ids, from, to)
205
- setState(prev => prev === null ? prev : { ...prev, modelOrder: order })
206
- setSortBusy(true)
207
- try {
208
- setState(await api.setModelOrder(order))
209
- setError(null)
210
- } catch (cause) {
211
- setState(prev => prev === null ? prev : { ...prev, modelOrder: state.modelOrder })
212
- setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
213
- } finally {
214
- setSortBusy(false)
215
- }
216
- }, [state, t])
217
-
218
- // The namespace is served by the host once the scope is ready. While it is
219
- // merely loading, keep the card mounted (the tab already dispatched it);
220
- // if it is unavailable (deployment without the host half), render nothing.
221
- if (snapshot.status === 'unavailable') return null
222
- const writable = snapshot.writable
223
-
224
- const models = useMemo(() => state?.models ?? [], [state])
225
-
226
- return (
227
- <li className="mr-settings-card" data-open={open ? '' : undefined}>
228
- <button
229
- type="button"
230
- className="mr-settings-head"
231
- aria-expanded={open}
232
- aria-label={(open ? t('collapse') : t('expand')) + ':' + t('settingsTitle')}
233
- onClick={() => setOpen(!open)}
234
- >
235
- <span className="mr-settings-head-text">
236
- <span className="mr-settings-title">{t('settingsTitle')}</span>
237
- <span className="mr-settings-sub">{t('settingsCardDescription')}</span>
238
- </span>
239
- <span className="mr-settings-chevron" data-open={open ? '' : undefined} aria-hidden="true">
240
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
241
- <path d="M3.5 5.25L7 8.75L10.5 5.25" stroke="currentColor" strokeWidth="1.5"
242
- strokeLinecap="round" strokeLinejoin="round" />
243
- </svg>
244
- </span>
245
- </button>
246
- {open && (
247
- <div className="mr-settings-body">
248
- {!writable && (
249
- <p className="mr-settings-readonly" role="status">{t('readOnly')}</p>
250
- )}
251
- <div className="mr-root">
252
- <label className="mr-quick-switch">
253
- <input
254
- type="checkbox"
255
- checked={state?.showQuickSwitch ?? true}
256
- disabled={loading || quickSwitchBusy || !writable}
257
- onChange={(event) => void toggleQuickSwitch(event.target.checked)}
258
- />
259
- <span className="mr-quick-switch-copy">
260
- <span className="mr-quick-switch-label">{t('showQuickSwitchLabel')}</span>
261
- <span className="mr-quick-switch-desc">{t('showQuickSwitchDescription')}</span>
262
- </span>
263
- </label>
264
- <label className="mr-quick-switch">
265
- <input
266
- type="checkbox"
267
- checked={state?.ignoreModelIdPrefix ?? true}
268
- disabled={loading || ignorePrefixBusy || !writable}
269
- onChange={(event) => void toggleIgnorePrefix(event.target.checked)}
270
- />
271
- <span className="mr-quick-switch-copy">
272
- <span className="mr-quick-switch-label">{t('ignorePrefixLabel')}</span>
273
- <span className="mr-quick-switch-desc">{t('ignorePrefixDescription')}</span>
274
- </span>
275
- </label>
276
- <div className="mr-sort">
277
- <div className="mr-sort-head">
278
- <span className="mr-sort-title">{t('sortTitle')}</span>
279
- <span className="mr-sort-desc">{t('sortDescription')}</span>
280
- </div>
281
- <div className="mr-sort-modes" role="radiogroup" aria-label={t('sortTitle')}>
282
- {(['custom', 'name', 'recent'] as const).map(mode => (
283
- <label key={mode} className="mr-sort-mode">
284
- <input
285
- type="radio"
286
- name="mr-model-sort"
287
- checked={state?.modelSort === mode}
288
- disabled={loading || sortBusy || !writable}
289
- onChange={() => void changeModelSort(mode)}
290
- />
291
- <span className="mr-sort-mode-copy">
292
- <span className="mr-sort-mode-label">{t(`sortMode${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
293
- <span className="mr-sort-mode-desc">{t(`sortModeDesc${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
294
- </span>
295
- </label>
296
- ))}
297
- </div>
298
- {state?.modelSort === 'custom' && (
299
- <div className="mr-sort-custom">
300
- <p className="mr-sort-hint">{t('sortCustomHint')}</p>
301
- <ol className="mr-sort-list">
302
- {models.map((model, index) => {
303
- const dropTarget = modelDrag !== null && modelDrag.index !== index
304
- return (
305
- <li
306
- key={model.id}
307
- className={[
308
- 'mr-sort-item',
309
- dropTarget ? 'mr-sort-item-drop' : '',
310
- sortBusy ? 'mr-sort-item-busy' : '',
311
- ].filter(Boolean).join(' ')}
312
- draggable={!sortBusy && writable}
313
- onDragStart={(event) => {
314
- setModelDrag({ index })
315
- event.dataTransfer.effectAllowed = 'move'
316
- try {
317
- event.dataTransfer.setData('text/plain', model.id)
318
- } catch {
319
- // drag data is cosmetic for our own handler
320
- }
321
- }}
322
- onDragOver={(event) => {
323
- if (modelDrag !== null && modelDrag.index !== index) {
324
- event.preventDefault()
325
- event.dataTransfer.dropEffect = 'move'
326
- }
327
- }}
328
- onDrop={(event) => {
329
- if (modelDrag !== null && modelDrag.index !== index) {
330
- event.preventDefault()
331
- moveModel(modelDrag.index, index)
332
- }
333
- setModelDrag(null)
334
- }}
335
- onDragEnd={() => setModelDrag(null)}
336
- >
337
- <span className="mr-grip" aria-hidden="true">⠿</span>
338
- <span className="mr-sort-model-name">{model.name}</span>
339
- <span className="mr-sort-model-id">{model.id}</span>
340
- </li>
341
- )
342
- })}
343
- </ol>
344
- </div>
345
- )}
346
- </div>
347
- <div className="mr-toolbar">
348
- <span className="mr-count">{t('modelsCount', { count: models.length })}</span>
349
- <button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>
350
- {t('refresh')}
351
- </button>
352
- </div>
353
- {error !== null && (
354
- <div className="mr-error" role="alert">{error}</div>
355
- )}
356
- {loading && models.length === 0 && (
357
- <div className="mr-empty">{t('loading')}</div>
358
- )}
359
- {!loading && models.length === 0 && (
360
- <div className="mr-empty">{t('empty')}</div>
361
- )}
362
- <div className="mr-models">
363
- {models.map(model => (
364
- <ModelCard
365
- key={model.id}
366
- model={model}
367
- t={t}
368
- readOnly={!writable}
369
- busy={busyModel === model.id}
370
- dragging={drag}
371
- onDragStart={index => setDrag({ modelId: model.id, index })}
372
- onDragEnd={() => setDrag(null)}
373
- onDrop={index => {
374
- if (drag !== null && drag.modelId === model.id) {
375
- void reorder(model, drag.index, index)
376
- }
377
- setDrag(null)
378
- }}
379
- onSwitch={providerId => void switchActive(model, providerId)}
380
- />
381
- ))}
382
- </div>
383
- </div>
384
- </div>
385
- )}
386
- </li>
387
- )
388
- }
389
- }
390
-
391
- interface ModelCardProps {
392
- model: RouterModelView
393
- t: Translate
394
- readOnly: boolean
395
- busy: boolean
396
- dragging: { modelId: string; index: number } | null
397
- onDragStart: (index: number) => void
398
- onDragEnd: () => void
399
- onDrop: (index: number) => void
400
- onSwitch: (providerId: string) => void
401
- }
402
-
403
- function ModelCard(props: ModelCardProps): JSX.Element {
404
- const { model, t, readOnly, busy, dragging } = props
405
- return (
406
- <section className={`mr-card${busy ? ' mr-card-busy' : ''}`}>
407
- <header className="mr-card-header">
408
- <span className="mr-model-name">{model.name}</span>
409
- <span className="mr-model-id">{model.id}</span>
410
- <span className="mr-provider-count">{t('providerCount', { count: model.providers.length })}</span>
411
- </header>
412
- <p className="mr-drag-hint">{t('dragHint')}</p>
413
- <ul className="mr-providers">
414
- {model.providers.map((provider, index) => {
415
- const active = provider.id === model.active
416
- const selectable = provider.credentialConfigured
417
- const dropTarget = dragging !== null && dragging.modelId === model.id && dragging.index !== index
418
- return (
419
- <li
420
- key={provider.id}
421
- className={[
422
- 'mr-provider',
423
- active ? 'mr-provider-active' : '',
424
- !selectable ? 'mr-provider-nocred' : '',
425
- dropTarget ? 'mr-provider-drop' : '',
426
- ].filter(Boolean).join(' ')}
427
- draggable={!busy && !readOnly && model.providers.length > 1}
428
- title={selectable ? undefined : t('noCredential')}
429
- onDragStart={(event) => {
430
- props.onDragStart(index)
431
- event.dataTransfer.effectAllowed = 'move'
432
- try {
433
- event.dataTransfer.setData('text/plain', model.id)
434
- } catch {
435
- // drag data is cosmetic for our own handler
436
- }
437
- }}
438
- onDragOver={(event) => {
439
- if (dragging !== null && dragging.modelId === model.id && dragging.index !== index) {
440
- event.preventDefault()
441
- event.dataTransfer.dropEffect = 'move'
442
- }
443
- }}
444
- onDrop={(event) => {
445
- if (dragging !== null && dragging.modelId === model.id) {
446
- event.preventDefault()
447
- props.onDrop(index)
448
- }
449
- }}
450
- onDragEnd={() => props.onDragEnd()}
451
- >
452
- <span className="mr-grip" aria-hidden="true">⠿</span>
453
- <button
454
- type="button"
455
- className="mr-provider-button"
456
- disabled={!selectable || busy || active || readOnly}
457
- onClick={() => props.onSwitch(provider.id)}
458
- >
459
- <span className="mr-provider-name">{provider.name}</span>
460
- {!selectable && <span className="mr-provider-nocred-label">· {t('noCredential')}</span>}
461
- </button>
462
- {active && <span className="mr-active-badge">{t('activeBadge')}</span>}
463
- </li>
464
- )
465
- })}
466
- </ul>
467
- </section>
468
- )
469
- }