@fonlan/dsh-model-router 0.3.5 → 0.3.6
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 +44 -68
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.tsx +40 -18
- package/src/client/locales.ts +3 -7
- package/src/client/{settings-card.css → settings-section.css} +19 -59
- package/src/client/{settings-card.tsx → settings-section.tsx} +159 -176
|
@@ -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;
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Model Router
|
|
2
|
+
* The Model Router settings page (设置 → 侧栏「模型路由」).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
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 through the `model-router` settings
|
|
10
|
+
* namespace, so a switch here is live for the next request globally.
|
|
10
11
|
*
|
|
11
12
|
* The bound settings scope (the same `model-router` namespace the host half
|
|
12
|
-
* registers) supplies the
|
|
13
|
-
* loading the
|
|
14
|
-
* without the host half) nothing renders
|
|
15
|
-
*
|
|
16
|
-
* mutation controls.
|
|
13
|
+
* registers) supplies the page's dispatch state: while the namespace is merely
|
|
14
|
+
* loading the page stays mounted, and when it is unavailable (deployment
|
|
15
|
+
* without the host half) nothing renders. A read-only deployment shows the
|
|
16
|
+
* banner and disables the mutation controls.
|
|
17
17
|
*/
|
|
18
18
|
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
19
19
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -21,7 +21,7 @@ type ClientContext = Context
|
|
|
21
21
|
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
|
22
22
|
import { LOCALE_NS } from './locales'
|
|
23
23
|
import { api, type ModelRouterState, type ModelSortMode, type RouterModelView } from './api'
|
|
24
|
-
import './settings-
|
|
24
|
+
import './settings-section.css'
|
|
25
25
|
|
|
26
26
|
/** Client settings scope face (subset of the app client modules). */
|
|
27
27
|
export interface SettingsScopeFace {
|
|
@@ -32,7 +32,7 @@ export interface SettingsScopeFace {
|
|
|
32
32
|
subscribe(listener: () => void): () => void
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export interface
|
|
35
|
+
export interface SettingsSectionProps {
|
|
36
36
|
/** The bound `model-router` settings scope (from the slot entry's inject face). */
|
|
37
37
|
scope: SettingsScopeFace
|
|
38
38
|
}
|
|
@@ -65,8 +65,8 @@ function useLocaleRevision(ctx: ClientContext): number {
|
|
|
65
65
|
return useSyncExternalStore(subscribe, getSnapshot)
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
export function
|
|
69
|
-
// Bound translation is namespace-typed; the
|
|
68
|
+
export function makeSettingsSection(ctx: ClientContext): (props: SettingsSectionProps) => JSX.Element | null {
|
|
69
|
+
// Bound translation is namespace-typed; the page's props use the plain
|
|
70
70
|
// Translate face (string keys), which the dict satisfies structurally.
|
|
71
71
|
const t: Translate = (() => {
|
|
72
72
|
try {
|
|
@@ -76,7 +76,7 @@ export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps)
|
|
|
76
76
|
}
|
|
77
77
|
})()
|
|
78
78
|
|
|
79
|
-
return function
|
|
79
|
+
return function ModelRouterSettingsSection(props: SettingsSectionProps): JSX.Element | null {
|
|
80
80
|
const { scope } = props
|
|
81
81
|
// Bind the methods: React invokes getSnapshot/subscribe as bare functions,
|
|
82
82
|
// and SettingsScopeController's methods depend on `this`.
|
|
@@ -85,8 +85,6 @@ export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps)
|
|
|
85
85
|
() => scope.getSnapshot(),
|
|
86
86
|
)
|
|
87
87
|
useLocaleRevision(ctx)
|
|
88
|
-
// Card-local disclosure: collapsed by default, like the built-in plugin cards.
|
|
89
|
-
const [open, setOpen] = useState(false)
|
|
90
88
|
const [state, setState] = useState<ModelRouterState | null>(null)
|
|
91
89
|
const [error, setError] = useState<string | null>(null)
|
|
92
90
|
const [loading, setLoading] = useState(true)
|
|
@@ -216,174 +214,159 @@ export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps)
|
|
|
216
214
|
}, [state, t])
|
|
217
215
|
|
|
218
216
|
// The namespace is served by the host once the scope is ready. While it is
|
|
219
|
-
// merely loading, keep the
|
|
220
|
-
// if it is unavailable (deployment without the host half), render
|
|
217
|
+
// merely loading, keep the page mounted (the sidebar already dispatched
|
|
218
|
+
// it); if it is unavailable (deployment without the host half), render
|
|
219
|
+
// nothing.
|
|
221
220
|
if (snapshot.status === 'unavailable') return null
|
|
222
221
|
const writable = snapshot.writable
|
|
223
222
|
|
|
224
223
|
const models = useMemo(() => state?.models ?? [], [state])
|
|
225
224
|
|
|
226
225
|
return (
|
|
227
|
-
<
|
|
228
|
-
<
|
|
229
|
-
|
|
230
|
-
className="mr-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
)}
|
|
226
|
+
<div className="mr-page">
|
|
227
|
+
<header className="mr-page-head">
|
|
228
|
+
<h3 className="mr-page-title">{t('settingsTitle')}</h3>
|
|
229
|
+
<p className="mr-page-sub">{t('sectionSub')}</p>
|
|
230
|
+
</header>
|
|
231
|
+
<div className="mr-body">
|
|
232
|
+
{!writable && (
|
|
233
|
+
<p className="mr-settings-readonly" role="status">{t('readOnly')}</p>
|
|
234
|
+
)}
|
|
235
|
+
<div className="mr-root">
|
|
236
|
+
<label className="mr-quick-switch">
|
|
237
|
+
<input
|
|
238
|
+
type="checkbox"
|
|
239
|
+
checked={state?.showQuickSwitch ?? true}
|
|
240
|
+
disabled={loading || quickSwitchBusy || !writable}
|
|
241
|
+
onChange={(event) => void toggleQuickSwitch(event.target.checked)}
|
|
242
|
+
/>
|
|
243
|
+
<span className="mr-quick-switch-copy">
|
|
244
|
+
<span className="mr-quick-switch-label">{t('showQuickSwitchLabel')}</span>
|
|
245
|
+
<span className="mr-quick-switch-desc">{t('showQuickSwitchDescription')}</span>
|
|
246
|
+
</span>
|
|
247
|
+
</label>
|
|
248
|
+
<label className="mr-quick-switch">
|
|
249
|
+
<input
|
|
250
|
+
type="checkbox"
|
|
251
|
+
checked={state?.ignoreModelIdPrefix ?? true}
|
|
252
|
+
disabled={loading || ignorePrefixBusy || !writable}
|
|
253
|
+
onChange={(event) => void toggleIgnorePrefix(event.target.checked)}
|
|
254
|
+
/>
|
|
255
|
+
<span className="mr-quick-switch-copy">
|
|
256
|
+
<span className="mr-quick-switch-label">{t('ignorePrefixLabel')}</span>
|
|
257
|
+
<span className="mr-quick-switch-desc">{t('ignorePrefixDescription')}</span>
|
|
258
|
+
</span>
|
|
259
|
+
</label>
|
|
260
|
+
<div className="mr-sort">
|
|
261
|
+
<div className="mr-sort-head">
|
|
262
|
+
<span className="mr-sort-title">{t('sortTitle')}</span>
|
|
263
|
+
<span className="mr-sort-desc">{t('sortDescription')}</span>
|
|
346
264
|
</div>
|
|
347
|
-
<div className="mr-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
/>
|
|
265
|
+
<div className="mr-sort-modes" role="radiogroup" aria-label={t('sortTitle')}>
|
|
266
|
+
{(['custom', 'name', 'recent'] as const).map(mode => (
|
|
267
|
+
<label key={mode} className="mr-sort-mode">
|
|
268
|
+
<input
|
|
269
|
+
type="radio"
|
|
270
|
+
name="mr-model-sort"
|
|
271
|
+
checked={state?.modelSort === mode}
|
|
272
|
+
disabled={loading || sortBusy || !writable}
|
|
273
|
+
onChange={() => void changeModelSort(mode)}
|
|
274
|
+
/>
|
|
275
|
+
<span className="mr-sort-mode-copy">
|
|
276
|
+
<span className="mr-sort-mode-label">{t(`sortMode${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
|
|
277
|
+
<span className="mr-sort-mode-desc">{t(`sortModeDesc${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
|
|
278
|
+
</span>
|
|
279
|
+
</label>
|
|
381
280
|
))}
|
|
382
281
|
</div>
|
|
282
|
+
{state?.modelSort === 'custom' && (
|
|
283
|
+
<div className="mr-sort-custom">
|
|
284
|
+
<p className="mr-sort-hint">{t('sortCustomHint')}</p>
|
|
285
|
+
<ol className="mr-sort-list">
|
|
286
|
+
{models.map((model, index) => {
|
|
287
|
+
const dropTarget = modelDrag !== null && modelDrag.index !== index
|
|
288
|
+
return (
|
|
289
|
+
<li
|
|
290
|
+
key={model.id}
|
|
291
|
+
className={[
|
|
292
|
+
'mr-sort-item',
|
|
293
|
+
dropTarget ? 'mr-sort-item-drop' : '',
|
|
294
|
+
sortBusy ? 'mr-sort-item-busy' : '',
|
|
295
|
+
].filter(Boolean).join(' ')}
|
|
296
|
+
draggable={!sortBusy && writable}
|
|
297
|
+
onDragStart={(event) => {
|
|
298
|
+
setModelDrag({ index })
|
|
299
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
300
|
+
try {
|
|
301
|
+
event.dataTransfer.setData('text/plain', model.id)
|
|
302
|
+
} catch {
|
|
303
|
+
// drag data is cosmetic for our own handler
|
|
304
|
+
}
|
|
305
|
+
}}
|
|
306
|
+
onDragOver={(event) => {
|
|
307
|
+
if (modelDrag !== null && modelDrag.index !== index) {
|
|
308
|
+
event.preventDefault()
|
|
309
|
+
event.dataTransfer.dropEffect = 'move'
|
|
310
|
+
}
|
|
311
|
+
}}
|
|
312
|
+
onDrop={(event) => {
|
|
313
|
+
if (modelDrag !== null && modelDrag.index !== index) {
|
|
314
|
+
event.preventDefault()
|
|
315
|
+
moveModel(modelDrag.index, index)
|
|
316
|
+
}
|
|
317
|
+
setModelDrag(null)
|
|
318
|
+
}}
|
|
319
|
+
onDragEnd={() => setModelDrag(null)}
|
|
320
|
+
>
|
|
321
|
+
<span className="mr-grip" aria-hidden="true">⠿</span>
|
|
322
|
+
<span className="mr-sort-model-name">{model.name}</span>
|
|
323
|
+
<span className="mr-sort-model-id">{model.id}</span>
|
|
324
|
+
</li>
|
|
325
|
+
)
|
|
326
|
+
})}
|
|
327
|
+
</ol>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
<div className="mr-toolbar">
|
|
332
|
+
<span className="mr-count">{t('modelsCount', { count: models.length })}</span>
|
|
333
|
+
<button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>
|
|
334
|
+
{t('refresh')}
|
|
335
|
+
</button>
|
|
336
|
+
</div>
|
|
337
|
+
{error !== null && (
|
|
338
|
+
<div className="mr-error" role="alert">{error}</div>
|
|
339
|
+
)}
|
|
340
|
+
{loading && models.length === 0 && (
|
|
341
|
+
<div className="mr-empty">{t('loading')}</div>
|
|
342
|
+
)}
|
|
343
|
+
{!loading && models.length === 0 && (
|
|
344
|
+
<div className="mr-empty">{t('empty')}</div>
|
|
345
|
+
)}
|
|
346
|
+
<div className="mr-models">
|
|
347
|
+
{models.map(model => (
|
|
348
|
+
<ModelCard
|
|
349
|
+
key={model.id}
|
|
350
|
+
model={model}
|
|
351
|
+
t={t}
|
|
352
|
+
readOnly={!writable}
|
|
353
|
+
busy={busyModel === model.id}
|
|
354
|
+
dragging={drag}
|
|
355
|
+
onDragStart={index => setDrag({ modelId: model.id, index })}
|
|
356
|
+
onDragEnd={() => setDrag(null)}
|
|
357
|
+
onDrop={index => {
|
|
358
|
+
if (drag !== null && drag.modelId === model.id) {
|
|
359
|
+
void reorder(model, drag.index, index)
|
|
360
|
+
}
|
|
361
|
+
setDrag(null)
|
|
362
|
+
}}
|
|
363
|
+
onSwitch={providerId => void switchActive(model, providerId)}
|
|
364
|
+
/>
|
|
365
|
+
))}
|
|
383
366
|
</div>
|
|
384
367
|
</div>
|
|
385
|
-
|
|
386
|
-
</
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
387
370
|
)
|
|
388
371
|
}
|
|
389
372
|
}
|