@huai_xin/dsh-model-picker 0.0.0-stage → 0.1.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/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
+ })