@mzzsfy/dsh-model-capability-editor 0.2.4 → 0.4.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/README.md CHANGED
@@ -13,7 +13,7 @@ DeepSeek Harness 模型能力编辑插件:编辑 `llm-pi-ai` 管理的第三方
13
13
  ## 功能
14
14
 
15
15
  - provider 选择(来源为 describe 返回的 `providers` 键),列出各模型行(独立编辑卡形态,当前仅作为锚点破坏时的浮动回退面板呈现)。
16
- - 推理档位七档(off / minimal / low / medium / high / xhigh / max,与宿主 pi-ai `THINKING_LEVELS` 一致)复选框 + 每档线上值输入,判定表:
16
+ - 推理档位七档(off / minimal / low / medium / high / xhigh / max,与宿主 pi-ai `THINKING_LEVELS` 一致)开关(语义保留 checkbox,视觉为 `mce-switch` 开关形态)+ 每档线上值输入,判定表:
17
17
 
18
18
  | 勾选状态 | 写回行为 |
19
19
  |---|---|
@@ -22,7 +22,9 @@ DeepSeek Harness 模型能力编辑插件:编辑 `llm-pi-ai` 管理的第三方
22
22
  | off 勾选拼写留空,存在其他勾选档 | 对象形态 `off: null` |
23
23
  | off 勾选拼写填值 | 对象形态 `off: "拼写"` |
24
24
 
25
- - input 三态:未声明(删除字段)/ 仅文本(`["text"]`)/ 文本+图像(`["text", "image"]`)。
25
+ - input 四态:未声明(删除字段)/ 仅文本(`["text"]`)/ 文本+图像(`["text", "image"]`)/ 仅图片(`["image"]`,纯视觉网关不收文本输入)
26
+ - 保存前本地校验:非 off 档勾选但拼写空白时拒绝保存并提示具体模型与档位(空白拼写会静默以档位名作线上值,易被误当作已填;off 档留空有专门语义,不参与校验)。卡片与行内编辑块均拦截。保存与校验反馈经全站浮出通知(`@mzzsfy/dsh-toast` 可选依赖,缺失时降级 console)。
27
+ - 一键草稿填充:卡片底部「填充草稿」对当前 provider 内未声明任何档位的模型填入七档全勾、拼写留空(线上值=档位名)的草稿;只改内存草稿,不触碰 inputMode,已声明档位的模型不受影响,写回仍需手动保存。
26
28
  - 保存 = mutate set `providers.<route>.models` 整个数组:以 describe 读到的数组为基线,未编辑条目原样保留,settings 未声明的(自动发现的)模型不被删除;providers 缺失或 models 非数组时拒绝保存(防静默覆写为空数组)。
27
29
  - 修订冲突:重读 describe 取新 revision,按字段级 diff 仅重放本次修改(仅用户改过的模型条目的 `reasoningEfforts` / `input` 两字段,其余字段保留最新文档值),重试一次;再冲突报错终止并保留用户输入,绝不静默覆盖。"本次修改"以草稿加载时点冻结的种子为参照:加载后他方对基线的修改,不会被零编辑或仅改单一字段的保存静默回滚(该保证覆盖保存开始后的并发写,revision 乐观锁语义)。
28
30
  - `settings` wire 面缺失 / describe 失败 / `writable === false`:卡片显示具体原因并只读,绝不静默;describe 返回缺 revision 时拒绝盲写。
@@ -38,7 +40,12 @@ dsh plugin --profile web add @mzzsfy/dsh-model-capability-editor
38
40
 
39
41
  ## 前置:dsh 本体版本
40
42
 
41
- 需要 dsh 本体 0.1.2 及以上(读写经 `remote.settings` 服务面,该面由 0.1.2 引入的 host 侧 `dsh-api-settings-controller` 提供)。更旧的本体上无此服务面,client 半区因注入声明 `remote.settings` 未满足而保持未激活(无 UI、无告警、不影响 web 启动与其他插件);请先升级 dsh,或改用插件 0.1.2(settings 面,已停止维护)。
43
+ 支持 dsh 0.1.1-rc.2 / 0.1.2-rc.1 / 0.1.5-alpha.2(实测)。settings 传输按宿主代际自适应:
44
+
45
+ - 0.1.2+(boot wire 带 `batches` 批次调度):`remote.settings` 以点分 inject 声明交由 cordis 门控,fiber 等 namespace `$mount` 完成才激活,激活即就绪。宿主 `parseBootManifest` 将 `batches` 校验为必填数组(缺失即整页拒绝启动),判据被上游 schema 钉死;宿主未来若移除该字段,本插件退化为 api 面或恒定禁用,不阻塞 boot。
46
+ - 0.1.1(旧 boot wire,无该 namespace):点分声明会永远 pending 拖垮整页 web boot,故声明两代基座 `remote` + `connection`,settings 传输在 apply 内异步定面轮询:从 `connection.api` 面起等,面在首拍即定型;缺失持续满等待窗(1s)则恒定禁用并告警,不影响 web 启动。
47
+
48
+ 注意 0.1.1 本体的 `llm-pi-ai` schema 不认识 0.1.5 引入的 `compat.chatTemplateArgs` 字段,settings.yaml 中该字段会让 0.1.1 的 `llm-pi-ai` settings 注册整体失败(host 侧行为),编辑器随之无数据;从 yaml 删除该字段即恢复。
42
49
 
43
50
  ## 前置:移除 dsh-better-reasoning-effort
44
51
 
@@ -61,16 +68,16 @@ dsh plugin --profile web add @mzzsfy/dsh-model-capability-editor
61
68
  npm test
62
69
  ```
63
70
 
64
- 纯逻辑层单测(node --test,无外部依赖):档位四态判定表与拼写回填、input 三态、整组写回基线合并、冲突字段级重放与一次重试判定、竞品痕迹检测、wire 信封适配(含 transport 抛错透传)与端到端冲突重放、client.js 与 logic.mjs 的 wire 适配段同源守卫。
71
+ 纯逻辑层单测(node --test,无外部依赖):档位四态判定表与拼写回填、input 四态(含纯图像)、保存前空拼写校验、一键草稿填充、整组写回基线合并、冲突字段级重放与一次重试判定、竞品痕迹检测、wire 信封适配(含 transport 抛错透传)与端到端冲突重放、client.js 与 logic.mjs 的 wire 适配段同源守卫,以及锚点判定与行内目标解析、注入编排与生命周期、开关守卫、client id 等其余模块测试。
65
72
 
66
73
  ## License
67
74
 
68
75
  MIT
69
76
 
70
- ## 开发安装(不经 npm 发布直接装仓库副本)
77
+ ## 开发安装(仓库工作副本直挂,不经 npm 发布)
71
78
 
72
79
  ```sh
