编写 Cordis 插件
Cordis 桥接层挂载一棵插件树,各行在 cordis.patch.yml 中指定。添加能力只需编写 Cordis 插件并添加一行,无需修改 Libre WebUI 源码。本页介绍宿主所依赖的约定。
请先阅读 Cordis 桥接,了解插件树如何组合,再查阅 Cordis 配置 中的行字段。
两种插件形式
Cordis 插件可以是函数,也可以是具有 apply 方法的对象。两者都由 Loader 解析。
// Function form.
export function apply(ctx, config) {
// ...
}
// Object form, when the plugin also declares dependencies.
export const name = 'my-plugin';
export const inject = ['tools'];
export function apply(ctx, config) {
// ...
}
Loader 读取模块命名空间,因此从配置行加载的插件需要将 apply(以及 inject)作为具名导出。默认导出也可使用,但随附的 DSH 插件采用具名形式,建议保持一致。
声明依赖
inject 是完整的依赖机制。只有所有具名服务都存在时,Cordis 才会运行插件;某个服务被撤销后重新提供时,插件会再次运行。
export const inject = ['tools', 'systemPrompt'];
export function apply(ctx, config) {
// `ctx.tools` and `ctx.systemPrompt` are guaranteed present here.
}
以下两点尤其重要:
- 缺少服务不属于错误。 对应行会一直静默等待。因此,
GET /api/cordis/health报告每项服务的状态,而不是单个布尔值。 - 注入决定执行顺序。 无需自行安排各行的顺序;随附组合中的行顺序不决定加载顺序。
对于编写时视为可选的依赖,请在 apply 内使用 ctx.inject。如果服务已经存在,它会立即执行回调;之后每当服务出现时,也会执行:
export function apply(ctx) {
ctx.inject(['typert'], inner => {
inner.typert.lookups.register('session', {/* ... */});
});
}
依赖已经存在时,ctx.inject 同步执行,因此较晚挂载的插件仍会在 apply 期间完成注册,而不是等到后续事件循环。
提供服务
继承 Service 并将服务名传给 super。使用方通过这个名称对应的属性读取服务,注册则由插件的纤程持有。
import { Service } from '@deepseek-ai/cordis';
export class WidgetRegistry extends Service {
static provide = 'widgets';
constructor(ctx) {
super(ctx, 'widgets');
this.widgets = new Map();
}
register(widget) {
// Return the disposer so the caller's fiber owns the entry.
return this.ctx.effect(() => {
this.widgets.set(widget.id, widget);
return () => this.widgets.delete(widget.id);
}, 'widgets.register()');
}
}
export default WidgetRegistry;
对于具有行为的服务,优先使用 Service 子类而非 ctx.reflect.provide(...):子类只注册一次名称,暴露带类型的方法,并随所属纤程自动撤销。
管理副作用
每项副作用都必须有归属。 这是桥接层能够保证回滚的核心规则,也是最容易违反的一条。
| 副作用 | 由谁管理 |
|---|---|
| 服务注册 | 自动归属于插件的纤程 |
| 事件监听器 | 插件内的 ctx.on(...) |
| 需要清理的资源 | ctx.effect(() => disposer) |
| 定时器 | ctx.setTimeout / ctx.setInterval |
| 工具注册 | 从 ctx.effect 返回清理函数 |
ctx.effect 接受返回清理函数的函数,或产出清理函数的生成器:
ctx.effect(() => {
const registration = ctx.llm.registerAdapter(['my-route'], adapter);
return () => registration();
}, 'my-adapter.register');
标签用于诊断,并非装饰:清理失败时,它用于标识对应的副作用。
接管 Cordis 无法感知的资源,例如套接字、工作线程或句柄时,必须自行负责释放:
ctx.effect(() => {
const worker = startWorker();
return () => worker.terminate();
}, 'my-plugin.worker');
哪些做法会破坏回滚
- 在与传入
apply的上下文不同的上下文上注册。这样服务会在其配置行移除后继续存在。 - 使用全局
setTimeout创建定时器。它会让进程继续运行,而且永远不会被取消。 - 订阅外部事件发射器,却未在清理函数中取消订阅。
- 写入模块级单例。清理无法撤销该操作,因此行被移除后值仍然可见;应改用每个纤程独有的状态。
配置
插件配置就是其配置行中的 config 映射。请声明模式,让拼写错误在挂载时立即报错,而不是静默使用默认值:
import z from '@deepseek-ai/schemastery';
export const Config = z.object({
route: z.string().required(),
maxRetries: z.number().default(2),
});
export function apply(ctx, config) {
// `config` is validated before this runs.
}
组合文档中的配置值可以使用 !!js。表达式在所属行的纤程内求值,并能访问 Loader 上下文:可以使用 process.env 和 ctx.get(...),不能使用 import.meta。name 从不求值,因此配置行的模块标识必须是字符串字面量。
完整示例
仓库附带两个可运行的测试夹具,供桥接测试套件使用。两者都很小,适合作为起点复制。
模型适配器:scripts/fixtures/cordis/fake-adapter.mjs 在 ctx.llm 上注册提供商路由,通过副作用返回注册的清理函数,并用固定文本回答每个请求。完整的提供商约定就是:声明 inject、注册,并管理注册的生命周期。
生命周期探针:scripts/fixtures/cordis/lifecycle-probe.mjs 提供服务、订阅事件并记录自身的清理过程,回滚测试据此确认服务和订阅都确实消失。
添加一行即可挂载任一示例:
- id: my-adapter
name: './scripts/fixtures/cordis/fake-adapter.mjs'
config:
route: my-route
相对模块标识相对于组合文件所在目录解析;裸模块标识通过后端软件包解析。
测试插件
桥接测试套件展示了以下模式:
scripts/test-cordis-bridge.mjs在临时目录中挂载真实插件树,断言服务可用,验证接口约定,并确认清理撤销了服务、释放了监听器。scripts/test-cordis-bridge-http.mjs通过真实 HTTP 服务器调用路由,包括 NDJSON 流式传输。
若要在没有提供商的情况下测试插件,请设置 model.provider: none,并挂载插件及引擎行。若插件依赖 llm,请在没有真实适配器使用的路由上挂载测试适配器,并设置 model.provider: none,避免宿主为同一路由挂载竞争适配器。
只有移除配置行后不留下任何资源,插件才是正确的。请断言整个过程:注册、观察、清理、再次观察。
检查清单
-
apply和inject是具名导出。 - 服务是
Service子类,并使用稳定的provide名称。 - 每项注册都返回清理函数,每个清理函数都由
ctx.effect返回。 - 定时器来自
ctx,而不是全局函数。 - 可变状态保存在实例上,而不是模块单例中。
- 配置通过模式验证。
- 组合或设置文档中不出现任何密钥。
- 测试确认插件清理后不留下任何资源。