@huai_xin/dsh-model-picker 0.1.0 → 0.1.2

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 (3) hide show
  1. package/README.md +28 -115
  2. package/client.js +70 -25
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,140 +1,53 @@
1
1
  # dsh-model-picker
2
2
 
3
- DSH(DeepSeek Harness)的一个插件:**替换输入框里的模型选择器**,做成可搜索、按 provider 分组的列表。
3
+ 给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的输入框换一个**模型选择器**:可搜索、按 provider 分组,模型和思考档位分成两个独立控件。
4
4
 
5
- ## 安装
6
-
7
- ```sh
8
- dsh plugin --profile <profile> add /path/to/dsh-model-picker # 本地目录
9
- dsh plugin --profile <profile> add <包名> # 发布后
10
- ```
11
-
12
- 装完**刷新页面**(客户端模块在页面启动时加载)。
13
-
14
- ## 四个"很难查"的坑(都踩过)
5
+ DSH 自带的模型选择器是按两级菜单设计的。模型一多,找一个模型要翻两层;思考档位藏在二级菜单里,改一次要点三次。这个插件把它换成一次点开的列表:直接搜、直接选。
15
6
 
16
- 1. **`exports` 必须包含 `"./package.json": "./package.json"`。**
17
- 客户端模块扫描器是用 `createRequire(...).resolve('<包名>/package.json')` 定位包的,
18
- 解析失败就**直接跳过这个 entry**——没有报错、没有日志,插件只是永远不出现在浏览器里。
19
- (`test/unit/client.mjs` 里钉住了这一条;skill 自带的模板恰好缺它。)
20
- 2. **不要在插件对象上声明 fiber 级 `inject`;把等待放进 `apply`。**
21
- 这一条是踩了很久才查清的。Cordis 里 fiber 级 `inject` 是一条**必须被满足的依赖边**:
22
- 服务不可见,`apply` 就永远不被调用,entry 停在 `loading`,然后整个渲染进程启动失败:
7
+ ## 它做了什么
23
8
 
24
- ```
25
- Error: web boot: 1 entry did not activate
26
- @huai_xin/dsh-model-picker: loading
27
- RendererStartupFailure: Renderer boot failed for 1 plugin(s)
28
- ```
9
+ **可搜索的模型列表** — 点开即输入,按名字或 id 过滤,按 provider 分组显示。
29
10
 
30
- 本插件改成 `apply` 内部 `ctx.inject([...], install)` 软等待:依赖没到只损失这次注册,
31
- 不会拖垮整个 boot。**注意这跟 `dsh.client.inject`(清单里那条)是两回事**——后者只影响
32
- 激活顺序,早先把它清空并没有解决问题,日志里一直是"`factory` 已运行、`apply` 未调用"。
33
- 3. **`ctx.remote` / `ctx.remote.session` 必须经 `inject`(或 `ctx.get`)读取。**
34
- 属性代理对未声明的服务直接抛 `cannot get property "remote.session" without inject`,
35
- 而这条异常发生在**组件首次渲染时**,表现为整个槽位消失:entry 被 `abdicate`
36
- (`listSubTree` 里 `active: false`),官方 occupant 又已被压住,于是两边都不渲染。
37
- 4. **客户端半边要等宿主重启后才会被收录**:改完 `client.js` 只刷新页面,常常还是旧的。
11
+ **模型与思考档位分开** — 两个按钮、两个浮层,互不干扰。换模型不会顺手改掉档位,改档位也不会换模型。
38
12
 
39
- 排查用:宿主日志里的 `[model-picker] host 半边已 apply`(宿主侧)与
40
- `[model-picker] 客户端自检:…`(浏览器侧各阶段上报,见 `index.js` 的路由)。
41
- 宿主那行有、客户端一行都没有 ⇒ 模块没被下发;只有 `factory 已运行` 而没有 `apply 已调用`
42
- ⇒ entry 卡在依赖上(就是第 2 条)。启动失败时 `%APPDATA%/DSH Desktop/diagnostics/*.zip`
43
- 里的 `dsh-*.error.log` 会指名是哪个 entry。
13
+ **和官方选择器共用一份状态** — 不是另起炉灶。它复用 DSH 自己的 `modelDirectories` 服务,所以你在输入框里选的模型,和 `/model` 命令弹窗里看到的,永远是同一个。切换过程中(Host 还没确认时)两个入口都会显示"正在切换"。
44
14
 
