dsh-default-model-lock 0.0.0-stage → 0.1.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
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ [![](https://img.shields.io/badge/powered_by-dafeiyu-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/Dafeiyu111)
2
+
3
+ # 默认模型锁定
4
+
5
+ 为 DeepSeek Harness 提供一把共享默认模型的锁:在设置页选好 provider、model 和推理档位,启用保存后,**共享默认值被钉死**。
6
+
7
+ 起因:在 UI 里切一次模型,会把 `cordis.yml` 里的默认模型一起改掉(还会丢掉 `reasoningEffort` 行),于是心跳、看板定时任务、新会话全都跟着换模型。本插件把这件事变成显式开关。
8
+
9
+ ## 安装
10
+
11
+ ```powershell
12
+ # 先看会做什么(不写任何文件)
13
+ pwsh -File scripts/install-profile.ps1 -DryRun
14
+
15
+ # 装进目标 profile(自动备份 package.json 与 cordis.patch.yml)
16
+ pwsh -File scripts/install-profile.ps1 -Profile "$env:USERPROFILE\.dsh\profiles\desktop"
17
+ ```
18
+
19
+ 重载插件后,设置页出现「默认模型锁定」一节;改源码后必须重载/重启 DSH 才生效(刷新页面不够)。
20
+ 卸载与还原步骤见 [docs/INSTALL.md](docs/INSTALL.md)。
21
+
22
+ ## 行为
23
+
24
+ - **聊天里仍可自由切换模型**:`session.selectModel()` 只影响当前会话。
25
+ - **但切不动共享默认值**:`saveSelection()` 在进入原服务保存队列之前就被拦下,写入直接 fulfilled。
26
+ - `currentSelection()` 返回配置里的精确选择;新会话、headless、心跳、定时任务读到的是锁定值。
27
+ - 关闭锁定即恢复官方行为,并把 provider/model 从配置里 `unset`,官方默认值重新生效(插件从不改写官方默认模型配置)。
28
+ - 推理档位留空 = 交给模型适配器的默认档;填了就按填的。
29
+ - 无效 provider/model **fail closed**:不猜、不 fallback、不写任何东西。
30
+ - 只在加载了本插件的 profile 里生效。
31
+
32
+ ## 拦截点
33
+
34
+ 包装 `@deepseek-ai/dsh-agent-default-model` 服务实例上的 `currentSelection()` 与 `saveSelection()`:
35
+
36
+ - 用 Cordis 的 `symbols.original` 取回原始服务对象,在**实例**上包装 own methods —— 覆盖普通 `ctx` 属性、`ctx.get()`、`root`、headless 的同形读取,以及已缓存的服务对象。
37
+ - 不改 prototype、不改磁盘、不调模型接口。
38
+ - 卸载时恢复原 descriptor;服务重建时自动重新安装。
39
+
40
+ ## 设置页
41
+
42
+ `settings.section` 槽位注册(经 `ctx.slots.inject`),页面读真实目录:`ctx.remote.session.modelCatalog()` → Typert `{ ok, value }` → `{ groups[{ id, name, models[{ id, name, reasoning: { efforts[{ id, name }], defaultEffort } }] }] }`。
43
+
44
+ - provider → model → effort 三级联动,只显示目录里真实存在的东西。
45
+ - 保存走一次原子 `mutate(ops, revision)`,`SettingsPathOpView` 操作,带 revision fence。
46
+ - 目录加载失败 → 显示错误、不给选项,此时只能关闭锁定。
47
+ - 已保存的模型若已不可用 → 标「不可用」,**不自动替换**。
48
+ - 关闭锁定时允许 provider/model 为空。
49
+
50
+ 设置服务存在时,插件会调 `settings.configure({ auto: false })` 隐藏官方自动生成的通用表单,保证设置页只有这一个入口;设置服务本身按**可选注入**处理(缺席时不报错,锁定照常工作)。
51
+
52
+ 已在独立 `DSH_HOME` + 端口 3199 的真实 Electron carrier 上跑通完整链路(真实 Loader 加载、Config 投影、`settings/update` 落盘、`currentSelection()` 拦截生效、官方配置零写入、解锁复原),结果见 [docs/INSTALL.md](docs/INSTALL.md) 第 6 节。
53
+
54
+ ## 目录
55
+
56
+ ```
57
+ index.js 插件入口 + Config schema(volatile 字段)
58
+ core.mjs 锁的实现(服务实例方法包装)
59
+ lib/client.js 客户端产物:手写 __ModuleLoader__ 模块(无构建步骤)
60
+ client/types.ts 真实 DSH 契约类型(参考,不参与安装)
61
+ client/index.tsx 初稿的迁移记录(已废弃)
62
+ test/core.test.mjs 后端 6 项:真 Cordis + 真 AgentDefaultModel
63
+ test/client.test.mjs 前端 9 项:直接加载 lib/client.js
64
+ scripts/install-profile.ps1 装进 profile(默认 dry-run,自动备份)
65
+ scripts/restore-backup.ps1 按备份回滚(只删 junction,不删源码)
66
+ scripts/isolation-probe.mjs 隔离验证探针(独立 DSH_HOME + 3199)
67
+ docs/INSTALL.md 安装、边界与隔离验证结果
68
+ ```
69
+
70
+ ## 自检
71
+
72
+ ```bash
73
+ npm run check # node --check:骨架与产物语法
74
+ npm test # 15 项全绿
75
+ ```
76
+
77
+ ## 兼容性与边界
78
+
79
+ 目标 DSH 0.2.1-rc.2 / Cordis 4.0.5;升级 DSH 后需重新验证包装契约。**已经提交进原保存队列的写入无法撤销** —— 锁定生效前那一刻排队的那次保存仍会落盘;此外,别的插件若在包装前缓存了方法引用,也可能绕开包装。插件不改核心源码,也不采用「监听配置变化再写回」的方案。
80
+
81
+ 安装与实机验证见 [docs/INSTALL.md](docs/INSTALL.md)。
package/README_EN.md ADDED
@@ -0,0 +1,82 @@
1
+ [![](https://img.shields.io/badge/powered_by-dafeiyu-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/Dafeiyu111)
2
+
3
+ # Default Model Lock
4
+
5
+ A shared-default-model lock for DeepSeek Harness. Pick a provider, a model and an optional reasoning effort in Settings, enable the lock, and the shared default stays pinned.
6
+
7
+ Why it exists: switching the model once in the UI also rewrites the default in `cordis.yml` (and drops the `reasoningEffort` line), so heartbeats, scheduled board runs and new sessions silently change model too. This plugin makes that an explicit switch instead.
8
+
9
+ ## Install
10
+
11
+ ```powershell
12
+ # Dry run first — writes nothing
13
+ pwsh -File scripts/install-profile.ps1 -DryRun
14
+
15
+ # Install into a profile (backs up package.json and cordis.patch.yml)
16
+ pwsh -File scripts/install-profile.ps1 -Profile "$env:USERPROFILE\.dsh\profiles\desktop"
17
+ ```
18
+
19
+ After reloading the plugin, a "Default Model Lock" section appears in Settings. Editing plugin
20
+ sources requires a reload/restart of DSH — refreshing the page is not enough.
21
+ Uninstall and restore steps: [docs/INSTALL.md](docs/INSTALL.md).
22
+
23
+ ## Behavior
24
+
25
+ - **Chat model switching keeps working**: `session.selectModel()` still applies to the current session.
26
+ - **The shared default cannot be moved**: `saveSelection()` is intercepted *before* it reaches the original save queue, and the call resolves without writing.
27
+ - `currentSelection()` returns the exact configured selection, so new sessions, headless runs, heartbeats and scheduled tasks all read the locked value.
28
+ - Disabling the lock restores official behavior and `unset`s provider/model, so the original default applies again. The plugin never rewrites the official default-model configuration.
29
+ - An empty reasoning effort delegates to the adapter's per-model default.
30
+ - An invalid provider/model **fails closed**: no guessing, no fallback, no write.
31
+ - Effective only in profiles that load this plugin.
32
+
33
+ ## Where it intercepts
34
+
35
+ It wraps `currentSelection()` and `saveSelection()` on the `@deepseek-ai/dsh-agent-default-model` service *instance*:
36
+
37
+ - The original service object is recovered through Cordis `symbols.original`, and own methods are replaced on the instance, which covers plain `ctx` properties, `ctx.get()`, `root`, headless-shaped reads and already-cached service references.
38
+ - No prototype edits, no disk writes, no model API calls.
39
+ - Original descriptors are restored on disposal; the lock reinstalls itself when the service is rebuilt.
40
+
41
+ ## Settings page
42
+
43
+ Registered on the `settings.section` slot through `ctx.slots.inject`, reading the real catalog: `ctx.remote.session.modelCatalog()` → Typert `{ ok, value }` → `{ groups[{ id, name, models[{ id, name, reasoning: { efforts[{ id, name }], defaultEffort } }] }] }`.
44
+
45
+ - Linked provider → model → effort selects, listing only what the catalog actually contains.
46
+ - One atomic `mutate(ops, revision)` per save, using `SettingsPathOpView` operations fenced by the namespace revision.
47
+ - A failed catalog read shows the error and offers no options; with no catalog you can still disable the lock.
48
+ - A stored model that is no longer available is labeled unavailable and **never auto-replaced**.
49
+ - Disabling the lock allows an empty provider/model.
50
+
51
+ When the settings service is present, the plugin calls `settings.configure({ auto: false })` to hide the official auto-generated generic form, so there is exactly one entry; the settings service itself is an **optional injection** (absent it does not fail, the lock keeps working).
52
+
53
+ The full chain has been exercised on a real Electron carrier with an isolated `DSH_HOME` and port 3199 (real Loader load, Config projection, `settings/update` persistence, `currentSelection()` interception taking effect, zero writes to the official config, restore on unlock). See section 6 of [docs/INSTALL.md](docs/INSTALL.md).
54
+
55
+ ## Layout
56
+
57
+ ```
58
+ index.js entry point + Config schema (volatile fields)
59
+ core.mjs the lock (service-instance method wrapping)
60
+ lib/client.js client artifact: hand-written __ModuleLoader__ module
61
+ client/types.ts verified DSH contract types (reference only)
62
+ client/index.tsx migration notes for the abandoned draft
63
+ test/core.test.mjs 6 backend tests: real Cordis + real AgentDefaultModel
64
+ test/client.test.mjs 9 frontend tests: load lib/client.js directly
65
+ scripts/install-profile.ps1 install into a profile (dry-run by default, auto-backup)
66
+ scripts/restore-backup.ps1 rollback from backup (removes the junction only)
67
+ scripts/isolation-probe.mjs isolation probe (own DSH_HOME + port 3199)
68
+ docs/INSTALL.md install, limits, and isolation results
69
+ ```
70
+
71
+ ## Checks
72
+
73
+ ```bash
74
+ npm run check # node --check over the runtime and the client artifact
75
+ npm test # 15 tests, all green
76
+ ```
77
+
78
+ ## Compatibility and limits
79
+
80
+ Built against DSH 0.2.1-rc.2 / Cordis 4.0.5; the wrapping contract must be re-verified after a DSH upgrade. **Writes already queued in the original save queue cannot be withdrawn** — a save submitted just before the lock engages still lands. A plugin that cached an independent method reference before wrapping may also bypass it. No core source edits, and no watch-then-rewrite scheme.
81
+
82
+ Installation and on-device verification: [docs/INSTALL.md](docs/INSTALL.md).
@@ -0,0 +1,8 @@
1
+ - insert:
2
+ - id: default-model-lock
3
+ name: dsh-default-model-lock
4
+ config:
5
+ enabled: false
6
+ provider: ''
7
+ model: ''
8
+ reasoningEffort: ''
package/core.mjs ADDED
@@ -0,0 +1,79 @@
1
+ // Runtime-only lock: no profile writes and no model selection requests.
2
+ export function readPolicy(config) {
3
+ const read = (key) => {
4
+ const value = config[key];
5
+ return value && typeof value.get === 'function' ? value.get() : value;
6
+ };
7
+ const enabled = read('enabled');
8
+ if (typeof enabled !== 'boolean') throw new TypeError('default-model-lock: enabled must be boolean');
9
+ if (!enabled) return { enabled: false };
10
+ const provider = read('provider');
11
+ const model = read('model');
12
+ const effort = read('reasoningEffort');
13
+ for (const [key, value] of [['provider', provider], ['model', model]]) {
14
+ if (typeof value !== 'string' || !value.trim() || value !== value.trim()) {
15
+ throw new TypeError(`default-model-lock: ${key} must be a non-empty, trimmed string`);
16
+ }
17
+ }
18
+ if (typeof effort !== 'string' || effort !== effort.trim()) {
19
+ throw new TypeError('default-model-lock: reasoningEffort must be a trimmed string (empty means omitted)');
20
+ }
21
+ return { enabled: true, selection: { provider, model, ...(effort === '' ? {} : { reasoningEffort: effort }) } };
22
+ }
23
+
24
+ const installations = new WeakMap();
25
+
26
+ export function installLock(ctx, config, originalSymbol) {
27
+ const service = ctx.get('agentDefaultModel');
28
+ if (!service) throw new Error('default-model-lock: agentDefaultModel is unavailable');
29
+ const target = service[originalSymbol] ?? service;
30
+ if (installations.has(target)) throw new Error('default-model-lock: another lock owns this service');
31
+ const keys = ['currentSelection', 'saveSelection'];
32
+ const descriptors = new Map(keys.map((key) => [key, Object.getOwnPropertyDescriptor(target, key)]));
33
+ const original = Object.fromEntries(keys.map((key) => [key, target[key]]));
34
+ for (const key of keys) {
35
+ if (typeof original[key] !== 'function') throw new TypeError(`default-model-lock: missing ${key}`);
36
+ const descriptor = descriptors.get(key);
37
+ if (descriptor && !descriptor.configurable) throw new TypeError(`default-model-lock: ${key} is not configurable`);
38
+ }
39
+ if (!Object.isExtensible(target)) throw new TypeError('default-model-lock: service is not extensible');
40
+ let active = true;
41
+ const wrappers = {
42
+ currentSelection: function (...args) {
43
+ if (!active) return Reflect.apply(original.currentSelection, this, args);
44
+ const policy = readPolicy(config);
45
+ return policy.enabled ? { ...policy.selection } : Reflect.apply(original.currentSelection, this, args);
46
+ },
47
+ saveSelection: async function (...args) {
48
+ if (!active) return Reflect.apply(original.saveSelection, this, args);
49
+ const policy = readPolicy(config);
50
+ if (!policy.enabled) return Reflect.apply(original.saveSelection, this, args);
51
+ // Intentionally never enter the original serialized save queue while locked.
52
+ },
53
+ };
54
+ const token = {};
55
+ installations.set(target, token);
56
+ const dispose = () => {
57
+ if (!active) return;
58
+ active = false;
59
+ for (const key of keys) {
60
+ // Do not clobber a later third-party patch.
61
+ if (Object.getOwnPropertyDescriptor(target, key)?.value !== wrappers[key]) continue;
62
+ const descriptor = descriptors.get(key);
63
+ if (descriptor) Object.defineProperty(target, key, descriptor);
64
+ else delete target[key];
65
+ }
66
+ if (installations.get(target) === token) installations.delete(target);
67
+ };
68
+ try {
69
+ for (const key of keys) Object.defineProperty(target, key, {
70
+ configurable: true, writable: true, enumerable: descriptors.get(key)?.enumerable ?? false,
71
+ value: wrappers[key],
72
+ });
73
+ ctx.effect(() => dispose);
74
+ } catch (error) {
75
+ dispose();
76
+ throw error;
77
+ }
78
+ return dispose;
79
+ }
@@ -0,0 +1,101 @@
1
+ # 安装与验证
2
+
3
+ 目标环境:DSH 0.2.1-rc.2、Cordis 4.0.5。插件目录:`~/.deepseek harness/dsh-default-model-lock`。
4
+
5
+ ## 1. 没有构建步骤
6
+
7
+ 客户端产物 `lib/client.js` 是**手写**模块,启动时就带 `window.__ModuleLoader__.load({ id, inject, apply, factory })` 封装。页面用这个 envelope 收客户端插件,`factory(require)` 的 `require` 只能解析已声明的注入包,所以 esbuild 输出的 ESM 无法注册自己 —— 本插件不做打包。
8
+
9
+ `client/index.tsx`、`client/types.ts` 只作类型参考与迁移记录,不参与安装(`package.json` 的 `files` 也不含 `client/`)。
10
+
11
+ ## 2. 装进 profile
12
+
13
+ 插件通过 `link:` 依赖 + `cordis.patch.yml` 进入 profile:
14
+
15
+ ```yaml
16
+ # cordis.patch.yml(随包发布)
17
+ - insert:
18
+ - id: default-model-lock
19
+ name: dsh-default-model-lock
20
+ config:
21
+ enabled: false
22
+ provider: ''
23
+ model: ''
24
+ reasoningEffort: ''
25
+ ```
26
+
27
+ 在目标 profile 目录(例如 `~/.dsh/profiles/desktop`)里:
28
+
29
+ ```bash
30
+ # 1) 把插件目录挂进 profile 的 node_modules(不要复制,改源码后重载即生效)
31
+ # 在 <profile>/package.json 里加依赖:"dsh-default-model-lock": "link:<插件绝对路径>"
32
+ # 2) 确认 profile 的 cordis.patch.yml 已包含上面的 insert 段
33
+ ```
34
+
35
+ 重载插件后,设置页出现「默认模型锁定」一节。修改插件源码后必须重载/重启 DSH 才生效,刷新页面不够。
36
+
37
+ ### 用脚本装 / 回滚
38
+
39
+ ```powershell
40
+ # 先看会做什么(不写任何文件)
41
+ pwsh -File scripts/install-profile.ps1 -DryRun
42
+
43
+ # 真装:自动备份 package.json 与 cordis.patch.yml 到 .install-backup/<时间戳>/
44
+ pwsh -File scripts/install-profile.ps1 -Profile "$env:USERPROFILE\.dsh\profiles\desktop"
45
+
46
+ # 回滚:还原两个文件,并只删除 junction(不删插件源码目录)
47
+ pwsh -File scripts/restore-backup.ps1 -Profile "$env:USERPROFILE\.dsh\profiles\desktop" -BackupDir "<.install-backup 里的时间戳目录>"
48
+ ```
49
+
50
+ 脚本幂等:依赖已指向同一路径、patch 已含条目时跳过,不重复插入。
51
+
52
+ ## 3. 自检(无需启动 DSH)
53
+
54
+ ```bash
55
+ cd "~/.deepseek harness/dsh-default-model-lock"
56
+ npm run check # node --check:index.js / core.mjs / lib/client.js
57
+ npm test # 15 项:后端 6 项(真 Cordis + 真 AgentDefaultModel)+ 前端 9 项
58
+ ```
59
+
60
+ 前端测试用 `vm.runInThisContext` 加载真实的 `lib/client.js`,注入假的 `window.__ModuleLoader__`、`react`、`ctx`,因此测的是发布产物本身,不是它的副本。
61
+
62
+ ## 4. 实机验证清单
63
+
64
+ 1. 设置页 → 默认模型锁定 → 选出 provider / model(有 reasoning 的模型再选档位)→ 启用 → 保存。保存只发一次 `mutate`。
65
+ 2. 聊天里切换模型:只改本会话;再看默认模型锁定的已保存值未被改写。
66
+ 3. 新开会话:默认模型是锁定值。
67
+ 4. 关闭锁定并保存:`enabled=false` 且 provider/model 被 `unset`,官方默认值恢复。
68
+ 5. 把 provider 或 model 改成一个目录里不存在的值:页面显示「不可用」,不自动替换;启用状态下保存被拒。
69
+ 6. 断开 Host 后打开设置页:显示目录加载错误,不出现任何伪造选项;此时仍可关闭锁定并保存。
70
+
71
+ ## 5. 已知边界
72
+
73
+ - 插件在服务实例上包装 `currentSelection()` / `saveSelection()`,不改 prototype、不改磁盘、不调用模型接口。
74
+ - **已经排进原服务保存队列的写入无法撤销**:锁定生效前提交、尚未落盘的那一次保存仍会完成。
75
+ - 其他插件若在包装前缓存了方法的独立引用,可能绕过包装;通过 `ctx.agentDefaultModel`、`ctx.get('agentDefaultModel')`、`root.agentDefaultModel` 读取仍被覆盖。
76
+ - 只在加载了本插件的 profile/进程内生效。
77
+ - `settings` 采用可选注入:设置服务缺席时插件照常加载、锁定照常工作,只是不会隐藏官方自动生成的通用表单。因此设置页出现「两个入口」只会在 settings 不可用时发生。
78
+
79
+ ## 6. 隔离运行验证(2026-10-20 实测)
80
+
81
+ 用独立 `DSH_HOME` + 独立端口 3199 跑真实 Electron carrier,**不碰生产 profile**:
82
+
83
+ ```powershell
84
+ node scripts/isolation-probe.mjs prepare # 新建 .sandbox(已存在则拒绝覆盖)
85
+ node scripts/isolation-probe.mjs app-help # 只打印 CLI 帮助
86
+ node scripts/isolation-probe.mjs start # 前台起 127.0.0.1:3199(需手动 Ctrl+C)
87
+ ```
88
+
89
+ 实测结论(`settings/describe` 与 `session/modelCatalog` RPC,隔离实例内):
90
+
91
+ | 项 | 结果 |
92
+ | --- | --- |
93
+ | 插件被真实 Loader 加载 | ✅ `settings.describe` 的 22 个 ns 里有 `default-model-lock` |
94
+ | Config 投影 | ✅ `enabled/provider/model/reasoningEffort` 四字段,默认全空/关闭,`applies: live` |
95
+ | 自动页去重 | ✅ `autoGenerate: false`(自定义设置页唯一入口) |
96
+ | 设置写入链路 | ✅ `settings/update` 落 `user` 段,revision 0→1,同时 `value` 立即生效 |
97
+ | `currentSelection()` 拦截 | ✅ 锁到 `probe-provider/probe-model` 后,`session/modelCatalog` 的 `default` 从 `deepseek-official/deepseek-flash` 变为 `probe-provider/probe-model` |
98
+ | 官方配置零写入 | ✅ `agent-default-model` ns 全程 `revision: 0`、值保持官方默认 |
99
+ | 解锁复原 | ✅ 关闭锁定后 `default` 回到 `deepseek-official/deepseek-flash` |
100
+
101
+ 未覆盖:`saveSelection()` 被拦截后的具体落盘结果,因为隔离实例无凭据,`session/initializeDefaultModel` 在进入保存前就以 `provider "deepseek-account" has no available models` 退出;该行为的等价验证见 `test/core.test.mjs` 的 6 项真 Cordis 测试。
package/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { symbols } from '@deepseek-ai/cordis';
3
+ import { installLock } from './core.mjs';
4
+
5
+ export const name = 'default-model-lock';
6
+ export const inject = ['agentDefaultModel'];
7
+ export const Config = z.object({
8
+ enabled: z.boolean().default(false).description('锁定全局默认模型;不改变已有会话的显式模型').volatile(),
9
+ provider: z.string().default('').description('精确 provider ID;启用时不能为空').volatile(),
10
+ model: z.string().default('').description('精确 model ID;不会自动选择替代模型').volatile(),
11
+ reasoningEffort: z.string().default('').description('推理档位;留空则省略,使用模型默认').volatile(),
12
+ });
13
+
14
+ export function apply(ctx, config) {
15
+ installLock(ctx, config, symbols.original);
16
+ // 自定义设置页已承载这四个字段;settings 可选注入,拿到时才隐藏 Loader 自动生成的通用表单,
17
+ // 不把它写进 inject,避免 settings 缺席时整个插件挂起、锁定也不生效。
18
+ ctx.inject(['settings'], (child) => {
19
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
20
+ });
21
+ }
package/lib/client.js ADDED
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Browser half of dsh-default-model-lock.
3
+ *
4
+ * Written directly in the loader format every shipped client bundle uses
5
+ * (`window.__ModuleLoader__.load`), so the artifact needs no bundler: the id is
6
+ * the package name, React comes from the browser module table, and the returned
7
+ * object carries `inject` and `apply` like any other Client plugin.
8
+ *
9
+ * Contracts this file obeys (dsh 0.2.1-rc.2 / cordis 4.0.5):
10
+ * - settings registration goes through `ctx.slots.inject('settings.section', …)`
11
+ * wrapping `ctx.slots.register({ name, id, order, label, inject }, Component)`;
12
+ * - the model list is `ctx.remote.session.modelCatalog()`, which answers a
13
+ * Typert wrapper (`{ ok: true, value: ModelCatalog }`), not a catalog itself;
14
+ * - writes go through `ConfigForm.mutate(ops, expectedRevision)` once per save,
15
+ * with `SettingsPathOpView` operations, never `set(wholeObject)`.
16
+ */
17
+ window.__ModuleLoader__.load({
18
+ id: 'dsh-default-model-lock',
19
+ factory(require) {
20
+ 'use strict';
21
+
22
+ const React = require('react');
23
+ const h = React.createElement;
24
+ const { useState, useEffect, useMemo } = React;
25
+
26
+ /** Settings section identity; the order puts it right after Models. */
27
+ const SECTION_ID = 'default-model-lock';
28
+ // Shipped settings sections use 0/10/20/30/100/…; 25 keeps this page unique
29
+ // so its nav position never depends on registration order.
30
+ const SECTION_ORDER = 25;
31
+ const SECTION_LABEL = '默认模型锁';
32
+ /** Settings namespace === Host plugin entry id declared by cordis.patch.yml. */
33
+ const NAMESPACE = 'default-model-lock';
34
+ /** Fields this plugin's Config schema declares. */
35
+ const FIELD_KEYS = ['enabled', 'provider', 'model', 'reasoningEffort'];
36
+
37
+ const EMPTY_CONFIG = Object.freeze({ enabled: false, provider: '', model: '', reasoningEffort: '' });
38
+
39
+ // ---------------------------------------------------------------------
40
+ // Pure view-model helpers (exported through `__internals` for the tests).
41
+ // ---------------------------------------------------------------------
42
+
43
+ /** Coerce an unknown wire field to a string. */
44
+ function text(value) {
45
+ return typeof value === 'string' ? value : '';
46
+ }
47
+
48
+ /** Narrow a settings section (or nothing) to this plugin's four fields. */
49
+ function normalizeConfig(value) {
50
+ const source = value !== null && typeof value === 'object' ? value : {};
51
+ return {
52
+ enabled: source.enabled === true,
53
+ provider: text(source.provider),
54
+ model: text(source.model),
55
+ reasoningEffort: text(source.reasoningEffort),
56
+ };
57
+ }
58
+
59
+ /** @returns provider groups from a ModelCatalog, tolerating a missing catalog. */
60
+ function groupsOf(catalog) {
61
+ const value = catalog !== null && typeof catalog === 'object' ? catalog.value : undefined;
62
+ const groups = value !== null && typeof value === 'object' ? value.groups : undefined;
63
+ return Array.isArray(groups) ? groups.filter((group) => group !== null && typeof group === 'object') : [];
64
+ }
65
+
66
+ /** @returns one provider group by id, or undefined. */
67
+ function findGroup(catalog, providerId) {
68
+ if (!providerId) return undefined;
69
+ return groupsOf(catalog).find((group) => group.id === providerId);
70
+ }
71
+
72
+ /** @returns the models of one provider, in catalog order. */
73
+ function modelsOf(catalog, providerId) {
74
+ const group = findGroup(catalog, providerId);
75
+ return group !== undefined && Array.isArray(group.models) ? group.models.filter((model) => model !== null && typeof model === 'object') : [];
76
+ }
77
+
78
+ /** @returns one model entry by provider id and model id, or undefined. */
79
+ function findModel(catalog, providerId, modelId) {
80
+ if (!modelId) return undefined;
81
+ return modelsOf(catalog, providerId).find((model) => model.id === modelId);
82
+ }
83
+
84
+ /** @returns the adapter-owned reasoning efforts of one model (`reasoning.efforts`). */
85
+ function effortOptionsOf(model) {
86
+ const reasoning = model !== null && typeof model === 'object' ? model.reasoning : undefined;
87
+ const efforts = reasoning !== null && typeof reasoning === 'object' ? reasoning.efforts : undefined;
88
+ if (!Array.isArray(efforts)) return [];
89
+ return efforts
90
+ .filter((effort) => effort !== null && typeof effort === 'object' && typeof effort.id === 'string')
91
+ .map((effort) => ({ id: effort.id, name: text(effort.name) || effort.id, description: text(effort.description) }));
92
+ }
93
+
94
+ /** @returns provider select options built from the live catalog. */
95
+ function providerOptions(catalog) {
96
+ return groupsOf(catalog).map((group) => ({ value: group.id, label: text(group.name) || group.id }));
97
+ }
98
+
99
+ /** @returns model select options for one provider. */
100
+ function modelOptions(catalog, providerId) {
101
+ return modelsOf(catalog, providerId).map((model) => ({ value: model.id, label: text(model.name) || model.id }));
102
+ }
103
+
104
+ /** @returns effort select options for one exact model route. */
105
+ function effortOptions(catalog, providerId, modelId) {
106
+ return effortOptionsOf(findModel(catalog, providerId, modelId)).map((effort) => ({ value: effort.id, label: effort.name, description: effort.description }));
107
+ }
108
+
109
+ /**
110
+ * Describe whether a selection still exists in the live catalog. Never
111
+ * substitutes another model: a stale saved value stays visible as stale.
112
+ * @returns `{ state, text }` where state is available|provider|model|effort|unknown.
113
+ */
114
+ function describeAvailability(catalog, selection) {
115
+ if (catalog === null || typeof catalog !== 'object' || catalog.status !== 'ready' || !catalog.value) {
116
+ return { state: 'unknown', text: '模型目录尚未就绪,暂时无法确认它是否可用。' };
117
+ }
118
+ const group = findGroup(catalog, selection.provider);
119
+ if (group === undefined) {
120
+ return { state: 'provider', text: `provider「${selection.provider || '(空)'}」当前不在模型目录中。` };
121
+ }
122
+ const model = findModel(catalog, selection.provider, selection.model);
123
+ if (model === undefined) {
124
+ return { state: 'model', text: `模型「${selection.model || '(空)'}」当前不可用。` };
125
+ }
126
+ if (selection.reasoningEffort) {
127
+ const efforts = effortOptionsOf(model);
128
+ if (!efforts.some((effort) => effort.id === selection.reasoningEffort)) {
129
+ return { state: 'effort', text: `模型「${text(model.name) || model.id}」没有推理档位「${selection.reasoningEffort}」。` };
130
+ }
131
+ }
132
+ return { state: 'available', text: '' };
133
+ }
134
+
135
+ /**
136
+ * Build the single atomic write for a draft, or refuse it.
137
+ * Refusing is the fail-closed branch: an enabled lock never falls back to
138
+ * another model, and it is never written from a catalog we could not read.
139
+ * @returns `{ ok: true, ops }` or `{ ok: false, message }`.
140
+ */
141
+ function planSave(draft, catalog, saved) {
142
+ const current = normalizeConfig(saved);
143
+ const next = normalizeConfig(draft);
144
+ if (!next.enabled) {
145
+ // Disabling the lock is always allowed, with empty provider/model.
146
+ const ops = [];
147
+ if (current.enabled !== false) ops.push({ op: 'set', path: ['enabled'], value: false });
148
+ if (current.provider !== '') ops.push({ op: 'unset', path: ['provider'] });
149
+ if (current.model !== '') ops.push({ op: 'unset', path: ['model'] });
150
+ if (current.reasoningEffort !== '') ops.push({ op: 'unset', path: ['reasoningEffort'] });
151
+ return { ok: true, ops };
152
+ }
153
+ if (catalog === null || typeof catalog !== 'object' || catalog.status !== 'ready' || !catalog.value) {
154
+ return { ok: false, message: '模型目录未就绪,启用锁定必须先读到真实目录(不会猜测模型)。可以先关闭锁定后保存。' };
155
+ }
156
+ const provider = next.provider.trim();
157
+ const model = next.model.trim();
158
+ const effort = next.reasoningEffort.trim();
159
+ if (provider === '') return { ok: false, message: '启用锁定必须先选择一个 provider。' };
160
+ if (model === '') return { ok: false, message: '启用锁定必须先选择一个 model。' };
161
+ if (findGroup(catalog, provider) === undefined) return { ok: false, message: `provider「${provider}」不在当前模型目录中。` };
162
+ const entry = findModel(catalog, provider, model);
163
+ if (entry === undefined) return { ok: false, message: `模型「${model}」不属于 provider「${provider}」。` };
164
+ if (effort !== '' && !effortOptionsOf(entry).some((option) => option.id === effort)) {
165
+ return { ok: false, message: `模型「${model}」没有推理档位「${effort}」。` };
166
+ }
167
+ const ops = [
168
+ { op: 'set', path: ['enabled'], value: true },
169
+ { op: 'set', path: ['provider'], value: provider },
170
+ { op: 'set', path: ['model'], value: model },
171
+ effort === '' ? { op: 'unset', path: ['reasoningEffort'] } : { op: 'set', path: ['reasoningEffort'], value: effort },
172
+ ];
173
+ return { ok: true, ops };
174
+ }
175
+
176
+ /** @returns a readable message for a rejected Typert response or thrown value. */
177
+ function errorText(error) {
178
+ if (error === null || error === undefined) return '未知错误';
179
+ if (typeof error === 'string') return error;
180
+ const message = typeof error.message === 'string' ? error.message : '';
181
+ return message || String(error);
182
+ }
183
+
184
+ /** @returns the failure message of a `{ ok: false, error }` response. */
185
+ function failureMessage(response) {
186
+ const failure = response !== null && typeof response === 'object' ? response.error : undefined;
187
+ return errorText(failure !== undefined ? failure : response);
188
+ }
189
+
190
+ /** @returns whether a namespace schema declares this plugin's fields. */
191
+ function looksLikeThisPlugin(schema) {
192
+ let serialized;
193
+ try {
194
+ serialized = JSON.stringify(schema);
195
+ } catch {
196
+ return false;
197
+ }
198
+ if (typeof serialized !== 'string') return false;
199
+ return FIELD_KEYS.every((key) => serialized.includes(key));
200
+ }
201
+
202
+ // ---------------------------------------------------------------------
203
+ // Store shared by the section component.
204
+ // ---------------------------------------------------------------------
205
+
206
+ /**
207
+ * Owns the ConfigForm subscription, the live catalog read, and the draft.
208
+ * @param ctx - client root context carrying `configForms` and `remote`.
209
+ */
210
+ function createStore(ctx) {
211
+ let state = {
212
+ phase: 'loading',
213
+ writable: false,
214
+ revision: undefined,
215
+ config: EMPTY_CONFIG,
216
+ draft: EMPTY_CONFIG,
217
+ dirty: false,
218
+ saving: false,
219
+ notice: null,
220
+ catalog: { status: 'idle', value: null, error: null },
221
+ };
222
+ const listeners = new Set();
223
+ let form;
224
+ let detachForm = null;
225
+ let catalogLoading = false;
226
+ let disposed = false;
227
+
228
+ const getState = () => state;
229
+
230
+ const put = (patch) => {
231
+ if (disposed) return;
232
+ state = Object.assign({}, state, patch);
233
+ for (const listener of listeners) {
234
+ try {
235
+ listener();
236
+ } catch (error) {
237
+ console.error('default-model-lock: listener failed', error);
238
+ }
239
+ }
240
+ };
241
+
242
+ const subscribe = (listener) => {
243
+ listeners.add(listener);
244
+ return () => {
245
+ listeners.delete(listener);
246
+ };
247
+ };
248
+
249
+ /** Re-read the Host form snapshot into state, keeping an edited draft. */
250
+ const syncForm = () => {
251
+ if (!form) return;
252
+ const snapshot = form.getSnapshot();
253
+ const status = snapshot !== null && typeof snapshot === 'object' ? snapshot.status : undefined;
254
+ const config = normalizeConfig(snapshot !== null && typeof snapshot === 'object' ? snapshot.value : undefined);
255
+ const patch = {
256
+ phase: status === 'ready' ? 'ready' : status === 'unavailable' ? 'unavailable' : 'loading',
257
+ writable: snapshot !== null && typeof snapshot === 'object' && snapshot.writable === true,
258
+ revision: snapshot !== null && typeof snapshot === 'object' ? snapshot.revision : undefined,
259
+ config,
260
+ };
261
+ if (!state.dirty) patch.draft = config;
262
+ put(patch);
263
+ };
264
+
265
+ const attach = (namespace) => {
266
+ if (detachForm) {
267
+ detachForm();
268
+ detachForm = null;
269
+ }
270
+ form = ctx.configForms.get(namespace);
271
+ syncForm();
272
+ detachForm = form !== null && typeof form === 'object' && typeof form.subscribe === 'function' ? form.subscribe(syncForm) : null;
273
+ };
274
+
275
+ /** Fall back to a describe scan when the entry id is not the expected one. */
276
+ const watchDescribe = () => {
277
+ const face = typeof ctx.configForms.describe === 'function' ? ctx.configForms.describe() : undefined;
278
+ if (!face || typeof face.ensure !== 'function') return;
279
+ Promise.resolve()
280
+ .then(() => face.ensure())
281
+ .then(() => {
282
+ if (disposed || state.phase === 'ready') return;
283
+ const snapshot = face.getSnapshot();
284
+ const view = snapshot !== null && typeof snapshot === 'object' ? snapshot.view : undefined;
285
+ const namespaces = view !== null && typeof view === 'object' && Array.isArray(view.namespaces) ? view.namespaces : [];
286
+ if (namespaces.some((entry) => entry !== null && typeof entry === 'object' && entry.ns === NAMESPACE)) {
287
+ if (state.phase !== 'ready') attach(NAMESPACE);
288
+ return;
289
+ }
290
+ const match = namespaces.find((entry) => entry !== null && typeof entry === 'object' && looksLikeThisPlugin(entry.schema));
291
+ if (match !== undefined) attach(match.ns);
292
+ })
293
+ .catch((error) => {
294
+ console.warn('default-model-lock: settings describe scan failed', error);
295
+ });
296
+ };
297
+
298
+ /** Read the live model catalog through the session remote face. */
299
+ const loadCatalog = async () => {
300
+ if (disposed || catalogLoading) return;
301
+ catalogLoading = true;
302
+ put({ catalog: { status: 'loading', value: state.catalog.value, error: null } });
303
+ try {
304
+ const response = await ctx.remote.session.modelCatalog();
305
+ if (!response || response.ok !== true) throw new Error(failureMessage(response));
306
+ put({ catalog: { status: 'ready', value: response.value, error: null } });
307
+ } catch (error) {
308
+ put({ catalog: { status: 'error', value: null, error: errorText(error) } });
309
+ } finally {
310
+ catalogLoading = false;
311
+ }
312
+ };
313
+
314
+ /** Load the catalog once, on first render. */
315
+ const ensureCatalog = () => {
316
+ if (state.catalog.status !== 'idle') return;
317
+ void loadCatalog();
318
+ };
319
+
320
+ /** Re-read the catalog on demand. */
321
+ const refreshCatalog = async () => {
322
+ if (catalogLoading) return;
323
+ await loadCatalog();
324
+ };
325
+
326
+ /** Edit the draft, keeping provider → model → effort consistent. */
327
+ const setDraft = (patch) => {
328
+ const next = Object.assign({}, state.draft);
329
+ if (Object.prototype.hasOwnProperty.call(patch, 'enabled')) next.enabled = patch.enabled === true;
330
+ if (Object.prototype.hasOwnProperty.call(patch, 'provider')) {
331
+ const value = text(patch.provider);
332
+ if (value !== next.provider) {
333
+ next.provider = value;
334
+ next.model = '';
335
+ next.reasoningEffort = '';
336
+ }
337
+ }
338
+ if (Object.prototype.hasOwnProperty.call(patch, 'model')) {
339
+ const value = text(patch.model);
340
+ if (value !== next.model) {
341
+ next.model = value;
342
+ const efforts = effortOptionsOf(findModel(state.catalog, next.provider, value));
343
+ if (!efforts.some((effort) => effort.id === next.reasoningEffort)) next.reasoningEffort = '';
344
+ }
345
+ }
346
+ if (Object.prototype.hasOwnProperty.call(patch, 'reasoningEffort')) next.reasoningEffort = text(patch.reasoningEffort);
347
+ put({ draft: next, dirty: true, notice: null });
348
+ };
349
+
350
+ /** Discard the draft. */
351
+ const resetDraft = () => {
352
+ put({ draft: state.config, dirty: false, notice: null });
353
+ };
354
+
355
+ /** Write the draft as one atomic namespace mutation. */
356
+ const save = async () => {
357
+ if (!form || state.saving) return false;
358
+ const plan = planSave(state.draft, state.catalog, state.config);
359
+ if (!plan.ok) {
360
+ put({ notice: { kind: 'error', text: plan.message } });
361
+ return false;
362
+ }
363
+ if (plan.ops.length === 0) {
364
+ put({ dirty: false, notice: { kind: 'info', text: '没有需要保存的改动。' } });
365
+ return true;
366
+ }
367
+ put({ saving: true, notice: null });
368
+ try {
369
+ const accepted = await form.mutate(plan.ops, state.revision);
370
+ put({ saving: false });
371
+ if (accepted === true) {
372
+ put({ dirty: false });
373
+ syncForm();
374
+ put({ notice: { kind: 'success', text: '已保存:共享默认模型立即生效,无需重启。' } });
375
+ return true;
376
+ }
377
+ put({ notice: { kind: 'error', text: 'Host 拒绝或跳过了这次写入(配置可能已被别处修改),请刷新后重试。' } });
378
+ return false;
379
+ } catch (error) {
380
+ put({ saving: false, notice: { kind: 'error', text: errorText(error) } });
381
+ return false;
382
+ }
383
+ };
384
+
385
+ attach(NAMESPACE);
386
+ if (state.phase !== 'ready') watchDescribe();
387
+
388
+ return {
389
+ getState,
390
+ subscribe,
391
+ ensureCatalog,
392
+ refreshCatalog,
393
+ setDraft,
394
+ resetDraft,
395
+ save,
396
+ dispose() {
397
+ disposed = true;
398
+ if (detachForm) detachForm();
399
+ detachForm = null;
400
+ listeners.clear();
401
+ },
402
+ };
403
+ }
404
+
405
+ // ---------------------------------------------------------------------
406
+ // Rendering.
407
+ // ---------------------------------------------------------------------
408
+
409
+ const styles = {
410
+ root: { display: 'grid', gap: 16, maxWidth: 680, padding: '4px 0 24px', color: 'var(--dsw-alias-label-primary, inherit)', fontSize: 14, lineHeight: 1.55 },
411
+ title: { margin: 0, fontSize: 18, fontWeight: 600 },
412
+ description: { margin: 0, color: 'var(--dsw-alias-label-secondary, #666)' },
413
+ card: {
414
+ display: 'grid',
415
+ gap: 12,
416
+ padding: 16,
417
+ border: '1px solid var(--dsw-alias-settings-card-stroke, var(--dsw-alias-border-l3, #d8d8d8))',
418
+ borderRadius: 'var(--dsw-radius-md, 8px)',
419
+ background: 'var(--dsw-alias-settings-card-fill, transparent)',
420
+ },
421
+ row: { display: 'grid', gap: 6 },
422
+ label: { fontWeight: 600, color: 'var(--dsw-alias-label-primary, inherit)' },
423
+ help: { color: 'var(--dsw-alias-label-tertiary, #888)', fontSize: 12 },
424
+ select: {
425
+ minHeight: 34,
426
+ padding: '6px 8px',
427
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
428
+ border: '1px solid var(--dsw-alias-border-l3, #d8d8d8)',
429
+ background: 'transparent',
430
+ color: 'inherit',
431
+ font: 'inherit',
432
+ },
433
+ actions: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' },
434
+ button: {
435
+ minHeight: 34,
436
+ padding: '6px 18px',
437
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
438
+ border: '1px solid var(--dsw-alias-border-l3, #d8d8d8)',
439
+ background: 'transparent',
440
+ color: 'inherit',
441
+ font: 'inherit',
442
+ cursor: 'pointer',
443
+ },
444
+ error: { color: 'var(--dsw-alias-state-error-primary, #c0392b)', fontSize: 13 },
445
+ warn: { color: 'var(--dsw-alias-state-warn-label, #a06000)', fontSize: 13 },
446
+ success: { color: 'var(--dsw-alias-state-success-primary, #1f7a3d)', fontSize: 13 },
447
+ info: { color: 'var(--dsw-alias-label-secondary, #666)', fontSize: 13 },
448
+ mono: { fontFamily: 'var(--ds-font-family-code, monospace)', fontSize: 12 },
449
+ };
450
+
451
+ const NOTICE_STYLE = { error: styles.error, success: styles.success, info: styles.info };
452
+
453
+ /** Subscribe a component to the store without pulling in an extra hook library. */
454
+ function useStoreState(store) {
455
+ const [snapshot, setSnapshot] = useState(() => store.getState());
456
+ useEffect(() => {
457
+ setSnapshot(store.getState());
458
+ return store.subscribe(() => setSnapshot(store.getState()));
459
+ }, [store]);
460
+ return snapshot;
461
+ }
462
+
463
+ function Field(props) {
464
+ return h('div', { style: styles.row }, h('span', { style: styles.label }, props.label), props.children, props.help ? h('span', { style: styles.help }, props.help) : null);
465
+ }
466
+
467
+ function Select(props) {
468
+ return h(
469
+ 'select',
470
+ {
471
+ style: styles.select,
472
+ value: props.value,
473
+ disabled: props.disabled === true,
474
+ 'aria-label': props.ariaLabel,
475
+ onChange: (event) => props.onChange(event.target.value),
476
+ },
477
+ props.options.map((option) => h('option', { key: option.value, value: option.value }, option.label)),
478
+ );
479
+ }
480
+
481
+ function Notice(props) {
482
+ if (!props.notice) return null;
483
+ return h('span', { style: NOTICE_STYLE[props.notice.kind] || styles.info, role: props.notice.kind === 'error' ? 'alert' : undefined }, props.notice.text);
484
+ }
485
+
486
+ function DefaultModelLockSection(props) {
487
+ const store = props.store;
488
+ const state = useStoreState(store);
489
+ useEffect(() => {
490
+ store.ensureCatalog();
491
+ }, [store]);
492
+
493
+ const draft = state.draft;
494
+ const plan = useMemo(() => planSave(draft, state.catalog, state.config), [draft, state.catalog, state.config]);
495
+ const providers = providerOptions(state.catalog);
496
+ const models = modelOptions(state.catalog, draft.provider);
497
+ const efforts = effortOptions(state.catalog, draft.provider, draft.model);
498
+ const catalogStatus = state.catalog.status;
499
+ const savedAvailability = describeAvailability(state.catalog, state.config);
500
+ const showSavedWarning = state.config.enabled && savedAvailability.state !== 'available' && savedAvailability.state !== 'unknown';
501
+ const busy = state.saving;
502
+ const canEdit = state.writable && state.phase === 'ready';
503
+ const canSave = canEdit && !busy && plan.ok && (state.dirty || plan.ops.length > 0);
504
+
505
+ const catalogBlock = [];
506
+ if (catalogStatus === 'idle' || catalogStatus === 'loading') {
507
+ catalogBlock.push(h('span', { key: 'loading', style: styles.info }, '正在读取模型目录…'));
508
+ } else if (catalogStatus === 'error') {
509
+ catalogBlock.push(h('div', { key: 'error', style: styles.error, role: 'alert' }, `模型目录加载失败:${state.catalog.error}`));
510
+ catalogBlock.push(h('span', { key: 'error-hint', style: styles.help }, '这里不会伪造选项。可以重试读取,或先关闭锁定再保存。'));
511
+ } else if (providers.length === 0) {
512
+ catalogBlock.push(h('span', { key: 'empty', style: styles.warn }, '模型目录里没有任何可用 provider。'));
513
+ } else {
514
+ catalogBlock.push(
515
+ h(
516
+ Field,
517
+ { key: 'provider', label: 'Provider' },
518
+ h(Select, {
519
+ ariaLabel: 'Provider',
520
+ value: draft.provider,
521
+ disabled: !canEdit || busy,
522
+ options: [{ value: '', label: '请选择 provider' }].concat(providers),
523
+ onChange: (value) => store.setDraft({ provider: value }),
524
+ }),
525
+ ),
526
+ h(
527
+ Field,
528
+ { key: 'model', label: 'Model', help: draft.provider === '' ? '先选择 provider。' : undefined },
529
+ h(Select, {
530
+ ariaLabel: 'Model',
531
+ value: draft.model,
532
+ disabled: !canEdit || busy || draft.provider === '' || models.length === 0,
533
+ options: [{ value: '', label: models.length === 0 ? '该 provider 没有可选模型' : '请选择 model' }].concat(models),
534
+ onChange: (value) => store.setDraft({ model: value }),
535
+ }),
536
+ ),
537
+ h(
538
+ Field,
539
+ {
540
+ key: 'effort',
541
+ label: 'Reasoning effort',
542
+ help: draft.model === '' ? '先选择 model。' : efforts.length === 0 ? '该模型没有声明 reasoning.efforts,将使用模型默认档位。' : '留空表示使用模型默认档位。',
543
+ },
544
+ h(Select, {
545
+ ariaLabel: 'Reasoning effort',
546
+ value: draft.reasoningEffort,
547
+ disabled: !canEdit || busy || efforts.length === 0,
548
+ options: [{ value: '', label: '模型默认' }].concat(efforts),
549
+ onChange: (value) => store.setDraft({ reasoningEffort: value }),
550
+ }),
551
+ ),
552
+ );
553
+ }
554
+
555
+ return h(
556
+ 'section',
557
+ { style: styles.root, 'data-dsh-plugin': 'dsh-default-model-lock', 'aria-label': SECTION_LABEL },
558
+ h('h2', { style: styles.title }, SECTION_LABEL),
559
+ h('p', { style: styles.description }, '锁定共享默认模型:新会话、headless、心跳与定时任务都从这里取值;在聊天里手动切换模型仍只影响当前会话,不会改写这个默认值。'),
560
+ state.phase === 'unavailable'
561
+ ? h('div', { style: styles.error, role: 'alert' }, `没有找到 settings 命名空间「${NAMESPACE}」,请确认插件已随 profile 加载。`)
562
+ : null,
563
+ state.phase !== 'unavailable' && state.writable !== true
564
+ ? h('div', { style: styles.warn }, '当前 profile 是只读的,无法保存。')
565
+ : null,
566
+ h(
567
+ 'div',
568
+ { style: styles.card },
569
+ h(
570
+ Field,
571
+ {
572
+ label: '启用锁定',
573
+ help: '启用时必须从真实目录里选好 provider 与 model;关闭后默认模型回到 composition 里的值。',
574
+ },
575
+ h('input', {
576
+ type: 'checkbox',
577
+ checked: draft.enabled,
578
+ disabled: !canEdit || busy,
579
+ onChange: (event) => store.setDraft({ enabled: event.target.checked }),
580
+ }),
581
+ ),
582
+ h('div', { style: styles.row }, catalogBlock),
583
+ showSavedWarning ? h('div', { style: styles.warn }, `已保存的默认模型当前不可用(不会自动替换):${savedAvailability.text}`) : null,
584
+ showSavedWarning || draft.reasoningEffort === '' ? null : h('span', { style: styles.help }, `将写入:${draft.provider} / ${draft.model} / ${draft.reasoningEffort}`),
585
+ ),
586
+ h(
587
+ 'div',
588
+ { style: styles.actions },
589
+ h(
590
+ 'button',
591
+ {
592
+ type: 'button',
593
+ style: canSave ? styles.button : Object.assign({}, styles.button, { cursor: 'not-allowed', opacity: 0.6 }),
594
+ disabled: !canSave,
595
+ onClick: () => void store.save(),
596
+ },
597
+ busy ? '保存中…' : '保存',
598
+ ),
599
+ h(
600
+ 'button',
601
+ { type: 'button', style: styles.button, disabled: !canEdit || busy || !state.dirty, onClick: () => store.resetDraft() },
602
+ '重置',
603
+ ),
604
+ h('button', { type: 'button', style: styles.button, disabled: busy || catalogStatus === 'loading', onClick: () => void store.refreshCatalog() }, '刷新目录'),
605
+ plan.ok === false ? h('span', { style: styles.warn }, plan.message) : null,
606
+ plan.ok === true && plan.ops.length > 0 ? h('span', { style: styles.mono }, `${plan.ops.length} 项变更待提交`) : null,
607
+ h(Notice, { notice: state.notice }),
608
+ ),
609
+ );
610
+ }
611
+
612
+ return {
613
+ inject: ['slots', 'remote', 'remote.session', 'configForms'],
614
+ apply(ctx) {
615
+ const store = createStore(ctx);
616
+ ctx.effect(() => () => store.dispose(), 'default-model-lock: settings store');
617
+ ctx.slots.inject('settings.section', () =>
618
+ ctx.slots.register(
619
+ {
620
+ name: 'settings.section',
621
+ id: SECTION_ID,
622
+ order: SECTION_ORDER,
623
+ label: SECTION_LABEL,
624
+ inject: () => ({ store }),
625
+ },
626
+ DefaultModelLockSection,
627
+ ),
628
+ );
629
+ },
630
+ __internals: {
631
+ SECTION_ID,
632
+ SECTION_ORDER,
633
+ NAMESPACE,
634
+ normalizeConfig,
635
+ groupsOf,
636
+ findGroup,
637
+ findModel,
638
+ effortOptionsOf,
639
+ providerOptions,
640
+ modelOptions,
641
+ effortOptions,
642
+ describeAvailability,
643
+ planSave,
644
+ createStore,
645
+ looksLikeThisPlugin,
646
+ },
647
+ };
648
+ },
649
+ });
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "dsh-default-model-lock",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "在设置页固定共享默认模型,并在会话模型选择器保存默认值前拦截写入",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "index.js",
14
+ "core.mjs",
15
+ "lib",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "README_EN.md",
19
+ "docs"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22.19.0"
23
+ },
24
+ "dsh": {
25
+ "engines": {
26
+ "dsh": ">=0.2.1-rc.2"
27
+ },
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "inject": [
33
+ "@deepseek-ai/dsh-client-connection",
34
+ "@deepseek-ai/dsh-client-ui-settings",
35
+ "@deepseek-ai/dsh-api-session-controller"
36
+ ],
37
+ "platform": "web"
38
+ }
39
+ },
40
+ "peerDependencies": {
41
+ "@deepseek-ai/dsh": ">=0.2.1-rc.2",
42
+ "@deepseek-ai/cordis": "~4.0.5",
43
+ "@deepseek-ai/schemastery": "~3.18.5",
44
+ "react": "^18.2.0"
45
+ },
46
+ "scripts": {
47
+ "test": "node --test test/*.test.mjs",
48
+ "check": "node --check index.js && node --check core.mjs && node --check lib/client.js"
49
+ },
50
+ "license": "MIT",
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/hZsFN/dsh-default-model-lock.git"
54
+ },
55
+ "homepage": "https://github.com/hZsFN/dsh-default-model-lock#readme",
56
+ "bugs": {
57
+ "url": "https://github.com/hZsFN/dsh-default-model-lock/issues"
58
+ },
59
+ "keywords": [
60
+ "dsh",
61
+ "deepseek-harness",
62
+ "plugin",
63
+ "cordis",
64
+ "model",
65
+ "default-model",
66
+ "reasoning-effort"
67
+ ],
68
+ "author": "hZsFN"
69
+ }