读完这篇你能:把插件里写死的图片路径、透明度改成交互配置,不改代码、不重装就能换图。
前置:05-插件生命周期与 ctx.effect。约 20 分钟。
示例代码:dsh-chat-background-config
DSH 版本:0.2.0-rc.2
04-注册 HTTP 路由与注入页面样式里,图片路径是写死的常量:
const IMAGE_PATH = new URL('../image.jpg', import.meta.url).pathname
样式表也不接受参数——透明度想要可调,得先有人把值送进 CSS。
想换张图、调个透明度,只能改源码——改完还要重装、重启。如果你一个人用、一辈子只换一次图,这没问题。下面任意一条成立,就该把它升级成配置:
- 换台机器(公司 / 家里)要换图,但不想每台机器维护一份不同的源码;
- 想现场试几个透明度,而不是”改一次、装一次、重启一次”;
- 插件要给别人用,别人不该碰你的源码。
这篇把 04-注册 HTTP 路由与注入页面样式的背景插件做成可配置版本,代码单独放在 dsh_plugin/dsh-chat-background-config/。
目标
改完之后,换图只需要在 profile 里写一段配置:
# ~/.dsh/profiles/test/cordis.patch.yml
- id: dsh-chat-background-config
config:
image: /Users/you/Pictures/cat.jpg
opacity: 0.5
保存配置,背景变成 cat.jpg、透明度 0.5(配置热重载默认开着,不用重启)。什么都不写,就用插件自带的那张图、透明度 0.35。
DSH 设计
把写死的常量升级成配置之前,先看看配置在 DSH 里放在哪。插件树里每一行叫一个「条目」(entry),写着插件的名字、代码位置,以及一块可选的 config——「插件配置」就存放在这里。同一个条目的 config 可以有多个来源、按层叠加(详见文末「配置的层叠」),但无论来自哪一层,最终都以整块出现在条目上:补丁改 config 是整块替换,不做逐字段合并。这一点直接决定了插件的写法:插件必须在自己的 schema 里声明默认值,否则没人写 config 时它就没有东西可用。
配置从条目到插件手里,要先过一道关。DSH 把一个已加载的插件实例叫「fiber」,fiber 的定义就包括「持有经过校验的配置」。校验靠一个约定完成:插件导出一个名叫 Config 的校验器,它实现的是一种通用校验接口,名叫 standard-schema。校验库按这个规范暴露自己的校验器,框架按接口收,不关心你选了哪个库。装载时,框架先拿它校验条目上的 config 块。通过了,就按校验器里声明的默认值把缺的字段补全,配置归 fiber 持有,插件的启动函数才开始执行。通不过就抛 ValidationError,fiber 停在 FAILED 状态,启动函数根本不会跑。「配置写错当场失败」靠的不是插件自觉,是这道关卡的先后顺序。
改配置时走的还是这同一道关。fiber 有一条专门的更新通道:新配置在任何重载发生之前,先过同一道校验。通过了,才卸载旧实例——它注册过的路由、监听器等随之全部撤销——然后带着新配置重新装载。通不过,错误直接抛出,旧配置接着跑。「保存配置就生效」和「改坏了不会把插件搞挂」,是同一条设计的两面。
这张图同时解释了三条写配置时的自然推论:默认值只需写在校验器一处(补丁没写的字段由它兜底)、约束要尽量写进校验器(它在这道关上执行)、非法值宁可当场报错(好过一个带着错配置跑起来的插件)。
Config 的导出
Cordis 认一个约定:插件导出一个名叫 Config 的值,它就会在调用 apply 之前拿这个值校验 entry 上的 config 块,并把缺的字段补上默认值。
// src/dsh-chat-background.ts(新增部分)
import Schema from '@deepseek-ai/schemastery'
// 04-注册 HTTP 路由与注入页面样式 里写死的图片路径,现在降级成"默认值"
const DEFAULT_IMAGE = new URL('../image.jpg', import.meta.url).pathname
export interface Config {
/** 背景图片的绝对路径。 */
image: string
/** 图片显示强度:0 完全透明,1 完全不透明。 */
opacity: number
}
export const Config: Schema<Config> = Schema.object({
image: Schema.string().default(DEFAULT_IMAGE),
opacity: Schema.number().min(0).max(1).default(0.35),
})
export function apply(ctx: Context, config: Config): void {
// ...
}
- 默认值写在 schema 里(
.default(...)),不要写成apply里的config.image ?? ...。apply拿到的永远是补齐后的完整配置,代码里不用再防undefined。 - 约束也写在 schema 里。
.min(0).max(1)表示透明度只能是 0~1,Schema.string()表示图片路径必须是字符串。约束在这里声明,Cordis 就在加载时替你执行。
别忘了在本插件package.json中添加依赖——Schema 来自 @deepseek-ai/schemastery,不在 cordis 里。
"dependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
"@deepseek-ai/schemastery": "^3.18.2"
}
这份 package.json 是”插件的清单”:插件代码 import 的包都写在这里;profile 的 package.json 是”要挂哪些插件”的清单。两个文件分属两层,别混。
cd dsh_plugin/dsh-chat-background-config
pnpm install
config 的接入
配置声明好了,接下来是”哪里用它”。两处。
路由读取 config.image
图片路径可配之后,content-type 也得跟着路径走。先补一张扩展名对照表:
import { extname } from 'node:path'
// 允许的图片扩展名 → Content-Type;换成 PNG/WebP 时响应头要跟着变
const CONTENT_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
}
handler 只改一处——读 config.image,而不是模块顶层的常量:
ctx.inject(['webServer'], (serverCtx) => {
const webServer = serverCtx.webServer
ctx.effect(() => webServer.register({
kind: 'exact',
path: IMAGE_URL,
handler: async (_req, res) => {
const contentType = CONTENT_TYPES[extname(config.image).toLowerCase()]
?? 'application/octet-stream'
res.writeHead(200, { 'content-type': contentType })
res.end(await readFile(config.image))
},
}))
})
注意路由地址 IMAGE_URL 本身没变——浏览器拿图的通道是固定的,变的是通道背后读哪个文件。
样式改用 CSS 变量
04-注册 HTTP 路由与注入页面样式的 style.css 是静态文件,透明度一进来它就不再静态了。改法是把唯一的动态值提成 CSS 变量,样式表本身尽量不动:
/** 用配置值生成样式:静态选择器来自 style.css,只有遮罩浓度随配置变化。 */
function buildStyle(config: Config): string {
// 透明度的实现:在图片上盖一层与页面底色同色的遮罩,越不透明遮得越多
const veilPercent = Math.round((1 - config.opacity) * 100)
// var() 的兜底不能省:自定义属性的 var() 替换发生在「声明它的那个元素」上,
// 变量解析不出来时,用到它的整条 background-image 会一起失效(背景图消失)
const veil = `color-mix(in srgb, var(--dsw-alias-bg-base, transparent) ${veilPercent}%, transparent)`
const styleSheet = readFileSync(new URL('./style.css', import.meta.url), 'utf8')
// 变量定义在 body 上,不能放在 :root:--dsw-alias-bg-base 由主题插件定义在
// body,html 上取不到它,定义在 :root 会变成非法值,连带背景图整条不生效
return `body { --dsh-chat-background-veil: ${veil}; }\n${styleSheet}`
}
style.css 里对应改一行:
div:has(> [data-shell-overlay]) {
background-image:
linear-gradient(var(--dsh-chat-background-veil), var(--dsh-chat-background-veil)),
url('/dsh-chat-background-config/image.jpg');
background-size: cover;
}
为什么不直接在
style.css里写opacity: 0.35?因为那会把整个布局根、连同里面的文字和按钮一起变透明。图片的”透明度”是盖一层与页面底色同色的遮罩实现的:opacity越低,遮罩越浓。--dsw-alias-bg-base是 DSH 主题里的页面底色变量,明暗主题各有一份。
config 的位置
插件自己那份补丁(本包叫 patch.yaml,就是真正由 dsh.bundle.patch 声明进去的那份)只负责”把插件挂上树”,不写 config:
- insert:
- id: dsh-chat-background-config
name: '/绝对路径/dsh-chat-background-config/src/dsh-chat-background.ts'
你机器上的偏好写在 profile 的 cordis.patch.yml 里,用 entry 的 id 定位:
# ~/.dsh/profiles/test/cordis.patch.yml
- id: dsh-chat-background-config
config:
image: /Users/you/Pictures/cat.jpg
opacity: 0.5
理由和 01-profile、bundle 与插件的关系解释 profile 时是同一个:插件是”能力”,配置是”这台机器上的选择”。源码可以共享、可以发布;这台机器用哪张图,不该混进源码里。
安装与验证
cd 你的插件目录
dsh plugin --profile test add link:.
dsh --profile test --dump-config # 插件树里出现 dsh-chat-background-config
dsh --profile test --no-open # 启动,web 端口 3080
--dump-config 打印的是各层 patch 合成后的结果。改完 cordis.patch.yml 里的 config,再跑一次 --dump-config,就能确认新值真的进了配置树——比”重启后盯着页面猜”快得多。
自建 profile 默认就开着配置热重载:改完 cordis.patch.yml 保存就生效,不用重启启动命令(条件与边界见07-配置与源码的热重载)。
配置的层叠
一个 entry 的 config 不只一个来源。完整顺序是:
后面一层按 id 覆盖前面一层。而且覆盖是整块替换,不是逐字段合并:你写 config: { opacity: 0.5 },这一行原来整个 config(比如 { image: ..., opacity: 0.35 })都会被换掉,image 回落到 schema 默认值,而不是保留上一层的值。
由此得到一条规矩:默认值只写在 schema 一处。 不要一半放 schema、一半放插件自带的 patch.yaml,否则你在 profile 里覆盖另一个字段时,前一半会悄无声息地消失。真要在 profile 层覆盖,就把要保留的字段一起写全。
非法配置当场失败
schema 不只是文档,它在加载时执行。把透明度写成 1.5:
- id: dsh-chat-background-config
config:
opacity: 1.5
插件不会”凑合用默认值”,而是在 apply 之前就失败:
ValidationError: invalid config:
- $.opacity expected number <= 1 but got 1.5 (at opacity)
对应的 fiber 停在 FAILED 状态,插件根本没启动。
这正是我们想要的:配置错误在加载时炸掉,比插件带着一个你没打算要的值跑起来、过一会儿表现成”背景看着不太对”好排障得多。所以不要把 schema 写得太宽松——类型、范围、枚举这些能表达的约束都写进去,别用”容错”把错误吞掉。
image 指向的文件不存在,schema 判不了——它不知道你的磁盘上有什么,只能在请求到达、readFile 报错时暴露。引用型的配置(文件、服务、别的插件)大多如此:能自证的约束在 schema 里当场失败,要引用别的东西的,得等解析到它时才失败。
注意事项
id要对得上。 profile 的覆盖靠 entry 的id定位;id写错,这条 patch 会警告”entry not found”然后跳过,插件照常按默认值启动——现象就是”配置没生效”。- 图片路径写绝对路径。
readFile解析相对路径时基准是 DSH 进程的工作目录,不稳定;配置里直接给绝对路径。 - 跟 04-注册 HTTP 路由与注入页面样式的插件二选一。 本文的插件是独立一份(名字带
-config)。两套都挂着时,两边会往同一个布局根注入背景样式,后注入的盖住前面的。先在 profile 里用03-用 patch 覆盖 DSH 的默认配置里那招停用 04-注册 HTTP 路由与注入页面样式的那份(- id: <它的 entry id>+disabled: true)。 - 改 config 保存即生效。 配置热重载默认开着,不用重启进程;源码热重载默认关着,怎么开、哪些改动必须重启,见07-配置与源码的热重载。