@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.
@@ -1,81 +1,41 @@
1
- /* Model Router settings card — chrome aligned with the built-in plugin cards
2
- (设置 → 插件 → 插件配置), theme-consistent via DSW alias variables. */
3
- .mr-settings-card {
4
- border: 1px solid var(--dsw-alias-border-l2, #2a3346);
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
- .mr-settings-card:hover {
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-settings-head {
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-settings-head:focus-visible {
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-settings-title {
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-settings-sub {
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
- .mr-settings-chevron {
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-settings-body {
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
- /* ── card body: the router controls ─────────────────────────────────────── */
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 Settings Card (设置 → 插件 → 插件配置 → 模型路由).
2
+ * The Model Router settings page (设置 → 侧栏「模型路由」).
3
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.
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 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.
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-card.css'
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 SettingsCardProps {
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 makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps) => JSX.Element | null {
69
- // Bound translation is namespace-typed; the card's props use the plain
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 ModelRouterSettingsCard(props: SettingsCardProps): JSX.Element | null {
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 card mounted (the tab already dispatched it);
220
- // if it is unavailable (deployment without the host half), render nothing.
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
- <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
- )}
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-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
- />
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
- </li>
368
+ </div>
369
+ </div>
387
370
  )
388
371
  }
389
372
  }