@aalis/schema-config
配置表单 Schema 词汇契约包:ConfigSchema / SchemaField / SchemaGroup / SchemaArray 及 SchemaFieldTypes 扩展点;声明与读取插件配置的 defineConfig / ConfigOf / parseConfig;可按会话覆盖的字段 SessionFieldSpec / SessionFieldsOf / isSessionField,经 JSON 下发的 WireSchemaField / WireConfigSchema;对这套词汇的中立解释函数 defaultsFrom / deepMergeDefaults / validateConfig / readFieldValue / removeExtraFields,密钥引用的类型与工具 SecretRef / isSecretRef / mapSecretFields / SecretPosition,配置危险键闸与拷贝 isUnsafeConfigKey / cloneConfigObject,以及插件因配置问题无法激活时在 apply 里抛出的 configError / missingConfigError(后者用于缺必填项;name 为 ConfigError、不带 stack,激活失败日志只有一行,不像程序崩溃)。
@aalis/core 把插件的 configSchema 当作 opaque 数据透传、不解释任何字段;表单词汇(label / options / textarea …)属呈现层,统一住在本包。同一份 schema 既是表单描述,又是插件配置值类型的来源(ConfigOf)和运行时解析规则(parseConfig);渲染宿主(WebUI)与宿主政策(runtime 的配置同步)也用它消费 schema。
运行时只依赖同样零依赖的 @aalis/schema-i18n(给人看的文字是它的文字对象);对 @aalis/core 只有 type-only 锚点(以 peer 依赖声明)。字段类型可由其他 api 包经 declaration merging 扩展(如 api-llm 注入 'llm-ref')。
声明与读取
import { config, definePlugin, logger } from '@aalis/core';
import { defineConfig, parseConfig } from '@aalis/schema-config';
const configSchema = defineConfig({
apiKey: { type: 'string', label: 'API Key', required: true, secret: true },
port: { type: 'number', label: '端口', default: 8080, min: 1, max: 65535, integer: true },
mode: {
type: 'select',
label: '模式',
default: 'fast',
options: [
{ label: '快', value: 'fast' },
{ label: '稳', value: 'safe' },
],
},
});
export default definePlugin({
name: '@your-scope/plugin-x',
configSchema,
uses: { config, logger },
apply({ config, logger }) {
const cfg = parseConfig(configSchema, config.use, logger.use);
// cfg: { apiKey: string; port: number; mode: 'fast' | 'safe' }
},
});
defineConfig(schema):运行时原样返回传入的对象(不冻结、不加任何属性,WebUI 读的就是这个活对象),类型层保留字面量,返回值可直接放进definePlugin({ configSchema })。不要给变量标ConfigSchema类型注解,注解会把字面量拓宽掉,ConfigOf只能推出宽类型。推出的类型里各属性只读、options是字面量元组;需要在运行时改写 schema(如按已注册的服务补全选项)时,经ConfigSchema或SchemaField类型的引用改写。ConfigOf<S>:从 schema 推导配置值类型。字段取SchemaFieldTypes里登记的取值类型(见下表);select / multiselect 有静态options、没有dynamicOptions且allowCustom不为true时收窄为选项值的字面量联合,数字选项对应数字字面量。有default或required: true的字段必定存在,其余为可选;分组推为嵌套对象,总是存在;数组推为元素类型的数组,元素按同一规则推导。带session的顶层字段不在其中(见下文「可按会话覆盖的字段」)。parseConfig(schema, raw, logger?):按 schema 解析插件拿到的配置,返回ConfigOf类型的新对象,不改入参。之后的夹紧、跨字段约束等核对作用于返回值。
给人看的文字
- 字段、分组与数组的
label、description和选项的label可以写字符串,也可以写@aalis/schema-i18n的文字对象(插件自己原文字典里的t('…')),渲染宿主按读者语言渲染。default是取值,不是文字。 validateConfig的每条问题带code(missing、type、not-option、pattern、format、integer、min、max、reserved-key、validate)与原因的文字对象text;message是它的原文,日志用。configError(message)收字符串或文字对象,收文字对象时挂上它(name 仍是ConfigError),WebUI 按读者语言显示失败原因;missingConfigError的说法本身是文字对象,note同样可以是文字对象。- 本包自己的字典(ns
@aalis/schema-config)收核心配置表单(CORE_CONFIG_SCHEMA)、校验原因与配置错误的说法。
字段类型
本包内置的中立类型。「判定」一列是 validateConfig 与 parseConfig 共用的逐字段规则:
| type | 取值类型(ConfigOf) | 判定 |
|---|---|---|
string / textarea | string | 字符串;有限数字按字符串收;声明了 pattern 时按正则匹配;声明了 format 时按路径写法判定 |
number | number | 有限数值;去掉首尾空白后非空、能完整解析为有限数的字符串按数字收;min / max / integer |
boolean | boolean | 类型,不做转换 |
select | 选项值的字面量联合,否则 string | 字符串或有限数字;有静态 options、没有 dynamicOptions 且 allowCustom 不为 true 时须是某个选项值 |
multiselect | 选项值字面量联合的数组,否则 string[] | 数组;元素同 select;按输入顺序保留。ordered: true 时先后有意义(如语言的先后):去掉重复项(按选项归位后的值比,不算问题),WebUI 给排序控件 |
list | string[] | 数组,元素按字符串判定;有序,允许重复(如命令行参数) |
map | Record<string, string> | 对象,每个值按字符串判定(如环境变量表) |
- 标量宽容:YAML 里不加引号的纯数字(QQ 号、口令、端口、环境变量值)在期望字符串的位置照常可用,加了引号的数字在期望数字的位置照常可用;
parseConfig返回换算后的值。 format:写宿主文件路径的string/textarea字段声明。'absolute-path'只收绝对路径(/…、C:\…或\\…);'executable'另收不含路径分隔符的命令名(按 PATH 查找),C:foo这类带盘符的相对写法不算。判定忽略首尾空白,交给插件的值也去掉首尾空白;空串与只有空白都表示未填、不查(required字段算缺失);只按写法判定、与宿主平台无关,Windows 形状的绝对路径在 POSIX 宿主上同样通过。配置里的相对路径没有可靠的基准(宿主进程的 cwd 不带语义),所以不收。- 选项即取值范围:选项按字符串比较,
parseConfig取选项声明的原值(WebUI 把数字选项存成字符串,读出时归位成数字)。 dynamicOptions/allowCustom:两者影响取值判定,由本包声明。dynamicOptions是动态选项来源的服务名(WebUI 经 webui-server 调该服务的listModels()或等价方法获取选项),声明后静态options不再是取值范围,只查取值是字符串或数字;allowCustom表示接受选项之外的值,不查是否在选项内:WebUI 给 multiselect 提供手动输入;select 用它表示静态选项不全(例如由插件在运行时补全选项的字段)。- 扩展字段类型:api 包经 declaration merging 往
SchemaFieldTypes登记类型名与取值类型(如'llm-ref': ModelRef),ConfigOf据此推导。本包不按类型校验外来类型的取值,只调字段自己的validate(见下文):validateConfig放行,parseConfig除把''当作未配置外原样透传。
字段自己的校验与文字读写(validate / fromText / toText)
validate(value):合法返回undefined,不合法返回原因(非空字符串或文字对象);抛错或返回别的值都按不合法,原因写「校验时出错」,不转述抛出的错。在类型与约束都过了之后调,拿换算后的取值(外来类型是原值)。validateConfig报invalid(code为validate),parseConfig按无效处置(见下文),readFieldValue给出原因。list、map、multiselect 有坏元素时,validateConfig与readFieldValue只报元素、不调它,parseConfig先丢坏元素、再拿剩下的值调它。外来类型的字段要核取值就写它。原因会原样进parseConfig的告警与拒绝激活的消息,不要在里面写取值(字段可能是密钥)。fromText(text)/toText(value):文字与取值互换;内置类型不用写。- 三者是进程内函数,schema 经 JSON 下发时没有;线上类型是
WireSchemaField/WireConfigSchema。
可按会话覆盖的字段(session)
顶层字段写了 session(声明 SessionFieldSpec,成员由 @aalis/api-session-settings 并入)即可按会话覆盖:插件经会话设置的读取器按会话读它,改了不重载插件。isSessionField(entry) 判一个顶层条目是不是这种字段(分组、数组上写了不算,分组与数组元素里的字段不按它判)。
parseConfig不交出这类字段、不补default,配置里有这个键也不告警;ConfigOf同样不含。SessionFieldsOf<S>:这类字段经读取器读到的值。取值类型同ConfigOf,onInvalid: 'error'的为unknown(坏值原样交出,由插件核);有default的必定存在,没有的为可选。validateConfig、defaultsFrom、removeExtraFields照常把它当 schema 字段:它的全局层就是配置文档里的值,作者默认照常写进文档。readFieldValue(field, value):单个值的判定,与validateConfig同一套:合法给{ value }(换算后的取值;没配置的给undefined),不合法给{ invalid }(集合里有坏元素也算)。不回落default、不抛错、不拷贝。判的是取值,不是文档写法:密钥字段上的引用validateConfig放行,这里判为不合法。
密钥字段(secret)
secret: true 表示字段的值是凭据(API key、token、可能带账号密码的连接串),由本包声明,各宿主都按它处理:值不放在配置文档里,存进宿主的密钥存储(Node 宿主 @aalis/runtime 用数据目录的 secrets.yaml),文档里只写引用 { $secret: '<键>' }(类型 SecretRef),宿主把配置交给插件之前解析回原值,展示与回传都不给原值。标在 map、list 等非标量字段上时整个值算一个密钥。本包只声明词汇,不读写密钥存储。
isSecretRef(value):是不是引用,即只有$secret一个自有键、值为非空字符串的纯对象。只在secret: true的字段上有这层含义。mapSecretFields(schema, config, fn):逐个处理配置里密钥字段上的值(顶层、分组内、数组元素内;值为undefined的跳过),fn收到值与位置SecretPosition(path是点号路径,数组元素里的不带下标;inArray表示在数组元素里),fn返回undefined时删掉这个键。不改入参:有改动时返回浅拷贝(没动到的部分与入参共享),没有改动时返回入参本身,调用方要改写结果请先复制。宿主拆出、解析密钥与给呈现方去掉密钥都用它。validateConfig校验的是文档:密钥字段上的引用算合法,不查键在不在。parseConfig给插件用:密钥字段上仍是引用,说明宿主没能解析它(键不在密钥存储里),一律不可恢复,消息点名键名;不看default与onInvalid,在数组元素里也让整次解析失败,不只丢这一条元素。回落默认值会悄悄连上别的东西(例如默认的本机数据库),所以不回落。defaultsFrom不产出密钥字段:派生结果会被宿主写进配置文档,密钥的默认值不进文档;parseConfig照常补上默认值。
parseConfig 的处置
- 缺失:
undefined与null(YAML 裸键);required字段另把''当作缺失(WebUI 与脚手架用''表示未填;声明了format的只有空白也算),外来类型的''一律当作缺失(WebUI 给未选的llm-ref存'')。有default用default的拷贝;无default且required不可恢复;否则省略该键。 - 无效(不符合上表的判定,或字段的
validate判为不合法):未声明onInvalid时保持宽容处置:有default回落并告警;无default且required不可恢复;否则省略并告警。字段声明onInvalid: 'error'时,显式无效值即使有default也抛ConfigError;它不改变required的缺省含义,undefined/null仍走上述缺省规则。 - list / map / multiselect:默认坏元素、坏值逐个丢弃并告警,其余保留,剩下的值再过字段的
validate;稀疏数组的空位和 map 的保留键也属于坏成员。严格字段只要含一个坏成员就拒绝整个值,不返回部分结果。 - 分组:值不是对象时默认告警,整组按空对象解析,子字段各取默认值。显式给非对象且分组含严格字段时拒绝;
undefined/null仍视为缺省。 - 数组:值不是数组按无效处理,含严格元素字段时显式无效的数组值会被拒绝;元素不是对象、或元素内有不可恢复的字段时只丢这一条元素并告警,邻居元素照常解析;元素字段的默认值逐元素补齐(runtime 的配置同步不补数组元素的默认值)。
- 整体配置:显式非对象且 schema 含严格字段时拒绝;
undefined/null仍视为缺省。严格错误说明字段路径与安全的类型或约束原因;集合的下标可定位,map 的键可能含密钥,因此错误只定位到 map 字段,不写用户值或键。 - schema 外的键:丢弃并告警。runtime 已先裁掉顶层与分组内的,这里主要覆盖数组元素,以及不经 runtime 的宿主。
- 不可恢复(顶层或分组内):缺失抛
missingConfigError,无效抛configError,插件随之进 error 态,在 WebUI 保存或配置文件热重载后重试激活。密钥字段上没被解析的引用见上节,在任何位置都不可恢复。 - 告警文案:
配置项 <路径> <原因>,改用默认值 <JSON>、配置项 <路径> <原因>,已忽略、配置项 <数组路径>[i] 已忽略:<字段> <原因>。只写路径、类型名与约束,不写原值(可能是密钥);默认值会写出。
数组也可声明 onInvalid: 'error':非对象元素或元素内不可恢复的字段会拒绝整个数组,避免丢弃坏项后触发空数组的业务默认;元素内普通字段仍按自身的回落策略解析。未声明时继续逐项跳过,适合相互独立的 server 列表。
validateConfig(schema, config) 用同一份判定做只读检查,返回问题清单(kind 为 missing 或 invalid),不转换、不回填、不裁剪;它把 undefined / null 当作未配置,required 字段另把 ''(声明了 format 的字符串只有空白也算)当作缺失,与 parseConfig 同一判定;只在 required 且无 default 时报缺,声明了 default 的永远不算缺。onInvalid 改变 parseConfig 的处置,不改变校验器的判定或报告。宿主用它告警或拦截保存:runtime 的配置同步对启用中的插件打告警,WebUI 保存插件配置时拒绝本次新引入的 invalid。
list 与 map 的补充约定:
- 默认值:
default分别写数组与对象,defaultsFrom按值拷贝(secret: true的除外)。deepMergeDefaults合并默认值时递归合并对象(runtime 的配置同步与 WebUI 保存插件配置都用它),因此顶层或分组里的map会按键补齐:默认映射中的键总会回到用户配置里,删掉也无效。需要用户可删除的预置条目时,默认值写{},由插件代码兜底。list不参与合并,用户配置了就整体采用。 - 未知字段裁剪:
removeExtraFields把map当作叶子整体保留,不按键裁剪。 - 缺省 UI(WebUI 的 SchemaForm):两者都渲染为多行文本框。
list每行一项,空白行忽略;map每行一条KEY=VALUE,按第一个=切分,键去掉首尾空白,值原样保留,缺少=或键为空的行不保存。现有的数字、布尔项或值按字符串显示,编辑后写成字符串;其余无法按这套规则逐行表达的值(项或值本身含换行、list含空白项、null、嵌套的数组或对象等)会让该字段只读显示,需在配置文件中编辑。旧版存下的空串按空列表 / 空映射显示,保存时写成[]/{}。
消费方式:用宽区间,别用 caret
仓内五十余个包这样依赖本包(与 @aalis/core 的 peerDep 同一风格):
"dependencies": { "@aalis/schema-config": "workspace:>=0.9.0 <1.0.0" }
发布时该区间原样保留(实测 pnpm pack 产物即 >=0.9.0 <1.0.0),本地仍照常 link 到 workspace。
不要用 caret。 0.x 的 caret(^0.9.0 = >=0.9.0 <0.10.0)锁死 minor:本包加一个字段类型
就是一次 minor,届时所有已发布消费者的范围会拒收新版,node_modules 里出现两份副本——而
declaration merging 是按模块副本生效的,SchemaFieldTypes / SchemaField 的合并面会就此裂开
(一份合并了 'llm-ref',另一份没有)。宽区间让整个 0.x 段自由流动,加词汇零级联。
解析以调用方传入的值为准。宿主与 Core 的配置拷贝会移除 __proto__ / constructor / prototype 保留键,parseConfig 无法诊断在上游已被删除的条目;配置字典(含 env)不能使用这些键。