dsh 插件开发学习

14-把插件配置搬进设置页

读完这篇你能:把插件的配置项搬到设置页,同事在界面上改背景图、调透明度,不碰 cordis.yml;并且知道一个带界面的插件为什么要分 Host 与浏览器两半。

前置:13-用 Skill 固化提示词与规则。约 30 分钟。

示例代码:dsh-chat-background-settings

DSH 版本:0.2.0-rc.2

06-用 Config schema 声明插件配置把背景插件做成了可配置的:换图、调透明度不用改代码,在 profile 的 cordis.patch.yml 里写一段 config 就行。这套机制这篇原样保留——schema、层叠、非法值当场失败,一条都不动。

但插件给同事用时,「改 cordis.yml」本身就是门槛:他得找到 profile 目录、看懂 patch 语法、知道 id 不能写错、config 是整块替换——写错了插件还起不来。图片路径和透明度偏好每人不同,这些本该是「在界面上填个框」的事。

这篇给 06-用 Config schema 声明插件配置的插件加一个人类用的界面。DSH 自带一个设置服务,能把插件 Config 里声明为「可现场改」的字段投影成表单;我们再给插件带上一小截浏览器代码,把表单挂到「插件」页上。同事打开页面、改值、保存,不碰任何 YAML。代码单独放在 dsh_plugin/dsh-chat-background-settings/。

目标

  • 同一个背景插件,image / opacity 两个字段不变,升级成带设置页的版本:同事在侧边栏「插件」页打开它的配置页直接改。
  • 同事拿到插件只跑一条 ./install.sh,之后换图、调透明度全部在界面上完成。
  • 保存即生效:值落进 profile 的 cordis.patch.yml(设置服务替你写),运行中的插件不重启就拿到新值。
  • 06-用 Config schema 声明插件配置的 Config + schema + 层叠规则全部保留;这篇讲的是同一条链上的新入口。

DSH 设计

插件想上界面,就得分两半,桥是 web 界面栈的一项可选能力。插件代码跑在 DSH 的 Node 进程里,能读磁盘、开路由,但够不着浏览器里的页面:页面是另一个世界,那里没有 node:fs。一个插件想在页面上有一截自己的代码(浏览器半),靠的是客户端模块系统。它扫描运行中的插件行,找出在 package.json 里声明了「我有浏览器半」的包,把每个包的浏览器构建产物挂到带版本号的路由上,并在每次渲染页面时把「哪个包、脚本在哪」这张表注入页面。它不属于驱动对话的主干:没有界面的运行方式里它不存在,插件的 Node 半照常工作。这正是插件本体不 import 任何浏览器代码的原因。浏览器半的归属单位是包而不是插件行:扫描从每一行的代码文件向上找最近的 package.json,包名就是浏览器半的身份,所以一个包只有一份浏览器半。想让某个子插件的页面独立存续,就把它拆成自己的包。

界面挂上去了,设置服务在配置链上又是什么位置?先回顾配置本身:插件可以声明自己的配置字段(每个字段的类型、默认值、校验规则),配置时按固定的层叠顺序合成最终值,层叠的每一层是一份「补丁」(一份 YAML 文件,逐行声明插入、修改、停用哪个插件行)。设置服务做的事只有一件:把每个已挂载插件行里标了「可现场改」(volatile)的字段投影成一份表单,普通字段一律排除。所以它是配置的投影,不持有权威值。表单不需要注册:插件行挂上了,投影就在;表单的标识就是那一行在配置清单里的 id,也不需要声明对应关系。保存走回头路:先用插件的整份配置校验,非法值、过期的版本号都直接拒绝;再由配置编辑器把值写回当前这套配置方案的补丁文件。权威值从头到尾只有层叠后的配置,设置服务自己什么都不存。会被更高层补丁盖住的写入同样直接拒绝,这也是投影逻辑的一部分:写进去却生不了效的值,不如不收。