45
- ## 它如何与"装饰类"插件共存(目前会丢装饰)
15
+ **跟着模型列表实时变** — 别的插件改了可用模型(开关模型、导入账号、探测到模型不可用……),这个列表会立刻跟着变,不用刷新页面。
46
16
 
47
- 官方 picker 自己**声明**了子槽位 `conversation.input.model.label`,provider 插件往那里挂装饰
48
- (可用性、促销、繁忙度…)。替换者想保住这些装饰,就得自己声明并渲染这个子槽位——**但这在
49
- 当前 slots 架构下做不到**,原因是两条硬规则:
17
+ **新会话显示默认模型** — 而不是空着或显示"没有可用模型"。
50
18
 
51
- 1. **子槽位只能被声明一次**(`slot "..." is already declared`)。官方 occupant 还在槽位里
52
- (只是被 priority 压住不渲染),它的声明仍然有效,我们再声明一次就抛错。
53
- 2. **`renderSlot` 只能渲染自己声明的子槽位**,否则抛 `SlotOwnershipError`。不声明就无权渲染。
19
+ **子会话里自动隐藏** — 在被指派的 subagent 会话里,Host 不允许换模型,控件直接不渲染,而不是给你一个点了必失败的按钮。
54
20
 
55
- 第一版曾按"重新声明"实现,结果注册直接抛错,反而把官方 occupant 挤掉了——那次的表现是
56
- **整个选择器消失**。所以现在**不声明 `children`**:picker 正常渲染,但 provider 挂在
57
- `conversation.input.model.label` 上的装饰在这个槽位里不显示。
58
-
59
- 真要同时满足两者,只有让官方 `ui-model-selection` 的 occupant 完全缺席(停用那个 bundle),
60
- 代价是它的 `/model` 命令贡献一起消失。这是架构限制,不是本插件的实现缺陷。
61
-
62
- ## 数据层(与官方 `ui-model-selection` 同一套契约)
63
-
64
- 数据不是自己拼的,走的是和官方相同的三层结构,`test/unit/client.mjs` 逐条钉住:
65
-
66
- **1. 全局目录(`ModelCatalogDirectory` 的等价物)**
67
- - 一份、按 Host generation 作用域,所有会话共用;in-flight 请求去重。
68
- - 每次失效递增 generation,旧请求的迟到响应**直接丢弃**,不会覆盖新值。
69
- - `reasoning` 记住每个模型的档位元数据,**模型离开目录后仍能显示档位名字**。
21
+ ## 安装
70
22
 
71
- **2. 会话级目录(`ModelDirectory` 的等价物)**
72
- - store 形状与官方一致:`{ current, routable, groups, failures, status, pending, error }`。
73
- - `current` = 会话显式选择 ?? 目录 `default`(官方同样的优先级)。加载中显示"加载中",
74
- 而不是模型名,也不是"没有可用模型"。
75
- - `pending` 在 RPC 前后发布,触发器能显示正在结算的选择。
76
- - `select()` 带 generation 竞态保护:**后发起的胜出**,旧回复即便晚到也不写入。
77
- (单测用突变验证过:去掉这个守卫,断言立刻失败。)
78
- - 切模型时带上适配器的默认档位,与官方提交的三元组一致。
79
- - `failures` 渲染为提示行,某个 provider 组加载失败不再静默消失。
23
+ ```sh
24
+ dsh plugin --profile <你的 profile> add @huai_xin/dsh-model-picker
25
+ ```
80
26
 
81
- **3. `modelDirectories` 服务**
82
- - **优先复用官方已注册的服务**——这正是让输入框和 `/model` 弹窗共享同一份选择状态(含在途
83
- 状态)的关键。官方服务存在时不重复注册(重复 `ctx.provide` 会抛错)。
84
- - 官方服务缺席时(bundle 被停用、旧版本),本插件以同名提供一份**同契约**的实现,组件无感知。
85
- - 按 sessionId 键控目录,不按 binding 对象:binding 访问器可能每次返回新包装,按它做键会导致
86
- 每次重挂载都新建目录、丢掉在途状态。
27
+ 装完**重启宿主**,再刷新页面。客户端代码是在页面启动时加载的,只刷新页面通常还是旧的。
87
28
 
88
- **四个失效事件 + `connection/reset`**:与官方监听集合完全一致;只听 `llm/adapters-updated`
89
- 的旧版会在改设置/凭据后继续提供宿主已拒绝的模型。
29
+ 也可以从本地目录装:
90
30
 
