Halo 编辑器运行期元数据(Runtime Metadata)全解析:为 AI Agent 构建可读的富文本组件清单
【免费下载链接】haloHalo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo
导读
@halo-dev/richtext-editor是 Halo 建站系统内置的富文本编辑器包(源码位于 ui/packages/editor),它基于 Tiptap/ProseMirror 构建,并额外提供了一套运行期组件元数据(Runtime Metadata)机制:允许编辑器扩展(Node、Mark、Extension)声明描述自身 schema、属性、结构关系与 AI 使用方式的元数据,最终由生成器汇聚成一份稳定的运行期 Manifest 快照。本文以 runtime-metadata.md 为主线,结合editor-metadata模块源码与测试用例,讲解如何为自定义组件声明元数据、如何扩展现有组件、如何贡献全局属性说明、如何读取 Manifest,以及完整的数据校验与容量约束。
一、什么是运行期元数据:设计定位与核心概念
Halo 富文本编辑器中的每个 Node、Mark 和 Extension,本质上是对最终 ProseMirror schema 的一段描述。运行期元数据机制把这些描述"翻译"成结构化的、面向 AI 与外部消费者的组件清单(Manifest)。
元数据回答四类问题:
- schema 是什么:该组件的 kind(node/mark)、name、content 表达式、group、属性及默认值;
- 组件怎么用:它适合什么场景(
useWhen)、应避免什么场景(avoidWhen)、属性取值建议(attributeGuidance); - 组件之间什么关系:允许的父级、每个父级下出现的最小/最大次数(
structure); - 如何生成:是否允许 AI 直接产出 HTML(
generation.mode),以及是否需要外部能力(requiredCapabilities)。
从源码看,这些信息在 Editor 实例创建后,由 createHaloEditorManifest 从editor.extensionManager.extensions和editor.schema中同步解析生成。文档明确指出:元数据本身不会改变或约束组件行为,它是对运行期能力的纯描述;AI Agent 是当前的主要消费者,其他插件、工具也可以读取它来了解当前编辑器实际注册的组件。
二、声明新组件:addHaloEditorMetadata钩子
扩展作者通过 Tiptap 生命周期钩子addHaloEditorMetadata()声明元数据。文档给出的数学公式节点示例完整展示了声明结构:
import { Node } from "@halo-dev/richtext-editor"; export const MathBlock = Node.create({ name: "mathBlock", group: "block", atom: true, addAttributes() { return { formula: { default: "", }, }; }, parseHTML() { return [{ tag: 'div[data-type="math-block"]' }]; }, renderHTML({ HTMLAttributes }) { return ["div", { ...HTMLAttributes, "data-type": "math-block" }]; }, // 声明运行期元数据中的 AI 使用说明 addHaloEditorMetadata() { return { ai: { description: "A display mathematical formula.", exposure: "available", useWhen: ["Presenting a standalone mathematical expression."], attributeGuidance: { formula: { description: "Formula source written in LaTeX.", format: "LaTeX", examples: ["E = mc^2"], }, }, generation: { mode: "requires-capability", requiredCapabilities: ["math-to-html"], }, examples: [ '<div>import { ExtensionCodeBlock } from "@halo-dev/richtext-editor"; export const HighlightedCodeBlock = ExtensionCodeBlock.extend({ addAttributes() { return { ...this.parent?.(), highlightTheme: { default: null, }, }; }, addHaloEditorMetadata() { return { ai: { attributeGuidance: { highlightTheme: { description: "Syntax-highlighting theme.", allowedValues: ["github-light", "github-dark"], omitWhen: ["The editor default theme should be used."], }, }, }, }; }, });合并规则(有源码与测试双重印证)
- 自动合并,无需手动调
this.parent():resolveDeclaration会遍历从基类到子类的完整继承链(extensionChain),收集链上所有addHaloEditorMetadata钩子的返回值并逐层合并。测试用例"composes parent hooks once and lets child arrays replace parent arrays"验证了这一点:子类只声明description与aliases,基类的useWhen依然保留在最终元数据中。 - 数组采用"后者替换"策略:
mergeMetadataPatch使用es-toolkit的mergeWith,配合replaceArrays回调——子类声明的数组直接替换父类数组,而非拼接。 - 最终 Manifest 同时包含原组件描述与新属性:文档明确"最终 Manifest 会同时包含原 code block 的描述和
highlightTheme"。
仓库中 code-block.ts 正是这一模式的生产级实例:它给 Tiptap 的 code block 补充了collapsed、theme两个属性,并为language、collapsed、theme提供完整的attributeGuidance,其中allowedValues会动态来自this.options.languages/this.options.themes——说明元数据声明函数内可以访问运行期 options,从而让 Manifest 反映真实配置。
属性级指导(attributeGuidance)的完整字段
| 字段 | 作用 | 约束 |
|---|---|---|
description | 属性含义说明 | 必填,最长 1,000 字符 |
format | 属性值格式(如LaTeX、URL) | 可选 |
allowedValues | 允许取值白名单 | 最多 32 项,限 string/number/boolean/null |
examples | 取值示例 | 最多 32 项 |
useWhen/omitWhen/guidelines | 何时使用/何时省略/使用准则 | 数组最多 10 项 |
attributeGuidance既可以是对象,也可以简写为字符串({ tone: "Configured tone" }等价于{ tone: { description: "Configured tone" } }),这一点在HaloEditorAIAttributeGuidanceDeclaration联合类型中定义,并有测试覆盖。
四、为全局属性贡献说明:Plain Extension 的contributions机制
普通 Extension 不会成为 Manifest 组件,但它可以通过contributions向明确命名的 Node 或 Mark注入元数据——这非常适合addGlobalAttributes()场景,例如给paragraph和heading同时补充tone属性说明:
import { Extension } from "@halo-dev/richtext-editor"; export const Tone = Extension.create({ name: "tone", addGlobalAttributes() { return [ { types: ["paragraph", "heading"], attributes: { tone: { default: null, }, }, }, ]; }, addHaloEditorMetadata() { return { contributions: [ { targets: [ { kind: "node", name: "paragraph" }, { kind: "node", name: "heading" }, ], metadata: { ai: { attributeGuidance: { tone: { description: "Writing tone for this block.", allowedValues: ["neutral", "friendly", "formal"], }, }, }, }, }, ], }; }, });贡献的落地规则
从 manifest.ts 的实现看:
- 只作用于最终 schema 中真实存在的目标:
componentExists会检查editor.schema.nodes[name]/editor.schema.marks[name],目标不存在则忽略并告警Ignored metadata contribution for missing node "xxx"。 - 冲突按 priority 决胜负:所有贡献先按
priority(未配置时默认DEFAULT_PRIORITY = 100)、再按注册顺序、最后按声明顺序排序,后序覆盖前序。测试用例"merges directed contributions by priority and registration order"验证:priority 同为 200 的两个贡献,后注册的high-last胜出。 - targets 自动去重:
uniqueTargets以kind:name为键去重。
五、组件自身的结构说明:structure元数据
组件只声明自己的父级和数量关系,不跨组件声明子节点(子节点关系由 schema 的content表达式与各组件的structure各自描述):
addHaloEditorMetadata() { return { ai: { description: "An optional caption belonging to a figure.", }, structure: { allowedParents: ["figure"], minPerParent: 0, maxPerParent: 1, }, }; }这表示:当前组件只能位于figure下,在每个figure中可省略且最多出现一次。
源码中的校验与归一化
normalizeStructure会检查:
allowedParents非空,且每个父级必须真实存在于schema.nodes;minPerParent/maxPerParent必须是>= 0的整数(validCount);minPerParent > maxPerParent时整体丢弃并告警。
仓库内置的 figure/index.ts 是structure与ai组合的完整范例:figure组件声明exposure: "recommended"、generation.mode: "direct-html",并给出三种媒体(image/video/audio)的完整 HTML 示例;而figureCaption则声明allowedParents: ["figure"]、minPerParent: 0、maxPerParent: 1。测试用例"normalizes figureCaption structure and produces stable signatures"断言了该结构。
六、禁用与兼容性:失败软着陆设计
ai: false明确禁用 AI 主动使用
return { ai: false };ai: false表示建议 AI 不主动使用该组件;- 但组件的 schema 信息(kind、name、content、attributes、htmlParseRules 等)仍然完整出现在 Manifest 中供 AI 理解上下文;
- 测试用例中
doc(文档根节点)即使用{ ai: false },仍出现在组件列表中。
失败软着陆(Fail Soft)
文档列出的三类异常处理,全部有源码实现支撑:
| 异常场景 | 处理方式 | 源码位置 |
|---|---|---|
| 声明钩子抛错 | 忽略该扩展的元数据,保留其他有效数据,开发环境告警 | resolveDeclaration中的 try/catch |
| 字段无效(类型错误、超长、引用不存在的属性/父节点) | 丢弃无效字段,保留有效字段 | sanitize*系列函数 +normalizeAI对缺失属性的attributeGuidance告警 |
| 超出容量限制 | 逐项截断或整体丢弃该组件 AI 元数据,schema 组件本身保留 | assignStringArray、sanitizeExamples、applyMetadata、enforceManifestMetadataLimit |
关键设计意图:生产环境不会因元数据问题阻止编辑器运行——告警仅通过import.meta.env.DEV判断,只在开发环境输出到 console(见warn函数)。
安全边界
- 未知字段会被丢弃:测试用例传入
systemPrompt: "ignored"和unknown: true,最终 Manifest 中均不存在; - 禁止放入敏感内容:不要把 system prompt、可执行回调或敏感信息放入元数据——因为它们会被序列化进 Manifest 并可能被分发给 AI 等消费者。
完整容量限制一览
| 限制项 | 上限 | 源码常量 |
|---|---|---|
| 每段文本(description、guidelines 等) | 1,000 字符 | MAX_TEXT_LENGTH |
| 说明数组与 aliases | 10 项 | MAX_GUIDANCE_ITEMS(aliases 单项最长 100) |
allowedValues与属性示例 | 32 项 | MAX_ATTRIBUTE_VALUES |
| 组件 HTML 示例 | 3 个、每个 ≤ 4 KiB | MAX_HTML_EXAMPLES/MAX_HTML_EXAMPLE_BYTES |
| 单组件 AI 元数据 | 16 KiB | MAX_COMPONENT_AI_BYTES |
| 单个 Manifest 的 AI 元数据总量 | 128 KiB | MAX_MANIFEST_AI_BYTES |
enforceManifestMetadataLimit按组件排序依次累加 AI 元数据体积,超出 128 KiB 后丢弃后续组件的 AI 信息(schema 组件仍保留)。测试用例"keeps schema components when component or Manifest AI limits are exceeded"验证了这一行为。
七、读取运行期 Manifest:AI 插件如何接入
Editor 创建完成后即可同步生成最终快照:
import { createHaloEditorManifest, type HaloEditorManifest, type VueEditor, } from "@halo-dev/richtext-editor"; function editorManifest(editor: VueEditor): HaloEditorManifest { return createHaloEditorManifest(editor); }Manifest 的结构
HaloEditorManifest(types.ts)包含三部分:
interface HaloEditorManifest { version: 1; // 固定版本号 signature: string; // 稳定的内容签名 components: HaloEditorComponent[]; // 全部 Node 与 Mark 的规范化描述 }组件描述的内容
nodeComponent/markComponent从最终 schema 中提取(见 manifest.ts):
- Node 组件:
content表达式、group、inline、atom、leaf、code、whitespace、selectable、draggable、defining、isolating; - Mark 组件:
excludes、inclusive、spanning; - 公共部分:
attributes(名称、是否必填、默认值,按名称排序)、htmlParseRules(tag/style/priority)、以及可选的ai与structure。
关于signature的稳定性
签名通过object-hash对{ version, components }生成。测试用例"changes the signature only when normalized schema or metadata changes"验证了关键性质:
- 无关的纯 Extension 注册顺序变化,不影响 signature(因为最终组件只取 schema 中真实存在的 node/mark);
- 组件描述或元数据变化,signature 必然变化;
- 同一配置重复生成,signature 完全一致(
"normalizes figureCaption structure and produces stable signatures"用例)。
这意味着 AI 插件可以缓存 Manifest 并基于 signature 做增量判断,知道"编辑器能力是否发生变化"。
消费者的职责边界
文档结尾明确指出使用原则:
- AI 插件可以把 Manifest 加入模型上下文(context);
- 其他消费者可以用它了解当前编辑器实际注册的组件;
- 消费者自行决定是否根据其中的建议进行额外校验——元数据是"建议"而非"强制约束"。
八、从测试看行为保证:manifest.spec.ts的关键断言
仓库在 manifest.spec.ts 中对上述机制做了系统验证,值得在接入时参考:
| 测试用例 | 验证点 |
|---|---|
| 使用最终配置的 schema 并包含嵌套组件 | addExtensions嵌套注册的组件会进入 Manifest;options 配置(如tone: "warm")会反映到属性的defaultValue与元数据描述 |
| 父钩子只执行一次、子数组替换父数组 | 继承链合并语义 |
| 按 priority 与注册顺序合并贡献 | 冲突裁决规则 |
| 只保留最终重复身份 | 同一名称多个扩展时,最后生效的扩展胜出 |
| 失败软着陆 | 未知字段、超长数组、缺失属性、无效父节点、抛错钩子均被优雅处理 |
| 内置组件全覆盖 | 默认ExtensionsKit下每个组件都有显式 AI 声明且无告警 |
| 内置组件可生成代表性变体 | figure的 img/video/audio、heading的 h1-h3、columns的 cols=2/3 等示例均包含在元数据中 |
最后两条用例对插件作者尤为重要:Halo 内置的所有默认组件都有完整的 AI 元数据声明,插件扩展这些组件时会自动继承其描述,无需重复编写。
九、实战接入清单
- 自定义 Node/Mark:在
Node.create/Mark.create中实现addHaloEditorMetadata(),至少提供ai.description;如需 AI 生成,补充generation与examples。 - 扩展内置组件:
.extend()后只写局部补丁,继承链自动合并;数组字段记得用"替换"语义设计。 - 全局属性说明:用 Plain Extension +
contributions向目标组件注入attributeGuidance,注意目标必须真实存在。 - 结构约束:为有明确父子关系的组件声明
structure.allowedParents+minPerParent/maxPerParent。 - 敏感组件:返回
{ ai: false }阻止 AI 主动生成,schema 信息仍保留。 - 读取与消费:Editor 就绪后调用
createHaloEditorManifest(editor),把signature与components交给 AI 上下文或能力探测逻辑。 - 容量自检:遵循第六节限制表,避免元数据被截断或丢弃;利用开发环境 console 告警(前缀
[halo-editor-metadata])定位无效声明。
结语
Halo 编辑器运行期元数据机制,本质上是把"编辑器最终长什么样、每个组件怎么用"变成一份结构化、可校验、带签名、AI 可消费的声明式契约。通过addHaloEditorMetadata钩子与createHaloEditorManifest生成器,插件作者可以低成本地为 AI 提供高质量的组件使用指导,而 Halo 内置组件与完整的测试套件则保证了这份契约的稳定性与兼容性。对于希望在 Halo 生态中构建 AI 写作、内容生成等能力的开发者,掌握本文所述的声明语法、合并规则、贡献机制与容量约束,是让 AI 正确理解并使用编辑器组件的第一步。
【免费下载链接】haloHalo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考