「可现场改」本身也不是设置服务的特例,它是装载器(Loader:按配置清单挂载插件、管它们生命周期的部件)的引用机制。字段标了 volatile,插件拿到的就不是一个裸值,而是一个稳定引用:

// 普通字段:apply 拿到一个裸值,配置变了要走重挂载才读得到新值
const image: string = config.image

// volatile 字段:apply 拿到一个稳定引用,值变、引用不变
config.image.get()   // 每次调用读到的都是当前值

「值变、引用不变」由装载器承担:配置热重载到达时它比对前后两份配置,发现只有 volatile 字段变了,就把新值解析后直接提交进运行中的引用,插件不重启,并只向拥有这份配置的插件派一个通知事件(依据 local/deepseek-harness-dsh-v0.2.0-rc.2/vendor/loader/README.md)。设置页的「保存即生效」只是这条机制的一个触发源;手改补丁文件里同一行的 volatile 字段,走同一条快道。混着普通字段的改动享受不到它,那回到普通字段既有的重挂载待遇。

mermaid 图表

最后是界面一侧的两个设计。配置页的挂载点由界面声明:自带配置的插件渲染在界面的「插件」页,用的是该页声明的三个挂载位——按官方插件、按整个组合包(bundle:一批插件打包在一起的分发形式)、按某一行(键是「包名#行 id」)。插件的浏览器半把组件注册到对应的键上,页面再按行的 id 找到设置服务里的同名表单喂给它。另一个设计是密钥从设计上不出进程:字段声明里标了「密钥」角色的值一律不进表单响应,页面只拿得到「设没设过」的标记,表单不是配置的全量镜像。每份表单还带一个「自动生成页面」的策略位(默认开),留给将来按字段声明自动生成界面的客户端;目前没有任何客户端实现它,所以页面得插件自己写。

设置页因此只是同一条配置层叠链上的一个新入口:值从层叠来,回层叠去,运行中的插件经引用机制当场拿到新值。

一个包两半

到目前为止,专栏里所有插件代码都跑在同一个地方:DSH 的 Node 进程(叫 Host)。它能读磁盘、注册 HTTP 路由,但它够不着浏览器里的页面——页面是 DSH 自己渲染的 React 应用,跑在另一个世界,那里没有 node:fs,也读不到你磁盘上的源码。

设置页是页面的一部分,所以插件要分两半:

  • Host 半:就是原来那个插件(src/dsh-chat-background.ts),照旧由 Loader 按 patch 挂进 Host 进程。
  • 浏览器半:一个新文件(src/client/index.tsx),跑在浏览器里,负责把配置页挂到界面上。它也叫客户端模块——插件随包携带的、由页面加载的那一截代码。

谁把浏览器半送进页面?DSH 的客户端模块系统(dsh-client-modules,web 界面自带的服务)。它做三件事:扫描 Loader 已挂载的行,找出声明了「我有浏览器半」的包;把每个包的浏览器半构建产物挂到 /plugins/<包名>/client.js 路由上;每次渲染页面 HTML 时,把当前这张「包 → 脚本地址」的表注入页面(window.__DSH_BOOT__)。浏览器里的模块表按表取回脚本、登记,然后在浏览器侧的另一个 Cordis 上下文里跑浏览器半的 apply——两边都是 Cordis 插件,只是各跑各的进程。

mermaid 图表

行与包的归属是这样定的:扫描从每一行的模块文件往上找最近的 package.json,那个包就是这一行的身份。我们的行写的是 src/dsh-chat-background.ts 的绝对路径,往上走就是本包自己的 package.json——所以「我有浏览器半」的声明写在它身上就行。

浏览器半的生死跟着行走:行被停用(比如 03-用 patch 覆盖 DSH 的默认配置的 disabled: true),它注册的配置页跟着消失;行回来,页面也回来。Host 半与浏览器半不需要互相 import——两边各跑各的 apply,靠 entry id 这个共同的名字在设置服务里会合。

04-注册 HTTP 路由与注入页面样式的 webserver/index-inject 能往页面里注入样式和脚本,为什么设置页不走那条路?注入的是死内容,而设置页是活的:它要读当前值、提交修改、跟着页面的主题和语言走。这些是页面应用内部的活,得由跑在页面里的代码来做。

dsh.client 与 ./client

「我有浏览器半」的声明是 package.json 里的两个字段:

"exports": {
  "./client": "./lib/client.js"
},
"dsh": {
  "bundle": { "patch": "./patch.yaml" },
  "client": { "platform": "web" }
}
  • dsh.client:声明这个包有浏览器半,platform: 'web' 表示它给 web 界面用。扫描看到这个字段才会继续;声明了却找不到 ./client 导出,启动时直接报错——启动期的失败是响亮的,不会静默跳过。运行期(比如热重载之后)某个包的浏览器半坏了,只记一条警告,不拖累其他包。
  • exports["./client"]:浏览器半的构建产物地址。注意它指向 lib/client.js 而不是源码——页面加载的是打包后的文件,所以包里多了一步构建(下节)。

dsh.bundle 那行是 02-最小插件的结构与配置就有的老声明,不变。

浏览器半的构建

产物必须长成一个固定样子,页面的模块表才认得:一个 CJS 脚本,首尾把代码包进一次登记调用——

window.__ModuleLoader__.load({ id: '<包名>', factory: (require) => {
  // …打包后的代码…
  return module.exports
} })

模块表按 id 登记 factory;插件被激活时调用它,并传入一个 require——页面已备好 react、@deepseek-ai/cordis 等平台基线模块,这个 require 能直接答它们。所以构建时这些包要保持外部引用,其余依赖一律打包进产物:模块表答不上的 require 在运行时直接抛错。格式选 cjs 也是跟着约定走:factory 约定用 module.exports 交出插件的导出。

DSH 仓库内部用 tsdown 的 clientBundle 预设产出这个格式,但预设不随 npm 发布;仓库外的包(比如我们的)要自己复现这份契约。tsdown.config.ts 全文:

/**
 * 浏览器半的构建配置:复现 clientBundle 预设的输出契约——
 * 一个 CJS 浏览器包,首尾包进 window.__ModuleLoader__.load({ id, factory }),
 * react 与 cordis 保持外部引用,由页面里的模块表提供。
 */
import { defineConfig } from 'tsdown'

export default defineConfig({
    entry: { client: 'src/client/index.tsx' },
    outDir: 'lib',
    format: 'cjs',
    platform: 'browser',
    dts: false,
    sourcemap: true,
    deps: {
        neverBundle: ['react', 'react/jsx-runtime', 'react-dom', '@deepseek-ai/cordis'],
    },
    outputOptions: {
        // 产物名固定为 lib/client.js:/plugins 路由的组合地址按 <包名>/client.js 拼。
        entryFileNames: 'client.js',
        intro: 'var module = { exports: {} }; var exports = module.exports;',
        banner: `window.__ModuleLoader__.load({ id: 'dsh-chat-background-settings', factory: (require) => {`,
        footer: 'return module.exports; } });',
    },
})

pnpm run build 跑一次就产出 lib/client.js。这是浏览器半与 Host 半的一个现实差别:Host 半走源码直载(07-配置与源码的热重载的 tsx 钩子),浏览器半永远吃构建产物——改完 src/client/ 要重新构建并重启 DSH,页面才会拿到新版本(产物的修订号取自文件元数据,启动或 HMR 监听通知时才重新读)。

volatile 字段

设置服务不管插件的全部配置,只投影声明为 volatile(可现场改)的字段。06-用 Config schema 声明插件配置的 schema 升级就一处:两个字段各加一个 .volatile():

import type { Context, Volatile } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'

export interface Config {
    /** 背景图片的绝对路径。 */
    image: Volatile<string>
    /** 图片显示强度:0 完全透明,1 完全不透明。 */
    opacity: Volatile<number>
}

// 不写 `: Schema<Config>` 标注:volatile 字段的序列化形态与接口里的
// Volatile<T> 引用不同,标注会与 schemastery 的 default 签名冲突。
export const Config = Schema.object({
    image: Schema.string().default(DEFAULT_IMAGE).volatile(),
    opacity: Schema.number().min(0).max(1).default(0.35).volatile(),
})

类型跟着变:volatile 字段在 apply 里拿到的不是裸值,而是一个稳定引用,config.image.get() 读当前值。「稳定」的意思是值变了引用不变——插件不用重启,下一次 .get() 就是新值(怎么变的见「热提交」一节)。所以 Host 半里所有读配置的地方,从 06-用 Config schema 声明插件配置的 config.image 改成 config.image.get()。

Volatile 类型从 @deepseek-ai/cordis 导出。普通字段(不加 .volatile())一切照旧:不进设置页,只在 cordis.yml 里改,改了走普通重挂载。哪些字段该标 volatile?同事会现场调的(图、透明度)标上;影响插件加载行为的(比如依赖哪个服务)不要标—— volatile 提交不重启插件,需要重启才能生效的值放普通字段。

默认值的位置不变,还写在 schema 的 .default(...) 里:volatile 改的是「值怎么提交进运行中的插件」,层叠规则一条没动。同事什么都不填时,拿到的还是包内那张 image.svg 和透明度 0.35。

设置页的配对

设置服务(dsh-settings,web 界面自带)把每个活跃 entry 的 volatile 字段投影成一份表单,表单的 namespace 就是这一行在 profile 里的 entry id——我们是 dsh-chat-background-settings。不需要注册、不需要声明对应关系:行挂上了,namespace 就存在;行被停用,表单跟着消失。

页面这边,DSH 的「插件」页(侧边栏「插件」)给插件自己的配置留了三个挂载点:plugins.item 给安装自带的官方插件,plugins.bundle.config 给整个 bundle,plugins.row.config 给 bundle 里的一行。DSH 自带的那几个设置页(shell 执行器、agent loop 等)就是每个一个伴生包,挂在 plugins.item 上;我们的背景插件不是官方件,是自己 bundle 里的一行,用最细的那个挂载点,key 是 <包名>#<行 id>。浏览器半的全部工作,就是往这个槽位注册一个组件。src/client/index.tsx 全文:

/**
 * 聊天背景插件的浏览器半:在 Plugins 页给本插件的那一行挂一个配置页。
 *
 * 这个文件不跑在 Node 里。它被打包成 lib/client.js,由客户端模块系统
 * (dsh-client-modules)送进浏览器,在页面里的另一个 Cordis 上下文上再跑
 * 一次 apply。页面通过 Plugins 页声明的 plugins.row.config 槽位挂上去;
 * Plugins 页按这一行的 entry id 找到设置服务里的同名 namespace,把该
 * namespace 的表单(state + mutate)作为 prop 喂给我们的组件。
 *
 * 基准版本:dsh 0.2.0-rc.2(源码快照 local/deepseek-harness-dsh-v0.2.0-rc.2/)。
 */
import { useState } from 'react'
import type { Context } from '@deepseek-ai/cordis'
// 只引入类型声明,运行时由页面的模块表提供实现:
// ctx.slots 的 Context 合并(渲染器声明),SlotMap 里的 'plugins.row.config'
// 条目,以及那一行的页面 props(view + form)。
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-slots'
import type { ConfigPageForm, PluginConfigViewProps } from '@deepseek-ai/dsh-client-ui-plugin-manager/client'

/** 槽位 key:<包名>#<行 id>,行 id 以 bundle 的 patch.yaml 声明为准。 */
const SLOT_KEY = 'dsh-chat-background-settings#dsh-chat-background-settings'

export const inject = ['slots']

export function apply(ctx: Context): void {
    // slots.inject 等槽位声明出现再注册,槽位消失(或本插件卸载)时撤销。
    ctx.effect(() => ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
        name: 'plugins.row.config',
        key: SLOT_KEY,
    }, BackgroundSettingsPage)))
}

