Перейти до основного вмісту

Створення плагінів 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. Якщо сервіси вже існують, callback виконується відразу, а потім щоразу після їх появи:

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 не знає, наприклад сокет, worker або дескриптор, ви повинні самостійно його звільнити:

ctx.effect(() => {
const worker = startWorker();
return () => worker.terminate();
}, 'my-plugin.worker');

Що порушує відкат

  • Реєстрація в іншому контексті, ніж переданий у apply. Сервіс залишиться після видалення рядка.
  • Створення таймера глобальним setTimeout. Він утримує процес активним і ніколи не скасовується.
  • Підписка на зовнішній emitter без відписки у функції очищення.
  • Запис у singleton модуля. Звільнення не скасує запис, тому значення залишиться після видалення рядка. Зберігайте стан окремо для кожного файбера.

Налаштування

Налаштування плагіна містяться в мапі 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

Відносні специфікатори визначаються від каталогу документа композиції; специфікатори пакетів вирішуються через пакет backend.

Тестування плагіна

Приклади тестування є в наборах моста:

  • 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, а не глобальні функції.
  • Змінний стан зберігається в екземплярі, а не в singleton модуля.
  • Налаштування перевіряються схемою.
  • Документи композиції та налаштувань не містять секретів.
  • Тест підтверджує, що після очищення плагіна нічого не залишається.