Skip to content

配置即数据 ​

在 Console 上改一个端口,要么整个生效,要么完全回滚——磁盘上的 zhin.config.yml 永远不会停在写了一半的状态。能这样做,是因为 zhin.js 的配置不是代码,而是一份被 schema 严格约束的数据文档:每个包用 schema.json 声明自己的配置契约,运行时把整份文档对着由插件树组合出的有效 schema 做 Ajv strict 校验,然后按 owner 把配置投影给每个插件。配置变更走事务,没有中间态。

文档结构 ​

yaml
# 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:

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:

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

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

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

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

yaml
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):

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

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