/** 一行的配置页:view 是 'summary' 时渲染一句话,'page' 时渲染表单。 */
function BackgroundSettingsPage({ view, form }: PluginConfigViewProps) {
    if (view === 'summary') return <>背景图与透明度</>
    if (form === undefined) return null
    return <BackgroundForm form={form} />
}

function BackgroundForm({ form }: { form: ConfigPageForm }) {
    const [message, setMessage] = useState('')
    const { state } = form
    if (state.status !== 'ready' || state.value === undefined) return <p>正在读取当前配置…</p>
    const value = state.value as { image?: string, opacity?: number }
    return (
        // key=revision:保存被接受后 revision 增加,表单重挂载,输入框回到新值。
        <form key={state.revision} onSubmit={(event) => {
            event.preventDefault()
            const data = new FormData(event.currentTarget)
            void form.mutate([
                { op: 'set', path: ['image'], value: String(data.get('image')) },
                { op: 'set', path: ['opacity'], value: Number(data.get('opacity')) },
            ], state.revision).then((ok) => setMessage(
                ok ? '已保存,刷新页面看新背景。' : '保存被拒绝:值不合法,或配置刚在别处被改过。',
            ))
        }}>
            <p>
                <label>背景图路径 <input name="image" size={40} defaultValue={value.image} /></label>
            </p>
            <p>
                <label>透明度(0–1) <input name="opacity" type="number" min={0} max={1} step={0.05} defaultValue={value.opacity} /></label>
            </p>
            <button type="submit" disabled={!state.writable}>保存</button>
            <p>{message}</p>
        </form>
    )
}

