@fonlan/dsh-model-router 0.3.0 → 0.3.1

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,16 +1,40 @@
1
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.
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.
7
17
  */
8
18
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
9
19
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
10
20
  import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
11
21
  import { LOCALE_NS } from './locales'
12
22
  import { api, type ModelRouterState, type RouterModelView } from './api'
13
- import './settings-section.css'
23
+ import './settings-card.css'
24
+
25
+ /** Client settings scope face (subset of @deepseek-ai/dsh-client-runtime). */
26
+ export interface SettingsScopeFace {
27
+ getSnapshot(): {
28
+ status: 'loading' | 'ready' | 'unavailable'
29
+ writable: boolean
30
+ }
31
+ subscribe(listener: () => void): () => void
32
+ }
33
+
34
+ export interface SettingsCardProps {
35
+ /** The bound `model-router` settings scope (from the slot entry's inject face). */
36
+ scope: SettingsScopeFace
37
+ }
14
38
 
15
39
  function arrayMove<T>(list: readonly T[], from: number, to: number): T[] {
16
40
  const next = [...list]
@@ -40,8 +64,8 @@ function useLocaleRevision(ctx: ClientContext): number {
40
64
  return useSyncExternalStore(subscribe, getSnapshot)
41
65
  }
42
66
 
43
- export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
44
- // Bound translation is namespace-typed; the section's props use the plain
67
+ export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps) => JSX.Element | null {
68
+ // Bound translation is namespace-typed; the card's props use the plain
45
69
  // Translate face (string keys), which the dict satisfies structurally.
46
70
  const t: Translate = (() => {
47
71
  try {
@@ -51,8 +75,17 @@ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
51
75
  }
52
76
  })()
53
77
 
54
- return function ModelRouterSettingsSection(): JSX.Element {
78
+ return function ModelRouterSettingsCard(props: SettingsCardProps): JSX.Element | null {
79
+ const { scope } = props
80
+ // Bind the methods: React invokes getSnapshot/subscribe as bare functions,
81
+ // and SettingsScopeController's methods depend on `this`.
82
+ const snapshot = useSyncExternalStore(
83
+ (listener) => scope.subscribe(listener),
84
+ () => scope.getSnapshot(),
85
+ )
55
86
  useLocaleRevision(ctx)
87
+ // Card-local disclosure: collapsed by default, like the built-in plugin cards.
88
+ const [open, setOpen] = useState(false)
56
89
  const [state, setState] = useState<ModelRouterState | null>(null)
57
90
  const [error, setError] = useState<string | null>(null)
58
91
  const [loading, setLoading] = useState(true)
@@ -144,71 +177,104 @@ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
144
177
  }
145
178
  }, [state])
146
179
 
180
+ // The namespace is served by the host once the scope is ready. While it is
181
+ // merely loading, keep the card mounted (the tab already dispatched it);
182
+ // if it is unavailable (deployment without the host half), render nothing.
183
+ if (snapshot.status === 'unavailable') return null
184
+ const writable = snapshot.writable
185
+
147
186
  const models = useMemo(() => state?.models ?? [], [state])
148
187
 
149
188
  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>
189
+ <li className="mr-settings-card" data-open={open ? '' : undefined}>
190
+ <button
191
+ type="button"
192
+ className="mr-settings-head"
193
+ aria-expanded={open}
194
+ aria-label={(open ? t('collapse') : t('expand')) + ':' + t('settingsTitle')}
195
+ onClick={() => setOpen(!open)}
196
+ >
197
+ <span className="mr-settings-head-text">
198
+ <span className="mr-settings-title">{t('settingsTitle')}</span>
199
+ <span className="mr-settings-sub">{t('settingsCardDescription')}</span>
162
200
  </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>
201
+ <span className="mr-settings-chevron" data-open={open ? '' : undefined} aria-hidden="true">
202
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
203
+ <path d="M3.5 5.25L7 8.75L10.5 5.25" stroke="currentColor" strokeWidth="1.5"
204
+ strokeLinecap="round" strokeLinejoin="round" />
205
+ </svg>
174
206
  </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>
