@mzzsfy/dsh-model-capability-editor 0.2.4 → 0.3.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 档留空有专门语义,不参与校验)。卡片与行内编辑块均拦截。
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 时拒绝盲写。
@@ -61,16 +63,16 @@ dsh plugin --profile web add @mzzsfy/dsh-model-capability-editor
61
63
  npm test
62
64
  ```
63
65
 
64
- 纯逻辑层单测(node --test,无外部依赖):档位四态判定表与拼写回填、input 三态、整组写回基线合并、冲突字段级重放与一次重试判定、竞品痕迹检测、wire 信封适配(含 transport 抛错透传)与端到端冲突重放、client.js 与 logic.mjs 的 wire 适配段同源守卫。
66
+ 纯逻辑层单测(node --test,无外部依赖):档位四态判定表与拼写回填、input 四态(含纯图像)、保存前空拼写校验、一键草稿填充、整组写回基线合并、冲突字段级重放与一次重试判定、竞品痕迹检测、wire 信封适配(含 transport 抛错透传)与端到端冲突重放、client.js 与 logic.mjs 的 wire 适配段同源守卫,以及锚点判定与行内目标解析、注入编排与生命周期、开关守卫、client id 等其余模块测试。
65
67
 
66
68
  ## License
67
69
 
68
70
  MIT
69
71
 
70
- ## 开发安装(不经 npm 发布直接装仓库副本)
72
+ ## 开发安装(仓库工作副本直挂,不经 npm 发布)
71
73
 
72
74
  ```sh