配对的最后一环由「插件」页完成:它按行的 entry id 去设置服务找同名 namespace,找到就把这份表单作为 form prop 喂给我们的组件。同一个组件会被渲染两次:view 是 'summary' 时要一句话摘要(行的描述缺省时顶上去),是 'page' 时才是表单。组件拿到两样东西:

  • form.state:当前值(value)、分层信息(base / user,下节讲)、revision(版本号,写的时候回传,防止覆盖别人刚改的值)、writable(这个 profile 接不接受写入)。
  • form.mutate(ops, revision):提交一组字段修改。ops 是逐路径的(set / unset),不用整表回传;Host 收到后先用插件的完整 Config schema 校验,非法值直接拒绝,文件和运行值都不动。

注册用的是 ctx.slots.inject 而不是直接 ctx.slots.register:槽位由「插件」页声明,声明顺序不由我们控制,inject 等它出现再注册,它消失时撤销我们的贡献——09-订阅 DSH 事件与五种分发模式「监听器的归属」那一套,在浏览器里同样成立。

组件本身是最小形态:两个输入框、一个保存按钮,故意不用任何组件库——槽位、form、namespace 这三样才是本篇的机制,React 部分换成你喜欢的任何写法都行。

配置

dsh-chat-background-settings/       ← 插件包(bundle)
├── src/
│   ├── dsh-chat-background.ts      ← Host 半:Config(volatile)+ apply
│   ├── style.css                   ← 背景样式(同 06)
│   └── client/
│       └── index.tsx               ← 浏览器半:注册配置页
├── image.svg                       ← 默认背景图(包内自带)
├── package.json                    ← dsh.bundle + dsh.client + ./client 导出
├── patch.yaml                      ← 挂载指令(install.sh 生成)
├── tsconfig.json
├── tsdown.config.ts                ← 浏览器半的构建
└── install.sh

