การเขียนปลั๊กอิน 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 ตรวจการตั้งค่า
- ไม่มีความลับในเอกสารองค์ประกอบหรือการตั้งค่า
- มีการทดสอบว่าปลั๊กอินไม่ทิ้งร่องรอยหลังคืนทรัพยากร