73
- dsh plugin --profile web add file:./packages/dsh-model-capability-editor
75
+ node scripts/dev-link.mjs dsh-model-capability-editor # 仓库根执行:归一 profile 依赖行 + 挂 junction
74
76
  ```
75
77
 
76
- `file:` 安装指向仓库工作副本,改代码后重跑该命令即同步,无需发版。
78
+ 工作副本以 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.3.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -115,8 +115,14 @@ const OFF_LEVEL = 'off'
115
115
  const INPUT_UNSET = 'unset'
116
116
  const INPUT_TEXT = 'text'
117
117
  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]: '文本+图像' }
118
+ const INPUT_IMAGE = 'image-only'
119
+ const INPUT_MODES = [INPUT_UNSET, INPUT_TEXT, INPUT_TEXT_IMAGE, INPUT_IMAGE]
120
+ const INPUT_MODE_LABELS = {
121
+ [INPUT_UNSET]: '未声明',
122
+ [INPUT_TEXT]: '仅文本',
123
+ [INPUT_TEXT_IMAGE]: '文本+图像',
124
+ [INPUT_IMAGE]: '仅图片',
125
+ }
120
126
  // 竞品 dsh-better-reasoning-effort 的 host autofill 写入痕迹标记字段
121
127
  const COMPETITOR_MARKERS = ['reasoningEffortsUnset', 'inputUnset']
122
128
 
@@ -158,19 +164,55 @@ function draftsToEfforts(drafts, baselineValue) {
158
164
  return result
159
165
  }
160
166
 
161
- // input 数组 → 三态;未声明与空数组(schema 视为未回答)同为未声明
167
+ // input 数组 → 四态;未声明与空数组(schema 视为未回答)同为未声明;仅 image 为纯图像
162
168
  function inputToMode(value) {
163
169
  if (!Array.isArray(value) || value.length === 0) return INPUT_UNSET
164
- return value.indexOf('image') >= 0 ? INPUT_TEXT_IMAGE : INPUT_TEXT
170
+ const hasText = value.indexOf('text') >= 0
171
+ const hasImage = value.indexOf('image') >= 0
172
+ if (hasText) return hasImage ? INPUT_TEXT_IMAGE : INPUT_TEXT
173
+ return hasImage ? INPUT_IMAGE : INPUT_UNSET
165
174
  }
166
175
 
167
- // 三态 → input 写回值(undefined = 删除字段)
176
+ // 四态 → input 写回值(undefined = 删除字段)
168
177
  function modeToInput(mode) {
169
178
  if (mode === INPUT_TEXT) return ['text']
170
179
  if (mode === INPUT_TEXT_IMAGE) return ['text', 'image']
180
+ if (mode === INPUT_IMAGE) return ['image']
171
181
  return undefined
172
182
  }
173
183
 
184
+ // 保存前本地校验:非 off 档勾选但拼写为空白。空拼写会静默以档位名作线上值
185
+ // (draftsToEfforts 语义),易被误当作"已填"。返回违规档位列表,空数组即通过;
186
+ // off 档留空有专门语义(false / off:null),不参与此校验。
187
+ function invalidReasoningLevels(draft) {
188
+ const invalid = []
189
+ for (const level of EFFORT_LEVELS) {
190
+ if (level === OFF_LEVEL) continue
191
+ if (draft.checked[level] === true && String(draft.spellings[level] || '').trim().length === 0) {
192
+ invalid.push(level)
193
+ }
194
+ }
195
+ return invalid
196
+ }
197
+
198
+ // 一键草稿填充:只动内存草稿,写回仍走显式保存。仅补"未勾选任何档位"的模型
199
+ // (即 reasoningEfforts 未声明的手声明模型,本插件核心场景):七档全勾、拼写
200
+ // 留空(线上值 = 档位名)。已编辑(有勾选)与 inputMode 一律不碰,防覆盖既有声明。
201
+ function fillDrafts(drafts, models) {
202
+ const filled = new Map(drafts)
203
+ for (const model of Array.isArray(models) ? models : []) {
204
+ const key = String(model.id)
205
+ const draft = filled.get(key)
206
+ if (draft === undefined) continue
207
+ const hasChecked = EFFORT_LEVELS.some((level) => draft.checked[level] === true)
208
+ if (hasChecked) continue
209
+ const checked = {}
210
+ for (const level of EFFORT_LEVELS) checked[level] = true
211
+ filled.set(key, { ...draft, checked, spellings: { ...draft.spellings } })
212
+ }
213
+ return filled
214
+ }
215
+
174
216
  // reasoningEfforts 基线可表达形态:未声明/false/null/纯对象;异型形态跳过重写防误删
175
217
  function isExpressibleEfforts(value) {
176
218
  return value === undefined || value === false || value === null
@@ -550,6 +592,16 @@ function CapabilityCard(props) {
550
592
  }
551
593
 
552
594
  async function save() {
595
+ // 保存前本地校验:非 off 档勾选但拼写空白,空格式的线上值易被误当作"已填"
596
+ const invalid = []
597
+ for (const [id, draft] of state.drafts) {
598
+ const levels = invalidReasoningLevels(draft)
599
+ if (levels.length > 0) invalid.push(id + ' (' + levels.join(', ') + ')')
600
+ }
601
+ if (invalid.length > 0) {
602
+ notify('以下模型的档位拼写为空,请填写线上值或取消勾选:' + invalid.join('; '), 'error')
603
+ return
604
+ }
553
605
  setSaving(true)
554
606
  try {
555
607
  const { models: written, droppedDraftIds } = await saveModels(props.settings, state.route, state.drafts)
@@ -618,6 +670,27 @@ function CapabilityCard(props) {
618
670
  state.models.filter((model) => model !== null && typeof model === 'object').length === 0
619
671
  ? h('div', { className: 'mce-label' }, '该 provider 暂无模型条目。')
620
672
  : null,
673
+ h('div', { className: 'mce-row' },
674
+ h('button', {
675
+ className: 'mce-btn',
676
+ disabled: saving || switching || state.route === null,
677
+ onClick: () => {
678
+ const filled = fillDrafts(state.drafts, state.models)
679
+ let count = 0
680
+ for (const [id, draft] of filled) {
681
+ if (state.drafts.get(id) !== draft) count += 1
682
+ }
683
+ if (count > 0) {
684
+ draftsRouteRef.current = state.route
685
+ patch({ drafts: filled })
686
+ }
687
+ notify(count > 0
688
+ ? '已为 ' + count + ' 个未声明档位的模型填充草稿(七档全勾、线上值=档位名),检查后手动保存'
689
+ : '没有需要填充的模型:所有模型均已声明档位', count > 0 ? 'ok' : 'error')
690
+ },
691
+ }, '填充草稿'),
692
+ h('span', { className: 'mce-label' }, '仅填内存草稿,写回仍需手动保存;已声明档位的模型不受影响。'),
693
+ ),
621
694
  h('div', { className: 'mce-row' },
622
695
  h('button', { className: 'mce-btn', disabled: saving || switching || state.route === null, onClick: save }, saving ? '保存中…' : '保存'),
623
696
  h('span', { className: 'mce-label' }, '保存 = 整组写回当前 provider 的 models 数组,未编辑的模型原样保留。'),
@@ -678,6 +751,12 @@ function RowEditor(props) {
678
751
  }, [])
679
752
 
680
753
  async function apply() {
754
+ // 保存前本地校验:非 off 档勾选但拼写空白,空格式的线上值易被误当作"已填"
755
+ const invalidLevels = invalidReasoningLevels(state.draft)
756
+ if (invalidLevels.length > 0) {
757
+ notify('档位 ' + invalidLevels.join(', ') + ' 的拼写为空,请填写线上值或取消勾选', 'error')
758
+ return
759
+ }
681
760
  setSaving(true)
682
761
  try {
683
762
  // S3:官方行内 ID 输入是活动状态,改名已落盘则以新 ID 为目标,否则回落原 ID
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
  // 数组等非本插件写入的异型形态)不参与档位重写,编辑时跳过该字段防误删。
@@ -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,8 +20,9 @@ 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
27
  + ' anchorsBroken, resolveTargetId, unwrapResult, makeSettingsFace, describeNs, modelsOf, findNsEntry,'
27
28
  + ' writeModels, saveModels, draftsFromModels };',
@@ -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 + '合并:有草稿条目重写两字段,其余原样保留;孤儿草稿可观测', () => {