207
+ </button>
208
+ {open && (
209
+ <div className="mr-settings-body">
210
+ {!writable && (
211
+ <p className="mr-settings-readonly" role="status">{t('readOnly')}</p>
212
+ )}
213
+ <div className="mr-root">
214
+ <label className="mr-quick-switch">
215
+ <input
216
+ type="checkbox"
217
+ checked={state?.showQuickSwitch ?? true}
218
+ disabled={loading || quickSwitchBusy || !writable}
219
+ onChange={(event) => void toggleQuickSwitch(event.target.checked)}
220
+ />
221
+ <span className="mr-quick-switch-copy">
222
+ <span className="mr-quick-switch-label">{t('showQuickSwitchLabel')}</span>
223
+ <span className="mr-quick-switch-desc">{t('showQuickSwitchDescription')}</span>
224
+ </span>
225
+ </label>
226
+ <label className="mr-quick-switch">
227
+ <input
228
+ type="checkbox"
229
+ checked={state?.ignoreModelIdPrefix ?? true}
230
+ disabled={loading || ignorePrefixBusy || !writable}
231
+ onChange={(event) => void toggleIgnorePrefix(event.target.checked)}
232
+ />
233
+ <span className="mr-quick-switch-copy">
234
+ <span className="mr-quick-switch-label">{t('ignorePrefixLabel')}</span>
235
+ <span className="mr-quick-switch-desc">{t('ignorePrefixDescription')}</span>
236
+ </span>
237
+ </label>
238
+ <div className="mr-toolbar">
239
+ <span className="mr-count">{t('modelsCount', { count: models.length })}</span>
240
+ <button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>
241
+ {t('refresh')}
242
+ </button>
243
+ </div>
244
+ {error !== null && (
245
+ <div className="mr-error" role="alert">{error}</div>
246
+ )}
247
+ {loading && models.length === 0 && (
248
+ <div className="mr-empty">{t('loading')}</div>
249
+ )}
250
+ {!loading && models.length === 0 && (
251
+ <div className="mr-empty">{t('empty')}</div>
252
+ )}
253
+ <div className="mr-models">
254
+ {models.map(model => (
255
+ <ModelCard
256
+ key={model.id}
257
+ model={model}
258
+ t={t}
259
+ readOnly={!writable}
260
+ busy={busyModel === model.id}
261
+ dragging={drag}
262
+ onDragStart={index => setDrag({ modelId: model.id, index })}
263
+ onDragEnd={() => setDrag(null)}
264
+ onDrop={index => {
265
+ if (drag !== null && drag.modelId === model.id) {
266
+ void reorder(model, drag.index, index)
267
+ }
268
+ setDrag(null)
269
+ }}
270
+ onSwitch={providerId => void switchActive(model, providerId)}
271
+ />
272
+ ))}
273
+ </div>
274
+ </div>
275
+ </div>
190
276
  )}
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>
277
+ </li>
212
278
  )
213
279
  }
214
280
  }
