跳到主要内容

编写 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.envctx.get(...),不能使用 import.metaname 从不求值,因此配置行的模块标识必须是字符串字面量。

完整示例

仓库附带两个可运行的测试夹具,供桥接测试套件使用。两者都很小,适合作为起点复制。

模型适配器scripts/fixtures/cordis/fake-adapter.mjsctx.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,避免宿主为同一路由挂载竞争适配器。

只有移除配置行后不留下任何资源,插件才是正确的。请断言整个过程:注册、观察、清理、再次观察。

检查清单

  • applyinject 是具名导出。
  • 服务是 Service 子类,并使用稳定的 provide 名称。
  • 每项注册都返回清理函数,每个清理函数都由 ctx.effect 返回。
  • 定时器来自 ctx,而不是全局函数。
  • 可变状态保存在实例上,而不是模块单例中。
  • 配置通过模式验证。
  • 组合或设置文档中不出现任何密钥。
  • 测试确认插件清理后不留下任何资源。