73
- dsh plugin --profile web add file:./packages/dsh-model-capability-editor
80
+ node scripts/dev-link.mjs dsh-model-capability-editor # 仓库根执行:归一 profile 依赖行 + 挂 junction
74
81
  ```
75
82
 
76
- `file:` 安装指向仓库工作副本,改代码后重跑该命令即同步,无需发版。
83
+ 工作副本以 junction 挂进 profile,client 半区改动刷新页面即生效,无需发版;规约与全仓归一见仓库根 `node scripts/dev-link.mjs all`。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-model-capability-editor",
3
3
  "description": "在设置页编辑第三方模型的思考档位与图片输入声明,解决手写配置易错与档位拼写不可控",
4
- "version": "0.2.4",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -1,4 +1,4 @@
1
- // 模型能力编辑 Client 半区:settings.section 独立设置页卡片。
1
+ // 模型能力编辑 Client 半区:官方模型页行内注入,锚点破坏时浮动入口回退。
2
2
  // 以 DSH client-modules 自注册格式发布:__ModuleLoader__.load({id, factory}),
3
3
  // factory(require) 中 require('react') 与 require('react-dom/client') 由 DSH client
4
4
  // runtime 的模块表解析(宿主种子一级键,缺失即整个宿主 UI 不存在,不降级)。
@@ -32,6 +32,12 @@ window.__ModuleLoader__.load({
32
32
  // 插件实例代际:宿主热重载/禁用重建时区分新旧实例,样式清理只认自有属主
33
33
  let instanceSeq = 0
34
34
 
35
+ // 宿主代际判据:0.1.2+ 的 client-modules boot wire 带 batches 批次调度
36
+ // (宿主 parseBootManifest 将其校验为必填数组,缺失即整页拒绝启动,判据
37
+ // 被上游 schema 钉死);0.1.1 的 wire 无此字段。宿主未来若移除 batches,
38
+ // 退化为 api 面或恒定禁用,不阻塞 boot
39
+ const hasBatchesWire = typeof window !== 'undefined' && window.__DSH_BOOT__?.batches !== undefined
40
+
35
41
  const CSS = [
36
42
  '.mce-card { display:flex; flex-direction:column; gap:10px; color:inherit; font-size:13px;',
37
43
  ' border:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)); border-radius:10px; padding:12px;',
@@ -115,8 +121,14 @@ const OFF_LEVEL = 'off'
115
121
  const INPUT_UNSET = 'unset'
116
122
  const INPUT_TEXT = 'text'
117
123
  const INPUT_TEXT_IMAGE = 'text-image'
118
- const INPUT_MODES = [INPUT_UNSET, INPUT_TEXT, INPUT_TEXT_IMAGE]
119
- const INPUT_MODE_LABELS = { [INPUT_UNSET]: '未声明', [INPUT_TEXT]: '仅文本', [INPUT_TEXT_IMAGE]: '文本+图像' }
124
+ const INPUT_IMAGE = 'image-only'
125
+ const INPUT_MODES = [INPUT_UNSET, INPUT_TEXT, INPUT_TEXT_IMAGE, INPUT_IMAGE]
126
+ const INPUT_MODE_LABELS = {
127
+ [INPUT_UNSET]: '未声明',
128
+ [INPUT_TEXT]: '仅文本',
129
+ [INPUT_TEXT_IMAGE]: '文本+图像',
130
+ [INPUT_IMAGE]: '仅图片',
131
+ }
120
132
  // 竞品 dsh-better-reasoning-effort 的 host autofill 写入痕迹标记字段
121
133
  const COMPETITOR_MARKERS = ['reasoningEffortsUnset', 'inputUnset']
122
134
 
@@ -158,19 +170,55 @@ function draftsToEfforts(drafts, baselineValue) {
158
170
  return result
159
171
  }
160
172
 
161
- // input 数组 → 三态;未声明与空数组(schema 视为未回答)同为未声明
173
+ // input 数组 → 四态;未声明与空数组(schema 视为未回答)同为未声明;仅 image 为纯图像
162
174
  function inputToMode(value) {
163
175
  if (!Array.isArray(value) || value.length === 0) return INPUT_UNSET
164
- return value.indexOf('image') >= 0 ? INPUT_TEXT_IMAGE : INPUT_TEXT
176
+ const hasText = value.indexOf('text') >= 0
177
+ const hasImage = value.indexOf('image') >= 0
178
+ if (hasText) return hasImage ? INPUT_TEXT_IMAGE : INPUT_TEXT
179
+ return hasImage ? INPUT_IMAGE : INPUT_UNSET
165
180
  }
166
181
 
167
- // 三态 → input 写回值(undefined = 删除字段)
182
+ // 四态 → input 写回值(undefined = 删除字段)
168
183
  function modeToInput(mode) {
169
184
  if (mode === INPUT_TEXT) return ['text']
170
185
  if (mode === INPUT_TEXT_IMAGE) return ['text', 'image']
186
+ if (mode === INPUT_IMAGE) return ['image']
171
187
  return undefined
172
188
  }
173
189
 
190
+ // 保存前本地校验:非 off 档勾选但拼写为空白。空拼写会静默以档位名作线上值
191
+ // (draftsToEfforts 语义),易被误当作"已填"。返回违规档位列表,空数组即通过;
192
+ // off 档留空有专门语义(false / off:null),不参与此校验。
193
+ function invalidReasoningLevels(draft) {
194
+ const invalid = []
195
+ for (const level of EFFORT_LEVELS) {
196
+ if (level === OFF_LEVEL) continue
197
+ if (draft.checked[level] === true && String(draft.spellings[level] || '').trim().length === 0) {
198
+ invalid.push(level)
199
+ }
200
+ }
201
+ return invalid
202
+ }
203
+
204
+ // 一键草稿填充:只动内存草稿,写回仍走显式保存。仅补"未勾选任何档位"的模型
205
+ // (即 reasoningEfforts 未声明的手声明模型,本插件核心场景):七档全勾、拼写
206
+ // 留空(线上值 = 档位名)。已编辑(有勾选)与 inputMode 一律不碰,防覆盖既有声明。
207
+ function fillDrafts(drafts, models) {
208
+ const filled = new Map(drafts)
209
+ for (const model of Array.isArray(models) ? models : []) {
210
+ const key = String(model.id)
211
+ const draft = filled.get(key)
212
+ if (draft === undefined) continue
213
+ const hasChecked = EFFORT_LEVELS.some((level) => draft.checked[level] === true)
214
+ if (hasChecked) continue
215
+ const checked = {}
216
+ for (const level of EFFORT_LEVELS) checked[level] = true
217
+ filled.set(key, { ...draft, checked, spellings: { ...draft.spellings } })
218
+ }
219
+ return filled
220
+ }
221
+
174
222
  // reasoningEfforts 基线可表达形态:未声明/false/null/纯对象;异型形态跳过重写防误删
175
223
  function isExpressibleEfforts(value) {
176
224
  return value === undefined || value === false || value === null
@@ -267,23 +315,50 @@ function anchorsBroken({ titleMatched, hasEditor, modelIdInputCount }) {
267
315
  return titleMatched === true && hasEditor === true && modelIdInputCount === 0
268
316
  }
269
317
 
270
- function unwrapResult(result) {
271
- if (result !== null && typeof result === 'object' && result.ok === true) return result.value
272
- const error = new Error(result && result.error && result.error.message
273
- ? result.error.message
274
- : 'settings RPC 调用失败')
275
- error.code = result && result.error ? result.error.code : undefined
276
- throw error
318
+ // settings 传输 → 插件内部 settings 面(describe() / mutate(ns, ops, revision))。
319
+ // 两种宿主传输形态,能力面在 0.1.1 即存在:
320
+ // - typed remote (dsh 0.1.2+):方法直返 RemoteResult 信封 {ok,value|error};
321
+ // - connection.api 面(dsh 0.1.1+):settings.describe() 无参,settings.mutate({ns,ops,...})
322
+ // 单对象参数,返回 {rpcId,result:{ok,value|error}} 信封。
323
+ // 面缺失或形状不完整返回 null,由调用方降级呈现只读原因。
324
+ function unwrapEnvelope(envelope) {
325
+ if (envelope !== null && typeof envelope === 'object' && envelope.ok === true) return envelope.value
326
+ if (envelope !== null && typeof envelope === 'object' &&
327
+ envelope.result !== null && typeof envelope.result === 'object') {
328
+ if (envelope.result.ok === true) return envelope.result.value
329
+ return rejectRpc(envelope.result.error)
330
+ }
331
+ return rejectRpc(envelope && envelope.error)
332
+ }
333
+
334
+ function rejectRpc(error) {
335
+ const rpcError = new Error(error && error.message ? error.message : 'settings RPC 调用失败')
336
+ rpcError.code = error ? error.code : undefined
337
+ throw rpcError
277
338
  }
278
339
 
279
- // dsh 0.1.2 remote.settings 服务 → 插件内部 settings 面(describe() / mutate(ns, ops, revision))。
280
- // remote 面缺失或形状不完整返回 null,由调用方降级呈现只读原因。
281
- function makeSettingsFace(remote) {
282
- if (remote === null || typeof remote !== 'object' ||
283
- typeof remote.describe !== 'function' || typeof remote.mutate !== 'function') return null
340
+ function makeSettingsFace(transport) {
341
+ if (transport === null || typeof transport !== 'object') return null
342
+ const typed = typeof transport.describe === 'function' && typeof transport.mutate === 'function'
343
+ if (typed) {
344
+ return {
345
+ describe: async () => unwrapEnvelope(await transport.describe()),
346
+ mutate: async (ns, ops, expectedRevision) =>
347
+ unwrapEnvelope(await transport.mutate(ns, ops, expectedRevision)),
348
+ }
349
+ }
350
+ const api = transport.api
351
+ if (api === null || typeof api !== 'object' ||
352
+ api.settings === null || typeof api.settings !== 'object' ||
353
+ typeof api.settings.describe !== 'function' || typeof api.settings.mutate !== 'function') return null
284
354
  return {
285
- describe: async () => unwrapResult(await remote.describe()),
286
- mutate: async (ns, ops, expectedRevision) => unwrapResult(await remote.mutate(ns, ops, expectedRevision)),
355
+ describe: async () => unwrapEnvelope(await api.settings.describe({})),
356
+ mutate: async (ns, ops, expectedRevision) =>
357
+ unwrapEnvelope(await api.settings.mutate({
358
+ ns,
359
+ ops,
360
+ ...(expectedRevision === undefined ? {} : { expectedRevision }),
361
+ })),
287
362
  }
288
363
  }
289
364
 
@@ -478,7 +553,7 @@ function CapabilityCard(props) {
478
553
  patch({ phase: 'readonly', reason: 'remote.settings 服务面缺失,无法读写模型声明' })
479
554
  return
480
555
  }
481
- const value = unwrapResult(await settings.describe())
556
+ const value = await settings.describe()
482
557
  if (value.writable !== true) {
483
558
  patch({ phase: 'readonly', reason: 'settings 当前只读,模型能力编辑不可用' })
484
559
  return
@@ -517,7 +592,7 @@ function CapabilityCard(props) {
517
592
  patch({ route: nextRoute, phase: 'ready' })
518
593
  void (async () => {
519
594
  try {
520
- const value = unwrapResult(await props.settings.describe())
595
+ const value = await props.settings.describe()
521
596
  if (seq !== routeSeqRef.current) return
522
597
  const ns = findNsEntry(value)
523
598
  if (ns === undefined) {
@@ -550,6 +625,16 @@ function CapabilityCard(props) {
550
625
  }
551
626
 
552
627
  async function save() {
628
+ // 保存前本地校验:非 off 档勾选但拼写空白,空格式的线上值易被误当作"已填"
629
+ const invalid = []
630
+ for (const [id, draft] of state.drafts) {
631
+ const levels = invalidReasoningLevels(draft)
632
+ if (levels.length > 0) invalid.push(id + ' (' + levels.join(', ') + ')')
633
+ }
634
+ if (invalid.length > 0) {
635
+ notify('以下模型的档位拼写为空,请填写线上值或取消勾选:' + invalid.join('; '), 'error')
636
+ return
637
+ }
553
638
  setSaving(true)
554
639
  try {
555
640
  const { models: written, droppedDraftIds } = await saveModels(props.settings, state.route, state.drafts)
@@ -558,7 +643,7 @@ function CapabilityCard(props) {
558
643
  ? '已保存,但模型 ' + droppedDraftIds.join(', ') + ' 已被其他写者删除,对应修改未写入'
559
644
  : '已保存并写回 settings.yaml', droppedDraftIds.length > 0 ? 'error' : 'ok')
560
645
  try {
561
- const value = unwrapResult(await props.settings.describe())
646
+ const value = await props.settings.describe()
562
647
  const ns = findNsEntry(value)
563
648
  if (ns === undefined) {
564
649
  // 保存后命名空间被他方移除:明确告知刷新,不再裸抛
@@ -618,6 +703,27 @@ function CapabilityCard(props) {
618
703
  state.models.filter((model) => model !== null && typeof model === 'object').length === 0
619
704
  ? h('div', { className: 'mce-label' }, '该 provider 暂无模型条目。')
620
705
  : null,
706
+ h('div', { className: 'mce-row' },
707
+ h('button', {
708
+ className: 'mce-btn',
709
+ disabled: saving || switching || state.route === null,
710
+ onClick: () => {
711
+ const filled = fillDrafts(state.drafts, state.models)
712
+ let count = 0
713
+ for (const [id, draft] of filled) {
714
+ if (state.drafts.get(id) !== draft) count += 1
715
+ }
716
+ if (count > 0) {
717
+ draftsRouteRef.current = state.route
718
+ patch({ drafts: filled })
719
+ }
720
+ notify(count > 0
721
+ ? '已为 ' + count + ' 个未声明档位的模型填充草稿(七档全勾、线上值=档位名),检查后手动保存'
722
+ : '没有需要填充的模型:所有模型均已声明档位', count > 0 ? 'ok' : 'error')
723
+ },
724
+ }, '填充草稿'),
725
+ h('span', { className: 'mce-label' }, '仅填内存草稿,写回仍需手动保存;已声明档位的模型不受影响。'),
726
+ ),
621
727
  h('div', { className: 'mce-row' },
622
728
  h('button', { className: 'mce-btn', disabled: saving || switching || state.route === null, onClick: save }, saving ? '保存中…' : '保存'),
623
729
  h('span', { className: 'mce-label' }, '保存 = 整组写回当前 provider 的 models 数组,未编辑的模型原样保留。'),
@@ -663,7 +769,7 @@ function RowEditor(props) {
663
769
  patch({ phase: 'error', notice: 'remote.settings 服务面缺失,无法读写模型声明' })
664
770
  return
665
771
  }
666
- const value = unwrapResult(await settings.describe())
772
+ const value = await settings.describe()
667
773
  const ns = findNsEntry(value)
668
774
  if (!alive) return
669
775
  if (ns === undefined) { patch({ phase: 'hidden' }); return }
@@ -678,18 +784,24 @@ function RowEditor(props) {
678
784
  }, [])
679
785
 
680
786
  async function apply() {
787
+ // 保存前本地校验:非 off 档勾选但拼写空白,空格式的线上值易被误当作"已填"
788
+ const invalidLevels = invalidReasoningLevels(state.draft)
789
+ if (invalidLevels.length > 0) {
790
+ notify('档位 ' + invalidLevels.join(', ') + ' 的拼写为空,请填写线上值或取消勾选', 'error')
791
+ return
792
+ }
681
793
  setSaving(true)
682
794
  try {
683
795
  // S3:官方行内 ID 输入是活动状态,改名已落盘则以新 ID 为目标,否则回落原 ID
684
796
  const el = props.idInputEl
685
797
  const liveId = el && el.isConnected ? el.value : modelId
686
- const first = unwrapResult(await settings.describe())
798
+ const first = await settings.describe()
687
799
  const nsFirst = findNsEntry(first)
688
800
  const baselineIds = new Set(nsFirst !== undefined ? modelsOf(nsFirst.value, route).map((entry) => String(entry.id)) : [])
689
801
  const targetId = resolveTargetId(liveId, modelId, baselineIds)
690
802
  const { models: written, droppedDraftIds } = await saveModels(settings, route, new Map([[targetId, state.draft]]))
691
803
  // S4:保存后重读重建草稿,基线新鲜,保留他方词汇表外档位
692
- const second = unwrapResult(await settings.describe())
804
+ const second = await settings.describe()
693
805
  const nsSecond = findNsEntry(second)
694
806
  const latest = nsSecond !== undefined
695
807
  ? modelsOf(nsSecond.value, route).find((entry) => String(entry.id) === String(targetId))
@@ -760,11 +872,15 @@ function RowEditor(props) {
760
872
  }
761
873
 
762
874
  return {
763
- // 点分声明 remote.settings 面:cordis 注入门控使 fiber 等 namespace
764
- // 挂载完成后才激活(官方插件同构),apply 期面必然就绪,无需探测分支
765
- inject: ['remote', 'remote.settings'],
875
+ // inject 按宿主代际二选一(判据 hasBatchesWire 见上):0.1.2+ 以点分
876
+ // 声明交由 cordis 门控(fiber 等 namespace $mount 完成才激活,激活即
877
+ // 就绪;不声明则点分读取永远抛错,实测);0.1.1 的 boot wire 无该
878
+ // namespace,点分声明会永远 pending 拖垮整页 web boot
879
+ // (assertEntriesActive 对 pending throw,实测),故声明两代都具备的
880
+ // 基座服务,settings 传输在 apply 内异步定面轮询。
881
+ inject: hasBatchesWire ? ['remote', 'remote.settings'] : ['remote', 'connection'],
766
882
  apply(ctx) {
767
- const settings = makeSettingsFace(ctx.remote.settings)
883
+ let settings = null
768
884
 
769
885
  // 行内注入器:MutationObserver 监听官方设置页,reconcile 把编辑块
770
886
  // 挂进已展开的模型行;官方结构变化导致锚点全失时,在模型页右侧注入
@@ -785,6 +901,47 @@ function RowEditor(props) {
785
901
  // 本实例代际号:样式属主判定用
786
902
  const instanceId = ++instanceSeq
787
903
 
904
+ // settings 面按代际取形:0.1.2+ 点分声明下 fiber 等挂载完成才激活,
905
+ // apply 时已就绪,直接取形;0.1.1 无 typed 面,轮询从 api 相位起——
906
+ // connection.api 面是 0.1.1 唯一 settings RPC 通道。两代路径对读取
907
+ // 抛错/面缺失均收敛为恒定只读降级:定面完成前 settings 为 null,
908
+ // reconcile 早退
909
+ const FACE_POLL_INTERVAL_MS = 50
910
+ // 等待窗:各代宿主上面在首拍即定型,窗口只是防御性上界,非真实时延
911
+ const API_FACE_WAIT_MS = 1000
912
+ const FACE_UNAVAILABLE_WARN = '[dsh-model-capability-editor] settings RPC 面不可用,编辑功能禁用'
913
+ // 0.1.1 轮询:读取抛错(cordis get trap)与面缺失同义;窗口判定前置,
914
+ // 任何路径都不绕过终止性——满窗即恒定禁用,不再重排
915
+ const faceStartedAt = Date.now()
916
+ const facePoll = () => {
917
+ if (disposed) return
918
+ const expired = Date.now() - faceStartedAt >= API_FACE_WAIT_MS
919
+ try {
920
+ settings = makeSettingsFace(ctx.connection)
921
+ if (settings !== null) { scheduleScan(); return }
922
+ } catch {
923
+ // namespace/服务读取未就绪:按轮询节拍重试
924
+ }
925
+ if (expired) {
926
+ console.warn(FACE_UNAVAILABLE_WARN)
927
+ return
928
+ }
929
+ setTimeout(facePoll, FACE_POLL_INTERVAL_MS)
930
+ }
931
+ if (hasBatchesWire) {
932
+ try {
933
+ settings = makeSettingsFace(ctx.remote !== undefined ? ctx.remote.settings : undefined)
934
+ } catch (error) {
935
+ // 一次性终态判定,失败原因随告警留痕
936
+ console.warn(FACE_UNAVAILABLE_WARN, error)
937
+ settings = null
938
+ }
939
+ if (settings === null) console.warn(FACE_UNAVAILABLE_WARN)
940
+ else scheduleScan()
941
+ } else {
942
+ facePoll()
943
+ }
944
+
788
945
  function docInfo() {
789
946
  const outlet = document.querySelector('[data-slot="settings.section"]')
790
947
  if (outlet === null) return null
@@ -862,6 +1019,8 @@ function RowEditor(props) {
862
1019
 
863
1020
  function reconcile() {
864
1021
  if (disposed) return
1022
+ // 定面未完成:不做任何 RPC 与注入,面就绪后 scheduleScan 会再触发
1023
+ if (settings === null) return
865
1024
  // 已脱离文档的挂载点:官方页卸载或重建了行,释放对应 root
866
1025
  for (const [container, root] of roots) {
867
1026
  if (!container.isConnected) {
@@ -900,7 +1059,7 @@ function RowEditor(props) {
900
1059
  }
901
1060
  void (async () => {
902
1061
  try {
903
- const value = unwrapResult(await settings.describe())
1062
+ const value = await settings.describe()
904
1063
  if (disposed || seq !== reconcileSeq) return
905
1064
  const ns = findNsEntry(value)
906
1065
  if (ns === undefined) return
package/src/logic.mjs CHANGED
@@ -9,7 +9,8 @@ export const OFF_LEVEL = 'off'
9
9
  export const INPUT_UNSET = 'unset'
10
10
  export const INPUT_TEXT = 'text'
11
11
  export const INPUT_TEXT_IMAGE = 'text-image'
12
- export const INPUT_MODES = [INPUT_UNSET, INPUT_TEXT, INPUT_TEXT_IMAGE]
12
+ export const INPUT_IMAGE = 'image-only'
13
+ export const INPUT_MODES = [INPUT_UNSET, INPUT_TEXT, INPUT_TEXT_IMAGE, INPUT_IMAGE]
13
14
  // 竞品 dsh-better-reasoning-effort 的 host autofill 写入痕迹:模型条目上的
14
15
  // 词汇表外标记字段,出现即说明竞品仍在运行,双写者并存。
15
16
  export const COMPETITOR_MARKERS = ['reasoningEffortsUnset', 'inputUnset']
@@ -54,19 +55,56 @@ export function draftsToEfforts(drafts, baselineValue) {
54
55
  return result
55
56
  }
56
57
 
57
- // input 数组 → 三态。未声明与空数组(schema 视为未回答)同为未声明。
58
+ // input 数组 → 四态。未声明与空数组(schema 视为未回答)同为未声明;仅 image
59
+ // (无 text)为纯图像输入(部分视觉网关不收文本)。非法成员仅不影响判定。
58
60
  export function inputToMode(value) {
59
61
  if (!Array.isArray(value) || value.length === 0) return INPUT_UNSET
60
- return value.indexOf('image') >= 0 ? INPUT_TEXT_IMAGE : INPUT_TEXT
62
+ const hasText = value.indexOf('text') >= 0
63
+ const hasImage = value.indexOf('image') >= 0
64
+ if (hasText) return hasImage ? INPUT_TEXT_IMAGE : INPUT_TEXT
65
+ return hasImage ? INPUT_IMAGE : INPUT_UNSET
61
66
  }
62
67
 
63
- // 三态 → input 写回值(undefined 表示删除字段)。
68
+ // 四态 → input 写回值(undefined 表示删除字段)。
64
69
  export function modeToInput(mode) {
65
70
  if (mode === INPUT_TEXT) return ['text']
66
71
  if (mode === INPUT_TEXT_IMAGE) return ['text', 'image']
72
+ if (mode === INPUT_IMAGE) return ['image']
67
73
  return undefined
68
74
  }
69
75
 
76
+ // 保存前本地校验:非 off 档勾选但拼写为空白。空拼写会静默以档位名作线上值
77
+ // (draftsToEfforts 语义),易被误当作"已填"。返回违规档位列表,空数组即通过;
78
+ // off 档留空有专门语义(false / off:null),不参与此校验。
79
+ export function invalidReasoningLevels(draft) {
80
+ const invalid = []
81
+ for (const level of EFFORT_LEVELS) {
82
+ if (level === OFF_LEVEL) continue
83
+ if (draft.checked[level] === true && String(draft.spellings[level] || '').trim().length === 0) {
84
+ invalid.push(level)
85
+ }
86
+ }
87
+ return invalid
88
+ }
89
+
90
+ // 一键草稿填充:只动内存草稿,写回仍走显式保存。仅补"未勾选任何档位"的模型
91
+ // (即 reasoningEfforts 未声明的手声明模型,本插件核心场景):七档全勾、拼写
92
+ // 留空(线上值 = 档位名)。已编辑(有勾选)与 inputMode 一律不碰,防覆盖既有声明。
93
+ export function fillDrafts(drafts, models) {
94
+ const filled = new Map(drafts)
95
+ for (const model of Array.isArray(models) ? models : []) {
96
+ const key = String(model.id)
97
+ const draft = filled.get(key)
98
+ if (draft === undefined) continue
99
+ const hasChecked = EFFORT_LEVELS.some((level) => draft.checked[level] === true)
100
+ if (hasChecked) continue
101
+ const checked = {}
102
+ for (const level of EFFORT_LEVELS) checked[level] = true
103
+ filled.set(key, { ...draft, checked, spellings: { ...draft.spellings } })
104
+ }
105
+ return filled
106
+ }
107
+
70
108
  // reasoningEfforts 基线的可表达形态:未声明(undefined)/ false / null / 纯对象。
71
109
  // 未声明必须可表达:给无档位模型添加档位声明是本插件核心场景。其余(字符串、
72
110
  // 数组等非本插件写入的异型形态)不参与档位重写,编辑时跳过该字段防误删。
@@ -170,23 +208,50 @@ export function anchorsBroken({ titleMatched, hasEditor, modelIdInputCount }) {
170
208
  return titleMatched === true && hasEditor === true && modelIdInputCount === 0
171
209
  }
172
210
 
173
- function unwrapResult(result) {
174
- if (result !== null && typeof result === 'object' && result.ok === true) return result.value
175
- const error = new Error(result && result.error && result.error.message
176
- ? result.error.message
177
- : 'settings RPC 调用失败')
178
- error.code = result && result.error ? result.error.code : undefined
179
- throw error
211
+ // settings 传输 → 插件内部 settings 面(describe() / mutate(ns, ops, revision))。
212
+ // 两种宿主传输形态,能力面在 0.1.1 即存在:
213
+ // - typed remote (dsh 0.1.2+):方法直返 RemoteResult 信封 {ok,value|error};
214
+ // - connection.api 面(dsh 0.1.1+):settings.describe() 无参,settings.mutate({ns,ops,...})
215
+ // 单对象参数,返回 {rpcId,result:{ok,value|error}} 信封。
216
+ // 面缺失或形状不完整返回 null,由调用方降级呈现只读原因。
217
+ function unwrapEnvelope(envelope) {
218
+ if (envelope !== null && typeof envelope === 'object' && envelope.ok === true) return envelope.value
219
+ if (envelope !== null && typeof envelope === 'object' &&
220
+ envelope.result !== null && typeof envelope.result === 'object') {
221
+ if (envelope.result.ok === true) return envelope.result.value
222
+ return rejectRpc(envelope.result.error)
223
+ }
224
+ return rejectRpc(envelope && envelope.error)
225
+ }
226
+
227
+ function rejectRpc(error) {
228
+ const rpcError = new Error(error && error.message ? error.message : 'settings RPC 调用失败')
229
+ rpcError.code = error ? error.code : undefined
230
+ throw rpcError
180
231
  }
181
232
 
182
- // dsh 0.1.2 remote.settings 服务 → 插件内部 settings 面(describe() / mutate(ns, ops, revision))。
183
- // remote 面缺失或形状不完整返回 null,由调用方降级呈现只读原因。
184
- export function makeSettingsFace(remote) {
185
- if (remote === null || typeof remote !== 'object' ||
186
- typeof remote.describe !== 'function' || typeof remote.mutate !== 'function') return null
233
+ export function makeSettingsFace(transport) {
234
+ if (transport === null || typeof transport !== 'object') return null
235
+ const typed = typeof transport.describe === 'function' && typeof transport.mutate === 'function'
236
+ if (typed) {
237
+ return {
238
+ describe: async () => unwrapEnvelope(await transport.describe()),
239
+ mutate: async (ns, ops, expectedRevision) =>
240
+ unwrapEnvelope(await transport.mutate(ns, ops, expectedRevision)),
241
+ }
242
+ }
243
+ const api = transport.api
244
+ if (api === null || typeof api !== 'object' ||
245
+ api.settings === null || typeof api.settings !== 'object' ||
246
+ typeof api.settings.describe !== 'function' || typeof api.settings.mutate !== 'function') return null
187
247
  return {
188
- describe: async () => unwrapResult(await remote.describe()),
189
- mutate: async (ns, ops, expectedRevision) => unwrapResult(await remote.mutate(ns, ops, expectedRevision)),
248
+ describe: async () => unwrapEnvelope(await api.settings.describe({})),
249
+ mutate: async (ns, ops, expectedRevision) =>
250
+ unwrapEnvelope(await api.settings.mutate({
251
+ ns,
252
+ ops,
253
+ ...(expectedRevision === undefined ? {} : { expectedRevision }),
254
+ })),
190
255
  }
191
256
  }
192
257
 
@@ -1,6 +1,9 @@
1
- // client 注入契约 BDD:remote.settings 面的存在性由 cordis 注入门控保证
2
- // (点分 inject 声明使 fiber namespace 挂载完成后才激活,官方插件同构),
3
- // apply 内不做面缺失探测——面在 apply 期必然就绪,直接消费。
1
+ // client 注入契约 BDD:inject 按宿主代际二选一——0.1.2+(带 __DSH_BOOT__
2
+ // wire)点分声明 remote.settings cordis 门控等挂载,0.1.1(无 wire)声明
3
+ // 两代基座 remote+connection,settings 传输在 apply 内按面形状异步轮询——
4
+ // 0.1.2+ 的 remote.settings 直返 RemoteResult 信封,0.1.1 的 connection.api
5
+ // 走 HTTP 面({rpcId,result} 信封);面形状由 makeSettingsFace 判定,缺失面
6
+ // 降级只读呈现,不阻塞 fiber。
4
7
  // require 桩采用宿主语义:未知模块名 throw(宿主 client-modules 对种子表外
5
8
  // 键直接抛错),使顶层硬依赖与可选消费的降级路径都被真实执行。
6
9
 
@@ -45,13 +48,130 @@ function loadFactory() {
45
48
  return factoryPromise
46
49
  }
47
50
 
48
- test('注入契约:inject 声明 remote 基座与 remote.settings 点分面', async () => {
51
+ // apply 的定面轮询(settle→scheduleScan)会在测试结束后的一拍触达 document;
52
+ // 桩 querySelector 恒 null,扫描回调即静默返回,异步尾巴不抛错
53
+ globalThis.document ??= { querySelector: () => null, getElementById: () => null }
54
+ // applyTracked 真实执行 effect fn(其中 new MutationObserver),桩不派发变更
55
+ globalThis.MutationObserver ??= class { observe() {} disconnect() {} }
56
+
57
+ // cordis effect 语义:注册即执行,返回的 disposer 由宿主持有;测试以
58
+ // applyTracked 持有 disposer,用例结束前统一清算,停掉面轮询的定时器链。
59
+ // 在原对象上临时替换 effect(禁止 spread):spread 会把 getter 展开成
60
+ // 数据属性,面读取计数桩(getter)从此对 apply 内不可见,断言恒真失效
61
+ function applyTracked(entry, ctx) {
62
+ const disposers = []
63
+ const userEffect = ctx.effect
64
+ ctx.effect = (fn, name) => {
65
+ if (userEffect !== undefined) userEffect(fn, name)
66
+ disposers.push(fn())
67
+ return name
68
+ }
69
+ try { entry.apply(ctx) } finally {
70
+ if (userEffect === undefined) delete ctx.effect
71
+ else ctx.effect = userEffect
72
+ }
73
+ return () => { for (const dispose of disposers) dispose() }
74
+ }
75
+
76
+ // apply 期面已就绪路径走 batches wire 快取;0.1.1 路径走 facePoll。测试环境
77
+ // 无 __DSH_BOOT__,默认全走 0.1.1 分支;带 wire 场景由 wire 测试覆盖
78
+ // 0.1.2+ wire 判据 = batches 批次调度字段(0.1.1 wire 无)
79
+ const BOOT_WIRE = { rev: 'test', batches: [] }
80
+ function withBootWire(value, fn) {
81
+ const had = Object.prototype.hasOwnProperty.call(globalThis.window, '__DSH_BOOT__')
82
+ const previous = globalThis.window.__DSH_BOOT__
83
+ if (value === undefined) delete globalThis.window.__DSH_BOOT__
84
+ else globalThis.window.__DSH_BOOT__ = value
85
+ try { return fn() } finally {
86
+ if (had) globalThis.window.__DSH_BOOT__ = previous
87
+ else delete globalThis.window.__DSH_BOOT__
88
+ }
89
+ }
90
+
91
+ test('注入契约:0.1.2+(wire.batches)点分声明 remote.settings,0.1.1 声明基座', async () => {
49
92
  const factory = await loadFactory()
50
- const entry = factory(requireOf())
51
- assert.deepEqual(entry.inject, ['remote', 'remote.settings'])
93
+ withBootWire(BOOT_WIRE, () => {
94
+ const entry = factory(requireOf())
95
+ assert.deepEqual(entry.inject, ['remote', 'remote.settings'])
96
+ })
97
+ withBootWire(undefined, () => {
98
+ const entry = factory(requireOf())
99
+ assert.deepEqual(entry.inject, ['remote', 'connection'])
100
+ })
52
101
  })
53
102
 
54
- test('禁用分支清偿:client.js 不再含面缺失探测告警(时序竞态已由门控消除)', () => {
103
+ test('面选择:0.1.1 形态(无 wire)typed 缺失走 connection.api 面', async () => {
104
+ const factory = await loadFactory()
105
+ withBootWire(undefined, () => {
106
+ const effects = []
107
+ const dispose = applyTracked(factory(requireOf()), {
108
+ remote: {},
109
+ connection: { api: { settings: { describe() {}, mutate() {} } } },
110
+ effect: (fn, name) => effects.push(name),
111
+ })
112
+ assert.deepEqual(effects, ['model-capability-editor: models-page injector'])
113
+ dispose()
114
+ })
115
+ })
116
+
117
+ test('面选择:0.1.2+(wire)typed 面挂载完成,apply 期直接消费', async () => {
118
+ const factory = await loadFactory()
119
+ withBootWire(BOOT_WIRE, () => {
120
+ const effects = []
121
+ const dispose = applyTracked(factory(requireOf()), {
122
+ remote: { settings: { describe() {}, mutate() {} } },
123
+ connection: null,
124
+ effect: (fn, name) => effects.push(name),
125
+ })
126
+ assert.deepEqual(effects, ['model-capability-editor: models-page injector'])
127
+ dispose()
128
+ })
129
+ })
130
+
131
+ test('定面轮询:0.1.1 直接走 api 面定面成功;面缺失满窗恒定禁用并停轮询', async (t) => {
132
+ const factory = await loadFactory()
133
+ // mock 定时器:轮询/扫描链与 Date.now() 全部确定性推进,不真等超时窗口。
134
+ // settings 面经 connection.api 属性读取,以 getter 计数锁定"读了多少拍":
135
+ // 首拍定面 ⇒ 计数 1 且不再增长;恒定禁用 ⇒ 计数随窗口耗尽停止
136
+ const trackedConnection = (settingsFace) => ({
137
+ get connection() {
138
+ this.reads = (this.reads ?? 0) + 1
139
+ return { api: { settings: settingsFace } }
140
+ },
141
+ })
142
+ t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
143
+ withBootWire(undefined, () => {
144
+ // 场景一(0.1.1 主干路径):api 面可定面 → 首拍定型,零告警,不再读
145
+ const warnOk = t.mock.method(console, 'warn', () => {})
146
+ const okConn = trackedConnection({ describe() {}, mutate() {} })
147
+ const disposeOk = applyTracked(factory(requireOf()), okConn)
148
+ assert.equal(okConn.reads, 1, 'api 面必须首拍定面')
149
+ t.mock.timers.tick(1000 + 50)
150
+ assert.equal(okConn.reads, 1, '定面后不得继续轮询')
151
+ assert.equal(warnOk.mock.callCount(), 0, 'api 面可定面时不得有降级告警')
152
+ disposeOk()
153
+ warnOk.mock.restore()
154
+ // 场景二(两代皆无):窗口耗尽 → 恒定禁用告警,轮询终止(计数停止增长)
155
+ t.mock.timers.reset()
156
+ t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
157
+ const warns = []
158
+ const warnNone = t.mock.method(console, 'warn', (text) => warns.push(text))
159
+ const noneConn = trackedConnection(undefined)
160
+ const disposeNone = applyTracked(factory(requireOf()), noneConn)
161
+ t.mock.timers.tick(1000 + 50)
162
+ assert.ok(
163
+ warns.some((text) => text.includes('settings RPC 面不可用')),
164
+ '两代面皆缺必须留下禁用告警',
165
+ )
166
+ const readsAtExhaust = noneConn.reads
167
+ t.mock.timers.tick(1000 + 50)
168
+ disposeNone()
169
+ assert.equal(noneConn.reads, readsAtExhaust, '恒定禁用后轮询必须终止')
170
+ assert.equal(warns.length, 1, '禁用告警必须只出现一次')
171
+ })
172
+ })
173
+
174
+ test('禁用分支清偿:client.js 不再含面缺失探测告警', () => {
55
175
  const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
56
176
  assert.ok(!source.includes('缺少 remote.settings'), '残留面缺失探测告警分支')
57
177
  })
@@ -88,14 +208,29 @@ test('toast 可选消费:模块表有 toast 走 show,缺失降级 console.warn',
88
208
  assert.equal(typeof withoutToast.apply, 'function')
89
209
  })
90
210
 
91
- test('apply 期面已就绪:settings 面直接消费,注册注入 effect', async () => {
211
+ test('apply 注册注入 effect:两代路径均成立', async () => {
92
212
  const effects = []
93
213
  const factory = await loadFactory()
94
214
  const entry = factory(requireOf())
95
- const result = entry.apply({
96
- remote: { settings: { describe() {}, mutate() {} } },
97
- effect: (fn, name) => effects.push(name),
215
+ withBootWire(undefined, () => {
216
+ // 0.1.1:typed 面在场也不被消费(版本排他,轮询只读 connection)
217
+ let typedReads = 0
218
+ const remote = {}
219
+ Object.defineProperty(remote, 'settings', { get() { typedReads += 1; return { describe() {}, mutate() {} } } })
220
+ const disposeLegacy = applyTracked(entry, { remote, connection: null, effect: (fn, name) => effects.push(name) })
221
+ assert.equal(typedReads, 0, '0.1.1 路径不得消费 typed 面')
222
+ disposeLegacy()
223
+ })
224
+ withBootWire(undefined, () => {
225
+ // 两代路径下 effect 注册契约一致
226
+ const effectsWire = []
227
+ const dispose = applyTracked(entry, {
228
+ remote: { settings: { describe() {}, mutate() {} } },
229
+ connection: null,
230
+ effect: (fn, name) => effectsWire.push(name),
231
+ })
232
+ assert.deepEqual(effectsWire, ['model-capability-editor: models-page injector'])
233
+ dispose()
98
234
  })
99
- assert.equal(result, undefined)
100
235
  assert.deepEqual(effects, ['model-capability-editor: models-page injector'])
101
236
  })
@@ -0,0 +1,54 @@
1
+ // client.js 求值形态守卫:宿主以经典 script 整源求值,顶层词法声明落页面全局词法环境,
2
+ // 跨 bundle 同名即整脚本 SyntaxError 拒载。本包 client.js 以单条 __ModuleLoader__.load
3
+ // 语句承载全部代码(声明均在 factory 作用域内),本守卫锁定"顶层零词法声明"不变量,
4
+ // 防未来声明误置顶层。vm.runInContext 与浏览器经典 script 同语义(全局词法环境跨脚本共享)。
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { readFileSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { dirname, join } from 'node:path'
10
+ import vm from 'node:vm'
11
+
12
+ const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8').trimEnd()
13
+ const DECLARATION_NAMES = [
14
+ ...new Set(
15
+ [...CLIENT_SOURCE.matchAll(/^(?:const|let|var|class|(?:async )?function\*?) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
16
+ ),
17
+ ]
18
+ const BUNDLE_ID = '@mzzsfy/dsh-model-capability-editor'
19
+
20
+ test('Given 同 context 已有外部 CSS 声明(宿主经典 script 全局词法环境), When 整源求值 client.js, Then 双源共存不拒载且外部声明原值不变', () => {
21
+ const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
22
+ vm.runInContext('const CSS = 1', ctx)
23
+ vm.runInContext(CLIENT_SOURCE, ctx)
24
+ assert.equal(vm.runInContext('CSS', ctx), 1)
25
+ })
26
+
27
+ test('Given client.js 已整源求值, When 同 context 再声明外部 CSS, Then 双源共存不拒载', () => {
28
+ const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
29
+ vm.runInContext(CLIENT_SOURCE, ctx)
30
+ vm.runInContext('const CSS = 1', ctx)
31
+ })
32
+
33
+ test('Given client.js 整源求值完成, When 逐名以 const 重声明探针行首声明名, Then 全局词法环境零泄漏', () => {
34
+ const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
35
+ vm.runInContext(CLIENT_SOURCE, ctx)
36
+ const leaked = DECLARATION_NAMES.filter((name) => {
37
+ try {
38
+ vm.runInContext(`const ${name} = null`, ctx)
39
+ return false
40
+ } catch {
41
+ return true
42
+ }
43
+ })
44
+ assert.deepEqual(leaked, [])
45
+ })
46
+
47
+ test('Given 宿主 loader 就绪, When 整源求值 client.js, Then 自注册照常发生且 id 不变', () => {
48
+ const registrations = []
49
+ const ctx = vm.createContext({ window: { __ModuleLoader__: { load: (registration) => registrations.push(registration) } } })
50
+ vm.runInContext(CLIENT_SOURCE, ctx)
51
+ assert.equal(registrations.length, 1)
52
+ assert.equal(registrations[0].id, BUNDLE_ID)
53
+ assert.equal(typeof registrations[0].factory, 'function')
54
+ })
@@ -20,10 +20,11 @@ function clientLogic() {
20
20
  const section = source.slice(begin + '/* LOGIC-BEGIN */'.length, end)
21
21
  const factory = new Function(
22
22
  section
23
- + '; return { NS, CONFLICT_CODE, EFFORT_LEVELS, OFF_LEVEL, INPUT_UNSET, INPUT_TEXT, INPUT_TEXT_IMAGE, INPUT_MODES,'
23
+ + '; return { NS, CONFLICT_CODE, EFFORT_LEVELS, OFF_LEVEL, INPUT_UNSET, INPUT_TEXT, INPUT_TEXT_IMAGE, INPUT_IMAGE, INPUT_MODES,'
24
24
  + ' COMPETITOR_MARKERS, effortsToDrafts, draftsToEfforts, isExpressibleEfforts, inputToMode, modeToInput,'
25
+ + ' invalidReasoningLevels, fillDrafts,'
25
26
  + ' applyDraft, mergeBaselineModels, detectCompetitorTraces, stashDrafts, restoreDrafts, isModelsTitle,'
26
- + ' anchorsBroken, resolveTargetId, unwrapResult, makeSettingsFace, describeNs, modelsOf, findNsEntry,'
27
+ + ' anchorsBroken, resolveTargetId, unwrapEnvelope, rejectRpc, makeSettingsFace, describeNs, modelsOf, findNsEntry,'
27
28
  + ' writeModels, saveModels, draftsFromModels };',
28
29
  )
29
30
  return factory()
@@ -40,11 +41,12 @@ test('parity: 共享常量双副本一致', () => {
40
41
  assert.equal(C.INPUT_UNSET, logic.INPUT_UNSET)
41
42
  assert.equal(C.INPUT_TEXT, logic.INPUT_TEXT)
42
43
  assert.equal(C.INPUT_TEXT_IMAGE, logic.INPUT_TEXT_IMAGE)
44
+ assert.equal(C.INPUT_IMAGE, logic.INPUT_IMAGE)
43
45
  assert.deepEqual(C.INPUT_MODES, logic.INPUT_MODES)
44
46
  })
45
47
 
46
48
  function defineScenarios(prefix, L) {
47
- const { effortsToDrafts, draftsToEfforts, inputToMode, modeToInput, applyDraft, mergeBaselineModels, detectCompetitorTraces, draftsFromModels, stashDrafts, restoreDrafts, isModelsTitle, anchorsBroken, resolveTargetId } = L
49
+ const { effortsToDrafts, draftsToEfforts, inputToMode, modeToInput, applyDraft, mergeBaselineModels, detectCompetitorTraces, draftsFromModels, stashDrafts, restoreDrafts, isModelsTitle, anchorsBroken, resolveTargetId, invalidReasoningLevels, fillDrafts } = L
48
50
 
49
51
  test(prefix + '竞品痕迹:标记字段命中与非对象条目跳过', () => {
50
52
  assert.deepEqual(
@@ -131,14 +133,61 @@ function defineScenarios(prefix, L) {
131
133
  )
132
134
  })
133
135
 
134
- test(prefix + 'input 三态映射', () => {
135
- assert.equal(inputToMode(undefined), 'unset')
136
- assert.equal(inputToMode([]), 'unset')
137
- assert.equal(inputToMode(['text']), 'text')
138
- assert.equal(inputToMode(['text', 'image']), 'text-image')
139
- assert.deepEqual(modeToInput('text'), ['text'])
140
- assert.deepEqual(modeToInput('text-image'), ['text', 'image'])
141
- assert.equal(modeToInput('unset'), undefined)
136
+ test(prefix + 'input 四态映射:未声明/文本/双模态/纯图像/未知成员数组', () => {
137
+ const { INPUT_UNSET, INPUT_TEXT, INPUT_TEXT_IMAGE, INPUT_IMAGE } = L
138
+ assert.equal(inputToMode(undefined), INPUT_UNSET)
139
+ assert.equal(inputToMode([]), INPUT_UNSET)
140
+ assert.equal(inputToMode(['text']), INPUT_TEXT)
141
+ assert.equal(inputToMode(['text', 'image']), INPUT_TEXT_IMAGE)
142
+ assert.equal(inputToMode(['image']), INPUT_IMAGE, 'image 无 text 为纯图像')
143
+ assert.equal(inputToMode(['audio']), INPUT_UNSET, '未知成员数组按 schema 未回答处理')
144
+ assert.deepEqual(modeToInput(INPUT_TEXT), ['text'])
145
+ assert.deepEqual(modeToInput(INPUT_TEXT_IMAGE), ['text', 'image'])
146
+ assert.deepEqual(modeToInput(INPUT_IMAGE), ['image'])
147
+ assert.equal(modeToInput(INPUT_UNSET), undefined)
148
+ })
149
+
150
+ test(prefix + '保存前校验:非 off 勾选空拼写即违规,off 与未勾选不参与', () => {
151
+ const { invalidReasoningLevels } = L
152
+ assert.deepEqual(invalidReasoningLevels({ checked: {}, spellings: {} }), [])
153
+ assert.deepEqual(
154
+ invalidReasoningLevels({ checked: { low: true, high: true }, spellings: { low: 'u', high: '' } }),
155
+ ['high'],
156
+ '混合场景只报空拼写的档位',
157
+ )
158
+ assert.deepEqual(
159
+ invalidReasoningLevels({ checked: { low: true }, spellings: { low: ' ' } }),
160
+ ['low'],
161
+ '纯空白拼写按空处理',
162
+ )
163
+ assert.deepEqual(
164
+ invalidReasoningLevels({ checked: { off: true }, spellings: { off: '' } }),
165
+ [],
166
+ 'off 留空有 false/off:null 语义,不参与校验',
167
+ )
168
+ })
169
+
170
+ test(prefix + '一键草稿填充:只补未声明模型,已声明模型与 inputMode 不动', () => {
171
+ const { fillDrafts, draftsFromModels } = L
172
+ const models = [
173
+ { id: 'fresh', name: '未声明' },
174
+ { id: 'declared', reasoningEfforts: { low: 'low' } },
175
+ ]
176
+ const drafts = draftsFromModels(models)
177
+ const filled = fillDrafts(drafts, models)
178
+ const fresh = filled.get('fresh')
179
+ assert.equal(fresh.checked.off, true)
180
+ assert.equal(fresh.checked.max, true, '七档全勾')
181
+ assert.deepEqual(fresh.spellings, {}, '拼写留空 = 线上值取档位名')
182
+ assert.equal(fresh.inputMode, 'unset', 'inputMode 不动')
183
+ assert.equal(filled.get('declared').checked.low, true)
184
+ assert.equal(filled.get('declared').checked.high, undefined, '已声明模型不被填充')
185
+ assert.equal(filled.get('declared').spellings.low, 'low', '已声明模型的拼写保留')
186
+ // 原 Map 不被原地修改(内存草稿填充可撤销:重载即回)
187
+ assert.equal(drafts.get('fresh').checked.off, undefined)
188
+ // 空档基线上的低风险幂等:再填一次无变化
189
+ const again = fillDrafts(filled, models)
190
+ assert.equal(again.get('fresh') === filled.get('fresh'), true)
142
191
  })
143
192
 
144
193
  test(prefix + '合并:有草稿条目重写两字段,其余原样保留;孤儿草稿可观测', () => {
@@ -233,14 +282,14 @@ test('client.js 语法可被 node 解析', () => {
233
282
  execFileSync(process.execPath, ['--check', join(PKG_ROOT, 'src', 'client.js')])
234
283
  })
235
284
 
236
- // 保存流双副本守卫:client.js 的 unwrapResult/makeSettingsFace/describeNs/modelsOf/
237
- // writeModels/saveModels/draftsFromModels 全段与 logic.mjs 必须逐字符一致(单文件
238
- // 格式无法 require,靠此测试防漂移;client 终点 LOGIC-END,logic 侧到文件尾)。
239
- // 锚点为 `function unwrapResult`:它是全部 RPC 解包路径的唯一入口,副本在旧锚点
240
- // '// dsh 0.1.2 remote.settings' 之前,不纳入守卫会让解包漂移无任何测试兜底。
285
+ // 保存流双副本守卫:client.js 的 unwrapEnvelope/rejectRpc/makeSettingsFace/describeNs/
286
+ // modelsOf/writeModels/saveModels/draftsFromModels 全段与 logic.mjs 必须逐字符一致
287
+ // (单文件格式无法 require,靠此测试防漂移;client 终点 LOGIC-END,logic 侧到文件尾)。
288
+ // 锚点即 `function unwrapEnvelope` 本身:它是全部 RPC 信封解包路径的唯一入口;
289
+ // 旧锚点 `function unwrapResult` 已随函数重命名废止。
241
290
  test('client.js wire 适配与保存流段与 logic.mjs 同源', () => {
242
291
  const sectionOf = (source, label) => {
243
- const begin = source.indexOf('function unwrapResult')
292
+ const begin = source.indexOf('function unwrapEnvelope')
244
293
  assert.ok(begin >= 0, label + ' 缺少守卫段起点')
245
294
  // 终点:client 侧守卫段以 LOGIC-END 收束;logic 侧守卫段即文件尾
246
295
  const end = source.indexOf('/* LOGIC-END */')
@@ -1,5 +1,8 @@
1
- // BDD:dsh 0.1.2 remote.settings 服务面适配官方 RemoteResult 信封 {ok,value|error}
2
- // 适配为插件内部 RPC 面;保存流经适配层端到端保持冲突重放语义。
1
+ // BDD:settings 传输适配两种宿主形态统一为插件内部 RPC 面:
2
+ // - dsh 0.1.2+ typed remote 面(remote.settings):方法直返 RemoteResult 信封 {ok,value|error}
3
+ // - dsh 0.1.1+ connection.api 面:settings.describe() 无参,settings.mutate({ns,ops,...})
4
+ // 单对象参数,返回 {rpcId,result:{ok,value|error}} 信封
5
+ // 保存流经适配层端到端保持冲突重放语义。
3
6
 
4
7
  import test from 'node:test'
5
8
  import assert from 'node:assert/strict'
@@ -21,6 +24,91 @@ test('适配面:transport 抛错原样上抛,不被吞成假信封', async () =>
21
24
  await assert.rejects(() => face.mutate('llm-pi-ai', [], 1), (error) => error === boom)
22
25
  })
23
26
 
27
+ test('connection.api 面(0.1.1):describe 走 {rpcId,result} 信封', async () => {
28
+ let calls = 0
29
+ const face = makeSettingsFace({ api: { settings: {
30
+ describe: async (payload) => {
31
+ calls += 1
32
+ assert.deepEqual(payload, {})
33
+ return { rpcId: 'r1', result: { ok: true, value: { writable: true, namespaces: [] } } }
34
+ },
35
+ mutate: async () => { throw new Error('不应调用') },
36
+ } } })
37
+ const value = await face.describe()
38
+ assert.equal(calls, 1)
39
+ assert.deepEqual(value, { writable: true, namespaces: [] })
40
+ })
41
+
42
+ test('connection.api 面(0.1.1):mutate 单对象参数,result.ok 解包', async () => {
43
+ const calls = []
44
+ const face = makeSettingsFace({ api: { settings: {
45
+ describe: async () => ({ rpcId: 'r', result: { ok: true, value: {} } }),
46
+ mutate: async (payload) => {
47
+ calls.push(payload)
48
+ return { rpcId: 'r', result: { ok: true, value: { ns: payload.ns, revision: 8 } } }
49
+ },
50
+ } } })
51
+ const written = await face.mutate('llm-pi-ai', [{ op: 'set', path: ['a'], value: 1 }], 7)
52
+ await face.mutate('llm-pi-ai', [{ op: 'unset', path: ['b'] }], undefined)
53
+ assert.deepEqual(calls, [
54
+ { ns: 'llm-pi-ai', ops: [{ op: 'set', path: ['a'], value: 1 }], expectedRevision: 7 },
55
+ { ns: 'llm-pi-ai', ops: [{ op: 'unset', path: ['b'] }] },
56
+ ])
57
+ assert.deepEqual(written, { ns: 'llm-pi-ai', revision: 8 })
58
+ })
59
+
60
+ test('connection.api 面:result.ok false 以 code 透传冲突', async () => {
61
+ const face = makeSettingsFace({ api: { settings: {
62
+ describe: async () => ({ rpcId: 'r', result: { ok: true, value: {} } }),
63
+ mutate: async () => ({ rpcId: 'r', result: { ok: false, error: { code: CONFLICT_CODE, message: 'stale' } } }),
64
+ } } })
65
+ await assert.rejects(
66
+ () => face.mutate('llm-pi-ai', [], 1),
67
+ (error) => error.code === CONFLICT_CODE,
68
+ )
69
+ })
70
+
71
+ test('面选择:describe+mutate 双全即 typed 面,api 形状不再探测', async () => {
72
+ let apiCalls = 0
73
+ const face = makeSettingsFace({
74
+ describe: async () => ({ ok: true, value: {} }),
75
+ mutate: async () => ({ ok: true, value: {} }),
76
+ api: { settings: { describe: async () => { apiCalls += 1; return {} }, mutate: async () => ({}) } },
77
+ })
78
+ await face.describe()
79
+ assert.equal(apiCalls, 0, 'typed 面命中后不得触达 connection.api')
80
+ })
81
+
82
+ test('connection.api 端到端:保存流走 HTTP 信封,冲突一次重放成功', async () => {
83
+ let describeCount = 0
84
+ let mutateCount = 0
85
+ const face = makeSettingsFace({ api: { settings: {
86
+ describe: async () => {
87
+ describeCount += 1
88
+ const revision = describeCount === 1 ? 1 : 2
89
+ return {
90
+ rpcId: 'r',
91
+ result: {
92
+ ok: true,
93
+ value: {
94
+ writable: true,
95
+ namespaces: [{ ns: 'llm-pi-ai', revision, value: { providers: { 'new-api': { models: [{ id: 'auto' }] } } } }],
96
+ },
97
+ },
98
+ }
99
+ },
100
+ mutate: async (payload) => {
101
+ mutateCount += 1
102
+ if (mutateCount === 1) return { rpcId: 'r', result: { ok: false, error: { code: CONFLICT_CODE, message: 'stale' } } }
103
+ assert.equal(payload.expectedRevision, 2)
104
+ return { rpcId: 'r', result: { ok: true, value: { ns: payload.ns, revision: 3 } } }
105
+ },
106
+ } } })
107
+ const result = await saveModels(face, 'new-api', new Map([['auto', draft]]))
108
+ assert.equal(mutateCount, 2)
109
+ assert.deepEqual(result.models[0].reasoningEfforts, { high: 'ultra' })
110
+ })
111
+
24
112
  test('makeSettingsFace:remote 缺失或形状不完整返回 null', () => {
25
113
  for (const remote of [null, undefined, {}, { describe: () => {} }, { mutate: () => {} }]) {
26
114
  assert.equal(makeSettingsFace(remote), null)