DSH 双面插件:把「模型能力勾选」做进官方**「设置 → 模型」页**的每个模型条目里。
在官方编辑器展开的模型条目上注入两组控件:
- [图片输入]:勾选 → 该模型条目写入
input: ["text","image"];取消 → 删除input字段(继承内置目录/默认值)。 - 思考强度(最低 / 低 / 中 / 高 / 超高 / 最高 →
minimal / low / medium / high / xhigh / max): 勾选任意档位 → 写入reasoningEfforts: { off:, low: low, … };全部取消 → 删除 该字段。
这两项本来就是 dsh-llm-pi-ai 原生支持的配置(input 模态与 reasoningEfforts
思考档位),只是官方 UI 刻意不暴露、只能手改 settings.yaml:
"There is deliberately no reasoning-effort control, here or on the editor card: effort is a per-MODEL capability … The composer's model picker offers each model its own levels instead." ——
dsh-client-ui-settings-models/lib/client.js(0.1.5-rc.1 原文)
composer / /model 弹窗提供的是为下一个请求挑档位(只能选模型已公布的档位,
且不写回 settings);本插件补的是声明这个模型有哪些能力并持久化到
settings.yaml。二者互补。
本插件把勾选动作自动化:勾一下立即回显,写盘在官方编辑卡片关闭/保存之后完成
——不必点官方「保存」也不必等它;卡片一关(保存成功、取消、离开设置页都算),
勾选即写入 settings.yaml(settings 原子写 + schema 校验 + 热重载),模型选择器里
该模型的图文能力与思考档位随即生效。
为什么不是「勾一下立刻写盘」:官方编辑卡片把打开那一刻的 settings
revision 冻结在 React state 里(dsh-client-ui-settings-models 的
expectedRevision;自定义提供方创建卡是 openedAt),此后任何转发事件都不会
重新取值,草稿也只存在于卡片内部。因此卡片打开期间插件若写入 llm-pi-ai,
用户随后点「保存」必然以 settings/conflict 失败,提示
「这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。」
——这正是本插件旧实现的两个报障之一。改为「卡片打开期间只暂存、关闭后才提交」
后,插件的写入与官方卡片的写入不再抢同一个 revision。
版本号跟随所对齐的 DSH 官方版本,并追加一个小版本号:官方 0.1.5-rc.1
→ 本插件 0.1.5-rc.1.1(同一官方版本上的后续修订依次为 .2、.3…)。
当前 0.1.5-rc.1.2(对齐 DSH 0.1.5-rc.1;.2 为「暂存写盘」修复)。
| 规范 | 本插件的实现 |
|---|---|
包声明 dsh.bundle.patch,随包携带该 patch 文件 → 成为 profile 的层 |
package.json 的 dsh.bundle.patch: "./cordis.patch.yml" + 仓库根 cordis.patch.yml |
浏览器半边用 dsh.client 声明 |
dsh.client.platform: "web";导出 ./client → lib/client.js |
安装 = dsh plugin --profile <name> add <pkg>(CLI 跑 pnpm 并按「依赖是否声明 dsh.bundle」自动重建 dsh.profile.bundles 层序) |
pnpm plugin:install 委托给官方 CLI,只做构建自检 + 旧方案迁移清理 + 层序核对 |
| Host→浏览器 RPC 走 Typert Remote(官方一等路径,SRC 落地) | class ModelTogglesService extends TypertRemoteService;Cordis service key = wire namespace modelToggles;三个 @Remote 方法由 Api Gateway 的 collectSrcClaims() 扫 ctx.reflect.props 自动认领到共享 /api 上 |
| 客户端调用走官方 Gateway 通道 | ctx.connection.rpc.call('/api', 'modelToggles/<method>', { args });业务失败抛 RemoteError(稳定 code + 结构化 details),由 Gateway 编码到 wire 的 error 分支 |
| 浏览器 bundle 是 lazy-CJS 工厂,id = 包名 | 构建 banner/footer 注册 window.__ModuleLoader__.load({ id: "dsh-model-toggles", factory }) |
样式以 style[data-plugin] / data-plugin-css 归属插件 |
src/client/styles.ts |
| 卸载随 fiber 释放 | 全部注册都包在 ctx.effect(...) 里 |
因为官方已实现而删除的实现(自 v0.1.5-rc.1.1 起):
- 自造安装器:两处 junction 农场 + 往 profile
cordis.patch.yml手写insert:行 → 官方dsh plugin已实现(并按已安装状态重建层序); - 自造 RPC 传输:
webServer.register路由 + 自定义x-dsh-model-toggles头 + 同源 CORS 预检闸门 + 自管请求体大小/JSON 信封 → Typert Remote + Api Gateway 拥有端点认领、参数映射、Host/Origin 信任闸门、 浏览器会话鉴权、rpcId 关联与错误编码(脚本裸访问现在得到 401 = 已挂载且受鉴权保护); - 私有 RPC channel(
/dsh-model-toggles/rpc)→ 端点改挂在官方共享/api上。
勾选 ──▶ 官方编辑卡片打开? ──是──▶ 客户端暂存(控件立即回显 + 「待写入」提示)
└─否──▶ capsSet(Remote 端点)
① 写影子段(settings.yaml 的 model-toggles:)
② 立即调和 ──▶ 写 llm-pi-ai.providers.<route>.models(原子)
卡片关闭/保存 ──▶ 客户端提交暂存(capsSet,同上)──▶ 写盘 + 调和
官方编辑器保存 ──▶ settings 变更事件 ──▶ 兜底调和(几十 ms 内补回勾选字段)
└─▶ 顺手清理影子段:已删模型/路由的影子键自动移除
启动 ──▶ 调和 + 清理各一次(修复历史覆盖、清掉旧版本遗留的无效影子键)
提交暂存的时机只有三个:官方卡片由打开变为关闭(DOM 观察器识别)、
卡片打开期间收到 settings/document-updated(官方保存已落盘,但卡片还开着 →
仍不写)、以及(理论上控件只在卡片内,故几乎不会发生)没有卡片时直接勾选。
暂存意图最多保留 5 分钟:新增模型/新增提供方这类尚未保存的草稿在官方保存
落盘后会自动补写;用户放弃草稿则超时丢弃。
勾选状态的事实源是插件自己的 settings 段(model-toggles.providers.<route>.<model>),
不是官方编辑器的草稿。官方编辑器以「打开时的基线」做最小 path ops 保存,可能
用过期数组覆盖我们的字段;插件监听 llm-pi-ai 段变更后自动调和,把勾选
字段补回对应条目——收敛、不循环,官方保存也冲不掉。
浏览器半边不注册任何设置 section,只观察 DOM(MutationObserver)往官方
模型页注入控件;官方模型页只有 settings.models.provider-card /
settings.models.footer 两个 slot,没有「每个模型条目」的扩展点,因此这一层
只能走 DOM 注入(见「已知取舍」)。
前置:
- DSH ≥ 0.1.2-rc.1(Typert 协议包与 Api Gateway 的源模式认领自该版本存在;已在 0.1.2-rc.1 与
0.1.5-rc.1 上核对),profile 为
web,且llm-pi-ai.providers已配置至少一个 提供方(模型页能列出路由); - 本机有 Node 20+、pnpm 与
dshCLI。
安装(幂等):
pnpm plugin:install安装器依次:构建产物检查 → 冒烟自检 → bundle 声明自检(dsh.bundle.patch
存在、dsh.client.platform === "web")→ 旧方案残留迁移清理 → 官方安装
dsh plugin --profile web add link:<本仓库> → 层序核对(dsh.profile.bundles
必须含 dsh-model-toggles)→ 尽力而为的 HTTP 验证。
常用参数:--profile <name>(默认 web)、--spec <pnpm-spec>(默认
link:<仓库>,源码改动即时生效)、--rebuild、--dry-run。
安装后必须重启一次 DSH Desktop。桌面部署的 dsh 进程不热重载 profile 层序
(实测:补丁/层变更在运行实例上不生效),重启后 host 半边才挂载,浏览器 bundle
由 /plugins combo 提供。
使用:
- 设置 → 模型 → 点某提供方的「编辑」;
- 展开模型条目(高级箭头,
Capacities); - 勾选「图片输入」与思考强度档位——控件立即回显并显示「待写入:保存或关闭本卡片后生效」;
- 关闭/保存该卡片(「保存」成功会关闭卡片;取消或离开设置页同样算)→ 勾选写入
~/.dsh/settings.yaml的llm-pi-ai.providers.<route>.models[n].{input, reasoningEfforts}(经影子段调和); - 官方编辑器的「保存」可照常使用,勾选不会被它冲掉,也不会再出现 「这张卡片打开期间,这些设置已被其他地方改动」的冲突提示;
- 新增(还没保存的)模型行同样可以勾选:先按官方流程把模型保存进列表,插件会在 保存后自动补写,无需重勾(勾选不会「勾上后自动消失」)。
写盘效果示例(settings.yaml):
model-toggles:
providers:
openrouter:
stealth/ox-alpha:
image: true
efforts: [high, max]
llm-pi-ai:
providers:
openrouter:
models:
- id: stealth/ox-alpha
input: [text, image]
reasoningEfforts:
off:
high: high
max: max卸载:
pnpm plugin:uninstall委托 dsh plugin --profile web remove dsh-model-toggles(本包自动退出层序),
并幂等清理旧方案残留。卸载只移除层与解析链接,不清理已生效的配置:需要时
手工删除 settings.yaml 里的 model-toggles: 段,以及模型条目里由插件写入的
input / reasoningEfforts 字段。
- 取消勾选 = 受管关闭:影子层写
image: false/efforts: [],调和时删除 对应字段 —— 不是「恢复原样」,而是「确保无此能力」。想彻底回到「跟随内置 目录」,删掉影子段对应键即可让字段随默认值。 - 不复活删除,影子随删自动清:事件驱动的兜底调和在引擎层缺省
allowCreate=false(调用方漏传也绝不复活);只有显式勾选(capsSet 的立即 调和,allowCreate=true且createIds限定为本次勾选的目标)才允许创建缺失 条目/接管目录。官方编辑器里删除模型/路由后,清理在「官方保存触发的调和 / 启动调和 / capsSet 立即调和」时执行——路由被删清整段、模型不在显式列表清单 键、空覆盖一并清;无显式models:列表的路由(跟随内置目录)无法凭列表判定 删除,非空影子键保留。影子段自身变更的事件只调和不清理(防误删刚写入、尚未 调和成功的键)。影子段因此无需人工维护;代价是重新添加同 id 模型时不再恢复 之前的勾选(记忆已随删除自动清理)。 off档位恒写入为off:(null = 支持关闭思考、不发参数):一旦声明任何思考 档位,若不保留 off,该模型将无法显式关闭思考。dsh-llm-pi-ai 的解析规则是null只允许出现在off上,且空reasoningEfforts/ 只有off会被拒绝 —— 本插件的「全部取消 = 删字段」正好避开这两种非法形状。- 勾选目标 = 条目在已保存配置里的模型 id(服务端强制):官方编辑器里临时
改名/新增的模型(未保存进 models 列表)勾选会被服务端拒绝(
model-toggles/model-unsaved) —— 防止插件写入裸条目、与官方保存的插入操作撞出重复 id(重复会让调和器的条目 校验永久报错)。客户端对此不报错、也不回弹:意图留在暂存表里,等官方把模型 保存落盘后的document-updated自动补写(见下条);实测这就是「新增模型界面 勾上后自动消失」的修复点。 - 暂存提交(写盘时机):官方编辑卡片打开期间勾选只进客户端暂存表(控件立即
回显 + 「待写入」提示),卡片一关(保存成功/取消/离开设置页)或收到
document-updated时才提交 —— 因为卡片冻结了打开时的 settings revision, 卡片打开期间写llm-pi-ai会让它的「保存」必然settings/conflict。暂存意图 保留 5 分钟后丢弃(用户放弃了官方草稿);服务端真实拒绝(如写盘失败)则立即 丢弃并回读服务端真相反弹控件,不留假勾选。 - 未写
models:列表的路由(直接使用内置目录):第一次显式勾选会以「内置目录 全量 passthrough(保留 name / 容量字段)+ 勾选条目」接管为显式列表;接管后 内置目录日后的更新不会自动出现(需手工同步或删除 models 列表恢复)。目录 装载为懒加载(ensureCatalog,调和前必等待),但只认「路由键 = pi-ai 内置 provider 名」的路由;自定义路由键目录不可知 → 拒绝接管并提示先添加模型。 capsGet 对目录路由回退内置目录,能力显示不为空。 - 同一次暂存提交按 (route, model) 串行(一次一个键),同一模型连续勾两个维度会
合并成一次载荷,不会互相覆盖;重试型失败带 1.5s 退避,避免 DOM 变更风暴打
RPC,收到
document-updated立即清退避重试;RPC 带 15s 超时。
cordis.patch.yml 本包的 profile 层(dsh.bundle.patch 指向它)—— 官方安装时
由 dsh plugin 自动并入 dsh.profile.bundles
src/capabilities.ts 纯逻辑:efforts 形状 / 有效状态 / 合并(接管、受管关闭、幂等、
不复活)/ 影子段清理计划(随删自动清、目录路由不误清)/
目录条目字段保留转换(catalogEntriesOf)
src/index.ts Host:TypertRemoteService(service key = wire namespace
`modelToggles`)+ 三个 @Remote 端点(metaRoutes / capsGet /
capsSet)+ 影子段 + 调和引擎 + settings 事件接线
src/client/inject.ts DOM 注入层(aria-label/类名子串锚点,防御式、幂等)+
`editorCardOpen()`「官方卡片是否打开」判定 + 「待写入」提示
src/client/index.tsx 浏览器半边:按路由完整能力快照缓存 + token 失效保护 +
MutationObserver + 暂存表(卡片打开期间不写盘,关闭后提交)
src/client/rpc.ts ctx.connection.rpc.call('/api', 'modelToggles/<method>', { args }) 薄封装
scripts/smoke.mjs 冒烟:两个 bundle 真实求值 + 逻辑单测 + 真 cordis Context 上实例化
Service 断言 @Remote 标记 + 端点业务/收敛/复活防护直测
scripts/dom-test.mjs jsdom 集成:按官方编辑器真实 DOM 形状直测注入与上报
scripts/client-state-test.mjs 两模型同路由状态回归:事件失效 + capsSet 不得截断缓存
scripts/client-staging-test.mjs 暂存回归:卡片打开零写盘 / 草稿模型勾选不回弹 /
官方保存后自动补写 / 真实失败回显真相
scripts/verify-live.mjs 重启后一键活实例验证(只读)
scripts/check-shadow.mjs 影子段核对:解析 settings.yaml,报告指向不存在路由/模型的
无效键(目录 passthrough 路由跳过模型级核对)
scripts/legacy.mjs 旧安装方案(junction + profile 手写行)的幂等迁移清理
scripts/install.mjs 安装器(构建自检 + bundle 声明自检 + 迁移清理 + 官方 CLI + 层序核对)
scripts/uninstall.mjs 卸载器(官方 CLI remove + 迁移清理)
tsdown.config.ts 双面构建;host 半边经 ts.transpileModule 预降级标准装饰器
(`@Remote` 是 stage-3 语法,打包器不会为任何 target 降级它)
pnpm build # tsdown 双面构建(host ESM + client CJS + 纯逻辑产物)
pnpm typecheck # tsc --noEmit
pnpm verify # 冒烟 33 项(真 cordis Context 上断言 @Remote 标记 + 端点业务/
收敛/复活防护/影子自动清理/目录接管/事件接线)
pnpm test:dom # jsdom DOM 集成 7 项
pnpm test:client-state # 两模型同路由缓存回归(防止「勾一个另一个失效」)
pnpm test:client-staging # 暂存回归 7 项(卡片打开零写盘 / 草稿模型不回弹 /
# 官方保存后自动补写 / 真实失败回显真相)
pnpm test # typecheck + verify + test:dom + test:client-state
# + test:client-staging 五连
pnpm verify:live # 活实例只读验证(需 DSH 已重启加载本插件)
# 探测 `/api/modelToggles/metaRoutes`;脚本无会话 cookie 时得
# 401 = 端点已挂载且鉴权生效(这是期望);传 `?token=` 的 URL
# 或设 DSH_WEB_COOKIE 可做完整业务验证
自测期间发现并修复过的真实问题(回归测试均在案):dataset 连字符属性名
(浏览器会抛异常)、注入层全局 document 依赖、调和器「复活」被删除路由/模型的
缺陷(含事件调和漏传 allowCreate 被当成 true、官方编辑器每次保存都会把影子段
里已删除的模型按裸条目复活的接线 bug)、ensureCatalog 从未被调用导致目录接管
在生产恒失败的死接线、影子段自身变更事件触发清理会误删「刚写入、尚未调和成功」
的键、显示名撞名时勾选写错路由、未保存模型勾选可产生重复 id 条目,同一路由
单模型 capsSet 响应截断完整能力缓存、导致「勾一个另一个失效」的竞态,
调和器读条目时剥掉非受管字段、整写 models 数组把用户在官方编辑器保存的
contextWindow / maxTokens 一并抹掉的「上下文窗口丢失」,以及本轮修掉的两个:
卡片打开期间写盘导致官方「保存」必然 settings/conflict(用户可见提示
「这张卡片打开期间,这些设置已被其他地方改动…」)与新增(未保存)模型行勾选
后自动回弹(服务端 model-unsaved 被客户端当成失败立刻回读复位)——
修复方式是客户端暂存 + 卡片关闭/官方保存后提交,回归测试
scripts/client-staging-test.mjs 直接断言「卡片打开期间 capsSet 调用次数为 0」。
规范要点:host 半边是 TypertRemoteService(@Remote 端点由 Gateway 认领,无自建路由;Service 随 fiber 释放)、
settings 走 ctx.inject(['settings']) 可选依赖;@deepseek-ai/* 与
@earendil-works/pi-ai 保持 external(与运行时共享实例,pi-ai 为懒加载);
client 半边只 external react 家族、其余全内联、CJS 工厂经 __ModuleLoader__
注册;样式 style[data-plugin] 卸载清理。
- 勾选不是「点一下立刻落盘」,而是「卡片关闭/保存后落盘」:官方编辑卡片
把打开时的 settings revision 冻结在 React state 里且从不因转发事件重新取值
(
dsh-client-ui-settings-models的expectedRevision/openedAt,草稿也 只在卡片内部),所以卡片打开期间插件一旦写llm-pi-ai,官方下一次「保存」 必然失败(settings/conflict)。这是官方 CAS 语义下唯一无冲突的选择:控件 立即回显(写盘前是暂存态,带「待写入」提示),提交发生在卡片关闭之后。代价 是暂存意图只在浏览器内存里:卡片还开着时刷新/关闭标签页会丢(最多 5 分钟 后自动丢弃;新增模型草稿被拒时保留到官方保存或超时)。 - 界面锚点依赖官方编辑器结构(CSS-module 类名子串 + aria-label)。上游大改
DOM 时勾选可能不出现 —— 属非破坏性降级,更新锚点即可恢复。已在
0.1.2-rc.1 → 0.1.5-rc.1 上逐锚点核对:
modelEntry/modelRow/modelAdvanced/editorRoute/editorTitle/rowName/rowCard/setupCard/ 哈希前缀zGbnIq_全部未变(modelAdvanced仍是modelEntry的直接子节点)。没有官方「每个模型条目」的 slot,所以这层只能 DOM 注入。 - 路由解析依赖「显示名 → 路由键」目录(host 从 settings 实时提供);显示名与 路由键相同且无 editorRoute 文本时按显示名兜底。两个提供方同名时按歧义处理 (不注入、不写错路由)。
- RPC 鉴权交给 Connection + Api Gateway(Host/Origin 信任闸门 + 浏览器会话 cookie):
本机单用户下的威胁模型与「本机进程可直改 settings.yaml」同级。
verify-live/install.mjs的裸探测会拿到 401 —— 那是端点已挂载且受鉴权的信号。 - 为什么不是
ctx.remote.modelToggles.*:浏览器侧的./remote描述符挂载清单在dsh-api-remotes里是构建期写死的 15 个内置包,且上游明确写着「client runtimes need a separate composition owner before equivalent discovery is added」;第三方包 即使生成了./remote也不会被挂载。因此本插件走 Gateway 的**源模式(SRC)**认领: 端点确实挂在官方共享/api上、由官方分发与鉴权,只是浏览器侧没有类型投影。 上游补上客户端发现后,加一层./typert+./remote即可升级为完整 Typert。 - 装饰器:
@Remote只接受标准(TC39)装饰器形态,且装饰器是 stage-3 语法 —— 打包器不会为任何target降级,故tsdown.config.ts用ts.transpileModule预降级(与官方typertPlugin()对 TS 依赖的做法一致)。 - 桌面部署必须重启 DSH 才生效(不热重载 profile 层序);headless/CLI 每次启动
即生效(诊断日志
~/.dsh/dsh-model-toggles.apply.log可见 apply 三阶段:apply-enter→service-registered modelToggles)。 - 接管语义(见上):目录型路由一旦写显式 models 列表即脱离内置目录的自动更新。
- 插件 bundle 是 combo 形态
(
/plugins/??<id>/client.js&rev=<每次启动的随机 rev>,单文件形态不再应答); 浏览器经 boot graph 拿 combo URL 加载,verify-live需要读首页 graph, 匿名运行时该项自动降级为跳过。