package.json 全文:

{
  "name": "dsh-chat-background-settings",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    "./client": "./lib/client.js"
  },
  "dsh": {
    "bundle": {
      "patch": "./patch.yaml"
    },
    "client": {
      "platform": "web"
    }
  },
  "scripts": {
    "build": "tsdown"
  },
  "dependencies": {
    "@deepseek-ai/cordis": "^4.0.1",
    "@deepseek-ai/cordis-plugin-loader": "^1.0.5",
    "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
    "@deepseek-ai/schemastery": "^3.18.2"
  },
  "devDependencies": {
    "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.2.0-rc.2",
    "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
    "@types/react": "~18.3.1",
    "react": "^18.2.0",
    "tsdown": "^0.22.2"
  }
}

依赖分两边:Host 半的运行时依赖(cordis、schemastery、webserver 的类型)在 dependencies;浏览器半的依赖全是 devDependencies——react 和 cordis 在浏览器里由模块表提供,两个 dsh-client-* 包只引入类型(import type,构建时被剥掉),tsdown 是构建工具。

Host 半相对 06-用 Config schema 声明插件配置的改动都在前面讲过了:Config 加 .volatile(),读配置改 .get(),再加一行监听器看热提交。全文:

// src/dsh-chat-background.ts
/**
 * 聊天背景插件(设置页版)的 Host 半:注册图片路由、往页面注入样式。
 *
 * 与 06-用 Config schema 声明插件配置的 dsh-chat-background-config 是同一个插件,差别只有一处:两个
 * 配置字段声明成 .volatile()。volatile 字段会被设置服务投影成设置页上的
 * 表单项(namespace 就是 profile 里这一行的 entry id),同事在界面上改,
 * 不碰 cordis.yml;写入后 Loader 做 volatile 提交,不重启本插件,新值直接
 * 进入运行中的引用。
 *
 * 这个文件跑在 Node 里(Host 进程);浏览器里的设置页在 src/client/ 下。
 *
 * 基准版本:dsh 0.2.0-rc.2(源码快照 local/deepseek-harness-dsh-v0.2.0-rc.2/)。
 */
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { extname } from 'node:path'
import type { Context, Volatile } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
// 只引入类型声明:loader/volatile-update 的事件类型由 loader 包合并进来。
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'

