配置即数据
在 Console 上改一个端口,要么整个生效,要么完全回滚——磁盘上的 zhin.config.yml 永远不会停在写了一半的状态。能这样做,是因为 zhin.js 的配置不是代码,而是一份被 schema 严格约束的数据文档:每个包用 schema.json 声明自己的配置契约,运行时把整份文档对着由插件树组合出的有效 schema 做 Ajv strict 校验,然后按 owner 把配置投影给每个插件。配置变更走事务,没有中间态。
文档结构
# Root Plugin 的配置(对应 Root 包 schema.json)
plugin:
terminal:
interactive: true
# 子插件配置,按 instanceKey 命名空间隔离
plugins:
sandbox:
endpoints:
- id: full-bot-sandbox
owner: local-user
napcat:
connection: ws
endpoints:
- name: full-bot-napcat
url: ${ONEBOT11_WS_URL} # 环境变量插值
access_token: ${ONEBOT11_ACCESS_TOKEN}有效 schema 由 ConfigComposer(packages/im/runtime/src/config-composer.ts)按插件树组合:
plugin— Root Plugin 自己的 schema;plugins.<instanceKey>— 每个子插件的 schema,嵌套子插件递归挂在父 schema 的 properties 里;- Host 级键
http/database/ai/mcp/a2a/speech/htmlRenderer/assistant/log_level— 由 CLI 的 Root 安装器消费,不会进入任何插件的配置视图; - 顶层结构
additionalProperties: false:写错键名(比如plugin打成plugn)会直接报错,而不是被静默忽略。
schema.json:声明式契约
每个包根目录的 schema.json 就是它的配置契约。examples/minimal-bot/schema.json:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"commandPrefix": { "type": "string", "default": "/" },
"terminal": {
"type": "object",
"additionalProperties": false,
"default": {},
"properties": {
"interactive": { "type": "boolean", "default": true },
"prompt": { "type": "string", "default": "zhin> " }
}
}
}
}写 schema 时有几点约束要知道。根必须是 object schema;没有 schema.json 时按空 object 处理。根上不允许纯组合式 schema(anyOf/oneOf/allOf/$ref 而无 properties)——它能通过校验,但会让配置投影静默变空,因此被显式拒绝。框架会为每个插件注入可选 commandNamespace 字段,插件 schema 不得重复声明;未配置时命令没有插件命名空间。校验用 Ajv 2020,strict: true、allErrors: true、useDefaults: true。子插件的 instanceKey 若与父插件自己 schema 的某个属性同名,也会抛 ConfigSchemaCollisionError。
ConfigView:按 owner 投影
插件不会拿到自己在文档里的整棵子树(那里面还有它的后代)。ConfigComposer 用 pickOwnFields 按每个包自己的 schema 挑出顶层属性,冻结后作为该插件的 ConfigView:
// PluginSetupContext.config
context.config.get(); // 只含本插件 schema 声明过的字段环境变量插值(${VAR})在投影时展开;未设置的变量展开为空字符串。密钥一律走环境变量,不要写进 yaml。
适配器配置模型:顶层共享 + endpoints[i]
多账号适配器(一个插件实例挂多个平台连接)用 endpoints 数组声明。展开规则实现在 AdapterIndex(packages/im/adapter/src/adapter-index.ts):
plugins:
icqq:
commandPrefix: "/" # 顶层:所有 endpoint 共享
endpoints:
- name: bot-a
uin: ${ICQQ_UIN_A}
- name: bot-b
uin: ${ICQQ_UIN_B}
commandPrefix: "" # 逐项覆盖顶层- 每个 entry 的生效配置 = 实例配置(去掉
endpoints键)合并 entry 自己的字段;name必填。 - 展开后每个 endpoint 是独立记录,能力 id 形如
<slot>~<name>,Console 和消息链路按 name 寻址。 - entry 缺
name、name 含~或\0、name 重复:该项被丢弃并告警;全部无效时退化为单 endpoint。 endpoints为空/缺省时按实例配置创建单个 endpoint。
commandPrefix
命令前缀默认按消息所属的适配器实例解析(defaultCommandPrefixResolver):
- 消息带
metadata.endpoint且配置里有同名endpoints[i]→ 用该 entry 的commandPrefix; - 否则用实例顶层的
commandPrefix; - 都没有 →
''(无前缀,任意文本都尝试按命令匹配)。
配置文档事务与回滚
运行时改配置(Console 界面、patchConfig API)不是直接改文件,而是一个两阶段事务。ConfigDocumentPort 与结构化 patch 语义定义在零依赖的 @zhin.js/plugin-runtime:
interface ConfigDocumentPort {
read(): Promise<ConfigDocumentSnapshot>; // 文档 + revision(内容 sha256)
prepare(current, patches): Promise<PreparedConfigDocument>; // 候选文档,此时是惰性的
}
interface PreparedConfigDocument {
commit(): Promise<ConfigDocumentSnapshot>;
rollback(): Promise<void>;
}ConfigFileDocument(@zhin.js/config-file)封装两种格式共享的事务生命周期:
- 乐观并发:
prepare和commit都会重读文件并核对 revision;文件在读取后被外部改动则抛ConfigDocumentConflictError。 - 原子落盘:
commit先写临时文件再rename替换,保留原文件权限位。 - 一致性:候选文档与运行时校验过的候选不一致时抛
ConfigDocumentDivergenceError,宁可失败也不写分歧配置。 - 格式多态:
YamlConfigDocument在 AST 上应用 patch 并保留注释与缩进;JsonConfigDocument复用 Runtime 的结构化 patch 语义并保留缩进与换行风格。
composition root 只创建一个具体的 ConfigFileDocument。Root Runtime、Endpoint 配置命令和 Console 都接收这个实例,不再各自查找、解析或覆盖配置文件。Console 全文编辑通过 readSource() 取得原始文本、格式和 revision,再用 prepareReplacement() 提交;同一响应中的配置键也从该 revision 的文档投影,避免跨版本拼接结果。调用方之间发生竞争时明确返回 revision 冲突,不以最后写入者静默覆盖前一次修改。
事务被编入 generation 交接:RootRuntime.patchConfig 先走影子 prepare(见 generation 与生命周期),文件 commit 发生在新一代资源激活之后;若交接失败,回滚顺序相反——先恢复文件,再停用影子代。任何一步失败,磁盘上的 Root 配置和内存里的运行时都不会出现半更新状态。
外部直接编辑配置文件也可以。配置文件适配器通过 ConfigDocumentPort.sources 声明受监视的 权威文件,Runtime 重新读取、校验并比较实际投影后再决定影响范围:
plugins.<instanceKey>只变化时,仅替换对应 Plugin 的最浅子树;兄弟 Plugin 和 Root Resources 保持当前 generation;- Root Plugin 的
plugin配置变化时,重建 Root generation; http/database/ai/mcp/a2a/speech/htmlRenderer/assistant/log_level等 Host 配置变化时,请求进程重启,因为这些资源在 composition root 启动阶段创建,不能用 Plugin generation 假装已经替换;- 只改注释、空白或等价值时,只采纳新的文档 revision,不创建无意义 generation。
环境文件重载
CLI 把 .env 与当前环境的 .env.<environment> 包装成 EnvironmentLayersPort。端口保留 启动进程继承的基础环境,每次文件事件重新读取 dotenv overlay,不把项目密钥写回全局 process.env。Runtime 随后用新的 owner-scoped EnvStore 重新展开配置中的 ${VAR}、 ${VAR:-default} 和 ${VAR:=default}:
- 若 Host 配置的展开值变化,触发进程重启,新进程用新环境创建 HTTP、Database、Agent 等 Host 资源;
- 否则重建 Root generation,使插件配置引用和直接使用
EnvStore的插件同时切换到新快照; - dotenv 内容未产生有效环境变化时,不创建 generation。
环境文件和配置文件都走同一个 HMR 串行队列。候选配置校验或 shadow setup 失败时,旧 generation、旧环境快照和旧配置 revision 继续生效。