@huai_xin/dsh-model-picker 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +139 -2
- package/client.js +1209 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +11 -0
- package/index.js +77 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
package/client.js
ADDED
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — do not edit.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth: the modules under src/. Rebuild with `node scripts/build.mjs`.
|
|
5
|
+
* This bundle is what the Client module loader serves; it is the only browser
|
|
6
|
+
* entry the loader knows about for this package.
|
|
7
|
+
*/
|
|
8
|
+
//# module: src/core/store.js
|
|
9
|
+
/**
|
|
10
|
+
* A snapshot store with the contract the slot renderer's hooks expect.
|
|
11
|
+
*
|
|
12
|
+
* The Client's own engine (`@deepseek-ai/dsh-client-store`) is a Harness Client
|
|
13
|
+
* package, and a plain-JS plugin must not require those: they change without
|
|
14
|
+
* notice and there is no type check here. This is the same four-method surface
|
|
15
|
+
* the renderer needs — `getSnapshot`, `subscribe`, `set`, `update` — over a
|
|
16
|
+
* state object that is replaced, never mutated.
|
|
17
|
+
*
|
|
18
|
+
* @param init - the initial state object.
|
|
19
|
+
* @returns the store.
|
|
20
|
+
*/
|
|
21
|
+
function createStore(init) {
|
|
22
|
+
let state = init
|
|
23
|
+
const listeners = new Set()
|
|
24
|
+
|
|
25
|
+
const publish = (next) => {
|
|
26
|
+
state = next
|
|
27
|
+
for (const listener of [...listeners]) {
|
|
28
|
+
try {
|
|
29
|
+
listener()
|
|
30
|
+
} catch {
|
|
31
|
+
// One failing subscriber must not stop the others.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
getSnapshot: () => state,
|
|
38
|
+
subscribe: (listener) => {
|
|
39
|
+
listeners.add(listener)
|
|
40
|
+
return () => { listeners.delete(listener) }
|
|
41
|
+
},
|
|
42
|
+
set: (next) => publish(next),
|
|
43
|
+
update: (mutate) => {
|
|
44
|
+
const draft = { ...state }
|
|
45
|
+
mutate(draft)
|
|
46
|
+
publish(draft)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Subscribe to any `{ getSnapshot, subscribe }` source, for a component.
|
|
53
|
+
*
|
|
54
|
+
* @param useState - React's useState, passed in so this module needs no import.
|
|
55
|
+
* @param useEffect - React's useEffect.
|
|
56
|
+
* @param source - the observable source, or undefined while it is not resolved.
|
|
57
|
+
* @returns the current snapshot.
|
|
58
|
+
*/
|
|
59
|
+
function readSnapshot(useState, useEffect, source) {
|
|
60
|
+
const [value, setValue] = useState(() => source?.getSnapshot?.())
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (source === undefined || source === null) return undefined
|
|
63
|
+
const update = () => setValue(source.getSnapshot())
|
|
64
|
+
update()
|
|
65
|
+
return source.subscribe(update)
|
|
66
|
+
}, [source])
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
//# module: src/core/catalog.js
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The Host notifications that can change the catalogue.
|
|
74
|
+
*
|
|
75
|
+
* `llm/adapters-updated` covers a provider adding or removing a model; the other
|
|
76
|
+
* three cover the inputs a provider's availability is derived from (settings,
|
|
77
|
+
* stored credentials, credential references). The shipped directory subscribes to
|
|
78
|
+
* exactly these four, so a model switched off in a provider's own panel
|
|
79
|
+
* disappears here on the same tick it disappears there. Listening to the first
|
|
80
|
+
* one alone left this picker offering models the Host would refuse until the page
|
|
81
|
+
* was reloaded.
|
|
82
|
+
*/
|
|
83
|
+
const INVALIDATION_EVENTS = Object.freeze([
|
|
84
|
+
'llm/adapters-updated',
|
|
85
|
+
'settings/document-updated',
|
|
86
|
+
'credentials/record-updated',
|
|
87
|
+
'credentials/reference-updated'
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One Host-generation catalogue shared by every session.
|
|
92
|
+
*
|
|
93
|
+
* Mirrors the shipped `ModelCatalogDirectory`: a single value, one in-flight
|
|
94
|
+
* request shared by every reader, a generation counter that discards responses
|
|
95
|
+
* from a superseded generation, and a `reasoning` map that remembers each
|
|
96
|
+
* model's effort metadata *after* the model leaves the catalogue — so a level
|
|
97
|
+
* still shows its name while its model is gone.
|
|
98
|
+
*
|
|
99
|
+
* @param remote - the client remote face carrying `session.modelCatalog`.
|
|
100
|
+
* @returns the catalogue controller.
|
|
101
|
+
*/
|
|
102
|
+
function createCatalog(remote, onDiagnostic) {
|
|
103
|
+
const store = createStore({ value: null, status: 'idle', error: null })
|
|
104
|
+
const reasoning = new Map()
|
|
105
|
+
// Bumped by every invalidation. A response that resolves under an older
|
|
106
|
+
// generation is dropped instead of publishing a catalogue the Host has
|
|
107
|
+
// already superseded.
|
|
108
|
+
let generation = 0
|
|
109
|
+
let inflight
|
|
110
|
+
|
|
111
|
+
const note = (message) => {
|
|
112
|
+
try { onDiagnostic?.(message) } catch { /* diagnostics never break the load */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const reasoningFor = (selection) => selection === null || selection === undefined
|
|
116
|
+
? undefined
|
|
117
|
+
: reasoning.get(JSON.stringify([selection.provider, selection.model]))
|
|
118
|
+
|
|
119
|
+
const load = () => {
|
|
120
|
+
const state = store.getSnapshot()
|
|
121
|
+
if (state.status === 'ready' && state.value !== null) return Promise.resolve(state.value)
|
|
122
|
+
if (inflight !== undefined) return inflight
|
|
123
|
+
const run = remote?.session?.modelCatalog
|
|
124
|
+
if (typeof run !== 'function') {
|
|
125
|
+
store.set({ value: state.value, status: 'error', error: 'modelCatalog is unavailable' })
|
|
126
|
+
return Promise.resolve(undefined)
|
|
127
|
+
}
|
|
128
|
+
const at = generation
|
|
129
|
+
// Keep the previous value visible while reloading: an invalidation is not an
|
|
130
|
+
// error, and blanking the list would flash on every provider edit.
|
|
131
|
+
store.update((draft) => { draft.status = 'loading'; draft.error = null })
|
|
132
|
+
const operation = Promise.resolve(run.call(remote.session)).then((result) => {
|
|
133
|
+
if (result?.ok !== true) {
|
|
134
|
+
note(`modelCatalog 失败:${result?.error?.code ?? '?'} ${result?.error?.message ?? ''}`)
|
|
135
|
+
if (at === generation) {
|
|
136
|
+
store.set({
|
|
137
|
+
value: store.getSnapshot().value,
|
|
138
|
+
status: 'error',
|
|
139
|
+
error: result?.error?.message ?? 'failed'
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
144
|
+
const groups = Array.isArray(result.value?.groups) ? result.value.groups : []
|
|
145
|
+
note(`modelCatalog 成功:${groups.length} 组,gen ${at}/${generation}`)
|
|
146
|
+
if (at === generation) {
|
|
147
|
+
for (const group of groups) {
|
|
148
|
+
for (const model of group.models) {
|
|
149
|
+
reasoning.set(JSON.stringify([group.id, model.id]), model.reasoning)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
store.set({ value: result.value, status: 'ready', error: null })
|
|
153
|
+
} else {
|
|
154
|
+
// The result was dropped: another load superseded it. Say so, because a
|
|
155
|
+
// silently discarded success is what leaves the store on `loading`.
|
|
156
|
+
note(`modelCatalog 结果被丢弃(gen ${at} != ${generation})`)
|
|
157
|
+
}
|
|
158
|
+
return result.value
|
|
159
|
+
}).catch((error) => {
|
|
160
|
+
note(`modelCatalog 抛错:${error instanceof Error ? error.message : String(error)}`)
|
|
161
|
+
if (at === generation) {
|
|
162
|
+
store.set({
|
|
163
|
+
value: store.getSnapshot().value,
|
|
164
|
+
status: 'error',
|
|
165
|
+
error: error instanceof Error ? error.message : String(error)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
return undefined
|
|
169
|
+
}).finally(() => {
|
|
170
|
+
if (at === generation && inflight === operation) inflight = undefined
|
|
171
|
+
})
|
|
172
|
+
inflight = operation
|
|
173
|
+
return operation
|
|
174
|
+
}
|
|
175
|
+
const invalidate = (clear) => {
|
|
176
|
+
generation += 1
|
|
177
|
+
inflight = undefined
|
|
178
|
+
store.set({
|
|
179
|
+
value: clear === true ? null : store.getSnapshot().value,
|
|
180
|
+
status: 'idle',
|
|
181
|
+
error: null
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Re-read on the next explicit load, keeping the stale value on screen. */
|
|
186
|
+
const refresh = () => {
|
|
187
|
+
note(`refresh 被调用(gen ${generation} → ${generation + 1})`)
|
|
188
|
+
invalidate(false)
|
|
189
|
+
load()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Drop everything: the value belonged to a Host generation that is gone. */
|
|
193
|
+
const resetGeneration = () => { reasoning.clear(); invalidate(true); load() }
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Subscribe the invalidation events.
|
|
197
|
+
*
|
|
198
|
+
* Call this from the plugin's `apply` body, not from a scope or slot callback.
|
|
199
|
+
* The gateway registers a forwarded-event listener in the CALLING fiber's
|
|
200
|
+
* private event domain, and only the plugin's own `apply` fiber is in the
|
|
201
|
+
* domain the dispatcher publishes to — a subscription installed from a
|
|
202
|
+
* `ctx.inject` callback is accepted (it returns a disposer) but never fires.
|
|
203
|
+
*
|
|
204
|
+
* @param target - the client remote carrying `$on`.
|
|
205
|
+
* @param onEvent - optional observer called with each delivered event name.
|
|
206
|
+
* @returns a disposer removing every subscription.
|
|
207
|
+
*/
|
|
208
|
+
const attach = (target, onEvent) => {
|
|
209
|
+
const offs = []
|
|
210
|
+
const failed = []
|
|
211
|
+
for (const event of INVALIDATION_EVENTS) {
|
|
212
|
+
try {
|
|
213
|
+
if (typeof target?.$on !== 'function') {
|
|
214
|
+
failed.push(`${event}: 目标没有 $on`)
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
const off = target.$on(event, () => {
|
|
218
|
+
onEvent?.(event)
|
|
219
|
+
refresh()
|
|
220
|
+
})
|
|
221
|
+
if (typeof off === 'function') offs.push(off)
|
|
222
|
+
else failed.push(`${event}: 返回的不是 disposer`)
|
|
223
|
+
} catch (error) {
|
|
224
|
+
failed.push(`${event}: ${error instanceof Error ? error.message : String(error)}`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
note(`订阅结果:成功 ${offs.length}/${INVALIDATION_EVENTS.length}${failed.length > 0 ? `;问题:${failed.join(' | ')}` : ''}`)
|
|
228
|
+
return () => { for (const off of offs) off() }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Whether this target can carry event subscriptions at all. */
|
|
232
|
+
const canAttach = (target) => typeof target?.$on === 'function'
|
|
233
|
+
|
|
234
|
+
return { store, reasoningFor, load, refresh, resetGeneration, attach, canAttach }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
//# module: src/core/selection.js
|
|
238
|
+
/**
|
|
239
|
+
* Reading a model selection out of whatever shape the Host handed over.
|
|
240
|
+
*
|
|
241
|
+
* The projection carries the selection; older and newer Host generations have
|
|
242
|
+
* spelled the fields differently (`provider` / `providerId` / `provider_id`),
|
|
243
|
+
* so every known shape is accepted and an unknown one simply yields `null`
|
|
244
|
+
* rather than guessing.
|
|
245
|
+
*
|
|
246
|
+
* @param projection - a projection frame, a selection, or nothing.
|
|
247
|
+
* @returns `{ provider, model, reasoningEffort }`, or null when unreadable.
|
|
248
|
+
*/
|
|
249
|
+
function readSelection(projection) {
|
|
250
|
+
const value = projection?.next ?? projection?.current ?? projection
|
|
251
|
+
if (value === null || typeof value !== 'object') return null
|
|
252
|
+
const provider = value.provider ?? value.providerId ?? value.provider_id
|
|
253
|
+
const model = value.model ?? value.modelId ?? value.model_id
|
|
254
|
+
if (typeof provider !== 'string' || typeof model !== 'string') return null
|
|
255
|
+
return { provider, model, reasoningEffort: value.reasoningEffort }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Whether this session may change its model at all.
|
|
260
|
+
*
|
|
261
|
+
* An addressed subagent session cannot: the Host rejects the RPC. The shipped
|
|
262
|
+
* picker renders nothing in that case rather than offering a control whose every
|
|
263
|
+
* click fails. `subagentAddress` is the same probe it uses; a client generation
|
|
264
|
+
* without it counts as available, so an older build keeps showing the control
|
|
265
|
+
* instead of silently losing it.
|
|
266
|
+
*
|
|
267
|
+
* @param sessions - the sessions service face.
|
|
268
|
+
* @param sessionId - the session being rendered.
|
|
269
|
+
* @returns whether the model control should be offered.
|
|
270
|
+
*/
|
|
271
|
+
function isAvailable(sessions, sessionId) {
|
|
272
|
+
try {
|
|
273
|
+
if (typeof sessions?.subagentAddress !== 'function') return true
|
|
274
|
+
return sessions.subagentAddress(sessionId) === undefined
|
|
275
|
+
} catch {
|
|
276
|
+
return true
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
//# module: src/core/directory.js
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* One session's directory, mirroring the shipped `ModelDirectory`.
|
|
285
|
+
*
|
|
286
|
+
* The catalogue is Host-wide, but the *selection* is per session: which model is
|
|
287
|
+
* in effect, whether that model is still routable, what is in flight, and which
|
|
288
|
+
* provider groups failed to load. The shipped picker renders all four, and a
|
|
289
|
+
* replacement that collapsed them into "the catalogue plus the projection" loses
|
|
290
|
+
* the in-flight state (so a slow switch shows nothing) and the failure list (so a
|
|
291
|
+
* broken provider group is silently missing).
|
|
292
|
+
*
|
|
293
|
+
* The store contract is the same one the renderer's hooks read:
|
|
294
|
+
* `{ current, routable, groups, failures, status, pending, error }`.
|
|
295
|
+
*
|
|
296
|
+
* @param catalog - the shared catalogue controller.
|
|
297
|
+
* @param projectedFace - the session's `modelSelection` projection face.
|
|
298
|
+
* @param remote - the client remote face carrying `session.selectModel`.
|
|
299
|
+
* @param sessionId - the owning session.
|
|
300
|
+
* @returns the directory controller.
|
|
301
|
+
*/
|
|
302
|
+
function createDirectory(catalog, projectedFace, remote, sessionId) {
|
|
303
|
+
const store = createStore({
|
|
304
|
+
current: null,
|
|
305
|
+
routable: null,
|
|
306
|
+
groups: [],
|
|
307
|
+
failures: [],
|
|
308
|
+
status: 'idle',
|
|
309
|
+
pending: null,
|
|
310
|
+
error: null
|
|
311
|
+
})
|
|
312
|
+
// Latest selection wins: an older response must never overwrite a newer one.
|
|
313
|
+
let generation = 0
|
|
314
|
+
let disposed = false
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Recompute the effective selection from the two inputs that define it.
|
|
318
|
+
*
|
|
319
|
+
* `intended` is the session's explicit choice, else the catalogue default —
|
|
320
|
+
* exactly the shipped precedence. `routable` says whether the Host would still
|
|
321
|
+
* accept it, which is what lets the trigger distinguish "this model" from "a
|
|
322
|
+
* model that no longer exists".
|
|
323
|
+
*/
|
|
324
|
+
const sync = () => {
|
|
325
|
+
if (disposed) return
|
|
326
|
+
const catalogState = catalog.store.getSnapshot()
|
|
327
|
+
const projected = readSelection(projectedFace?.getSnapshot?.())
|
|
328
|
+
const intended = projected ?? readSelection(catalogState.value?.default)
|
|
329
|
+
const reasoning = catalog.reasoningFor(intended)
|
|
330
|
+
const effort = intended?.reasoningEffort ?? reasoning?.defaultEffort
|
|
331
|
+
const retainedEffort = effort === undefined
|
|
332
|
+
? undefined
|
|
333
|
+
: reasoning?.efforts?.find((level) => level.id === effort)?.name ?? effort
|
|
334
|
+
const ready = catalogState.status === 'ready' && catalogState.value !== null
|
|
335
|
+
if (!ready || intended === null) {
|
|
336
|
+
store.set({
|
|
337
|
+
current: catalogState.value === null ? null : store.getSnapshot().current,
|
|
338
|
+
...retainedEffort === undefined ? {} : { retainedEffort },
|
|
339
|
+
routable: null,
|
|
340
|
+
groups: catalogState.value?.groups ?? [],
|
|
341
|
+
failures: catalogState.value?.failures ?? [],
|
|
342
|
+
status: catalogState.status === 'error' ? 'error' : 'loading',
|
|
343
|
+
pending: store.getSnapshot().pending,
|
|
344
|
+
error: catalogState.error
|
|
345
|
+
})
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
const routable = (catalogState.value.groups ?? []).some((group) =>
|
|
349
|
+
group.id === intended.provider && (group.models ?? []).some((model) => model.id === intended.model))
|
|
350
|
+
store.set({
|
|
351
|
+
current: intended,
|
|
352
|
+
...retainedEffort === undefined ? {} : { retainedEffort },
|
|
353
|
+
routable,
|
|
354
|
+
groups: catalogState.value.groups,
|
|
355
|
+
failures: catalogState.value.failures ?? [],
|
|
356
|
+
status: store.getSnapshot().status === 'selecting' ? 'selecting' : 'ready',
|
|
357
|
+
pending: store.getSnapshot().pending,
|
|
358
|
+
error: null
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const unsubscribeCatalog = catalog.store.subscribe(() => { sync() })
|
|
363
|
+
const unsubscribeProjection = projectedFace?.subscribe?.(() => { sync() })
|
|
364
|
+
sync()
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Select the complete provider/model/effort triple.
|
|
368
|
+
*
|
|
369
|
+
* The pending selection is published before the RPC and cleared after it, so
|
|
370
|
+
* the trigger can show which row is settling. A response that arrives after a
|
|
371
|
+
* newer selection (or after disposal) is dropped rather than written, which is
|
|
372
|
+
* the bug a bare `busy` flag cannot avoid: two fast clicks would otherwise let
|
|
373
|
+
* the first reply overwrite the second choice.
|
|
374
|
+
*
|
|
375
|
+
* @param selection - provider, model, and optional reasoningEffort.
|
|
376
|
+
* @returns the Host result, or a synthesized failure.
|
|
377
|
+
*/
|
|
378
|
+
const select = async (selection) => {
|
|
379
|
+
// The adapter's default effort is part of the selection the shipped picker
|
|
380
|
+
// submits, so a model switch also settles the level instead of leaving the
|
|
381
|
+
// Host to guess on the next call.
|
|
382
|
+
const nextEffort = selection.reasoningEffort ?? catalog.reasoningFor(selection)?.defaultEffort
|
|
383
|
+
const next = { ...selection, ...nextEffort === undefined ? {} : { reasoningEffort: nextEffort } }
|
|
384
|
+
const generationAt = ++generation
|
|
385
|
+
store.update((draft) => {
|
|
386
|
+
draft.status = 'selecting'
|
|
387
|
+
draft.pending = next
|
|
388
|
+
draft.error = null
|
|
389
|
+
})
|
|
390
|
+
const run = remote?.session?.selectModel
|
|
391
|
+
if (typeof run !== 'function') {
|
|
392
|
+
store.update((draft) => {
|
|
393
|
+
draft.status = 'error'
|
|
394
|
+
draft.pending = null
|
|
395
|
+
draft.error = 'selectModel is unavailable'
|
|
396
|
+
})
|
|
397
|
+
return { ok: false, error: { code: 'unavailable', message: 'selectModel is unavailable' } }
|
|
398
|
+
}
|
|
399
|
+
let result
|
|
400
|
+
try {
|
|
401
|
+
result = await run.call(remote.session, {
|
|
402
|
+
sessionId,
|
|
403
|
+
provider: next.provider,
|
|
404
|
+
model: next.model,
|
|
405
|
+
...next.reasoningEffort === undefined ? {} : { reasoningEffort: next.reasoningEffort }
|
|
406
|
+
})
|
|
407
|
+
} catch (error) {
|
|
408
|
+
result = {
|
|
409
|
+
ok: false,
|
|
410
|
+
error: { code: 'threw', message: error instanceof Error ? error.message : String(error) }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (disposed || generationAt !== generation) {
|
|
414
|
+
return result?.ok === true ? { ok: true, value: undefined } : result
|
|
415
|
+
}
|
|
416
|
+
if (result?.ok !== true) {
|
|
417
|
+
store.update((draft) => {
|
|
418
|
+
draft.status = 'error'
|
|
419
|
+
draft.pending = null
|
|
420
|
+
draft.error = `${result?.error?.code ?? 'error'}: ${result?.error?.message ?? 'failed'}`
|
|
421
|
+
})
|
|
422
|
+
return result
|
|
423
|
+
}
|
|
424
|
+
store.update((draft) => {
|
|
425
|
+
draft.status = 'ready'
|
|
426
|
+
draft.pending = null
|
|
427
|
+
draft.error = null
|
|
428
|
+
})
|
|
429
|
+
/**
|
|
430
|
+
* Do NOT re-project here.
|
|
431
|
+
*
|
|
432
|
+
* `sync()` recomputes `current` from the session's projected selection. The
|
|
433
|
+
* Host answers `selectModel` before it publishes that projection, so calling
|
|
434
|
+
* `sync()` at this point reads the OLD selection and writes it straight back
|
|
435
|
+
* over the one just chosen — the picker then looks like the click did
|
|
436
|
+
* nothing. The projection's own subscription calls `sync()` when the Host
|
|
437
|
+
* actually publishes, which is the only moment the new value is real.
|
|
438
|
+
*
|
|
439
|
+
* Until that push arrives, `current` is the selection the Host just
|
|
440
|
+
* acknowledged. `selected` in the answer is authoritative when present;
|
|
441
|
+
* otherwise the triple we sent is what the Host accepted.
|
|
442
|
+
*/
|
|
443
|
+
const settled = readSelection(result?.value?.selected) ?? next
|
|
444
|
+
store.update((draft) => {
|
|
445
|
+
draft.current = settled
|
|
446
|
+
draft.routable = true
|
|
447
|
+
})
|
|
448
|
+
return { ok: true, value: undefined }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Invalidate an in-flight response from a previous Host generation. */
|
|
452
|
+
const resetConnected = () => {
|
|
453
|
+
if (disposed) return
|
|
454
|
+
generation += 1
|
|
455
|
+
store.update((draft) => {
|
|
456
|
+
if (draft.status === 'selecting') draft.status = 'idle'
|
|
457
|
+
draft.pending = null
|
|
458
|
+
draft.error = null
|
|
459
|
+
})
|
|
460
|
+
sync()
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Ensure the shared catalogue is loaded, then re-project. */
|
|
464
|
+
const load = async () => {
|
|
465
|
+
await catalog.load()
|
|
466
|
+
sync()
|
|
467
|
+
return store.getSnapshot()
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Scope teardown: late settlements lose write access to the store. */
|
|
471
|
+
const dispose = () => {
|
|
472
|
+
disposed = true
|
|
473
|
+
unsubscribeCatalog?.()
|
|
474
|
+
unsubscribeProjection?.()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return { store, select, load, resetConnected, dispose, sync }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
//# module: src/core/directories.js
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The `modelDirectories` service: one directory per session, shared by
|
|
484
|
+
* everything that renders a model seat.
|
|
485
|
+
*
|
|
486
|
+
* The shipped plugin registers exactly this service, and reusing its instance is
|
|
487
|
+
* what keeps a replacement picker and the `/model` popup agreeing on one
|
|
488
|
+
* selection state. When it is absent (the shipped bundle disabled, an older
|
|
489
|
+
* build), this plugin provides its own under the same name so the rest of the
|
|
490
|
+
* code has one contract either way.
|
|
491
|
+
*
|
|
492
|
+
* @param catalog - the shared catalogue controller.
|
|
493
|
+
* @param sessions - the sessions service face.
|
|
494
|
+
* @param remote - the client remote face.
|
|
495
|
+
* @returns the service object with `directoryFor`.
|
|
496
|
+
*/
|
|
497
|
+
function createDirectoryService(catalog, sessions, remote) {
|
|
498
|
+
/**
|
|
499
|
+
* Keyed by session id, not by binding object.
|
|
500
|
+
*
|
|
501
|
+
* The shipped resolver keys a WeakMap by `sessions.binding(id)` because it
|
|
502
|
+
* disposes each directory with the session scope. Keying by the binding here
|
|
503
|
+
* would be wrong for a different reason: a binding accessor is free to return a
|
|
504
|
+
* fresh wrapper per call, and each call would then allocate a new directory —
|
|
505
|
+
* every remount of the seat losing the in-flight selection and re-projecting
|
|
506
|
+
* from scratch. The id is the stable identity; `directoryFor` is called per
|
|
507
|
+
* render, so it must be cheap and idempotent.
|
|
508
|
+
*/
|
|
509
|
+
const directories = new Map()
|
|
510
|
+
|
|
511
|
+
const directoryFor = (sessionId) => {
|
|
512
|
+
const key = String(sessionId)
|
|
513
|
+
const existing = directories.get(key)
|
|
514
|
+
if (existing !== undefined) return existing
|
|
515
|
+
const binding = (() => {
|
|
516
|
+
try { return sessions?.binding?.(sessionId) } catch { return undefined }
|
|
517
|
+
})()
|
|
518
|
+
const projectedFace = (() => {
|
|
519
|
+
try { return binding?.session?.projections?.faceOf?.('modelSelection') } catch { return undefined }
|
|
520
|
+
})()
|
|
521
|
+
const directory = createDirectory(catalog, projectedFace, remote, sessionId)
|
|
522
|
+
directories.set(key, directory)
|
|
523
|
+
return directory
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return { directoryFor, directories }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
//# module: src/ui/Chevron.js
|
|
530
|
+
/**
|
|
531
|
+
* The chevron that turns when its panel is open.
|
|
532
|
+
*
|
|
533
|
+
* @param props - `{ h, open }`: the createElement function and the open flag.
|
|
534
|
+
* @returns the svg element.
|
|
535
|
+
*/
|
|
536
|
+
function Chevron({ h, open }) {
|
|
537
|
+
return h('svg', {
|
|
538
|
+
className: `dmp-chevron${open ? ' dmp-chevron-open' : ''}`,
|
|
539
|
+
viewBox: '0 0 16 16', width: 14, height: 14, 'aria-hidden': true
|
|
540
|
+
}, h('path', {
|
|
541
|
+
d: 'M4 6.5L8 10.5L12 6.5', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.6,
|
|
542
|
+
'stroke-linecap': 'round', 'stroke-linejoin': 'round'
|
|
543
|
+
}))
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
//# module: src/ui/styles.js
|
|
547
|
+
/**
|
|
548
|
+
* The picker's stylesheet.
|
|
549
|
+
*
|
|
550
|
+
* Host theme tokens only, plus the two composer variables the row sets when its
|
|
551
|
+
* controls no longer fit on one line. Every class is prefixed `dmp-` so a copied
|
|
552
|
+
* host pattern can never collide with the host's own rules.
|
|
553
|
+
*/
|
|
554
|
+
const CSS = `
|
|
555
|
+
.dmp-root { position: relative; display: flex; align-items: center; gap: 2px; min-width: 0; }
|
|
556
|
+
/* One popover anchor per control: the model list and the level list open above
|
|
557
|
+
their own button and never overlap each other. */
|
|
558
|
+
.dmp-control { position: relative; display: flex; align-items: center; min-width: 0; }
|
|
559
|
+
.dmp-trigger { display: flex; align-items: center; gap: 4px; min-width: 0; max-width: min(360px, 45cqw);
|
|
560
|
+
height: 28px; padding: 0 4px 0 8px; border: 0; border-radius: 6px; cursor: pointer;
|
|
561
|
+
background: transparent; color: var(--dsw-alias-label-secondary);
|
|
562
|
+
font: inherit; font-size: 13px; line-height: 20px; }
|
|
563
|
+
.dmp-trigger-effort { max-width: min(150px, 28cqw); padding: 0 4px 0 6px; font-size: 12px;
|
|
564
|
+
color: var(--dsw-alias-label-secondary); }
|
|
565
|
+
.dmp-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12)); }
|
|
566
|
+
.dmp-trigger:disabled { color: var(--dsw-alias-state-idle-primary); cursor: default; }
|
|
567
|
+
.dmp-trigger-icon { display: var(--dsh-composer-model-icon-display, none); flex: none; }
|
|
568
|
+
.dmp-trigger-text { display: var(--dsh-composer-model-text-display, block);
|
|
569
|
+
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
570
|
+
.dmp-chevron { flex: none; color: var(--dsw-alias-label-secondary); transition: transform .12s; }
|
|
571
|
+
.dmp-chevron-open { transform: rotate(180deg); }
|
|
572
|
+
.dmp-panel { position: absolute; bottom: calc(100% + 6px); left: 0; z-index: 40;
|
|
573
|
+
width: min(420px, 84vw); max-height: 320px; display: flex; flex-direction: column;
|
|
574
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px;
|
|
575
|
+
background: var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-layer-1));
|
|
576
|
+
box-shadow: 0 8px 28px rgba(0,0,0,.18); overflow: hidden; }
|
|
577
|
+
.dmp-search { flex: none; padding: 8px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
578
|
+
.dmp-search input { width: 100%; box-sizing: border-box; padding: 6px 8px; font: inherit; font-size: 12.5px;
|
|
579
|
+
color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2, transparent);
|
|
580
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; outline: none; }
|
|
581
|
+
.dmp-search input:focus-visible { border-color: var(--dsw-alias-brand-primary); }
|
|
582
|
+
.dmp-list { flex: 1; overflow-y: auto; padding: 4px 0 6px; }
|
|
583
|
+
.dmp-group { padding: 6px 12px 2px; font-size: 11px; letter-spacing: .04em;
|
|
584
|
+
color: var(--dsw-alias-label-secondary); }
|
|
585
|
+
.dmp-item { display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box;
|
|
586
|
+
padding: 6px 12px; border: 0; background: transparent; cursor: pointer; text-align: left;
|
|
587
|
+
color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; }
|
|
588
|
+
.dmp-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12)); }
|
|
589
|
+
.dmp-item-current { color: var(--dsw-alias-brand-primary); font-weight: 600; }
|
|
590
|
+
.dmp-item-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
591
|
+
.dmp-item-note { margin-left: auto; padding-left: 8px; font-size: 11px;
|
|
592
|
+
color: var(--dsw-alias-label-secondary); }
|
|
593
|
+
/* The level list holds short labels, so it stays narrow and tight. */
|
|
594
|
+
.dmp-panel-narrow { width: min(220px, 60vw); max-height: 260px; }
|
|
595
|
+
.dmp-note { padding: 10px 12px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
596
|
+
.dmp-note-error { color: var(--dsw-alias-state-error-primary); }
|
|
597
|
+
.dmp-retry { margin-top: 6px; padding: 4px 10px; border: 1px solid var(--dsw-alias-border-l2);
|
|
598
|
+
border-radius: 6px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; }
|
|
599
|
+
`
|
|
600
|
+
|
|
601
|
+
//# module: src/ui/dict.js
|
|
602
|
+
/**
|
|
603
|
+
* The picker's two dictionaries.
|
|
604
|
+
*
|
|
605
|
+
* Kept as a plain object rather than the Client locale service because the
|
|
606
|
+
* strings are this component's own copy, not a namespace other plugins
|
|
607
|
+
* translate. Adding a language is one more entry here.
|
|
608
|
+
*/
|
|
609
|
+
const DICT = {
|
|
610
|
+
'zh-CN': {
|
|
611
|
+
placeholder: '搜索模型',
|
|
612
|
+
empty: '没有匹配的模型',
|
|
613
|
+
loading: '正在读取模型列表…',
|
|
614
|
+
failed: '读取模型列表失败',
|
|
615
|
+
retry: '重试',
|
|
616
|
+
noCatalog: '当前没有可用模型',
|
|
617
|
+
trigger: (name) => `${name}(点此切换模型)`,
|
|
618
|
+
effortTitle: (name) => `思考档位:${name}(点此调整)`,
|
|
619
|
+
switching: '切换中…',
|
|
620
|
+
switchFailed: '切换失败',
|
|
621
|
+
providerDefault: '默认',
|
|
622
|
+
groupFailed: (name) => `${name} 加载失败`
|
|
623
|
+
},
|
|
624
|
+
en: {
|
|
625
|
+
placeholder: 'Search models',
|
|
626
|
+
empty: 'No matching model',
|
|
627
|
+
loading: 'Loading models…',
|
|
628
|
+
failed: 'Could not load the model list',
|
|
629
|
+
retry: 'Retry',
|
|
630
|
+
noCatalog: 'No model is currently available',
|
|
631
|
+
trigger: (name) => `${name} (click to switch model)`,
|
|
632
|
+
effortTitle: (name) => `Reasoning level: ${name} (click to change)`,
|
|
633
|
+
switching: 'Switching…',
|
|
634
|
+
switchFailed: 'Switch failed',
|
|
635
|
+
providerDefault: 'Default',
|
|
636
|
+
groupFailed: (name) => `${name} failed to load`
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Resolve a dictionary from a locale id.
|
|
642
|
+
*
|
|
643
|
+
* @param localeId - the active locale, e.g. `zh-CN` or `en-US`.
|
|
644
|
+
* @returns the matching dictionary, English when the language is unknown.
|
|
645
|
+
*/
|
|
646
|
+
function dictionaryFor(localeId) {
|
|
647
|
+
return DICT[String(localeId ?? '').toLowerCase().startsWith('zh') ? 'zh-CN' : 'en']
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
//# module: src/ui/ModelPicker.js
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
const LABEL_SLOT = 'conversation.input.model.label'
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The composer's model seat: a searchable, provider-grouped picker plus a
|
|
659
|
+
* separate reasoning-level control.
|
|
660
|
+
*
|
|
661
|
+
* Every value comes from the session's directory rather than the raw catalogue:
|
|
662
|
+
* `current` (the effective selection, projection or default), `routable`
|
|
663
|
+
* (whether the Host would still accept it), `pending` (the selection being
|
|
664
|
+
* settled), `failures` (provider groups that failed to load) and `error`.
|
|
665
|
+
* Deriving those in the component is what made an in-flight switch invisible and
|
|
666
|
+
* a broken provider group silently absent.
|
|
667
|
+
*
|
|
668
|
+
* @param props - slot props: `locked`, the session id, `renderSlot` (when the
|
|
669
|
+
* entry declared children), plus what `inject` supplied.
|
|
670
|
+
* @returns the rendered control, or null when the session cannot change models.
|
|
671
|
+
*/
|
|
672
|
+
function createModelPicker(React) {
|
|
673
|
+
const h = React.createElement
|
|
674
|
+
const { useCallback, useEffect, useMemo, useRef, useState } = React
|
|
675
|
+
|
|
676
|
+
return function ModelPicker(props) {
|
|
677
|
+
const { sessionId, locked, renderSlot, directory, catalog: catalogFace, available } = props
|
|
678
|
+
|
|
679
|
+
const localeId = typeof props.localeId === 'string' ? props.localeId : 'zh-CN'
|
|
680
|
+
const t = dictionaryFor(localeId)
|
|
681
|
+
|
|
682
|
+
// One panel at a time: the model list and the effort list are separate
|
|
683
|
+
// controls, and opening either closes the other.
|
|
684
|
+
const [panel, setPanel] = useState(null)
|
|
685
|
+
const [query, setQuery] = useState('')
|
|
686
|
+
const rootRef = useRef(null)
|
|
687
|
+
|
|
688
|
+
const state = readSnapshot(useState, useEffect, directory?.store) ?? {}
|
|
689
|
+
// `catalogFace` is the catalogue controller, not a store: its snapshot source
|
|
690
|
+
// is `store`. Passing the controller itself made the hook call a missing
|
|
691
|
+
// `getSnapshot` on first render.
|
|
692
|
+
const catalogState = readSnapshot(useState, useEffect, catalogFace?.store)
|
|
693
|
+
const groups = Array.isArray(state.groups) ? state.groups : []
|
|
694
|
+
const failures = Array.isArray(state.failures) ? state.failures : []
|
|
695
|
+
const selection = state.current ?? null
|
|
696
|
+
const pending = state.pending ?? null
|
|
697
|
+
const busy = pending !== null || state.status === 'selecting'
|
|
698
|
+
|
|
699
|
+
useEffect(() => {
|
|
700
|
+
/**
|
|
701
|
+
* Load the directory, not just a catalogue.
|
|
702
|
+
*
|
|
703
|
+
* In reuse mode `directory` is the shipped one and `catalogFace` is this
|
|
704
|
+
* plugin's own catalogue — loading the latter alone left the shipped
|
|
705
|
+
* directory's catalogue untouched, so its `groups` never advanced past
|
|
706
|
+
* whatever it held when the seat first mounted. `directory.load()` asks the
|
|
707
|
+
* object that actually feeds `state.groups`; its own `load` is idempotent
|
|
708
|
+
* and internally refreshes the catalogue it reads.
|
|
709
|
+
*/
|
|
710
|
+
if (typeof directory?.load === 'function') {
|
|
711
|
+
const outcome = directory.load()
|
|
712
|
+
if (outcome !== undefined && typeof outcome.then === 'function') outcome.catch(() => {})
|
|
713
|
+
} else if (catalogFace !== undefined && catalogFace !== null) {
|
|
714
|
+
catalogFace.load()
|
|
715
|
+
}
|
|
716
|
+
return undefined
|
|
717
|
+
}, [directory, catalogFace])
|
|
718
|
+
|
|
719
|
+
useEffect(() => {
|
|
720
|
+
if (panel === null) return undefined
|
|
721
|
+
const onKey = (event) => { if (event.key === 'Escape') setPanel(null) }
|
|
722
|
+
const onClick = (event) => {
|
|
723
|
+
if (rootRef.current !== null && !rootRef.current.contains(event.target)) setPanel(null)
|
|
724
|
+
}
|
|
725
|
+
document.addEventListener('keydown', onKey)
|
|
726
|
+
document.addEventListener('mousedown', onClick)
|
|
727
|
+
return () => {
|
|
728
|
+
document.removeEventListener('keydown', onKey)
|
|
729
|
+
document.removeEventListener('mousedown', onClick)
|
|
730
|
+
}
|
|
731
|
+
}, [panel])
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Choose a model, carrying the adapter's default effort when the caller did
|
|
735
|
+
* not name one — the shipped picker sends the same triple, so a model whose
|
|
736
|
+
* adapter defaults to a level records that level rather than an unset field.
|
|
737
|
+
*/
|
|
738
|
+
const select = useCallback(async (group, model, reasoningEffort) => {
|
|
739
|
+
if (directory === undefined || directory === null) return
|
|
740
|
+
const effort = reasoningEffort ?? model?.reasoning?.defaultEffort
|
|
741
|
+
const result = await directory.select({
|
|
742
|
+
provider: group.id,
|
|
743
|
+
model: model.id,
|
|
744
|
+
...effort === undefined ? {} : { reasoningEffort: effort }
|
|
745
|
+
})
|
|
746
|
+
if (result?.ok !== true) return
|
|
747
|
+
setPanel(null)
|
|
748
|
+
}, [directory])
|
|
749
|
+
|
|
750
|
+
const needle = query.trim().toLowerCase()
|
|
751
|
+
const filtered = useMemo(() => {
|
|
752
|
+
const match = (model) => needle.length === 0 ||
|
|
753
|
+
String(model.name ?? model.id).toLowerCase().includes(needle) ||
|
|
754
|
+
String(model.id).toLowerCase().includes(needle)
|
|
755
|
+
return groups
|
|
756
|
+
.map((group) => ({ group, models: (group.models ?? []).filter(match) }))
|
|
757
|
+
.filter((entry) => entry.models.length > 0)
|
|
758
|
+
}, [groups, needle])
|
|
759
|
+
|
|
760
|
+
const current = selection === null ? null : groups
|
|
761
|
+
.find((group) => group.id === selection.provider)?.models
|
|
762
|
+
?.find((model) => model.id === selection.model)
|
|
763
|
+
const currentGroup = selection === null ? null : groups.find((group) => group.id === selection.provider) ?? null
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* What the trigger says, in the shipped picker's precedence.
|
|
767
|
+
*
|
|
768
|
+
* While the directory is still loading and nothing is known yet, it says
|
|
769
|
+
* "loading" rather than naming a model it has not read or claiming there is
|
|
770
|
+
* none. A selection whose model left the catalogue still names the model,
|
|
771
|
+
* because the selection is what the Host will use until it is changed.
|
|
772
|
+
*/
|
|
773
|
+
const waiting = selection === null && state.status === 'loading'
|
|
774
|
+
const currentLabel = waiting
|
|
775
|
+
? t.loading
|
|
776
|
+
: current?.name ?? selection?.model ?? t.noCatalog
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Reasoning levels of the selected model.
|
|
780
|
+
*
|
|
781
|
+
* The catalogue entry carries `reasoning: { efforts, defaultEffort }`. An
|
|
782
|
+
* explicit choice wins; otherwise the adapter's default is what the next call
|
|
783
|
+
* will use, so that is what the control shows rather than a blank.
|
|
784
|
+
*/
|
|
785
|
+
const efforts = Array.isArray(current?.reasoning?.efforts) ? current.reasoning.efforts : []
|
|
786
|
+
const activeEffortId = selection?.reasoningEffort ?? current?.reasoning?.defaultEffort
|
|
787
|
+
const activeEffort = efforts.find((effort) => effort.id === activeEffortId) ?? null
|
|
788
|
+
const effortName = (effort) => effort?.name ?? effort?.id ?? String(activeEffortId ?? '')
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* The label, in the shipped picker's addressable wrapper.
|
|
792
|
+
*
|
|
793
|
+
* The `data-provider-id` / `data-model-id` span is kept because CSS that
|
|
794
|
+
* targets one model by id still works. `renderSlot` is probed rather than
|
|
795
|
+
* assumed: the renderer hands an entry that binding only for children the
|
|
796
|
+
* entry declared itself, and this one deliberately declares none (the shipped
|
|
797
|
+
* occupant owns the declaration), so the plain name is what actually renders.
|
|
798
|
+
*/
|
|
799
|
+
const renderLabel = (group, model, placement, selected) => h('span', {
|
|
800
|
+
'data-provider-id': group?.id,
|
|
801
|
+
'data-model-id': model?.id
|
|
802
|
+
}, renderSlot === undefined
|
|
803
|
+
? model?.name
|
|
804
|
+
: renderSlot(LABEL_SLOT, { provider: group, model, placement, selected }, { fallback: model?.name }))
|
|
805
|
+
|
|
806
|
+
const renderItem = (group, model) => {
|
|
807
|
+
const isCurrent = selection !== null &&
|
|
808
|
+
selection.provider === group.id && selection.model === model.id
|
|
809
|
+
return h('button', {
|
|
810
|
+
key: `${group.id}:${model.id}`,
|
|
811
|
+
type: 'button',
|
|
812
|
+
role: 'option',
|
|
813
|
+
'aria-selected': isCurrent,
|
|
814
|
+
className: `dmp-item${isCurrent ? ' dmp-item-current' : ''}`,
|
|
815
|
+
onClick: () => void select(group, model)
|
|
816
|
+
},
|
|
817
|
+
h('span', { className: 'dmp-item-name' }, renderLabel(group, model, 'option', isCurrent)))
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const renderGroup = ({ group, models }) => h('div', { key: group.id },
|
|
821
|
+
h('div', { className: 'dmp-group' }, group.name ?? group.id),
|
|
822
|
+
models.map((model) => renderItem(group, model)))
|
|
823
|
+
|
|
824
|
+
const notes = []
|
|
825
|
+
/**
|
|
826
|
+
* Three independent problems, reported separately.
|
|
827
|
+
*
|
|
828
|
+
* A catalogue failure belongs to the shared cache and affects every session;
|
|
829
|
+
* a selection failure belongs to this session's directory; a provider group
|
|
830
|
+
* that failed to load belongs to the catalogue's failure list and explains a
|
|
831
|
+
* group that is simply absent. Collapsing them into one message meant a
|
|
832
|
+
* failed switch erased the load error and vice versa.
|
|
833
|
+
*/
|
|
834
|
+
const catalogError = typeof catalogState?.error === 'string' ? catalogState.error : ''
|
|
835
|
+
const selectionError = typeof state.error === 'string' ? state.error : ''
|
|
836
|
+
if (state.status === 'loading' && groups.length === 0 && catalogError.length === 0) {
|
|
837
|
+
notes.push(h('div', { key: 'loading', className: 'dmp-note' }, t.loading))
|
|
838
|
+
}
|
|
839
|
+
if (catalogError.length > 0 || selectionError.length > 0) {
|
|
840
|
+
notes.push(h('div', { key: 'error', className: 'dmp-note dmp-note-error' },
|
|
841
|
+
catalogError.length > 0 ? catalogError : selectionError,
|
|
842
|
+
h('div', null, h('button', {
|
|
843
|
+
type: 'button', className: 'dmp-retry', onClick: () => catalogFace?.refresh()
|
|
844
|
+
}, t.retry))))
|
|
845
|
+
}
|
|
846
|
+
// A provider group whose catalogue failed: say so, instead of leaving a
|
|
847
|
+
// provider the user knows about silently missing from the list.
|
|
848
|
+
if (failures.length > 0) {
|
|
849
|
+
notes.push(h('div', { key: 'failures', className: 'dmp-note dmp-note-error' },
|
|
850
|
+
failures.map((failure) => h('div', { key: String(failure?.provider ?? failure?.id ?? failure) },
|
|
851
|
+
String(failure?.message ?? failure?.provider ?? failure)))))
|
|
852
|
+
}
|
|
853
|
+
if (state.status === 'ready' && filtered.length === 0) {
|
|
854
|
+
notes.push(h('div', { key: 'empty', className: 'dmp-note' }, t.empty))
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const effortButton = current === null || currentGroup === null || efforts.length === 0
|
|
858
|
+
? null
|
|
859
|
+
: h('button', {
|
|
860
|
+
type: 'button',
|
|
861
|
+
className: 'dmp-trigger dmp-trigger-effort',
|
|
862
|
+
disabled: locked === true || busy,
|
|
863
|
+
title: t.effortTitle(effortName(activeEffort)),
|
|
864
|
+
'aria-haspopup': 'listbox',
|
|
865
|
+
'aria-expanded': panel === 'effort',
|
|
866
|
+
onClick: () => setPanel((value) => (value === 'effort' ? null : 'effort'))
|
|
867
|
+
},
|
|
868
|
+
h('span', { className: 'dmp-trigger-text' }, effortName(activeEffort)),
|
|
869
|
+
h(Chevron, { h, open: panel === 'effort' }))
|
|
870
|
+
|
|
871
|
+
const effortPanel = panel === 'effort' && effortButton !== null
|
|
872
|
+
? h('div', { className: 'dmp-panel dmp-panel-narrow', role: 'listbox' },
|
|
873
|
+
h('div', { className: 'dmp-list' }, efforts.map((effort) => {
|
|
874
|
+
const isCurrent = effort.id === activeEffortId
|
|
875
|
+
return h('button', {
|
|
876
|
+
key: effort.id,
|
|
877
|
+
type: 'button',
|
|
878
|
+
role: 'option',
|
|
879
|
+
'aria-selected': isCurrent,
|
|
880
|
+
className: `dmp-item${isCurrent ? ' dmp-item-current' : ''}`,
|
|
881
|
+
onClick: () => void select(currentGroup, current, effort.id)
|
|
882
|
+
}, h('span', { className: 'dmp-item-name' }, effortName(effort)),
|
|
883
|
+
effort.description === undefined
|
|
884
|
+
? null
|
|
885
|
+
: h('span', { className: 'dmp-item-note' }, String(effort.description)))
|
|
886
|
+
})))
|
|
887
|
+
: null
|
|
888
|
+
|
|
889
|
+
const modelPanel = panel === 'model'
|
|
890
|
+
? h('div', { className: 'dmp-panel', role: 'listbox' },
|
|
891
|
+
h('div', { className: 'dmp-search' },
|
|
892
|
+
h('input', {
|
|
893
|
+
type: 'search',
|
|
894
|
+
value: query,
|
|
895
|
+
placeholder: t.placeholder,
|
|
896
|
+
'aria-label': t.placeholder,
|
|
897
|
+
autoFocus: true,
|
|
898
|
+
onChange: (event) => setQuery(event.target.value)
|
|
899
|
+
})),
|
|
900
|
+
notes,
|
|
901
|
+
h('div', { className: 'dmp-list' }, filtered.map(renderGroup)))
|
|
902
|
+
: null
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* An addressed subagent session cannot change its model — the Host rejects
|
|
906
|
+
* the RPC. The shipped picker renders nothing in that case rather than
|
|
907
|
+
* offering a control whose every click fails, so this one does the same.
|
|
908
|
+
* `available` is computed once per session, in `inject`.
|
|
909
|
+
*/
|
|
910
|
+
if (available === false) return null
|
|
911
|
+
|
|
912
|
+
return h('div', { className: 'dmp-root', ref: rootRef },
|
|
913
|
+
h('style', null, CSS),
|
|
914
|
+
// The model control and the reasoning-level control are deliberately two
|
|
915
|
+
// buttons: they are two independent choices, and a model change must not
|
|
916
|
+
// silently drag the level along with it.
|
|
917
|
+
h('div', { className: 'dmp-control' },
|
|
918
|
+
h('button', {
|
|
919
|
+
type: 'button',
|
|
920
|
+
className: 'dmp-trigger',
|
|
921
|
+
disabled: locked === true || busy,
|
|
922
|
+
title: t.trigger(currentLabel),
|
|
923
|
+
'aria-haspopup': 'listbox',
|
|
924
|
+
'aria-expanded': panel === 'model',
|
|
925
|
+
onClick: () => setPanel((value) => (value === 'model' ? null : 'model'))
|
|
926
|
+
},
|
|
927
|
+
h('span', { className: 'dmp-trigger-text' },
|
|
928
|
+
busy
|
|
929
|
+
? t.switching
|
|
930
|
+
: (current === null || currentGroup === null
|
|
931
|
+
? currentLabel
|
|
932
|
+
: renderLabel(currentGroup, current, 'trigger', true))),
|
|
933
|
+
h(Chevron, { h, open: panel === 'model' })),
|
|
934
|
+
modelPanel),
|
|
935
|
+
effortButton === null ? null : h('div', { className: 'dmp-control' }, effortButton, effortPanel))
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
//# module: src/client.js
|
|
940
|
+
/**
|
|
941
|
+
* Client entry: the module-loader registration and the plugin object.
|
|
942
|
+
*
|
|
943
|
+
* This file is the only browser entry the Client module loader knows about for
|
|
944
|
+
* this package. Everything it uses lives in sibling modules under `src/`, and
|
|
945
|
+
* `scripts/build.mjs` concatenates them into the served `client.js`.
|
|
946
|
+
*
|
|
947
|
+
* Why the picker shadows this slot at all: `conversation.input.model` is a
|
|
948
|
+
* `single` slot whose shipped occupant carries `replaceRisk: shadows-shipped-ui`.
|
|
949
|
+
* Shadowing it is a visual choice, so it lives in a bundle that can be turned
|
|
950
|
+
* off again without touching any provider registration.
|
|
951
|
+
*
|
|
952
|
+
* On the `conversation.input.model.label` child slot: the shipped picker declares
|
|
953
|
+
* it and provider plugins decorate it. This picker cannot join them — a child
|
|
954
|
+
* slot may be declared exactly once and the shipped occupant keeps its
|
|
955
|
+
* declaration even while shadowed, so a second declaration throws at
|
|
956
|
+
* registration. The renderer then hands an entry a `renderSlot` binding only for
|
|
957
|
+
* children that entry declared itself, so not declaring means not rendering. The
|
|
958
|
+
* decorations therefore do not appear here and each model is shown by its plain
|
|
959
|
+
* name. That is an accepted consequence of shadowing this slot, not an oversight.
|
|
960
|
+
*/
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
const PLUGIN = 'dsh-model-picker'
|
|
966
|
+
const SLOT = 'conversation.input.model'
|
|
967
|
+
/** The shipped service that owns per-session model directories. */
|
|
968
|
+
const MODEL_DIRECTORIES = 'modelDirectories'
|
|
969
|
+
/**
|
|
970
|
+
* BELOW the shipped occupant's `priority: 0`.
|
|
971
|
+
*
|
|
972
|
+
* For a `single` slot the LOWEST priority renders — the registry says so in its
|
|
973
|
+
* own error text ("register at a different priority to shadow it (lowest
|
|
974
|
+
* renders)"), and `entriesOfSlot` returns the first survivor of a
|
|
975
|
+
* priority-sorted list. A larger number would have lost to the shipped picker
|
|
976
|
+
* instead of shadowing it.
|
|
977
|
+
*/
|
|
978
|
+
const SHADOW_PRIORITY = -1
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Load-stage report.
|
|
982
|
+
*
|
|
983
|
+
* The packaged desktop app has no devtools, so a client half that fails to
|
|
984
|
+
* register is indistinguishable from one that never loaded. Each stage is posted
|
|
985
|
+
* to the Host half, which logs it next to its own `host 半边已 apply` line: the
|
|
986
|
+
* pair tells the two cases apart immediately.
|
|
987
|
+
*
|
|
988
|
+
* @param stage - a short description of the load stage reached.
|
|
989
|
+
*/
|
|
990
|
+
const report = (stage) => {
|
|
991
|
+
// The renderer console reaches the desktop app log, which is captured in every
|
|
992
|
+
// diagnostics bundle. A POST to the Host half is kept as the richer channel,
|
|
993
|
+
// but it cannot be the only one: it is a second thing that can fail (trust
|
|
994
|
+
// fence, no route yet, offline), and a missing report then looks exactly like
|
|
995
|
+
// "apply was never called".
|
|
996
|
+
try {
|
|
997
|
+
console.error(`[model-picker] ${stage}`)
|
|
998
|
+
} catch {
|
|
999
|
+
// no console in some hosts
|
|
1000
|
+
}
|
|
1001
|
+
try {
|
|
1002
|
+
void fetch('/model-picker/client-log', {
|
|
1003
|
+
method: 'POST',
|
|
1004
|
+
headers: { 'content-type': 'application/json' },
|
|
1005
|
+
body: JSON.stringify({ stage }),
|
|
1006
|
+
keepalive: true
|
|
1007
|
+
}).catch(() => {})
|
|
1008
|
+
} catch {
|
|
1009
|
+
// diagnostics must never break the picker
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* The failure worth capturing happens *outside* apply: the client composition
|
|
1015
|
+
* builds this plugin object, then never applies it, and whatever it threw on the
|
|
1016
|
+
* way is only visible in the page — which the packaged desktop app gives no
|
|
1017
|
+
* devtools for. Both global hooks forward to the host log instead.
|
|
1018
|
+
*
|
|
1019
|
+
* @param value - the thrown value, in any shape.
|
|
1020
|
+
* @returns a short description safe to log.
|
|
1021
|
+
*/
|
|
1022
|
+
const describe = (value) => {
|
|
1023
|
+
if (value instanceof Error) return `${value.name}: ${value.message}`
|
|
1024
|
+
try {
|
|
1025
|
+
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
1026
|
+
} catch {
|
|
1027
|
+
return String(value)
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
report('client.js 已执行,开始注册 factory')
|
|
1032
|
+
|
|
1033
|
+
try {
|
|
1034
|
+
window.addEventListener('error', (event) => {
|
|
1035
|
+
report(`页面 error:${describe(event?.error ?? event?.message)}`)
|
|
1036
|
+
})
|
|
1037
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
1038
|
+
report(`页面 unhandledrejection:${describe(event?.reason)}`)
|
|
1039
|
+
})
|
|
1040
|
+
} catch {
|
|
1041
|
+
// a missing window hook must never stop the plugin from registering
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
window.__ModuleLoader__.load({
|
|
1045
|
+
id: '@huai_xin/dsh-model-picker',
|
|
1046
|
+
factory(require) {
|
|
1047
|
+
report('factory 已运行,正在装配插件对象')
|
|
1048
|
+
const React = require('react')
|
|
1049
|
+
const ModelPicker = createModelPicker(React)
|
|
1050
|
+
|
|
1051
|
+
return {
|
|
1052
|
+
name: PLUGIN,
|
|
1053
|
+
/**
|
|
1054
|
+
* No fiber-level `inject`.
|
|
1055
|
+
*
|
|
1056
|
+
* A required service that is not visible from this context keeps the whole
|
|
1057
|
+
* entry in `loading`: Cordis never calls `apply`, and the client Loader
|
|
1058
|
+
* turns that into `web boot: 1 entry did not activate`, i.e. a dead
|
|
1059
|
+
* renderer. Waiting *inside* apply means an unmet dependency costs only the
|
|
1060
|
+
* registration instead of the entire boot.
|
|
1061
|
+
*/
|
|
1062
|
+
apply(ctx) {
|
|
1063
|
+
report(`apply 已调用(slots=${ctx.get('slots') === undefined ? '尚未就绪' : '就绪'})`)
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* The catalogue, its subscriptions, and the directory service are built
|
|
1067
|
+
* HERE, in `apply` itself — not inside the `ctx.inject` callback below.
|
|
1068
|
+
*
|
|
1069
|
+
* This is the one structural difference from the shipped picker that
|
|
1070
|
+
* matters: the shipped plugin calls `ctx.remote.$on(...)` directly in its
|
|
1071
|
+
* `apply` body. The gateway registers a forwarded-event listener in the
|
|
1072
|
+
* calling fiber's private event domain (`events.subscribe(this.ctx, …)`),
|
|
1073
|
+
* and a subscription made from a scope callback — even when that scope is
|
|
1074
|
+
* the same object — is not in the domain the dispatcher publishes to, so
|
|
1075
|
+
* the listener never fires. Building them here keeps the registration in
|
|
1076
|
+
* the plugin's own fiber, exactly like the shipped plugin.
|
|
1077
|
+
*/
|
|
1078
|
+
const remote = (() => {
|
|
1079
|
+
try { return ctx.get('remote') } catch { return undefined }
|
|
1080
|
+
})()
|
|
1081
|
+
const catalog = createCatalog(remote, (message) => { report(message) })
|
|
1082
|
+
const detachCatalog = catalog.attach(remote, (event) => {
|
|
1083
|
+
report(`收到失效事件 ${event}`)
|
|
1084
|
+
})
|
|
1085
|
+
report(`失效订阅:${catalog.canAttach(remote) ? '已挂到 remote.$on' : 'remote 没有 $on,无法订阅'}`)
|
|
1086
|
+
const detachReset = typeof ctx.on === 'function'
|
|
1087
|
+
? ctx.on('connection/reset', () => { catalog.resetGeneration() })
|
|
1088
|
+
: undefined
|
|
1089
|
+
const sessions = (() => {
|
|
1090
|
+
try { return ctx.get('sessions') } catch { return undefined }
|
|
1091
|
+
})()
|
|
1092
|
+
const shipped = (() => {
|
|
1093
|
+
try { return ctx.get(MODEL_DIRECTORIES) } catch { return undefined }
|
|
1094
|
+
})()
|
|
1095
|
+
const directories = shipped !== undefined && typeof shipped.directoryFor === 'function'
|
|
1096
|
+
? shipped
|
|
1097
|
+
: createDirectoryService(catalog, sessions, remote)
|
|
1098
|
+
const ownsDirectories = directories !== shipped
|
|
1099
|
+
report(`目录来源:${ownsDirectories ? '本插件自带实现' : '复用官方 modelDirectories'}`)
|
|
1100
|
+
const detachDirectories = ownsDirectories && typeof ctx.provide === 'function'
|
|
1101
|
+
? ctx.provide(MODEL_DIRECTORIES, directories)
|
|
1102
|
+
: undefined
|
|
1103
|
+
if (typeof ctx.effect === 'function') {
|
|
1104
|
+
ctx.effect(() => {
|
|
1105
|
+
detachCatalog()
|
|
1106
|
+
detachReset?.()
|
|
1107
|
+
detachDirectories?.()
|
|
1108
|
+
}, 'dsh-model-picker: catalogue and directories')
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const install = (scope) => {
|
|
1112
|
+
try {
|
|
1113
|
+
const at = scope ?? ctx
|
|
1114
|
+
const slots = at.slots ?? ctx.get('slots')
|
|
1115
|
+
if (slots === undefined) {
|
|
1116
|
+
report('slots 服务仍不可用,槽位未注册')
|
|
1117
|
+
return
|
|
1118
|
+
}
|
|
1119
|
+
const locale = ctx.get('locale')
|
|
1120
|
+
const localeId = typeof locale?.getLocale === 'function'
|
|
1121
|
+
? String(locale.getLocale()?.locale ?? 'zh-CN')
|
|
1122
|
+
: 'zh-CN'
|
|
1123
|
+
/**
|
|
1124
|
+
* Neither `ctx.remote` nor `ctx.remote.session` can be read through
|
|
1125
|
+
* the property proxy without an `inject` entry — Cordis throws
|
|
1126
|
+
* `cannot get property "remote.session" without inject`, which crashed
|
|
1127
|
+
* this component on its first render and got the entry abdicated.
|
|
1128
|
+
*
|
|
1129
|
+
* `ctx.get(...)` returns `undefined` instead of throwing, so the
|
|
1130
|
+
* picker degrades to its "could not load the model list" state rather
|
|
1131
|
+
* than taking the whole slot down.
|
|
1132
|
+
*/
|
|
1133
|
+
const remoteSession = at['remote.session'] ?? ctx.get('remote.session') ?? remote?.session
|
|
1134
|
+
|
|
1135
|
+
slots.inject(SLOT, () => {
|
|
1136
|
+
report('槽位回调已运行,准备注册 occupant')
|
|
1137
|
+
/**
|
|
1138
|
+
* NO `children` declaration, on purpose.
|
|
1139
|
+
*
|
|
1140
|
+
* A child slot may be declared exactly once, and the shipped picker
|
|
1141
|
+
* already declares `conversation.input.model.label` for its own
|
|
1142
|
+
* occupant. Declaring it here threw during registration, which is
|
|
1143
|
+
* what removed the shipped occupant and left the slot empty.
|
|
1144
|
+
*
|
|
1145
|
+
* The consequence is that provider decorations mounted on the label
|
|
1146
|
+
* child do not render while this picker is in charge. Rendering them
|
|
1147
|
+
* would require owning the declaration, i.e. the shipped
|
|
1148
|
+
* `ui-model-selection` occupant being absent entirely.
|
|
1149
|
+
*/
|
|
1150
|
+
const dispose = slots.register({
|
|
1151
|
+
name: SLOT,
|
|
1152
|
+
priority: SHADOW_PRIORITY,
|
|
1153
|
+
inject: (sessionId) => {
|
|
1154
|
+
const sessionSessions = ctx.get('sessions')
|
|
1155
|
+
/**
|
|
1156
|
+
* The per-session directory is the data face of this picker.
|
|
1157
|
+
*
|
|
1158
|
+
* `directoryFor` is the shipped service's own entry point; when
|
|
1159
|
+
* this plugin had to provide the service, the fallback has the
|
|
1160
|
+
* same signature, so the component never knows the difference.
|
|
1161
|
+
*/
|
|
1162
|
+
const directory = directories.directoryFor(sessionId)
|
|
1163
|
+
// The shipped directory projects inside its constructor and on
|
|
1164
|
+
// its subscriptions; it exposes no `sync`. Only this plugin's
|
|
1165
|
+
// fallback has one, so probe instead of assuming — calling it
|
|
1166
|
+
// unconditionally threw on the shipped service and took the
|
|
1167
|
+
// whole slot registration down with it.
|
|
1168
|
+
if (typeof directory?.sync === 'function') directory.sync()
|
|
1169
|
+
/**
|
|
1170
|
+
* The directory's own snapshot, reported once per registration.
|
|
1171
|
+
*
|
|
1172
|
+
* This is the line that tells the two "not refreshed" failure
|
|
1173
|
+
* modes apart: if the group count here stops matching what the
|
|
1174
|
+
* Host now offers, the directory is stale; if it tracks the Host
|
|
1175
|
+
* while the panel still shows old rows, the panel is not
|
|
1176
|
+
* rendering the update.
|
|
1177
|
+
*/
|
|
1178
|
+
const snapshot = directory?.store?.getSnapshot?.()
|
|
1179
|
+
report(`目录快照:${Array.isArray(snapshot?.groups) ? snapshot.groups.length : 0} 组 / 状态 ${snapshot?.status ?? '?'}`)
|
|
1180
|
+
return {
|
|
1181
|
+
sessionId,
|
|
1182
|
+
localeId,
|
|
1183
|
+
remote,
|
|
1184
|
+
remoteSession,
|
|
1185
|
+
sessions: sessionSessions,
|
|
1186
|
+
catalog,
|
|
1187
|
+
directory,
|
|
1188
|
+
available: isAvailable(sessionSessions, sessionId)
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}, ModelPicker)
|
|
1192
|
+
report('occupant 已注册(priority ' + SHADOW_PRIORITY + ')')
|
|
1193
|
+
return dispose
|
|
1194
|
+
})
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
report(`槽位注册抛错:${error instanceof Error ? `${error.message}` : String(error)}`)
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Soft wait: `apply` has already run, so a service that never shows up
|
|
1201
|
+
// can no longer fail the boot. The remotes are included because the
|
|
1202
|
+
// picker cannot do anything without them, and waiting here keeps the
|
|
1203
|
+
// failure to a missing control instead of a crashed one.
|
|
1204
|
+
if (typeof ctx.inject === 'function') ctx.inject(['slots', 'remote', 'remote.session'], install)
|
|
1205
|
+
else install(ctx)
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
})
|