export const name = 'dsh-chat-background-settings'

// 默认图是包内自带的 svg;同事在设置页填自己图片的绝对路径来覆盖它。
const DEFAULT_IMAGE = new URL('../image.svg', import.meta.url).pathname
// 路由地址不含扩展名:图片路径可配,Content-Type 按配置里那个文件的扩展名走。
const IMAGE_URL = '/dsh-chat-background-settings/image'

/** 允许的图片扩展名 → Content-Type。 */
const CONTENT_TYPES: Record<string, string> = {
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.png': 'image/png',
    '.webp': 'image/webp',
    '.gif': 'image/gif',
    '.svg': 'image/svg+xml',
}

export interface Config {
    /** 背景图片的绝对路径。 */
    image: Volatile<string>
    /** 图片显示强度:0 完全透明,1 完全不透明。 */
    opacity: Volatile<number>
}

// 不写 `: Schema<Config>` 标注:volatile 字段的序列化形态与接口里的
// Volatile<T> 引用不同,标注会与 schemastery 的 default 签名冲突。
export const Config = Schema.object({
    image: Schema.string().default(DEFAULT_IMAGE).volatile(),
    opacity: Schema.number().min(0).max(1).default(0.35).volatile(),
})

export function apply(ctx: Context, config: Config): void {
    // volatile 字段拿到的是稳定引用,读 .get() 永远是最新值:
    // 路由每次请求读一次,设置页保存后的下一次请求就用新图。
    ctx.inject(['webServer'], (serverCtx) => {
        ctx.effect(() => serverCtx.webServer.register({
            kind: 'exact',
            path: IMAGE_URL,
            handler: async (_req, res) => {
                const image = config.image.get()
                const contentType = CONTENT_TYPES[extname(image).toLowerCase()]
                    ?? 'application/octet-stream'
                res.writeHead(200, { 'content-type': contentType })
                res.end(await readFile(image))
            },
        }))
    })

    // 样式在每次页面渲染时重新生成,所以透明度保存后刷新页面即生效。
    ctx.on('webserver/index-inject', (table) => {
        table.push({ kind: 'style', text: buildStyle(config.opacity.get()) })
    })

    // volatile 提交成功时 Loader 向本插件派发这个事件,参数是变了的字段路径。
    // 07-配置与源码的热重载说过 ctx.logger 不往终端打,要肉眼可见的证据就自己 console.log。
    ctx.on('loader/volatile-update', (paths) => {
        console.log(`[${name}] 设置已热提交:${paths.map((path) => path.join('.')).join(', ')}`)
    })
}

