DSH plugin 界面开发:设置卡片怎么注册、Host 半与浏览器半怎么配合、client 打包怎么写
DSH plugin 的界面开发不是「写个前端页面」,而是在同一个包里写两半代码:Host 半用 ctx.settings.installSection() 注册一个设置命名空间,浏览器半向 settings.plugin.item 槽注册一张卡片;两半靠同一个命名空间自动配对,无需改动宿主仓库。 无论叫 DSH插件 还是 DeepSeek插件,界面开发的这套两半结构完全一致。
DSH plugin 界面开发的两半结构:一个包,两个入口
设置页由 Host 半与浏览器半组成,缺一半卡片不会出现。 官方 Cookbook 的原话是:Host 服务每个已注册的设置命名空间,「Plugins」区按卡片编辑的命名空间给它们配对,两半都在一个包里——Host 半在 src/,浏览器半在 src/client/,通过 exports['./client'] 导出并用 dsh.client 声明(来源)。
my-plugin/
├── src/index.ts # Host 半:注册命名空间
├── src/client/index.tsx # 浏览器半:注册卡片
└── package.json # exports['./client'] + dsh.client
配对键只有一个:命名空间。 建议把它写成常量(如 MY_PLUGIN_NS = 'my-plugin'),两半引用同一个常量,避免拼写漂移导致「卡片没出现」。
DSH plugin 的 Host 半:注册设置命名空间
已有 cordis.yml 条目的插件走 ctx.settings.installSection(),它会把条目分层放在用户文档之下,并且在没有设置提供方挂载时依然工作。 官方给出的宿主半写法如下:
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-settings'
import z from '@deepseek-ai/schemastery'
export const MY_PLUGIN_NS = 'my-plugin'
export interface Config {
endpoint?: string
retries?: number
}
export const Config: z<Config> = z.object({
endpoint: z.string(),
retries: z.number().step(1).min(0).default(3),
})
export function apply(ctx: Context, config: Config) {
let source = () => config
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.installSection(ctx, MY_PLUGIN_NS, Config, config, {
// schema 表达不了的约束:拒绝这次写入,而不是留到下次使用才报错。
validate: value => void assertReachable(value.endpoint),
setSource: (current) => { source = current },
onChange: () => { rebuildFromSettings(source()) },
})
})
}
两个容易忽略的声明:给字段加 role('secret') 会让它的值不出现在任何响应里(卡片要么把它写进 update/mutate 载荷,要么通过 credentials 域引用凭据);applies: 'restart' 告诉配置界面「拥有者要到下次启动才真正生效这次变更」。
DSH plugin 的浏览器半:把卡片挂进槽位
卡片注册进 settings.plugin.item 槽,key 必须与 Host 半的命名空间一致。 官方示例:
import type { Context as ClientContext } from '@deepseek-ai/cordis'
// 仅类型导入:该槽位的声明。跨插件协作一律走 cordis 服务,
// 值导入会被 client 的 bundle-purity 关卡拦下。
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
export function apply(ctx: ClientContext): void {
const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: MY_PLUGIN_NS }))
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
name: 'settings.plugin.item',
key: MY_PLUGIN_NS,
locale: 'settings.myPlugin',
inject: () => card.inject(),
}, MyPluginCard))
}
卡片拥有自己内部的一切:外观、控件、文案。注意上面那行 import type {}——跨插件只能用类型导入,值导入会被 bundle-purity 关卡拒绝。
DSH plugin 配置读写语义:value / base / user 三层
作用域快照携带表单需要的三层信息,判断「是否被覆盖」看的是键的存在性而不是值。(来源)
| 层 | 含义 | 用途 |
|---|---|---|
value | 解析后的生效值 | 表单回显 |
base | 组合层 | 展示「默认来自哪里」 |
user | 原始用户层 | 键存在 = 该字段被用户覆盖 |
写操作用 scope.set(field, value) 存单个字段,scope.unset(field) 把它清回组合层。每次写入都用读到的 revision 做栅栏——这能避免两个设置面板同时修改时互相覆盖。
DSH plugin 界面显示规则与打包要求
显示规则很直接:Host 服务该键且卡片注册了该键才渲染;Host 没服务就整张卡片不出现;Host 服务了但没有卡片认领,则什么都不渲染——这正是 ui-theme、permission、llm-* 这些命名空间不出现在该标签页的原因。卡片顺序等于注册顺序,带 key 的条目不能自己声明 order。
打包三条硬要求(来源):
package.json用exports['./client']暴露浏览器半,并声明dsh.client(含platform与它依赖的 client 包);- bundle 必须是加载器期望的 lazy-CJS 工厂产物——仓库外没有公开 preset,需要自行复现同样的输出格式;
- 不能跨插件值导入:卡片要自带外观与暂存模型,自己拥有暂存与 revision 栅栏。
只要 cordis.yml 挂上这个插件,它就出现在页面上,无需重建 Web 应用——因为客户端模块系统是扫描已启用条目、按 dsh.client 服务构建产物的。
DSH plugin 界面开发自检与下一步
发布前过一遍:
- 命名空间常量是否两半共用;
- Host 半是否处理了
validate(schema 表达不了的约束)、setSource、onChange; - 敏感字段是否加了
role('secret'),需要在下次启动才生效的是否标了applies: 'restart'; - 卡片是否只用类型导入跨插件;
exports['./client']与dsh.client是否齐备,bundle 是否为 lazy-CJS 工厂产物。
工具调用的卡片呈现是另一条线(presentCall / presentResult,且必须是纯函数),见 怎么写工具插件。界面之外的配置声明见 插件配置怎么用;打包与发布见 打包成 bundle 与 发布到插件中心。
常见问题
**DSH plugin 的设置页由 Host 半与浏览器半共同构成**:Host 半注册命名空间并决定服务哪些配置键,浏览器半注册卡片并决定怎么渲染。**两半靠命名空间自动配对**——「Plugins」区按卡片编辑的命名空间给它们配对,所以你只要在一个包的 src/ 与 src/client/ 里各写一半即可,无需改动宿主仓库(来源:官方 Cookbook「添加设置卡片」)。
**DSH plugin 的 Host 半用 ctx.settings.installSection() 注册设置命名空间**:传入插件上下文、命名空间常量、Config schema、当前配置与 { validate, setSource, onChange }。判断标准是「这个插件是否已有 cordis.yml 条目」——已有条目的消费方走 installSection 更稳,它会把条目分层放在用户文档之下,并且在没有设置提供方挂载时依然工作(来源:官方 Cookbook「添加设置卡片」)。
**DSH plugin 的浏览器半把卡片注册进 settings.plugin.item 槽**:用 ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name, key, locale, inject }, Card)),其中 key 必须是 Host 半注册的同一个命名空间。卡片内部通过 ctx.settingsScope.bind({ namespace }) 拿到作用域来读写配置(来源:官方 Cookbook「添加设置卡片」)。
**DSH plugin 设置卡片的作用域快照携带表单需要的三层信息**:value 是解析后的生效值、base 是组合层、user 是原始用户层。判断某个字段是否被用户覆盖,看的是 user 里那个键的**存在性**而不是它的值;scope.set(field, value) 写入单个字段,scope.unset(field) 把它清回组合层(来源:官方 Cookbook「添加设置卡片」)。
**DSH plugin 发布浏览器半时对打包有三条硬要求**:① package.json 用 exports['./client'] 暴露浏览器半并声明 dsh.client;② bundle 必须是加载器期望的 lazy-CJS 工厂产物;③ 不能跨插件做值导入——client 的 bundle-purity 关卡会拒绝跨插件的 value import,所以卡片要自带自己的外观与暂存模型。仓库内用共享 preset 的 clientBundle(),仓库外需自行复现同样的输出格式(来源:官方 Cookbook「添加设置卡片」)。
相关术语
- settings.installSection()
- installSection 是 DSH plugin 的 Host 半注册设置命名空间的入口:传入上下文、命名空间、Config schema、当前配置以及 validate / setSource / onChange 回调,让该插件的配置出现在设置页的「Plugins」区。— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-settings-card.md
- settings.plugin.item 槽
- settings.plugin.item 是设置页「Plugins」区的槽位,DSH plugin 的浏览器半把卡片注册进去;槽位按 key(即命名空间)配对 Host 半,Host 未服务的键不会渲染卡片。— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-settings-card.md
- ctx.settingsScope
- settingsScope 是 DSH plugin 浏览器半读写配置的作用域,每个写入都用读到的 revision 做栅栏;快照含 value / base / user 三层,set 写单字段、unset 清回组合层。— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-settings-card.md
- dsh.client
- package.json 里的 dsh.client 字段声明该 DSH plugin 带浏览器半,客户端模块系统据此扫描已启用条目并服务其构建后的 ./client 导出,因此挂进 cordis.yml 即出现在页面上,无需重建 Web 应用。— https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-settings-card.md
来源
- DeepSeek Harness 官方文档 - Cookbook:添加设置卡片· deepseek-ai
- DeepSeek Harness 官方文档 - 工具编写参考(UI 卡片)· deepseek-ai
- DeepSeek Harness 官方文档 - 插件配置(Config schema)· deepseek-ai