91
- **子会话**:addressed subagent 会话里 Host 会拒绝 `selectModel`,官方 picker 在那种会话里
92
- 什么都不渲染,本插件同样返回 `null`。
31
+ ```sh
32
+ dsh plugin --profile <你的 profile> add /path/to/dsh-model-picker
33
+ ```
93
34
 
94
- **仍与官方不同**(有意保留的范围差异):没有 `/model` 命令贡献,也不做 analytics 埋点——
95
- 这两项属于官方 bundle,不在"替换输入框选择器"的职责内。
35
+ 不想要了就直接停用或移除这个 bundle,不会影响模型本身是否可用。
96
36
 
97
- ## 工程结构
37
+ ## 已知限制
98
38
 
99
- 源码在 `src/` 下按职责拆分,`client.js` 是**构建产物**(勿手改):
39
+ **会丢掉 provider 插件挂在模型名上的装饰。** DSH 官方选择器声明了一个子槽位 `conversation.input.model.label`,provider 插件往那里挂徽标、可用性提示、促销标记之类的东西。替换者没法继承这个声明——子槽位只能声明一次,官方占用者即使被压住不渲染,它的声明依然有效。所以在这个选择器里,模型名后面不会有那些装饰。
100
40
 
101
- ```
102
- src/
103
- client.js 入口:模块注册 + 插件对象 + 装配(含服务复用与槽位注册)
104
- core/
105
- store.js snapshot store 契约(getSnapshot/subscribe/set/update)
106
- selection.js readSelection(兼容各代字段拼写)、isAvailable(子会话探测)
107
- catalog.js 全局目录:共享缓存、generation 失效、reasoning 记忆
108
- directory.js 会话级目录:current/routable/pending/failures + 竞态保护
109
- directories.js modelDirectories 服务的自带实现(官方缺席时使用)
110
- ui/
111
- ModelPicker.js 组件:触发器 + 模型面板 + 档位面板
112
- Chevron.js 展开箭头
113
- dict.js 中英字典
114
- styles.js 样式表(仅 --dsw-alias-* 主题令牌)
115
- scripts/build.mjs 零依赖打包器:按依赖顺序拼接 src/ → client.js
116
- client.js 构建产物,由 scripts/build.mjs 生成
117
- ```
41
+ 想同时要装饰和这套交互,只能停用官方的 `ui-model-selection` bundle,代价是它的 `/model` 命令一起消失。这是 DSH 槽位架构的限制,不是本插件能绕过的。
118
42
 
119
- **为什么需要构建**:Client 模块加载器给每个包只有一个浏览器入口
120
- (`exports["./client"]`),并以 classic script 执行,不会在运行时解析插件的 ESM 依赖图。
121
- 所以源码拆开维护、构建时拼接成单文件,是既能保持可维护性、又不要求宿主提供它没有的能力的做法。
43
+ ## 兼容性
122
44
 
123
- 打包器只做三件事:深度优先收集本地 `./x.js` 依赖、剥掉 `import`/`export` 语法、按依赖顺序
124
- 拼接。它不做 tree-shaking,也不解析裸模块——遇到裸 import 会直接报错(宿主模块应从 factory
125
- 的 `require` 取)。
45
+ 需要 DSH `0.2.1-rc` 及以上。模型数据全部走 DSH 自己的 client remote(`session.modelCatalog` / `session.selectModel`),插件本身不认识任何具体 provider——任何 provider 插件注册的模型都会出现在列表里。
126
46
 
127
- ```sh
128
- node scripts/build.mjs # 或 pnpm build:重新生成 client.js
129
- node test/unit/client.mjs # 或 pnpm test:构建 + 离线单测
130
- ```
47
+ ## 开发
131
48
 
132
- 单测会校验 `client.js` 是否由当前源码构建(每个模块有 `//# module:` 标记,且产物不得旧于
133
- 任何源文件),避免改完源码忘记重新构建、让测试跑在过期产物上。
49
+ 见 [AGENTS.md](AGENTS.md)。
134
50
 
135
- ## 开发
51
+ ## License
136
52
 
137
- ```sh
138
- pnpm build # 重新生成 client.js
139
- pnpm test # 构建 + 离线单测(无依赖,自带迷你 React)
140
- ```
53
+ MIT
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
  }
@@ -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.
1068
1087
  *
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.
1088
+ * Two facts pull in opposite directions and this is the seam between
1089
+ * them:
1090
+ *
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.0",
3
+ "version": "0.1.2",
4
4
  "description": "A searchable, provider-grouped replacement for the DSH composer's model picker.",
5
5
  "type": "module",
6
6
  "license": "MIT",