/** 用配置值生成样式:静态选择器来自 style.css,只有遮罩浓度随配置变化。 */
function buildStyle(opacity: number): string {
    // 透明度的实现:在图片上盖一层与页面底色同色的遮罩,越不透明遮得越多。
    const veilPercent = Math.round((1 - 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 与 06-用 Config schema 声明插件配置相同(遮罩变量 --dsh-chat-background-veil 由 Host 半注入),只有图片地址换成 /dsh-chat-background-settings/image。

install.sh 比 06-用 Config schema 声明插件配置多一步构建浏览器半;不写 profile 覆盖层——配置留给设置页,cordis.patch.yml 里还留着 08-替换内置能力的提供方与消费者的换源配置。跑完后 patch.yaml 被生成为:

- insert:
    - id: dsh-chat-background-settings
      name: '/绝对路径/dsh-chat-background-settings/src/dsh-chat-background.ts'

install.sh 本身:

#!/usr/bin/env bash
set -euo pipefail

DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

cd "$DIR"
pnpm install

# 浏览器半:产出 lib/client.js(dsh.client 声明的 ./client 导出指向它)。
pnpm run build

# patch.yaml 里的插件行要写绝对路径,按本机位置重新生成。
cat > patch.yaml <<EOF
- insert:
    - id: dsh-chat-background-settings
      name: '$DIR/src/dsh-chat-background.ts'
EOF

dsh plugin --profile test add "link:$DIR"

同事那边拿到这个目录,跑这一条就装完;之后的每次换图都在界面上。

配置的层叠

设置页读到的 value 不是某一层的内容,是三层叠完的结果,顺序就是 06-用 Config schema 声明插件配置讲的那条链:

mermaid 图表

form.state 里 base 和 user 分开放,好让页面区分「这个值是组合来的」还是「用户改过的」——一个字段出现在 user 里就算用户覆盖,哪怕值跟默认一样。--dump-config 打印的合成结果和设置页读到的 value 是同一条链的两个视图,可以对着看。

保存 = 设置服务把值写进用户文档那一层,也就是 ~/.dsh/profiles/test/cordis.patch.yml 里我们这个 id 下的 config 块。06-用 Config schema 声明插件配置里同事手写的那个块,现在由界面替他写,格式一字不差——写完你去打开那个文件,看到的就是 06-用 Config schema 声明插件配置的写法。更高层(home patch、命令行 --patch)的优先级不变:会被它们盖住的写入,保存时直接拒绝。页面上的「重置」则是删掉用户层,值回落到组合 base 和 schema 默认值。

热提交

06-用 Config schema 声明插件配置说过改 cordis.yml 保存即生效(配置热重载)。设置页写入走的也是这条路,但 volatile 字段多一条更快的通道:Loader 比较前后两份 config,发现只有 volatile 字段变了时,做 volatile 提交——不重启插件,把新值直接提交进运行中的引用,然后向本插件派发 loader/volatile-update,参数是变了的字段路径。这个事件只派给拥有这份配置的插件自己,别的插件收不到。

Host 半里留了一行监听器,保存后终端会打:

[dsh-chat-background-settings] 设置已热提交:image, opacity

生效时机按读法分:图片路由每次请求读 config.image.get(),下一次请求就是新图;样式在每次页面渲染时生成,所以透明度在刷新页面后看到。

两个边界情况。一是非法候选值:Loader 提交前也会过一遍 schema,不合法就记一条日志、保持运行值不变——不过设置页写入前 Host 已用完整 Config 校验过,这条路走到 Loader 时总是合法的,只有手改 cordis.yml 的人才可能触发。二是普通字段:volatile 提交只发生在「只有 volatile 字段变了」的时候;一次改动里混着普通字段,整个走普通重挂载——插件重启,那是普通字段的既有待遇,不是设置页的限制。

安装与验证

cd dsh_plugin/dsh-chat-background-settings && ./install.sh
dsh --profile test --dump-config | grep -A3 'id: dsh-chat-background-settings'
dsh --profile test --no-open

--dump-config 里能看到这一行已经挂上:

# == dsh-chat-background-settings
- id: dsh-chat-background-settings
  name: >-
    file:///绝对路径/dsh-chat-background-settings/src/dsh-chat-background.ts

打开侧边栏「插件」,在 Installed 里找到 dsh-chat-background-settings,它那一行上有 Configure;打开就是表单。把透明度改成 0.6 保存,然后核对三处:

  • 终端打出 [dsh-chat-background-settings] 设置已热提交:opacity;
  • ~/.dsh/profiles/test/cordis.patch.yml 里多出 - id: dsh-chat-background-settings 的 config 块;
  • 刷新页面,背景变浓。

第二处展开看,就是 06-用 Config schema 声明插件配置里手写的那种块,只是由设置服务代笔:

- id: dsh-chat-background-settings
  config:
    opacity: 0.6

再把透明度填成 1.5 保存:被拒绝,patch 文件不变。06-用 Config schema 声明插件配置的「非法配置当场失败」在设置页这条路上同样成立,只是失败发生在保存时而不是加载时。

最后验证一次生命周期:在 cordis.patch.yml 里给这一行加 disabled: true,保存后「插件」页上 Configure 消失;删掉再保存,页面回来。浏览器半的注册跟着行走,不用自己收拾。

注意事项

  • 跟 06-用 Config schema 声明插件配置的插件二选一。 两套都挂着时,两边往同一个布局根注入背景样式,后注入的盖住前面的。用03-用 patch 覆盖 DSH 的默认配置里那招停用旧的那份(- id: dsh-chat-background-config + disabled: true)。
  • 图片路径写绝对路径。 06-用 Config schema 声明插件配置的老规矩在设置页里一样成立:readFile 解析相对路径的基准是 DSH 进程的工作目录,不稳定;表单里直接填绝对路径。
  • 只有 volatile 字段进设置页。 普通字段仍走 cordis.yml,06-用 Config schema 声明插件配置的机制对它们原样有效。两条路改的是同一个 id 的同一份 config,不冲突。
  • 密钥类字段不进表单。 schema 里标了 role('secret') 的值不会出现在表单响应里,页面只拿得到「有没有设过」的标记;本插件没有这类字段,真有时用它标上。
  • 一个包一个浏览器半。 客户端模块系统按包名登记浏览器半;同一个包拆成多行时,浏览器半挂在包名那一行上,从子路径导出挂载的行不携带浏览器半。哪个子插件的页面需要独立存续,就把它拆成自己的包。
  • entry id 要能被 profile 唯一寻址。 设置服务只投影这样的 entry;我们的行由 bundle 的 patch 插进根树,满足。嵌套 include 里各层自己的配置不在这条路上,仍只能改文件。
  • 改完 src/client/ 要重新 pnpm run build 并重启。 页面吃的是 lib/client.js,不是源码。本演示的 tsdown 配置是输出契约的最小复现;仓库内的 clientBundle 预设还处理 CSS 模块、sourcemap 链接这些,插件做大了可以照着补。
  • 表单也能自动生成,但还没人实现。 设置服务给每份表单带一个 autoGenerate 策略位(默认开),留给「按 schema 自动生成页面」的客户端;DSH 自带的客户端目前都没有实现自动生成,所以页面得自己写。写了页面的插件可以用 ctx.settings.configure({ auto: false }) 显式声明这个策略。