@@ -216,6 +282,7 @@ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
216
282
  interface ModelCardProps {
217
283
  model: RouterModelView
218
284
  t: Translate
285
+ readOnly: boolean
219
286
  busy: boolean
220
287
  dragging: { modelId: string; index: number } | null
221
288
  onDragStart: (index: number) => void
@@ -225,7 +292,7 @@ interface ModelCardProps {
225
292
  }
226
293
 
227
294
  function ModelCard(props: ModelCardProps): JSX.Element {
228
- const { model, t, busy, dragging } = props
295
+ const { model, t, readOnly, busy, dragging } = props
229
296
  return (
230
297
  <section className={`mr-card${busy ? ' mr-card-busy' : ''}`}>
231
298
  <header className="mr-card-header">
@@ -248,7 +315,7 @@ function ModelCard(props: ModelCardProps): JSX.Element {
248
315
  !selectable ? 'mr-provider-nocred' : '',
249
316
  dropTarget ? 'mr-provider-drop' : '',
250
317
  ].filter(Boolean).join(' ')}
251
- draggable={!busy && model.providers.length > 1}
318
+ draggable={!busy && !readOnly && model.providers.length > 1}
252
319
  title={selectable ? undefined : t('noCredential')}
253
320
  onDragStart={(event) => {
254
321
  props.onDragStart(index)
@@ -277,7 +344,7 @@ function ModelCard(props: ModelCardProps): JSX.Element {
277
344
  <button
278
345
  type="button"
279
346
  className="mr-provider-button"
280
- disabled={!selectable || busy || active}
347
+ disabled={!selectable || busy || active || readOnly}
281
348
  onClick={() => props.onSwitch(provider.id)}
282
349
  >
283
350
  <span className="mr-provider-name">{provider.name}</span>
@@ -66,9 +66,13 @@ export class ModelRouterService implements RouterFacts {
66
66
  credentialConfigured: new Map(),
67
67
  }
68
68
  private inflight: Promise<RouterCatalog> | null = null
69
+ private cachedAt = 0
69
70
  private scope: SettingsScope<RouterConfigShape> | undefined
70
71
  private registration: AdapterRegistrationHandle | undefined
71
72
 
73
+ /** How long a built catalog is considered fresh before being rebuilt. */
74
+ private readonly catalogTtlMs = 30_000
75
+
72
76
  constructor(ctx: Context) {
73
77
  this.ctx = ctx
74
78
  }
@@ -124,12 +128,27 @@ export class ModelRouterService implements RouterFacts {
124
128
 
125
129
  // ── catalog lifecycle ────────────────────────────────────────────────────
126
130
 
127
- /** Rebuild the catalog (in-flight dedupe; failures keep the last good one). */
128
- refreshCatalog(): Promise<RouterCatalog> {
131
+ /**
132
+ * Return the live catalog, rebuilding it only when it is stale (older than
133
+ * `catalogTtlMs`) or `force` is set. Concurrent callers share one in-flight
134
+ * build; a failed build keeps the last good catalog.
135
+ *
136
+ * The TTL is what keeps the composer quick-switcher snappy: every
137
+ * `state()` RPC goes through here, and without a cache each one rebuilt the
138
+ * catalog — including per-provider `listModels` network calls — which is
139
+ * what made the button gray out (routerLoading) for the whole duration.
140
+ * Topology events (`llm/adapters-updated`) and explicit user refreshes pass
141
+ * `force` so the cache never hides real changes.
142
+ */
143
+ refreshCatalog(force = false): Promise<RouterCatalog> {
144
+ if (!force && this.inflight === null && Date.now() - this.cachedAt < this.catalogTtlMs) {
145
+ return Promise.resolve(this.cached)
146
+ }
129
147
  if (this.inflight !== null) return this.inflight
130
148
  this.inflight = buildCatalog(this.ctx, this.config().ignoreModelIdPrefix)
131
149
  .then((catalog) => {
132
150
  this.cached = catalog
151
+ this.cachedAt = Date.now()
133
152
  return catalog
134
153
  })
135
154
  .catch((error) => {
@@ -142,9 +161,9 @@ export class ModelRouterService implements RouterFacts {
142
161
  return this.inflight
143
162
  }
144
163
 
145
- /** Refresh, then persist any config drift (migration, new/vanished models). */
164
+ /** Refresh (forced), then persist any config drift (migration, new/vanished models). */
146
165
  async refreshAndReconcile(): Promise<void> {
147
- const catalog = await this.refreshCatalog()
166
+ const catalog = await this.refreshCatalog(true)
148
167
  if (this.scope === undefined) return
149
168
  const current = this.config()
150
169
  const { models, changed } = reconcileConfig(current, catalog.models)