ข้ามไปยังเนื้อหาหลัก

การเขียนปลั๊กอิน Cordis

สะพานเชื่อมโหลดต้นไม้ปลั๊กอินตามแถวใน cordis.patch.yml การเพิ่มความสามารถคือเขียนปลั๊กอิน Cordis และเพิ่มแถว ไม่ใช่แก้ซอร์ส Libre WebUI หน้านี้อธิบายข้อตกลงที่โฮสต์ใช้

อ่าน สะพานเชื่อม 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 อ่าน namespace ของโมดูล ดังนั้นต้อง export apply และ inject แบบมีชื่อ default export ใช้ได้เช่นกัน แต่ปลั๊กอิน DSH ที่ให้มาใช้แบบมีชื่อ จึงควรทำให้สอดคล้องกัน

การประกาศ dependency

inject เป็นกลไก dependency ทั้งหมด Cordis รอจนบริการที่ระบุครบจึงเรียกปลั๊กอิน และเรียกใหม่หากบริการถูกถอนแล้วกลับมา

export const inject = ['tools', 'systemPrompt'];

export function apply(ctx, config) {
// `ctx.tools` and `ctx.systemPrompt` are guaranteed present here.
}

ผลสำคัญสองข้อ:

  • บริการขาดไม่ถือเป็นข้อผิดพลาด แถวจะรอเงียบ ๆ ไม่สิ้นสุด จึงต้องให้ GET /api/cordis/health แสดงรายบริการแทน boolean เดียว
  • การฉีด dependency กำหนดลำดับ ไม่ต้องจัดคิวแถวด้วยตนเอง ลำดับในตัวอย่างไม่ได้กำหนดลำดับโหลด

สำหรับ dependency ที่ไม่บังคับ ใช้ ctx.inject ภายใน apply callback จะทำงานทันทีหากบริการมีแล้ว และทำอีกเมื่อบริการปรากฏ:

export function apply(ctx) {
ctx.inject(['typert'], inner => {
inner.typert.lookups.register('session', {/* ... */});
});
}

ctx.inject ทำงานแบบ synchronous เมื่อ dependency มีแล้ว ปลั๊กอินที่โหลดช้าจึงลงทะเบียนใน apply ไม่ต้องรอรอบถัดไป

การเผยแพร่บริการ

สืบทอด Service และส่งชื่อให้ super ชื่อคือ property ที่ผู้ใช้บริการอ่าน โดยไฟเบอร์ของปลั๊กอินเป็นเจ้าของการลงทะเบียน

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
การลงทะเบียนเครื่องมือdisposer ที่คืนจาก ctx.effect

ctx.effect รับฟังก์ชันที่คืน disposer หรือ generator ที่ส่ง disposer ออกมา:

ctx.effect(() => {
const registration = ctx.llm.registerAdapter(['my-route'], adapter);
return () => registration();
}, 'my-adapter.register');

ป้ายกำกับใช้วินิจฉัย ไม่ใช่ตกแต่ง เพราะเป็นชื่อผลข้างเคียงเมื่อการคืนทรัพยากรล้มเหลว

ทรัพยากรที่ Cordis ไม่รู้จัก เช่น socket, worker หรือ handle ต้องคืนด้วยตนเอง:

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

สิ่งที่ทำให้ย้อนกลับไม่ครบ

  • ลงทะเบียนบน context อื่น ที่ไม่ใช่ตัวส่งเข้า apply บริการจะอยู่ต่อหลังแถวถูกลบ
  • สร้าง timer ด้วย setTimeout แบบ global ซึ่งทำให้โปรเซสยังอยู่และไม่ถูกยกเลิก
  • สมัครฟัง emitter ภายนอกแต่ไม่ถอนใน disposer
  • เขียน singleton ระดับโมดูล การคืนทรัพยากรย้อนค่านั้นไม่ได้และยังเห็นค่าหลังลบแถว ควรใช้สถานะต่อไฟเบอร์

การตั้งค่า

การตั้งค่าปลั๊กอินคือ mapping config ของแถว ประกาศ schema เพื่อให้การพิมพ์ผิดล้มเหลวเมื่อโหลด แทนใช้ค่าเริ่มต้นเงียบ ๆ:

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 context โดย process.env และ ctx.get(...) ใช้ได้ แต่ import.meta ใช้ไม่ได้ name ไม่เคยถูกประเมิน จึงต้องเป็นข้อความ literal

ตัวอย่างครบชุด

repository มี fixture สองตัวที่ทำงานจริงในชุดทดสอบและเล็กพอจะคัดลอก

อะแดปเตอร์โมเดลscripts/fixtures/cordis/fake-adapter.mjs ลงทะเบียนเส้นทางใน ctx.llm คืน disposer จาก effect และตอบข้อความคงที่ สัญญาทั้งหมดคือประกาศ 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 server จริง รวม NDJSON

ถ้าทดสอบโดยไม่มีผู้ให้บริการ ให้ตั้ง model.provider: none และโหลดปลั๊กอินกับแถวเอนจิน หากพึ่ง llm ให้ใช้ fixture adapter บนเส้นทางที่ไม่มีตัวจริงใช้ และตั้ง model.provider: none เพื่อไม่ให้โฮสต์โหลดตัวที่แข่งขันกัน

ปลั๊กอินถูกต้องเมื่อการลบแถวไม่เหลือร่องรอย ทดสอบตามลำดับ: ลงทะเบียน สังเกต คืนทรัพยากร และสังเกตอีกครั้ง

รายการตรวจสอบ

  • apply และ inject เป็น named exports
  • บริการสืบทอด Service และมีชื่อ provide คงที่
  • ทุกการลงทะเบียนคืน disposer และคืน disposer ผ่าน ctx.effect
  • timer มาจาก ctx ไม่ใช่ global
  • สถานะที่แก้ไขได้อยู่ใน instance ไม่ใช่ singleton ของโมดูล
  • schema ตรวจการตั้งค่า
  • ไม่มีความลับในเอกสารองค์ประกอบหรือการตั้งค่า
  • มีการทดสอบว่าปลั๊กอินไม่ทิ้งร่องรอยหลังคืนทรัพยากร