dsh 插件开发学习

设置项与卡片速查

本文是知识卡,不用顺序读。给插件加设置项、或者在浏览器半里读写它时来查。

DSH 版本:0.2.0-rc.2

基准版本:dsh 0.2.0-rc.2(源码快照 local/deepseek-harness-dsh-v0.2.0-rc.2/)。文中每个成员名与行为都按这份源码核对过;换成别的版本时先读那一版的 docs/subsystems/settings.zh.md 与 docs/cookbook/adding-a-settings-card.zh.md。

对应源码:packages/settings/settings/src/index.ts(Host 侧 SettingsForms)、packages/client/ui-settings/src/client/config-form.ts(浏览器侧 configForms 与控制器的 Context 合并)、packages/client/ui-plugin-manager/src/client/slot-contract.ts(插件页的三个配置挂载点)、packages/client/ui-plugin-manager/src/client/config-ledger.ts(行配置的 key 规则)。

一次完整的注册与读写链路是:

mermaid 图表

Host 侧:volatile 字段

设置页只投影声明为 volatile 的字段,普通字段被排除在外。插件不注册任何东西:字段标上 .volatile(),表单就随这个 profile 条目存在。

写在哪 说明
interface Config 字段类型写成 Volatile<T>(从 @deepseek-ai/cordis 导出)。它描述 apply 收到的值
Config(schema) 字段链上加 .volatile(),例如 Schema.string().default(x).volatile()。不要给这个常量写 : Schema<Config> 标注——volatile 字段的序列化形态与接口里的 Volatile<T> 引用不同,标注会与 schemastery 的 default 签名冲突
读值 所有用到配置的地方改成 config.image.get()。稳定引用:值变了引用不变,不必重启插件
默认值 仍写在 schema 的 .default(...) 里,层叠规则不变
密文 role('secret') 的字段不进表单响应,改由凭据域管;要现场改的秘密字段标 .volatile()

哪个该标 volatile:会被人现场调的(图片、透明度、上限)。影响加载行为的(依赖哪个服务、监听谁)不要标——volatile 提交不重启插件,只有重启才生效的字段要留在普通字段里。

Host 侧:ctx.settings(SettingsForms)

成员 说明
describe(options?) 读活动条目上的 schema 与实时值,按 profile 条目 id 建表。远程调用要带 redactSecrets
update(ns, patch, revision?) 合并提交的字段
replace(ns, section, revision?) 先把即时字段重置回继承配置,再应用提交的字段
mutate(ns, ops, revision?) 逐路径提交,保留客户端响应里没返回的秘密值。ops 是 { op: 'set', path, value } \| { op: 'unset', path }
configure({ auto }, owner?) 只改这个插件实例的页面策略,不动 Config。auto 默认 true(允许客户端按 schema 自动生成页面);自带页面的插件传 false。同一实例注册两次会抛错
writable 当前 profile 收不收表单编辑
documentPath / prepareDocument() 原生配置编辑器要编辑的那份 profile patch
settings/document-updated Loader 配置变化后表单描述符失效。这是给 UI 的通知;插件只在要刷新注册信息时才听它

表单的 namespace

namespace 就是条目在当前 profile 里可唯一定位的那个本地 id,不是插件自起的字符串。我们的插件是 dsh-chat-background-settings:条目挂上,表单就存在;条目停用,表单跟着消失。同一个插件被配置两次时,两个条目 id 不同就是两份独立表单。

因此没有「namespace 命名规则」要遵守——唯一要求是这个 id 在 profile 里能被唯一定位。content 之类的嵌套条目仍只能改文件。

浏览器侧:ctx.configForms

成员 说明
get(entryId) 取(并按需创建)某个条目的表单。同一个 entryId 复用同一个实例,由服务持有,调用方不需要 dispose 它
whileServed(namespaces, register) 只在这些 namespace 被 Host 服务期间保留注册:register 跑一次并返回自己的 disposer,一旦没有任何一个被服务就撤销。注册页面用它,这样没配置该插件的部署里页面不留痕迹。调用方拿返回的 disposer 包进 ctx.effect
describe() 跨 namespace 的只读面(schema 自省、已服务 namespace 目录),与 get 同源

