Skip to content

配置即数据 ​

在 Console 上改一个端口,要么整个生效,要么完全回滚——磁盘上的 zhin.config.yml 永远不会停在写了一半的状态。

先记住这几点 ​

  • 配置是数据,不是代码。 每个插件用 schema.json(或 definePlugin 的 config)声明自己有哪些配置项、默认值和约束。
  • 启动时整体校验。 Zhin 把所有插件的声明组合起来,对整份配置做严格校验(Ajv strict);有错误就不会启动或不会生效。
  • 每个插件只看到自己的那一段。 你的 Bot 读 plugin:,子插件读 <instanceKey>:,彼此不可见。
  • 修改是事务。 通过 Console 或 zhin config 修改时,新配置校验或激活失败会自动回滚,旧配置继续生效。
  • 密钥放 .env。 在配置里写 ${VAR} 引用环境变量。

文档结构 ​

yaml
configVersion: 2
# Root Plugin 的配置(对应 Root 包 schema.json)
plugin:
  terminal:
    interactive: true

# 子插件配置,按 instanceKey 命名空间隔离
plugins:
  sandbox: "@zhin.js/adapter-sandbox"
  napcat: "@zhin.js/adapter-napcat"
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;
  • <instanceKey> — 每个直接实例的业务 schema;plugins 只保存别名到包的映射;
  • Host 级键 http / database / ai / mcp / a2a / speech / htmlRenderer / assistant / log_level — 由 CLI 的 Root 安装器消费,不会进入任何插件的配置视图;
  • 框架菜单设置 menuKeyword / menuStyle 继续位于顶层,只加入 Root 的 generation 配置视图,供 Command Feature 使用;不进入子插件视图,Root 业务 schema 不得重复声明。
  • 顶层结构 additionalProperties: false:写错键名(比如 plugin 打成 plugn)会直接报错,而不是被静默忽略。

schema.json:声明式契约 ​

每个包根目录的 schema.json 就是它的配置契约。examples/minimal-bot/schema.json:

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 和 disabledCommands 字段,插件 schema 不得重复声明;未配置时命令没有插件命名空间,也不禁用任何命令。校验用 Ajv 2020,strict: true、allErrors: true、useDefaults: true。子插件的 instanceKey 若与父插件自己 schema 的某个属性同名,也会抛 ConfigSchemaCollisionError。

ConfigView:按 owner 投影 ​

插件不会拿到自己在文档里的整棵子树(那里面还有它的后代)。ConfigComposer 用 pickOwnFields 按每个包自己的 schema 挑出顶层属性,冻结后作为该插件的 ConfigView:

ts
// PluginSetupContext.config
context.config.get(); // 只含本插件 schema 声明过的字段

环境变量插值(${VAR})在投影时展开;未设置的变量展开为空字符串。密钥一律走环境变量,不要写进 yaml。

适配器配置模型:顶层共享 + endpoints[i] ​

多账号适配器(一个插件实例挂多个平台连接)用 endpoints 数组声明。展开规则实现在 AdapterIndex(packages/im/adapter/src/adapter-index.ts):

yaml
configVersion: 2
plugins:
  icqq: "@zhin.js/adapter-icqq"
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):

  1. 消息带 metadata.endpoint 且配置里有同名 endpoints[i] → 用该 entry 的 commandPrefix;
  2. 否则用实例顶层的 commandPrefix;
  3. 都没有 → ''(无前缀,任意文本都尝试按命令匹配)。

配置文档事务与回滚 ​

运行时改配置(Console 界面、patchConfig API)不是直接改文件,而是一个两阶段事务。ConfigDocumentPort 与结构化 patch 语义定义在零依赖的 @zhin.js/plugin-runtime:

ts
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 重新读取、校验并比较实际投影后再决定影响范围:

  • <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 继续生效。