Перейти к основному содержимому

Разработка плагинов 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 модуля.
  • Конфигурация проверяется схемой.
  • В документах композиции и настроек нет секретов.
  • Тест подтверждает отсутствие следов плагина после освобождения.