@fonlan/dsh-model-router 0.2.1 → 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.
- package/README.md +7 -7
- package/lib/client.js +197 -117
- package/lib/client.js.map +1 -1
- package/lib/index.js +20 -4
- package/lib/types/server/service.d.ts +17 -3
- package/package.json +4 -1
- package/src/client/index.tsx +37 -14
- package/src/client/locales.ts +8 -2
- package/src/client/route-switcher.tsx +31 -6
- package/src/client/{settings-section.css → settings-card.css} +91 -7
- package/src/client/{settings-section.tsx → settings-card.tsx} +138 -71
- package/src/server/service.ts +23 -4
|
@@ -1,16 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Model Router
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* settings
|
|
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-
|
|
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
|
|
44
|
-
// Bound translation is namespace-typed; the
|
|
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
|
|
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
|
-
<
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
<span className="mr-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
</
|
|
176
|
-
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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>
|
package/src/server/service.ts
CHANGED
|
@@ -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
|
-
/**
|
|
128
|
-
|
|
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)
|