快照字段表:ConfigFormSnapshot<T>

getSnapshot() 的返回,也是卡片组件 props.form.state 的类型。

字段 说明
status 'loading' / 'ready' / 'unavailable'。Host 不服务这个 namespace,或连接把偏好留在进程内(memory 模式)时是 'unavailable'
value 最后一次被接受的、经 schema 解析的 section。首次接受前是 undefined
base Host 解析 value 时叠在上面的组合层,字段被清空后回落到的就是它
user 用户层原始内容。字段出现在这里就算被覆盖过,哪怕值和默认相同——判断「改没改过」看键在不在,不要比 value
revision namespace 修订号,写的时候带上做并发围栏;首次拿到 Host 视图前是 undefined
writable Host 文档收不收写入;memory 模式永远为 false
mode 'host'(与 Host 文档同步)/ 'memory'(远程页面,进程内)

写入方法表:ConfigForm<T>

成员 说明
getSnapshot() 当前快照,下次变化前返回同一引用,可直接喂给 useSyncExternalStore 或快照 store
subscribe(listener) 订阅快照替换,返回取消订阅的函数
set(field, value) 写一个字段
unset(field) 清掉一个字段,让它回落到组合层
mutate(ops, expectedRevision?) 一次原子提交一组路径操作,所有操作共用一个修订号、一次校验、一次持久化。传 expectedRevision 就用读到的那个修订号做围栏
返回值 三个写方法都返回 Promise<boolean>:true 是 Host 接受,false 是拒绝或跳过(已做恢复读)。传输层失败才 reject —— 判断「存没存上」看返回值,不要靠 try/catch

浏览器侧:卡片槽位

插件页(侧边栏「插件」,dsh-client-ui-plugin-manager)给别人的配置留了三个挂载点:

槽位 形态 给谁用 选项
plugins.item list 排官方 bundle 后面的官方插件配置页(内置那几个设置页一个包一个) id / order / label(可用 () => t('...') 跟语言走)/ locale
plugins.bundle.config keyed 整个 bundle 的配置,渲染在 bundle 页上 key = 包名
plugins.row.config keyed bundle 里某一行的配置,从那一行打开 key = <包名>#<行 id>

行配置的 key 由 rowConfigKey(bundle, rowId) 生成,就是 <包名>#<行 id>:包名是该行所在 bundle 的包名,行 id 以那份 patch 声明的为准(我们的 demo 两者同名,于是 key 是 dsh-chat-background-settings#dsh-chat-background-settings)。

条目组件收到的两个东西(PluginConfigViewProps):

prop 说明
view 'summary' 时渲染一句话摘要(行没有描述时顶上去),'page' 时渲染表单。同一个组件会被渲染两次,两种都要处理
form 只有 'page' 给,类型是 ConfigPageForm:{ state: ConfigFormSnapshot, mutate }。写入用它的 mutate(ops, state.revision),不要自己再造一份状态

浏览器半只用类型引入声明这些槽位的包(import type {} from '...'),运行时一个都不 import——页面已经把实现放进模块表了。

落盘位置与形状

设置页的写入落在当前 profile 的 patch里(packages/boot/config-editor 负责写,prepareDocument() 给出路径)。Home patch 与命令行 --patch overlay 参与优先级解析,但不是写入目标。旧的 $DSH_HOME/settings.yaml 已经废弃:启动时它的分节被并进活动 profile,原文件改名留存,之后不再读写。

两条生效路径对照

  改装载层(cordis.yml / patch / --patch) 设置页保存
写哪份文件 装载层配置 当前 profile patch
插件是否重启 是。这一行的 fiber 被 dispose,apply 带新 config 重跑 改动只有 volatile 字段时否:Loader 做 volatile 提交,新值直接进运行中的引用
谁读到新值 新一次 apply 里的 config 插件下一次 config.field.get()
改动里混着普通字段 照常重启 整个走普通重挂载——那是普通字段的既有待遇
通知 配置重载相关事件 loader/volatile-update(只派给拥有这份配置的插件)
失败时 该行回滚到原配置继续跑,告警 写被拒绝:返回 false,文件与运行值都不动;候选值不合法时 Loader 记日志、保持运行值