Разработка плагинов 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показывает состояние каждого сервиса, а не один логический флаг. - Внедрение определяет порядок. Не задавайте очередность вручную: расположение строк поставляемой композиции не определяет загрузку.
Для необязательной зависимости используйте ctx.inject внутри apply. Функция вызывается немедленно, если сервисы уже есть, и повторно при их появлении:
export function apply(ctx) {
ctx.inject(['typert'], inner => {
inner.typert.lookups.register('session', {/* ... */});
});
}
При уже доступных зависимостях ctx.inject работает синхронно: поздно загруженный плагин регистрируется во время apply, а не на следующем шаге цикла событий.
Публикация сервиса
Расширьте Service и передайте имя сервиса в super. Это имя свойства для потребителей; регистрацией владеет fiber плагина.
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(...): имя регистрируется один раз, доступны типизированные методы, а сервис автоматически отзывается вместе с fiber.
Владение побочными эффектами
У каждого побочного эффекта должен быть владелец. Это правило обеспечивает гарантию отката моста; нарушить его легко.
| Побочный эффект | Владелец |
|---|---|
| Регистрация сервиса | Автоматически fiber плагина |
| Обработчик события | ctx.on(...) внутри плагина |
| Ресурс, требующий освобождения | ctx.effect(() => disposer) |
| Таймер | ctx.setTimeout / ctx.setInterval |
| Регистрация инструмента | Функция освобождения, возвращённая из ctx.effect |
ctx.effect принимает функцию, возвращающую disposer, либо генератор функций освобождения:
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: он удерживает процесс и никогда не отменяется. - Подписка на внешний источник событий без отписки в disposer.
- Запись в singleton модуля. Освобождение её не отменяет; значение остаётся после удаления строки. Вместо этого храните состояние в fiber.
Конфигурация
Конфигурация плагина — отображение 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. Выражение вычисляется в fiber строки с доступным контекстом Loader: process.env и ctx.get(...) работают, import.meta — нет. name никогда не вычисляется, поэтому спецификатор модуля должен быть строковым литералом.
Полный пример
В репозитории есть два рабочих примера, используемых тестами моста. Оба достаточно малы для копирования.
Адаптер модели — scripts/fixtures/cordis/fake-adapter.mjs регистрирует маршрут провайдера в ctx.llm, возвращает disposer регистрации из эффекта и отвечает фиксированным текстом. Это весь контракт провайдера: объявить 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. - Каждая регистрация возвращает disposer, и каждый disposer возвращается из
ctx.effect. - Таймеры берутся из
ctx, не из глобальных функций. - Изменяемое состояние хранится в экземпляре, не в singleton модуля.
- Конфигурация проверяется схемой.
- В документах композиции и настроек нет секретов.
- Тест подтверждает отсутствие следов плагина после освобождения.