@huai_xin/dsh-model-picker 0.1.1 → 0.1.3

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.
Files changed (2) hide show
  1. package/client.js +71 -26
  2. package/package.json +1 -1
package/client.js CHANGED
@@ -96,10 +96,15 @@ const INVALIDATION_EVENTS = Object.freeze([
96
96
  * model's effort metadata *after* the model leaves the catalogue — so a level
97
97
  * still shows its name while its model is gone.
98
98
  *
99
- * @param remote - the client remote face carrying `session.modelCatalog`.
99
+ * @param resolveRemote - returns the client remote face carrying `session.modelCatalog`.
100
+ * A THUNK, not the face itself: this catalogue is built in `apply`, where the
101
+ * `remote` service is often not visible yet (it arrives later in the client boot
102
+ * sequence). Capturing the value here froze `undefined` and every load then
103
+ * reported "modelCatalog is unavailable".
104
+ * @param onDiagnostic - optional sink for load-stage notes.
100
105
  * @returns the catalogue controller.
101
106
  */
102
- function createCatalog(remote, onDiagnostic) {
107
+ function createCatalog(resolveRemote, onDiagnostic) {
103
108
  const store = createStore({ value: null, status: 'idle', error: null })
104
109
  const reasoning = new Map()
105
110
  // Bumped by every invalidation. A response that resolves under an older
@@ -112,6 +117,11 @@ function createCatalog(remote, onDiagnostic) {
112
117
  try { onDiagnostic?.(message) } catch { /* diagnostics never break the load */ }
113
118
  }
114
119
 
120
+ /** The remote face as it is right now, or undefined while the service is absent. */
121
+ const currentRemote = () => {
122
+ try { return typeof resolveRemote === 'function' ? resolveRemote() : resolveRemote } catch { return undefined }
123
+ }
124
+
115
125
  const reasoningFor = (selection) => selection === null || selection === undefined
116
126
  ? undefined
117
127
  : reasoning.get(JSON.stringify([selection.provider, selection.model]))
@@ -120,8 +130,10 @@ function createCatalog(remote, onDiagnostic) {
120
130
  const state = store.getSnapshot()
121
131
  if (state.status === 'ready' && state.value !== null) return Promise.resolve(state.value)
122
132
  if (inflight !== undefined) return inflight
133
+ const remote = currentRemote()
123
134
  const run = remote?.session?.modelCatalog
124
135
  if (typeof run !== 'function') {
136
+ note('modelCatalog 不可用:remote 尚未就绪')
125
137
  store.set({ value: state.value, status: 'error', error: 'modelCatalog is unavailable' })
126
138
  return Promise.resolve(undefined)
127
139
  }
@@ -491,10 +503,17 @@ function createDirectory(catalog, projectedFace, remote, sessionId) {
491
503
  *
492
504
  * @param catalog - the shared catalogue controller.
493
505
  * @param sessions - the sessions service face.
494
- * @param remote - the client remote face.
506
+ * @param resolveRemote - returns the client remote face. A thunk for the same
507
+ * reason the catalogue takes one: the service is often not visible yet when
508
+ * this object is built, and capturing it froze `undefined`.
495
509
  * @returns the service object with `directoryFor`.
496
510
  */
497
- function createDirectoryService(catalog, sessions, remote) {
511
+ function createDirectoryService(catalog, sessions, resolveRemote) {
512
+ /** The remote face as it is right now, or undefined while the service is absent. */
513
+ const currentRemote = () => {
514
+ try { return typeof resolveRemote === 'function' ? resolveRemote() : resolveRemote } catch { return undefined }
515
+ }
516
+
498
517
  /**
499
518
  * Keyed by session id, not by binding object.
500
519
  *
@@ -518,7 +537,7 @@ function createDirectoryService(catalog, sessions, remote) {
518
537
  const projectedFace = (() => {
519
538
  try { return binding?.session?.projections?.faceOf?.('modelSelection') } catch { return undefined }
520
539
  })()
521
- const directory = createDirectory(catalog, projectedFace, remote, sessionId)
540
+ const directory = createDirectory(catalog, projectedFace, currentRemote(), sessionId)
522
541
  directories.set(key, directory)
523
542
  return directory
524
543
  }
@@ -599,7 +618,7 @@ const CSS = `
599
618
  `
600
619
 
601
620
  //# module: src/ui/dict.js
602
- /**
621
+ /**
603
622
  * The picker's two dictionaries.
604
623
  *
605
624
  * Kept as a plain object rather than the Client locale service because the
@@ -1063,26 +1082,52 @@ window.__ModuleLoader__.load({
1063
1082
  report(`apply 已调用(slots=${ctx.get('slots') === undefined ? '尚未就绪' : '就绪'})`)
1064
1083
 
1065
1084
  /**
1066
- * The catalogue, its subscriptions, and the directory service are built
1067
- * HERE, in `apply` itself — not inside the `ctx.inject` callback below.
1085
+ * The catalogue is built HERE, in `apply`, but it reads the `remote`
1086
+ * service LAZILY.
1087
+ *
1088
+ * Two facts pull in opposite directions and this is the seam between
1089
+ * them:
1068
1090
  *
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.
1091
+ * - Event subscriptions must be installed from the plugin's own `apply`
1092
+ * fiber. The gateway registers a forwarded-event listener in the
1093
+ * CALLING fiber's private event domain, and a subscription made from a
1094
+ * `ctx.inject` callback lands in a different domain: `$on` returns a
1095
+ * disposer, so it looks successful, but the listener never fires.
1096
+ * - `remote` is frequently NOT visible yet when `apply` runs. Capturing
1097
+ * `ctx.get('remote')` here froze `undefined`, and every catalogue load
1098
+ * then answered "modelCatalog is unavailable".
1099
+ *
1100
+ * So the catalogue holds a resolver, not the face, and the subscription
1101
+ * is attached as soon as the service appears.
1077
1102
  */
1078
- const remote = (() => {
1103
+ const remoteFace = () => {
1079
1104
  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,无法订阅'}`)
1105
+ }
1106
+ const catalog = createCatalog(remoteFace, (message) => { report(message) })
1107
+ /**
1108
+ * Attach the invalidation listeners, retrying once the remote exists.
1109
+ *
1110
+ * `ctx.inject` runs the callback as soon as the named services are
1111
+ * visible, which is the point at which `$on` can be reached — and it runs
1112
+ * in this same `apply` fiber, so the subscription still lands in the
1113
+ * dispatching domain.
1114
+ */
1115
+ let detachCatalog = () => {}
1116
+ const attachCatalog = () => {
1117
+ const remote = remoteFace()
1118
+ if (remote === undefined) return false
1119
+ detachCatalog = catalog.attach(remote, (event) => {
1120
+ report(`收到失效事件 ${event}`)
1121
+ })
1122
+ report(`失效订阅:已挂到 remote.$on`)
1123
+ return true
1124
+ }
1125
+ if (!attachCatalog()) {
1126
+ report('remote 尚未就绪,等它出现后再挂失效订阅')
1127
+ if (typeof ctx.inject === 'function') {
1128
+ ctx.inject(['remote'], () => { attachCatalog() })
1129
+ }
1130
+ }
1086
1131
  const detachReset = typeof ctx.on === 'function'
1087
1132
  ? ctx.on('connection/reset', () => { catalog.resetGeneration() })
1088
1133
  : undefined
@@ -1094,7 +1139,7 @@ window.__ModuleLoader__.load({
1094
1139
  })()
1095
1140
  const directories = shipped !== undefined && typeof shipped.directoryFor === 'function'
1096
1141
  ? shipped
1097
- : createDirectoryService(catalog, sessions, remote)
1142
+ : createDirectoryService(catalog, sessions, remoteFace)
1098
1143
  const ownsDirectories = directories !== shipped
1099
1144
  report(`目录来源:${ownsDirectories ? '本插件自带实现' : '复用官方 modelDirectories'}`)
1100
1145
  const detachDirectories = ownsDirectories && typeof ctx.provide === 'function'
@@ -1130,7 +1175,7 @@ window.__ModuleLoader__.load({
1130
1175
  * picker degrades to its "could not load the model list" state rather
1131
1176
  * than taking the whole slot down.
1132
1177
  */
1133
- const remoteSession = at['remote.session'] ?? ctx.get('remote.session') ?? remote?.session
1178
+ const remoteSession = at['remote.session'] ?? ctx.get('remote.session') ?? remoteFace()?.session
1134
1179
 
1135
1180
  slots.inject(SLOT, () => {
1136
1181
  report('槽位回调已运行,准备注册 occupant')
@@ -1180,7 +1225,7 @@ window.__ModuleLoader__.load({
1180
1225
  return {
1181
1226
  sessionId,
1182
1227
  localeId,
1183
- remote,
1228
+ remote: remoteFace(),
1184
1229
  remoteSession,
1185
1230
  sessions: sessionSessions,
1186
1231
  catalog,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huai_xin/dsh-model-picker",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A searchable, provider-grouped replacement for the DSH composer's model picker.",
5
5
  "type": "module",
6
6
  "license": "MIT",