@nixxie-cms/ai-rag 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +163 -0
  3. package/dist/declarations/src/AiRagService.d.ts +50 -0
  4. package/dist/declarations/src/AiRagService.d.ts.map +1 -0
  5. package/dist/declarations/src/admin-page.d.ts +29 -0
  6. package/dist/declarations/src/admin-page.d.ts.map +1 -0
  7. package/dist/declarations/src/chunking.d.ts +8 -0
  8. package/dist/declarations/src/chunking.d.ts.map +1 -0
  9. package/dist/declarations/src/collection.d.ts +18 -0
  10. package/dist/declarations/src/collection.d.ts.map +1 -0
  11. package/dist/declarations/src/express.d.ts +36 -0
  12. package/dist/declarations/src/express.d.ts.map +1 -0
  13. package/dist/declarations/src/graphql.d.ts +23 -0
  14. package/dist/declarations/src/graphql.d.ts.map +1 -0
  15. package/dist/declarations/src/index.d.ts +39 -0
  16. package/dist/declarations/src/index.d.ts.map +1 -0
  17. package/dist/declarations/src/plugin.d.ts +53 -0
  18. package/dist/declarations/src/plugin.d.ts.map +1 -0
  19. package/dist/declarations/src/prompt.d.ts +14 -0
  20. package/dist/declarations/src/prompt.d.ts.map +1 -0
  21. package/dist/declarations/src/providers/AnthropicRagProvider.d.ts +16 -0
  22. package/dist/declarations/src/providers/AnthropicRagProvider.d.ts.map +1 -0
  23. package/dist/declarations/src/providers/GeminiRagProvider.d.ts +19 -0
  24. package/dist/declarations/src/providers/GeminiRagProvider.d.ts.map +1 -0
  25. package/dist/declarations/src/providers/OllamaRagProvider.d.ts +23 -0
  26. package/dist/declarations/src/providers/OllamaRagProvider.d.ts.map +1 -0
  27. package/dist/declarations/src/providers/OpenAiRagProvider.d.ts +17 -0
  28. package/dist/declarations/src/providers/OpenAiRagProvider.d.ts.map +1 -0
  29. package/dist/declarations/src/providers/ServiceRagProvider.d.ts +17 -0
  30. package/dist/declarations/src/providers/ServiceRagProvider.d.ts.map +1 -0
  31. package/dist/declarations/src/providers/index.d.ts +14 -0
  32. package/dist/declarations/src/providers/index.d.ts.map +1 -0
  33. package/dist/declarations/src/providers/types.d.ts +45 -0
  34. package/dist/declarations/src/providers/types.d.ts.map +1 -0
  35. package/dist/declarations/src/similarity.d.ts +12 -0
  36. package/dist/declarations/src/similarity.d.ts.map +1 -0
  37. package/dist/declarations/src/types.d.ts +319 -0
  38. package/dist/declarations/src/types.d.ts.map +1 -0
  39. package/dist/declarations/src/vector-store.d.ts +34 -0
  40. package/dist/declarations/src/vector-store.d.ts.map +1 -0
  41. package/dist/nixxie-cms-ai-rag.cjs.d.ts +2 -0
  42. package/dist/nixxie-cms-ai-rag.cjs.js +2507 -0
  43. package/dist/nixxie-cms-ai-rag.esm.js +2481 -0
  44. package/package.json +37 -0
  45. package/src/AiRagService.ts +640 -0
  46. package/src/admin-page.ts +135 -0
  47. package/src/chunking.ts +78 -0
  48. package/src/collection.ts +79 -0
  49. package/src/express.ts +212 -0
  50. package/src/graphql.ts +196 -0
  51. package/src/guard.ts +75 -0
  52. package/src/index.ts +102 -0
  53. package/src/plugin.ts +162 -0
  54. package/src/prompt.ts +62 -0
  55. package/src/providers/AnthropicRagProvider.ts +91 -0
  56. package/src/providers/GeminiRagProvider.ts +147 -0
  57. package/src/providers/OllamaRagProvider.ts +157 -0
  58. package/src/providers/OpenAiRagProvider.ts +108 -0
  59. package/src/providers/ServiceRagProvider.ts +44 -0
  60. package/src/providers/index.ts +67 -0
  61. package/src/providers/types.ts +44 -0
  62. package/src/semaphore.ts +26 -0
  63. package/src/similarity.ts +31 -0
  64. package/src/types.ts +346 -0
  65. package/src/vector-store.ts +136 -0
@@ -0,0 +1,2481 @@
1
+ import _defineProperty from '@babel/runtime/helpers/esm/defineProperty';
2
+ import { text, json, select, integer, timestamp } from '@nixxie-cms/core/fields';
3
+ import { g } from '@nixxie-cms/core';
4
+
5
+ const DEFAULTS = {
6
+ strategy: 'recursive',
7
+ chunkSize: 1200,
8
+ chunkOverlap: 200
9
+ };
10
+
11
+ /** Boundary separators tried in order, coarsest first (recursive strategy). */
12
+ const SEPARATORS = ['\n\n', '\n', '. ', '! ', '? ', '; ', ', ', ' '];
13
+
14
+ /**
15
+ * Split text into overlapping chunks. The recursive strategy prefers to cut on the
16
+ * coarsest natural boundary that keeps a chunk under `chunkSize`, falling back to finer
17
+ * boundaries (and finally a hard slice) so no chunk overruns the budget.
18
+ */
19
+ function chunkText(text, config = {}) {
20
+ var _config$strategy, _config$chunkSize, _config$chunkOverlap;
21
+ const strategy = (_config$strategy = config.strategy) !== null && _config$strategy !== void 0 ? _config$strategy : DEFAULTS.strategy;
22
+ const size = Math.max(1, (_config$chunkSize = config.chunkSize) !== null && _config$chunkSize !== void 0 ? _config$chunkSize : DEFAULTS.chunkSize);
23
+ const overlap = Math.max(0, Math.min((_config$chunkOverlap = config.chunkOverlap) !== null && _config$chunkOverlap !== void 0 ? _config$chunkOverlap : DEFAULTS.chunkOverlap, size - 1));
24
+ const normalized = text.replace(/\r\n/g, '\n').trim();
25
+ if (!normalized) return [];
26
+ if (normalized.length <= size) return [normalized];
27
+ const units = strategy === 'sentence' ? splitSentences(normalized) : strategy === 'fixed' ? hardSlice(normalized, size) : recursiveSplit(normalized, size);
28
+ return packWithOverlap(units, size, overlap);
29
+ }
30
+
31
+ /** Greedily pack atomic units into chunks <= size, carrying `overlap` chars between them. */
32
+ function packWithOverlap(units, size, overlap) {
33
+ const chunks = [];
34
+ let current = '';
35
+ for (const unit of units) {
36
+ if (current && current.length + unit.length + 1 > size) {
37
+ chunks.push(current.trim());
38
+ current = overlap > 0 ? current.slice(Math.max(0, current.length - overlap)) : '';
39
+ }
40
+ current = current ? `${current} ${unit}`.trim() : unit;
41
+ // A single oversized unit: hard-slice it.
42
+ while (current.length > size) {
43
+ chunks.push(current.slice(0, size).trim());
44
+ current = current.slice(size - overlap);
45
+ }
46
+ }
47
+ if (current.trim()) chunks.push(current.trim());
48
+ return chunks.filter(Boolean);
49
+ }
50
+ function recursiveSplit(text, size) {
51
+ if (text.length <= size) return [text];
52
+ for (const sep of SEPARATORS) {
53
+ if (!text.includes(sep)) continue;
54
+ const parts = text.split(sep).filter(Boolean);
55
+ if (parts.length < 2) continue;
56
+ return parts.flatMap(p => p.length > size ? recursiveSplit(p, size) : [p]);
57
+ }
58
+ return hardSlice(text, size);
59
+ }
60
+ function splitSentences(text) {
61
+ return text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean);
62
+ }
63
+ function hardSlice(text, size) {
64
+ const out = [];
65
+ for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
66
+ return out;
67
+ }
68
+
69
+ const DEFAULT_REFUSAL = "I don't have enough information in my knowledge base to answer that.";
70
+
71
+ /** Resolve guard config against defaults. */
72
+ function resolveGuard(config = {}) {
73
+ var _config$enabled, _config$refuseWhenNoC, _config$refusal, _config$requireCitati, _config$groundingChec, _config$refuseWhenUng, _config$allowModelKno;
74
+ const enabled = (_config$enabled = config.enabled) !== null && _config$enabled !== void 0 ? _config$enabled : true;
75
+ return {
76
+ enabled,
77
+ refuseWhenNoContext: enabled && ((_config$refuseWhenNoC = config.refuseWhenNoContext) !== null && _config$refuseWhenNoC !== void 0 ? _config$refuseWhenNoC : true),
78
+ refusal: (_config$refusal = config.refusal) !== null && _config$refusal !== void 0 ? _config$refusal : DEFAULT_REFUSAL,
79
+ requireCitations: enabled && ((_config$requireCitati = config.requireCitations) !== null && _config$requireCitati !== void 0 ? _config$requireCitati : true),
80
+ groundingCheck: enabled && ((_config$groundingChec = config.groundingCheck) !== null && _config$groundingChec !== void 0 ? _config$groundingChec : false),
81
+ groundingModel: config.groundingModel,
82
+ refuseWhenUngrounded: (_config$refuseWhenUng = config.refuseWhenUngrounded) !== null && _config$refuseWhenUng !== void 0 ? _config$refuseWhenUng : true,
83
+ allowModelKnowledge: (_config$allowModelKno = config.allowModelKnowledge) !== null && _config$allowModelKno !== void 0 ? _config$allowModelKno : false
84
+ };
85
+ }
86
+ /**
87
+ * Decide whether retrieval found enough relevant context to answer. When nothing clears the
88
+ * relevance bar and `refuseWhenNoContext` is on (and model knowledge isn't allowed), the
89
+ * assistant should refuse instead of guessing.
90
+ */
91
+ function shouldRefuseForNoContext(chunks, guard, minScore) {
92
+ var _chunks$0$score, _chunks$;
93
+ if (!guard.refuseWhenNoContext || guard.allowModelKnowledge) return false;
94
+ const best = (_chunks$0$score = (_chunks$ = chunks[0]) === null || _chunks$ === void 0 ? void 0 : _chunks$.score) !== null && _chunks$0$score !== void 0 ? _chunks$0$score : 0;
95
+ return chunks.length === 0 || best < minScore;
96
+ }
97
+
98
+ /**
99
+ * Post-hoc grounding check: ask a (cheap) model whether the drafted answer is fully
100
+ * supported by the retrieved context. Returns whether it's grounded plus an optional reason.
101
+ * Best-effort — any failure is treated as "grounded" so the guard never hard-fails a request.
102
+ */
103
+ async function checkGrounding(provider, answer, chunks, model) {
104
+ if (!answer.trim() || chunks.length === 0) return {
105
+ grounded: true
106
+ };
107
+ const context = chunks.map((c, i) => `[${i + 1}] ${c.content}`).join('\n\n').slice(0, 8000);
108
+ const system = 'You are a strict fact-checker. Decide if the ANSWER is fully supported by the CONTEXT. ' + 'Reply with a single JSON object: {"grounded": boolean, "reason": string}. ' + 'An answer that adds facts not present in the context is NOT grounded. A refusal or ' + '"I don\'t know" counts as grounded.';
109
+ try {
110
+ const res = await provider.generate([{
111
+ role: 'user',
112
+ content: `CONTEXT:\n${context}\n\nANSWER:\n${answer}`
113
+ }], {
114
+ system,
115
+ model,
116
+ temperature: 0,
117
+ maxTokens: 200
118
+ });
119
+ const match = res.text.match(/\{[\s\S]*\}/);
120
+ if (!match) return {
121
+ grounded: true
122
+ };
123
+ const parsed = JSON.parse(match[0]);
124
+ return {
125
+ grounded: parsed.grounded !== false,
126
+ reason: parsed.reason
127
+ };
128
+ } catch {
129
+ return {
130
+ grounded: true
131
+ };
132
+ }
133
+ }
134
+
135
+ const DEFAULT_MODEL$3 = 'claude-opus-4-8';
136
+ function loadAnthropic() {
137
+ try {
138
+ var _require$default;
139
+ return (_require$default = require('@anthropic-ai/sdk').default) !== null && _require$default !== void 0 ? _require$default : require('@anthropic-ai/sdk');
140
+ } catch {
141
+ throw new Error('[@nixxie-cms/ai-rag] The Anthropic provider requires @anthropic-ai/sdk. Run: npm install @anthropic-ai/sdk');
142
+ }
143
+ }
144
+
145
+ /** Anthropic (Claude) generation provider with native streaming. */
146
+ class AnthropicRagProvider {
147
+ constructor(config) {
148
+ var _config$model, _config$extra;
149
+ _defineProperty(this, "name", 'anthropic');
150
+ _defineProperty(this, "defaultModel", DEFAULT_MODEL$3);
151
+ if (!config.apiKey) throw new Error('[@nixxie-cms/ai-rag] Anthropic generation requires `apiKey`.');
152
+ const Anthropic = loadAnthropic();
153
+ this.client = new Anthropic({
154
+ apiKey: config.apiKey,
155
+ baseURL: config.baseUrl
156
+ });
157
+ this.model = (_config$model = config.model) !== null && _config$model !== void 0 ? _config$model : DEFAULT_MODEL$3;
158
+ this.maxTokens = 1024;
159
+ this.extra = (_config$extra = config.extra) !== null && _config$extra !== void 0 ? _config$extra : {};
160
+ }
161
+ buildBody(messages, options) {
162
+ var _options$model, _options$maxTokens, _options$extra;
163
+ return {
164
+ model: (_options$model = options === null || options === void 0 ? void 0 : options.model) !== null && _options$model !== void 0 ? _options$model : this.model,
165
+ max_tokens: (_options$maxTokens = options === null || options === void 0 ? void 0 : options.maxTokens) !== null && _options$maxTokens !== void 0 ? _options$maxTokens : this.maxTokens,
166
+ system: (options === null || options === void 0 ? void 0 : options.system) || undefined,
167
+ messages: messages.map(m => ({
168
+ role: m.role,
169
+ content: m.content
170
+ })),
171
+ ...((options === null || options === void 0 ? void 0 : options.temperature) !== undefined ? {
172
+ temperature: options.temperature
173
+ } : {}),
174
+ ...((options === null || options === void 0 ? void 0 : options.topP) !== undefined ? {
175
+ top_p: options.topP
176
+ } : {}),
177
+ ...this.extra,
178
+ ...((_options$extra = options === null || options === void 0 ? void 0 : options.extra) !== null && _options$extra !== void 0 ? _options$extra : {})
179
+ };
180
+ }
181
+ async generate(messages, options) {
182
+ var _res$content, _ref, _res$model, _res$usage, _res$usage2;
183
+ const res = await this.client.messages.create(this.buildBody(messages, options));
184
+ const text = ((_res$content = res.content) !== null && _res$content !== void 0 ? _res$content : []).filter(b => b.type === 'text').map(b => b.text).join('');
185
+ return {
186
+ text,
187
+ model: (_ref = (_res$model = res.model) !== null && _res$model !== void 0 ? _res$model : options === null || options === void 0 ? void 0 : options.model) !== null && _ref !== void 0 ? _ref : this.model,
188
+ usage: {
189
+ inputTokens: (_res$usage = res.usage) === null || _res$usage === void 0 ? void 0 : _res$usage.input_tokens,
190
+ outputTokens: (_res$usage2 = res.usage) === null || _res$usage2 === void 0 ? void 0 : _res$usage2.output_tokens
191
+ }
192
+ };
193
+ }
194
+ async *stream(messages, options) {
195
+ var _options$model2;
196
+ const stream = await this.client.messages.create({
197
+ ...this.buildBody(messages, options),
198
+ stream: true
199
+ });
200
+ let input = 0;
201
+ let output = 0;
202
+ for await (const event of stream) {
203
+ var _event$delta;
204
+ if (event.type === 'content_block_delta' && ((_event$delta = event.delta) === null || _event$delta === void 0 ? void 0 : _event$delta.type) === 'text_delta') {
205
+ yield {
206
+ delta: event.delta.text
207
+ };
208
+ } else if (event.type === 'message_start') {
209
+ var _event$message$usage$, _event$message;
210
+ input = (_event$message$usage$ = (_event$message = event.message) === null || _event$message === void 0 || (_event$message = _event$message.usage) === null || _event$message === void 0 ? void 0 : _event$message.input_tokens) !== null && _event$message$usage$ !== void 0 ? _event$message$usage$ : input;
211
+ } else if (event.type === 'message_delta') {
212
+ var _event$usage$output_t, _event$usage;
213
+ output = (_event$usage$output_t = (_event$usage = event.usage) === null || _event$usage === void 0 ? void 0 : _event$usage.output_tokens) !== null && _event$usage$output_t !== void 0 ? _event$usage$output_t : output;
214
+ }
215
+ }
216
+ yield {
217
+ done: true,
218
+ model: (_options$model2 = options === null || options === void 0 ? void 0 : options.model) !== null && _options$model2 !== void 0 ? _options$model2 : this.model,
219
+ usage: {
220
+ inputTokens: input,
221
+ outputTokens: output
222
+ }
223
+ };
224
+ }
225
+ }
226
+
227
+ const DEFAULT_MODEL$2 = 'gemini-2.0-flash';
228
+ const DEFAULT_EMBEDDING_MODEL$2 = 'text-embedding-004';
229
+ const DEFAULT_BASE$1 = 'https://generativelanguage.googleapis.com/v1beta';
230
+
231
+ /** Google Gemini provider over the public REST API — generation, streaming and embeddings. */
232
+ class GeminiRagProvider {
233
+ constructor(config) {
234
+ var _config$model, _config$model2, _config$baseUrl, _config$extra;
235
+ _defineProperty(this, "name", 'gemini');
236
+ _defineProperty(this, "defaultModel", DEFAULT_MODEL$2);
237
+ if (!config.apiKey) throw new Error('[@nixxie-cms/ai-rag] Gemini requires `apiKey`.');
238
+ this.apiKey = config.apiKey;
239
+ this.model = (_config$model = config.model) !== null && _config$model !== void 0 ? _config$model : DEFAULT_MODEL$2;
240
+ this.embeddingModel = (_config$model2 = config.model) !== null && _config$model2 !== void 0 ? _config$model2 : DEFAULT_EMBEDDING_MODEL$2;
241
+ this.base = ((_config$baseUrl = config.baseUrl) !== null && _config$baseUrl !== void 0 ? _config$baseUrl : DEFAULT_BASE$1).replace(/\/$/, '');
242
+ this.extra = (_config$extra = config.extra) !== null && _config$extra !== void 0 ? _config$extra : {};
243
+ }
244
+ buildBody(messages, options) {
245
+ var _options$extra;
246
+ return {
247
+ contents: messages.map(m => ({
248
+ role: m.role === 'assistant' ? 'model' : 'user',
249
+ parts: [{
250
+ text: m.content
251
+ }]
252
+ })),
253
+ ...(options !== null && options !== void 0 && options.system ? {
254
+ systemInstruction: {
255
+ parts: [{
256
+ text: options.system
257
+ }]
258
+ }
259
+ } : {}),
260
+ generationConfig: {
261
+ ...((options === null || options === void 0 ? void 0 : options.temperature) !== undefined ? {
262
+ temperature: options.temperature
263
+ } : {}),
264
+ ...((options === null || options === void 0 ? void 0 : options.maxTokens) !== undefined ? {
265
+ maxOutputTokens: options.maxTokens
266
+ } : {}),
267
+ ...((options === null || options === void 0 ? void 0 : options.topP) !== undefined ? {
268
+ topP: options.topP
269
+ } : {})
270
+ },
271
+ ...this.extra,
272
+ ...((_options$extra = options === null || options === void 0 ? void 0 : options.extra) !== null && _options$extra !== void 0 ? _options$extra : {})
273
+ };
274
+ }
275
+ extractText(data) {
276
+ var _data$candidates$0$co, _data$candidates;
277
+ const parts = (_data$candidates$0$co = data === null || data === void 0 || (_data$candidates = data.candidates) === null || _data$candidates === void 0 || (_data$candidates = _data$candidates[0]) === null || _data$candidates === void 0 || (_data$candidates = _data$candidates.content) === null || _data$candidates === void 0 ? void 0 : _data$candidates.parts) !== null && _data$candidates$0$co !== void 0 ? _data$candidates$0$co : [];
278
+ return parts.map(p => {
279
+ var _p$text;
280
+ return (_p$text = p.text) !== null && _p$text !== void 0 ? _p$text : '';
281
+ }).join('');
282
+ }
283
+ async generate(messages, options) {
284
+ var _options$model, _data$usageMetadata, _data$usageMetadata2;
285
+ const model = (_options$model = options === null || options === void 0 ? void 0 : options.model) !== null && _options$model !== void 0 ? _options$model : this.model;
286
+ const res = await fetch(`${this.base}/models/${model}:generateContent?key=${this.apiKey}`, {
287
+ method: 'POST',
288
+ headers: {
289
+ 'Content-Type': 'application/json'
290
+ },
291
+ body: JSON.stringify(this.buildBody(messages, options))
292
+ });
293
+ if (!res.ok) throw new Error(`[@nixxie-cms/ai-rag] Gemini generate failed (${res.status}): ${await res.text()}`);
294
+ const data = await res.json();
295
+ return {
296
+ text: this.extractText(data),
297
+ model,
298
+ usage: {
299
+ inputTokens: data === null || data === void 0 || (_data$usageMetadata = data.usageMetadata) === null || _data$usageMetadata === void 0 ? void 0 : _data$usageMetadata.promptTokenCount,
300
+ outputTokens: data === null || data === void 0 || (_data$usageMetadata2 = data.usageMetadata) === null || _data$usageMetadata2 === void 0 ? void 0 : _data$usageMetadata2.candidatesTokenCount
301
+ }
302
+ };
303
+ }
304
+ async *stream(messages, options) {
305
+ var _options$model2, _usage, _usage2;
306
+ const model = (_options$model2 = options === null || options === void 0 ? void 0 : options.model) !== null && _options$model2 !== void 0 ? _options$model2 : this.model;
307
+ const res = await fetch(`${this.base}/models/${model}:streamGenerateContent?alt=sse&key=${this.apiKey}`, {
308
+ method: 'POST',
309
+ headers: {
310
+ 'Content-Type': 'application/json'
311
+ },
312
+ body: JSON.stringify(this.buildBody(messages, options))
313
+ });
314
+ if (!res.ok || !res.body) {
315
+ throw new Error(`[@nixxie-cms/ai-rag] Gemini stream failed (${res.status}): ${await res.text()}`);
316
+ }
317
+ let usage;
318
+ for await (const data of parseSseJson(res.body)) {
319
+ const text = this.extractText(data);
320
+ if (text) yield {
321
+ delta: text
322
+ };
323
+ if (data !== null && data !== void 0 && data.usageMetadata) usage = data.usageMetadata;
324
+ }
325
+ yield {
326
+ done: true,
327
+ model,
328
+ usage: {
329
+ inputTokens: (_usage = usage) === null || _usage === void 0 ? void 0 : _usage.promptTokenCount,
330
+ outputTokens: (_usage2 = usage) === null || _usage2 === void 0 ? void 0 : _usage2.candidatesTokenCount
331
+ }
332
+ };
333
+ }
334
+ async embed(texts, model) {
335
+ var _data$embeddings;
336
+ const m = model !== null && model !== void 0 ? model : this.embeddingModel;
337
+ const res = await fetch(`${this.base}/models/${m}:batchEmbedContents?key=${this.apiKey}`, {
338
+ method: 'POST',
339
+ headers: {
340
+ 'Content-Type': 'application/json'
341
+ },
342
+ body: JSON.stringify({
343
+ requests: texts.map(text => ({
344
+ model: `models/${m}`,
345
+ content: {
346
+ parts: [{
347
+ text
348
+ }]
349
+ }
350
+ }))
351
+ })
352
+ });
353
+ if (!res.ok) throw new Error(`[@nixxie-cms/ai-rag] Gemini embed failed (${res.status}): ${await res.text()}`);
354
+ const data = await res.json();
355
+ return ((_data$embeddings = data.embeddings) !== null && _data$embeddings !== void 0 ? _data$embeddings : []).map(e => e.values);
356
+ }
357
+ }
358
+
359
+ /** Parse a `text/event-stream` of JSON `data:` lines into objects. */
360
+ async function* parseSseJson(body) {
361
+ const decoder = new TextDecoder();
362
+ let buffer = '';
363
+ for await (const piece of body) {
364
+ buffer += typeof piece === 'string' ? piece : decoder.decode(piece, {
365
+ stream: true
366
+ });
367
+ let idx;
368
+ while ((idx = buffer.indexOf('\n')) !== -1) {
369
+ const line = buffer.slice(0, idx).trim();
370
+ buffer = buffer.slice(idx + 1);
371
+ if (!line.startsWith('data:')) continue;
372
+ const payload = line.slice(5).trim();
373
+ if (!payload || payload === '[DONE]') continue;
374
+ try {
375
+ yield JSON.parse(payload);
376
+ } catch {
377
+ // Partial frame — ignore; the next chunk will complete it.
378
+ }
379
+ }
380
+ }
381
+ }
382
+
383
+ const DEFAULT_MODEL$1 = 'llama3.1';
384
+ const DEFAULT_EMBEDDING_MODEL$1 = 'nomic-embed-text';
385
+ const DEFAULT_BASE = 'http://localhost:11434';
386
+
387
+ /**
388
+ * Ollama provider for local (or remote) open models — generation, streaming and embeddings.
389
+ * Runs any Ollama model family (Llama, Mistral, Phi, Gemma, Qwen, …). No API key needed for a
390
+ * local server; point `baseUrl` at your Ollama host if it isn't on localhost.
391
+ */
392
+ class OllamaRagProvider {
393
+ constructor(config) {
394
+ var _config$baseUrl, _config$model, _config$model2, _config$extra;
395
+ _defineProperty(this, "name", 'ollama');
396
+ _defineProperty(this, "defaultModel", DEFAULT_MODEL$1);
397
+ this.base = ((_config$baseUrl = config.baseUrl) !== null && _config$baseUrl !== void 0 ? _config$baseUrl : DEFAULT_BASE).replace(/\/$/, '');
398
+ this.model = (_config$model = config.model) !== null && _config$model !== void 0 ? _config$model : DEFAULT_MODEL$1;
399
+ this.embeddingModel = (_config$model2 = config.model) !== null && _config$model2 !== void 0 ? _config$model2 : DEFAULT_EMBEDDING_MODEL$1;
400
+ this.apiKey = config.apiKey;
401
+ this.extra = (_config$extra = config.extra) !== null && _config$extra !== void 0 ? _config$extra : {};
402
+ }
403
+ headers() {
404
+ return {
405
+ 'Content-Type': 'application/json',
406
+ ...(this.apiKey ? {
407
+ Authorization: `Bearer ${this.apiKey}`
408
+ } : {})
409
+ };
410
+ }
411
+ buildBody(messages, options, stream) {
412
+ var _options$model, _options$extra;
413
+ return {
414
+ model: (_options$model = options === null || options === void 0 ? void 0 : options.model) !== null && _options$model !== void 0 ? _options$model : this.model,
415
+ stream,
416
+ messages: [...(options !== null && options !== void 0 && options.system ? [{
417
+ role: 'system',
418
+ content: options.system
419
+ }] : []), ...messages.map(m => ({
420
+ role: m.role,
421
+ content: m.content
422
+ }))],
423
+ options: {
424
+ ...((options === null || options === void 0 ? void 0 : options.temperature) !== undefined ? {
425
+ temperature: options.temperature
426
+ } : {}),
427
+ ...((options === null || options === void 0 ? void 0 : options.maxTokens) !== undefined ? {
428
+ num_predict: options.maxTokens
429
+ } : {}),
430
+ ...((options === null || options === void 0 ? void 0 : options.topP) !== undefined ? {
431
+ top_p: options.topP
432
+ } : {})
433
+ },
434
+ ...this.extra,
435
+ ...((_options$extra = options === null || options === void 0 ? void 0 : options.extra) !== null && _options$extra !== void 0 ? _options$extra : {})
436
+ };
437
+ }
438
+ async generate(messages, options) {
439
+ var _data$message$content, _data$message, _ref, _data$model;
440
+ const res = await fetch(`${this.base}/api/chat`, {
441
+ method: 'POST',
442
+ headers: this.headers(),
443
+ body: JSON.stringify(this.buildBody(messages, options, false))
444
+ });
445
+ if (!res.ok) throw new Error(`[@nixxie-cms/ai-rag] Ollama generate failed (${res.status}): ${await res.text()}`);
446
+ const data = await res.json();
447
+ return {
448
+ text: (_data$message$content = data === null || data === void 0 || (_data$message = data.message) === null || _data$message === void 0 ? void 0 : _data$message.content) !== null && _data$message$content !== void 0 ? _data$message$content : '',
449
+ model: (_ref = (_data$model = data === null || data === void 0 ? void 0 : data.model) !== null && _data$model !== void 0 ? _data$model : options === null || options === void 0 ? void 0 : options.model) !== null && _ref !== void 0 ? _ref : this.model,
450
+ usage: {
451
+ inputTokens: data === null || data === void 0 ? void 0 : data.prompt_eval_count,
452
+ outputTokens: data === null || data === void 0 ? void 0 : data.eval_count
453
+ }
454
+ };
455
+ }
456
+ async *stream(messages, options) {
457
+ var _options$model2, _usage, _usage2;
458
+ const res = await fetch(`${this.base}/api/chat`, {
459
+ method: 'POST',
460
+ headers: this.headers(),
461
+ body: JSON.stringify(this.buildBody(messages, options, true))
462
+ });
463
+ if (!res.ok || !res.body) {
464
+ throw new Error(`[@nixxie-cms/ai-rag] Ollama stream failed (${res.status}): ${await res.text()}`);
465
+ }
466
+ let usage;
467
+ // Ollama streams newline-delimited JSON objects.
468
+ for await (const obj of parseNdJson(res.body)) {
469
+ var _obj$message;
470
+ const delta = obj === null || obj === void 0 || (_obj$message = obj.message) === null || _obj$message === void 0 ? void 0 : _obj$message.content;
471
+ if (delta) yield {
472
+ delta
473
+ };
474
+ if (obj !== null && obj !== void 0 && obj.done) usage = obj;
475
+ }
476
+ yield {
477
+ done: true,
478
+ model: (_options$model2 = options === null || options === void 0 ? void 0 : options.model) !== null && _options$model2 !== void 0 ? _options$model2 : this.model,
479
+ usage: {
480
+ inputTokens: (_usage = usage) === null || _usage === void 0 ? void 0 : _usage.prompt_eval_count,
481
+ outputTokens: (_usage2 = usage) === null || _usage2 === void 0 ? void 0 : _usage2.eval_count
482
+ }
483
+ };
484
+ }
485
+ async embed(texts, model) {
486
+ const m = model !== null && model !== void 0 ? model : this.embeddingModel;
487
+ // /api/embed accepts a batch; older servers only support /api/embeddings (single).
488
+ const res = await fetch(`${this.base}/api/embed`, {
489
+ method: 'POST',
490
+ headers: this.headers(),
491
+ body: JSON.stringify({
492
+ model: m,
493
+ input: texts
494
+ })
495
+ });
496
+ if (res.ok) {
497
+ const data = await res.json();
498
+ if (Array.isArray(data === null || data === void 0 ? void 0 : data.embeddings)) return data.embeddings;
499
+ }
500
+ // Fallback: one request per text against the legacy endpoint.
501
+ const out = [];
502
+ for (const text of texts) {
503
+ const r = await fetch(`${this.base}/api/embeddings`, {
504
+ method: 'POST',
505
+ headers: this.headers(),
506
+ body: JSON.stringify({
507
+ model: m,
508
+ prompt: text
509
+ })
510
+ });
511
+ if (!r.ok) throw new Error(`[@nixxie-cms/ai-rag] Ollama embed failed (${r.status}): ${await r.text()}`);
512
+ const d = await r.json();
513
+ out.push(d.embedding);
514
+ }
515
+ return out;
516
+ }
517
+ }
518
+
519
+ /** Parse newline-delimited JSON from a stream body. */
520
+ async function* parseNdJson(body) {
521
+ const decoder = new TextDecoder();
522
+ let buffer = '';
523
+ for await (const piece of body) {
524
+ buffer += typeof piece === 'string' ? piece : decoder.decode(piece, {
525
+ stream: true
526
+ });
527
+ let idx;
528
+ while ((idx = buffer.indexOf('\n')) !== -1) {
529
+ const line = buffer.slice(0, idx).trim();
530
+ buffer = buffer.slice(idx + 1);
531
+ if (!line) continue;
532
+ try {
533
+ yield JSON.parse(line);
534
+ } catch {
535
+ // Ignore partial lines.
536
+ }
537
+ }
538
+ }
539
+ const tail = buffer.trim();
540
+ if (tail) {
541
+ try {
542
+ yield JSON.parse(tail);
543
+ } catch {
544
+ /* ignore */
545
+ }
546
+ }
547
+ }
548
+
549
+ const DEFAULT_MODEL = 'gpt-4o';
550
+ const DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small';
551
+ function loadOpenAi() {
552
+ try {
553
+ var _require$default;
554
+ return (_require$default = require('openai').default) !== null && _require$default !== void 0 ? _require$default : require('openai');
555
+ } catch {
556
+ throw new Error('[@nixxie-cms/ai-rag] The OpenAI provider requires the openai package. Run: npm install openai');
557
+ }
558
+ }
559
+
560
+ /** OpenAI (GPT) provider — generation, streaming and embeddings. */
561
+ class OpenAiRagProvider {
562
+ constructor(config) {
563
+ var _config$model, _config$model2, _config$extra;
564
+ _defineProperty(this, "name", 'openai');
565
+ _defineProperty(this, "defaultModel", DEFAULT_MODEL);
566
+ if (!config.apiKey) throw new Error('[@nixxie-cms/ai-rag] OpenAI requires `apiKey`.');
567
+ const OpenAI = loadOpenAi();
568
+ this.client = new OpenAI({
569
+ apiKey: config.apiKey,
570
+ baseURL: config.baseUrl
571
+ });
572
+ this.model = (_config$model = config.model) !== null && _config$model !== void 0 ? _config$model : DEFAULT_MODEL;
573
+ this.embeddingModel = (_config$model2 = config.model) !== null && _config$model2 !== void 0 ? _config$model2 : DEFAULT_EMBEDDING_MODEL;
574
+ this.extra = (_config$extra = config.extra) !== null && _config$extra !== void 0 ? _config$extra : {};
575
+ }
576
+ buildMessages(messages, system) {
577
+ return [...(system ? [{
578
+ role: 'system',
579
+ content: system
580
+ }] : []), ...messages.map(m => ({
581
+ role: m.role,
582
+ content: m.content
583
+ }))];
584
+ }
585
+ async generate(messages, options) {
586
+ var _options$model, _options$extra, _res$choices$0$messag, _res$choices, _ref, _res$model, _res$usage, _res$usage2;
587
+ const res = await this.client.chat.completions.create({
588
+ model: (_options$model = options === null || options === void 0 ? void 0 : options.model) !== null && _options$model !== void 0 ? _options$model : this.model,
589
+ messages: this.buildMessages(messages, options === null || options === void 0 ? void 0 : options.system),
590
+ ...((options === null || options === void 0 ? void 0 : options.maxTokens) !== undefined ? {
591
+ max_tokens: options.maxTokens
592
+ } : {}),
593
+ ...((options === null || options === void 0 ? void 0 : options.temperature) !== undefined ? {
594
+ temperature: options.temperature
595
+ } : {}),
596
+ ...((options === null || options === void 0 ? void 0 : options.topP) !== undefined ? {
597
+ top_p: options.topP
598
+ } : {}),
599
+ ...this.extra,
600
+ ...((_options$extra = options === null || options === void 0 ? void 0 : options.extra) !== null && _options$extra !== void 0 ? _options$extra : {})
601
+ });
602
+ return {
603
+ text: (_res$choices$0$messag = (_res$choices = res.choices) === null || _res$choices === void 0 || (_res$choices = _res$choices[0]) === null || _res$choices === void 0 || (_res$choices = _res$choices.message) === null || _res$choices === void 0 ? void 0 : _res$choices.content) !== null && _res$choices$0$messag !== void 0 ? _res$choices$0$messag : '',
604
+ model: (_ref = (_res$model = res.model) !== null && _res$model !== void 0 ? _res$model : options === null || options === void 0 ? void 0 : options.model) !== null && _ref !== void 0 ? _ref : this.model,
605
+ usage: {
606
+ inputTokens: (_res$usage = res.usage) === null || _res$usage === void 0 ? void 0 : _res$usage.prompt_tokens,
607
+ outputTokens: (_res$usage2 = res.usage) === null || _res$usage2 === void 0 ? void 0 : _res$usage2.completion_tokens
608
+ }
609
+ };
610
+ }
611
+ async *stream(messages, options) {
612
+ var _options$model2, _options$extra2, _options$model3, _usage, _usage2;
613
+ const stream = await this.client.chat.completions.create({
614
+ model: (_options$model2 = options === null || options === void 0 ? void 0 : options.model) !== null && _options$model2 !== void 0 ? _options$model2 : this.model,
615
+ messages: this.buildMessages(messages, options === null || options === void 0 ? void 0 : options.system),
616
+ stream: true,
617
+ stream_options: {
618
+ include_usage: true
619
+ },
620
+ ...((options === null || options === void 0 ? void 0 : options.maxTokens) !== undefined ? {
621
+ max_tokens: options.maxTokens
622
+ } : {}),
623
+ ...((options === null || options === void 0 ? void 0 : options.temperature) !== undefined ? {
624
+ temperature: options.temperature
625
+ } : {}),
626
+ ...((options === null || options === void 0 ? void 0 : options.topP) !== undefined ? {
627
+ top_p: options.topP
628
+ } : {}),
629
+ ...this.extra,
630
+ ...((_options$extra2 = options === null || options === void 0 ? void 0 : options.extra) !== null && _options$extra2 !== void 0 ? _options$extra2 : {})
631
+ });
632
+ let usage;
633
+ for await (const part of stream) {
634
+ var _part$choices;
635
+ const delta = (_part$choices = part.choices) === null || _part$choices === void 0 || (_part$choices = _part$choices[0]) === null || _part$choices === void 0 || (_part$choices = _part$choices.delta) === null || _part$choices === void 0 ? void 0 : _part$choices.content;
636
+ if (delta) yield {
637
+ delta
638
+ };
639
+ if (part.usage) usage = part.usage;
640
+ }
641
+ yield {
642
+ done: true,
643
+ model: (_options$model3 = options === null || options === void 0 ? void 0 : options.model) !== null && _options$model3 !== void 0 ? _options$model3 : this.model,
644
+ usage: {
645
+ inputTokens: (_usage = usage) === null || _usage === void 0 ? void 0 : _usage.prompt_tokens,
646
+ outputTokens: (_usage2 = usage) === null || _usage2 === void 0 ? void 0 : _usage2.completion_tokens
647
+ }
648
+ };
649
+ }
650
+ async embed(texts, model) {
651
+ var _res$data;
652
+ const res = await this.client.embeddings.create({
653
+ model: model !== null && model !== void 0 ? model : this.embeddingModel,
654
+ input: texts
655
+ });
656
+ const items = (_res$data = res.data) !== null && _res$data !== void 0 ? _res$data : [];
657
+ return items.slice().sort((a, b) => {
658
+ var _a$index, _b$index;
659
+ return ((_a$index = a.index) !== null && _a$index !== void 0 ? _a$index : 0) - ((_b$index = b.index) !== null && _b$index !== void 0 ? _b$index : 0);
660
+ }).map(d => d.embedding);
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Adapts an existing `NixxieAiService` (e.g. `context.services.ai` from @nixxie-cms/ai)
666
+ * into a RAG provider. Streaming is emulated by emitting the full answer as one delta,
667
+ * since `NixxieAiService` has no streaming surface.
668
+ */
669
+ class ServiceRagProvider {
670
+ constructor(service) {
671
+ _defineProperty(this, "name", 'service');
672
+ _defineProperty(this, "defaultModel", 'service');
673
+ this.service = service;
674
+ }
675
+ async generate(messages, options) {
676
+ const res = await this.service.chat(messages, {
677
+ model: options === null || options === void 0 ? void 0 : options.model,
678
+ system: options === null || options === void 0 ? void 0 : options.system,
679
+ temperature: options === null || options === void 0 ? void 0 : options.temperature,
680
+ maxTokens: options === null || options === void 0 ? void 0 : options.maxTokens
681
+ });
682
+ return {
683
+ text: res.text,
684
+ model: res.model,
685
+ usage: res.usage
686
+ };
687
+ }
688
+ async *stream(messages, options) {
689
+ const res = await this.generate(messages, options);
690
+ if (res.text) yield {
691
+ delta: res.text
692
+ };
693
+ yield {
694
+ done: true,
695
+ model: res.model,
696
+ usage: res.usage
697
+ };
698
+ }
699
+ async embed(texts) {
700
+ return this.service.embedMany(texts);
701
+ }
702
+ }
703
+
704
+ /** Build the generation provider from config (default provider: anthropic). */
705
+ function resolveGenerationProvider(config = {}) {
706
+ var _config$provider;
707
+ if (config.service) return new ServiceRagProvider(config.service);
708
+ const provider = (_config$provider = config.provider) !== null && _config$provider !== void 0 ? _config$provider : 'anthropic';
709
+ switch (provider) {
710
+ case 'anthropic':
711
+ return new AnthropicRagProvider(config);
712
+ case 'openai':
713
+ return new OpenAiRagProvider(config);
714
+ case 'gemini':
715
+ return new GeminiRagProvider(config);
716
+ case 'ollama':
717
+ return new OllamaRagProvider(config);
718
+ default:
719
+ {
720
+ const exhaustive = provider;
721
+ throw new Error(`[@nixxie-cms/ai-rag] Unknown generation provider: ${exhaustive}`);
722
+ }
723
+ }
724
+ }
725
+
726
+ /** Build the embedding provider from config (default provider: openai). */
727
+ function resolveEmbeddingProvider(config = {}) {
728
+ var _config$provider2;
729
+ if (config.service) return new ServiceRagProvider(config.service);
730
+ const provider = (_config$provider2 = config.provider) !== null && _config$provider2 !== void 0 ? _config$provider2 : 'openai';
731
+ switch (provider) {
732
+ case 'openai':
733
+ return new OpenAiRagProvider(config);
734
+ case 'gemini':
735
+ return new GeminiRagProvider(config);
736
+ case 'ollama':
737
+ return new OllamaRagProvider(config);
738
+ case 'anthropic':
739
+ throw new Error('[@nixxie-cms/ai-rag] Anthropic has no native embeddings endpoint. Use `openai`, ' + '`gemini` or `ollama` for the `embedding` provider (you can still use Anthropic for generation).');
740
+ default:
741
+ {
742
+ const exhaustive = provider;
743
+ throw new Error(`[@nixxie-cms/ai-rag] Unknown embedding provider: ${exhaustive}`);
744
+ }
745
+ }
746
+ }
747
+
748
+ const DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant that answers questions strictly using the provided knowledge ' + 'base context. Be accurate and concise. If the context does not contain the answer, say you ' + "don't know rather than guessing.";
749
+ const ALLOW_KNOWLEDGE_SYSTEM_PROMPT = 'You are a helpful assistant. Prefer the provided knowledge base context when answering. If ' + 'the context is insufficient you may use your general knowledge, but make clear which parts ' + 'are not from the knowledge base.';
750
+
751
+ /** Render retrieved chunks as a numbered, citable source block. */
752
+ function renderContext(chunks, maxChars) {
753
+ const lines = [];
754
+ let used = 0;
755
+ for (let i = 0; i < chunks.length; i++) {
756
+ const c = chunks[i];
757
+ const header = `[${i + 1}]${c.title ? ` ${c.title}` : ''}${c.source ? ` (${c.source})` : ''}`;
758
+ const body = c.content.trim();
759
+ const block = `${header}\n${body}`;
760
+ if (used + block.length > maxChars && lines.length > 0) break;
761
+ lines.push(block);
762
+ used += block.length;
763
+ }
764
+ return lines.join('\n\n');
765
+ }
766
+
767
+ /**
768
+ * Default prompt assembly: a system prompt carrying the numbered context + citation rules,
769
+ * the trimmed history, and the user's question. Overridable via `generation.buildPrompt`.
770
+ */
771
+ function buildRagPrompt(args, options) {
772
+ const contextBlock = renderContext(args.context, options.maxContextChars);
773
+ const rules = [];
774
+ if (args.context.length > 0) {
775
+ rules.push('Answer using ONLY the numbered context below.');
776
+ if (args.requireCitations) {
777
+ rules.push('Cite the sources you used inline as [n] matching the context numbers.');
778
+ }
779
+ rules.push("If the context does not contain the answer, say you don't have that information.");
780
+ }
781
+ const system = [args.systemPrompt, rules.length ? `\nRules:\n- ${rules.join('\n- ')}` : '', contextBlock ? `\nContext:\n${contextBlock}` : '\nContext: (none found)'].filter(Boolean).join('\n');
782
+ const messages = [...args.history, {
783
+ role: 'user',
784
+ content: args.question
785
+ }];
786
+ return {
787
+ system,
788
+ messages
789
+ };
790
+ }
791
+
792
+ /** A minimal counting semaphore used to cap concurrent generations. */
793
+ class Semaphore {
794
+ constructor(permits) {
795
+ _defineProperty(this, "waiters", []);
796
+ this.available = Math.max(1, permits);
797
+ }
798
+
799
+ /** Acquire a permit, resolving to a `release` function to call when done. */
800
+ async acquire() {
801
+ if (this.available > 0) {
802
+ this.available--;
803
+ return () => this.release();
804
+ }
805
+ await new Promise(resolve => this.waiters.push(resolve));
806
+ this.available--;
807
+ return () => this.release();
808
+ }
809
+ release() {
810
+ this.available++;
811
+ const next = this.waiters.shift();
812
+ if (next) next();
813
+ }
814
+ }
815
+
816
+ /** Dot product of two equal-length vectors. */
817
+ function dot(a, b) {
818
+ let sum = 0;
819
+ const n = Math.min(a.length, b.length);
820
+ for (let i = 0; i < n; i++) sum += a[i] * b[i];
821
+ return sum;
822
+ }
823
+
824
+ /** Euclidean norm of a vector. */
825
+ function norm(a) {
826
+ return Math.sqrt(dot(a, a));
827
+ }
828
+
829
+ /**
830
+ * Cosine similarity mapped from [-1, 1] into [0, 1] so it can be used as a relevance
831
+ * score and compared against a `minScore` threshold. Returns 0 for a zero vector.
832
+ */
833
+ function cosineSimilarity(a, b) {
834
+ const denom = norm(a) * norm(b);
835
+ if (denom === 0) return 0;
836
+ const cos = dot(a, b) / denom;
837
+ // Clamp for floating-point drift, then rescale to [0, 1].
838
+ return (Math.max(-1, Math.min(1, cos)) + 1) / 2;
839
+ }
840
+
841
+ /** Pre-normalise a vector to unit length (lets retrieval use a plain dot product). */
842
+ function normalize(a) {
843
+ const n = norm(a);
844
+ if (n === 0) return a.slice();
845
+ return a.map(x => x / n);
846
+ }
847
+
848
+ function prismaDelegate(context, listKey) {
849
+ var _context$prisma;
850
+ const delegate = (_context$prisma = context.prisma) === null || _context$prisma === void 0 ? void 0 : _context$prisma[listKey[0].toLowerCase() + listKey.slice(1)];
851
+ if (!delegate) {
852
+ throw new Error(`[@nixxie-cms/ai-rag] Collection "${listKey}" was not found in the Prisma client. ` + `Add it to your config (e.g. via \`ragPlugin()\` or \`knowledgeChunkCollection()\`) and run a migration.`);
853
+ }
854
+ return delegate;
855
+ }
856
+ function hasAllTags(recordTags, wanted) {
857
+ if (!wanted || wanted.length === 0) return true;
858
+ if (!recordTags || recordTags.length === 0) return false;
859
+ const set = new Set(recordTags);
860
+ return wanted.every(t => set.has(t));
861
+ }
862
+ function score(records, query) {
863
+ const scored = records.map(r => ({
864
+ ...r,
865
+ score: cosineSimilarity(query.embedding, r.embedding)
866
+ })).filter(r => hasAllTags(r.tags, query.tags)).filter(r => query.minScore === undefined ? true : r.score >= query.minScore);
867
+ scored.sort((a, b) => b.score - a.score);
868
+ return scored.slice(0, query.topK);
869
+ }
870
+
871
+ /**
872
+ * Default vector store: persists embeddings in the chunk collection in the host app's own
873
+ * database (any provider) and scores candidates with cosine similarity in Node. Simple and
874
+ * portable; for very large knowledge bases swap in a pgvector / external store via
875
+ * `retrieval.vectorStore`.
876
+ */
877
+ class SqlVectorStore {
878
+ constructor(collection) {
879
+ _defineProperty(this, "model", null);
880
+ this.collection = collection;
881
+ }
882
+ init(context) {
883
+ this.model = prismaDelegate(context, this.collection);
884
+ }
885
+ requireModel() {
886
+ if (!this.model) {
887
+ throw new Error('[@nixxie-cms/ai-rag] The vector store is not ready yet — it becomes available once the database has connected.');
888
+ }
889
+ return this.model;
890
+ }
891
+ async upsert(documentId, records) {
892
+ const model = this.requireModel();
893
+ await model.deleteMany({
894
+ where: {
895
+ documentId
896
+ }
897
+ });
898
+ if (records.length === 0) return;
899
+ // createMany can't always return rows and ignores unsupported JSON on some providers,
900
+ // so insert sequentially for portability.
901
+ for (const r of records) {
902
+ var _r$title, _r$source, _r$tags, _r$metadata;
903
+ await model.create({
904
+ data: {
905
+ documentId,
906
+ content: r.content,
907
+ embedding: r.embedding,
908
+ title: (_r$title = r.title) !== null && _r$title !== void 0 ? _r$title : null,
909
+ source: (_r$source = r.source) !== null && _r$source !== void 0 ? _r$source : null,
910
+ tags: (_r$tags = r.tags) !== null && _r$tags !== void 0 ? _r$tags : [],
911
+ metadata: (_r$metadata = r.metadata) !== null && _r$metadata !== void 0 ? _r$metadata : null
912
+ }
913
+ });
914
+ }
915
+ }
916
+ async deleteByDocument(documentId) {
917
+ await this.requireModel().deleteMany({
918
+ where: {
919
+ documentId
920
+ }
921
+ });
922
+ }
923
+ async query(query) {
924
+ const rows = await this.requireModel().findMany();
925
+ const records = rows.map(rowToRecord);
926
+ return score(records, query);
927
+ }
928
+ async count() {
929
+ return this.requireModel().count();
930
+ }
931
+ async clear() {
932
+ await this.requireModel().deleteMany({});
933
+ }
934
+ }
935
+ function rowToRecord(row) {
936
+ var _row$content, _row$embedding, _row$title, _row$source, _ref;
937
+ return {
938
+ id: String(row.id),
939
+ documentId: String(row.documentId),
940
+ content: (_row$content = row.content) !== null && _row$content !== void 0 ? _row$content : '',
941
+ embedding: Array.isArray(row.embedding) ? row.embedding : (_row$embedding = row.embedding) !== null && _row$embedding !== void 0 ? _row$embedding : [],
942
+ title: (_row$title = row.title) !== null && _row$title !== void 0 ? _row$title : undefined,
943
+ source: (_row$source = row.source) !== null && _row$source !== void 0 ? _row$source : undefined,
944
+ tags: Array.isArray(row.tags) ? row.tags : undefined,
945
+ metadata: (_ref = row.metadata) !== null && _ref !== void 0 ? _ref : undefined
946
+ };
947
+ }
948
+
949
+ /** Ephemeral in-process vector store. Useful for tests and small/transient knowledge bases. */
950
+ class InMemoryVectorStore {
951
+ constructor() {
952
+ _defineProperty(this, "byDocument", new Map());
953
+ }
954
+ async upsert(documentId, records) {
955
+ this.byDocument.set(documentId, records);
956
+ }
957
+ async deleteByDocument(documentId) {
958
+ this.byDocument.delete(documentId);
959
+ }
960
+ async query(query) {
961
+ const all = [];
962
+ for (const records of this.byDocument.values()) all.push(...records);
963
+ return score(all, query);
964
+ }
965
+ async count() {
966
+ let n = 0;
967
+ for (const records of this.byDocument.values()) n += records.length;
968
+ return n;
969
+ }
970
+ async clear() {
971
+ this.byDocument.clear();
972
+ }
973
+ }
974
+
975
+ function snippet(text, max = 240) {
976
+ const t = text.trim().replace(/\s+/g, ' ');
977
+ return t.length > max ? `${t.slice(0, max)}…` : t;
978
+ }
979
+
980
+ /** The main RAG assistant. Create via `createAiRag()`; register via `ragPlugin()`. */
981
+ class AiRagService {
982
+ constructor(config = {}) {
983
+ var _config$collections$d, _config$collections, _config$collections$c, _config$collections2, _config$retrieval$vec, _config$retrieval, _config$retrieval2, _config$generation$te, _config$generation, _config$generation$ma, _config$generation2, _config$generation3, _config$generation4, _config$generation$sy, _config$generation5, _config$generation6, _config$embedding$bat, _config$embedding, _config$embedding2, _config$retrieval$top, _config$retrieval3, _config$retrieval$min, _config$retrieval4, _config$retrieval$max, _config$retrieval5, _config$retrieval$can, _config$retrieval6, _config$chunking$chun, _config$chunking, _config$chunking$chun2, _config$chunking2, _config$chunking$stra, _config$chunking3, _config$chat$historyL, _config$chat, _config$limits$maxQue, _config$limits, _config$indexing$auto, _config$indexing, _config$indexing$onCo, _config$indexing2, _config$indexing3, _config$indexing$conc, _config$indexing4, _config$limits$maxCon, _config$limits2;
984
+ _defineProperty(this, "documents", null);
985
+ const documentsCollection = (_config$collections$d = (_config$collections = config.collections) === null || _config$collections === void 0 ? void 0 : _config$collections.documents) !== null && _config$collections$d !== void 0 ? _config$collections$d : 'KnowledgeBase';
986
+ const chunksCollection = (_config$collections$c = (_config$collections2 = config.collections) === null || _config$collections2 === void 0 ? void 0 : _config$collections2.chunks) !== null && _config$collections$c !== void 0 ? _config$collections$c : 'KnowledgeChunk';
987
+ this.generation = resolveGenerationProvider(config.generation);
988
+ this.embedder = resolveEmbeddingProvider(config.embedding);
989
+ this.vectorStore = (_config$retrieval$vec = (_config$retrieval = config.retrieval) === null || _config$retrieval === void 0 ? void 0 : _config$retrieval.vectorStore) !== null && _config$retrieval$vec !== void 0 ? _config$retrieval$vec : new SqlVectorStore(chunksCollection);
990
+ this.rerank = (_config$retrieval2 = config.retrieval) === null || _config$retrieval2 === void 0 ? void 0 : _config$retrieval2.rerank;
991
+ const guard = resolveGuard(config.guard);
992
+ const defaultSystem = guard.allowModelKnowledge ? ALLOW_KNOWLEDGE_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT;
993
+ this.r = {
994
+ documentsCollection,
995
+ chunksCollection,
996
+ generation: {
997
+ temperature: (_config$generation$te = (_config$generation = config.generation) === null || _config$generation === void 0 ? void 0 : _config$generation.temperature) !== null && _config$generation$te !== void 0 ? _config$generation$te : 0.2,
998
+ maxTokens: (_config$generation$ma = (_config$generation2 = config.generation) === null || _config$generation2 === void 0 ? void 0 : _config$generation2.maxTokens) !== null && _config$generation$ma !== void 0 ? _config$generation$ma : 1024,
999
+ topP: (_config$generation3 = config.generation) === null || _config$generation3 === void 0 ? void 0 : _config$generation3.topP,
1000
+ model: (_config$generation4 = config.generation) === null || _config$generation4 === void 0 ? void 0 : _config$generation4.model,
1001
+ systemPrompt: (_config$generation$sy = (_config$generation5 = config.generation) === null || _config$generation5 === void 0 ? void 0 : _config$generation5.systemPrompt) !== null && _config$generation$sy !== void 0 ? _config$generation$sy : defaultSystem,
1002
+ buildPrompt: (_config$generation6 = config.generation) === null || _config$generation6 === void 0 ? void 0 : _config$generation6.buildPrompt
1003
+ },
1004
+ embeddingBatchSize: (_config$embedding$bat = (_config$embedding = config.embedding) === null || _config$embedding === void 0 ? void 0 : _config$embedding.batchSize) !== null && _config$embedding$bat !== void 0 ? _config$embedding$bat : 64,
1005
+ embeddingModel: (_config$embedding2 = config.embedding) === null || _config$embedding2 === void 0 ? void 0 : _config$embedding2.model,
1006
+ topK: (_config$retrieval$top = (_config$retrieval3 = config.retrieval) === null || _config$retrieval3 === void 0 ? void 0 : _config$retrieval3.topK) !== null && _config$retrieval$top !== void 0 ? _config$retrieval$top : 5,
1007
+ minScore: (_config$retrieval$min = (_config$retrieval4 = config.retrieval) === null || _config$retrieval4 === void 0 ? void 0 : _config$retrieval4.minScore) !== null && _config$retrieval$min !== void 0 ? _config$retrieval$min : 0.2,
1008
+ maxContextChars: (_config$retrieval$max = (_config$retrieval5 = config.retrieval) === null || _config$retrieval5 === void 0 ? void 0 : _config$retrieval5.maxContextChars) !== null && _config$retrieval$max !== void 0 ? _config$retrieval$max : 6000,
1009
+ candidateMultiplier: (_config$retrieval$can = (_config$retrieval6 = config.retrieval) === null || _config$retrieval6 === void 0 ? void 0 : _config$retrieval6.candidateMultiplier) !== null && _config$retrieval$can !== void 0 ? _config$retrieval$can : 4,
1010
+ chunkSize: (_config$chunking$chun = (_config$chunking = config.chunking) === null || _config$chunking === void 0 ? void 0 : _config$chunking.chunkSize) !== null && _config$chunking$chun !== void 0 ? _config$chunking$chun : 1200,
1011
+ chunkOverlap: (_config$chunking$chun2 = (_config$chunking2 = config.chunking) === null || _config$chunking2 === void 0 ? void 0 : _config$chunking2.chunkOverlap) !== null && _config$chunking$chun2 !== void 0 ? _config$chunking$chun2 : 200,
1012
+ chunkStrategy: (_config$chunking$stra = (_config$chunking3 = config.chunking) === null || _config$chunking3 === void 0 ? void 0 : _config$chunking3.strategy) !== null && _config$chunking$stra !== void 0 ? _config$chunking$stra : 'recursive',
1013
+ historyLimit: (_config$chat$historyL = (_config$chat = config.chat) === null || _config$chat === void 0 ? void 0 : _config$chat.historyLimit) !== null && _config$chat$historyL !== void 0 ? _config$chat$historyL : 10,
1014
+ maxQueryChars: (_config$limits$maxQue = (_config$limits = config.limits) === null || _config$limits === void 0 ? void 0 : _config$limits.maxQueryChars) !== null && _config$limits$maxQue !== void 0 ? _config$limits$maxQue : 8000,
1015
+ guard,
1016
+ indexing: {
1017
+ auto: (_config$indexing$auto = (_config$indexing = config.indexing) === null || _config$indexing === void 0 ? void 0 : _config$indexing.auto) !== null && _config$indexing$auto !== void 0 ? _config$indexing$auto : true,
1018
+ onConnect: (_config$indexing$onCo = (_config$indexing2 = config.indexing) === null || _config$indexing2 === void 0 ? void 0 : _config$indexing2.onConnect) !== null && _config$indexing$onCo !== void 0 ? _config$indexing$onCo : true,
1019
+ schedule: (_config$indexing3 = config.indexing) === null || _config$indexing3 === void 0 ? void 0 : _config$indexing3.schedule,
1020
+ concurrency: (_config$indexing$conc = (_config$indexing4 = config.indexing) === null || _config$indexing4 === void 0 ? void 0 : _config$indexing4.concurrency) !== null && _config$indexing$conc !== void 0 ? _config$indexing$conc : 4
1021
+ }
1022
+ };
1023
+ this.chatGate = new Semaphore((_config$limits$maxCon = (_config$limits2 = config.limits) === null || _config$limits2 === void 0 ? void 0 : _config$limits2.maxConcurrentChats) !== null && _config$limits$maxCon !== void 0 ? _config$limits$maxCon : 8);
1024
+ }
1025
+
1026
+ /** Names of the collections this service reads/writes (used by the plugin). */
1027
+ get collections() {
1028
+ return {
1029
+ documents: this.r.documentsCollection,
1030
+ chunks: this.r.chunksCollection
1031
+ };
1032
+ }
1033
+ get indexingSchedule() {
1034
+ return this.r.indexing.schedule;
1035
+ }
1036
+
1037
+ // ── Lifecycle ──
1038
+
1039
+ async init(context) {
1040
+ var _this$vectorStore$ini, _this$vectorStore;
1041
+ this.documents = this.requireDelegate(context, this.r.documentsCollection);
1042
+ await ((_this$vectorStore$ini = (_this$vectorStore = this.vectorStore).init) === null || _this$vectorStore$ini === void 0 ? void 0 : _this$vectorStore$ini.call(_this$vectorStore, context));
1043
+ if (this.r.indexing.onConnect) {
1044
+ // Index anything still pending without blocking boot on failures.
1045
+ this.indexPending().catch(err => console.error('[@nixxie-cms/ai-rag] Initial indexing failed:', err));
1046
+ }
1047
+ }
1048
+ requireDelegate(context, listKey) {
1049
+ var _context$prisma;
1050
+ const delegate = (_context$prisma = context.prisma) === null || _context$prisma === void 0 ? void 0 : _context$prisma[listKey[0].toLowerCase() + listKey.slice(1)];
1051
+ if (!delegate) {
1052
+ throw new Error(`[@nixxie-cms/ai-rag] Collection "${listKey}" was not found in the Prisma client. ` + `Register it via \`ragPlugin()\` (or add \`${listKey}: knowledgeBaseCollection()\`) and run a migration.`);
1053
+ }
1054
+ return delegate;
1055
+ }
1056
+ requireDocuments() {
1057
+ if (!this.documents) {
1058
+ throw new Error('[@nixxie-cms/ai-rag] Not ready yet — the knowledge base is available once the database has connected.');
1059
+ }
1060
+ return this.documents;
1061
+ }
1062
+
1063
+ // ── Knowledge-base content ──
1064
+
1065
+ async addDocument(doc) {
1066
+ const [created] = await this.addDocuments([doc]);
1067
+ return created;
1068
+ }
1069
+ async addDocuments(docs) {
1070
+ const model = this.requireDocuments();
1071
+ const out = [];
1072
+ for (const doc of docs) {
1073
+ var _doc$title, _doc$source, _doc$tags, _doc$metadata;
1074
+ const row = await model.create({
1075
+ data: {
1076
+ title: (_doc$title = doc.title) !== null && _doc$title !== void 0 ? _doc$title : null,
1077
+ content: doc.content,
1078
+ source: (_doc$source = doc.source) !== null && _doc$source !== void 0 ? _doc$source : null,
1079
+ tags: (_doc$tags = doc.tags) !== null && _doc$tags !== void 0 ? _doc$tags : [],
1080
+ metadata: (_doc$metadata = doc.metadata) !== null && _doc$metadata !== void 0 ? _doc$metadata : null,
1081
+ status: 'pending',
1082
+ chunkCount: 0
1083
+ }
1084
+ });
1085
+ out.push(rowToDocument(row));
1086
+ }
1087
+ if (this.r.indexing.auto) {
1088
+ for (const d of out) {
1089
+ try {
1090
+ await this.index(d.id);
1091
+ } catch (err) {
1092
+ console.error(`[@nixxie-cms/ai-rag] Failed to index document ${d.id}:`, err);
1093
+ }
1094
+ }
1095
+ // Re-read to reflect post-index status.
1096
+ const refreshed = await Promise.all(out.map(d => this.getDocument(d.id)));
1097
+ return refreshed.map((d, i) => d !== null && d !== void 0 ? d : out[i]);
1098
+ }
1099
+ return out;
1100
+ }
1101
+ async getDocument(id) {
1102
+ const row = await this.requireDocuments().findUnique({
1103
+ where: {
1104
+ id
1105
+ }
1106
+ });
1107
+ return row ? rowToDocument(row) : undefined;
1108
+ }
1109
+ async listDocuments(query = {}) {
1110
+ var _query$skip;
1111
+ const where = {};
1112
+ if (query.status) where.status = {
1113
+ equals: query.status
1114
+ };
1115
+ if (query.search) {
1116
+ where.OR = [{
1117
+ title: {
1118
+ contains: query.search
1119
+ }
1120
+ }, {
1121
+ content: {
1122
+ contains: query.search
1123
+ }
1124
+ }, {
1125
+ source: {
1126
+ contains: query.search
1127
+ }
1128
+ }];
1129
+ }
1130
+ const rows = await this.requireDocuments().findMany({
1131
+ where,
1132
+ orderBy: {
1133
+ createdAt: 'desc'
1134
+ },
1135
+ skip: (_query$skip = query.skip) !== null && _query$skip !== void 0 ? _query$skip : 0,
1136
+ ...(query.take !== undefined ? {
1137
+ take: query.take
1138
+ } : {})
1139
+ });
1140
+ let docs = rows.map(rowToDocument);
1141
+ // Tag filtering is done in Node for cross-database portability (tags are stored as JSON).
1142
+ if (query.tags && query.tags.length) {
1143
+ docs = docs.filter(d => query.tags.every(t => {
1144
+ var _d$tags;
1145
+ return ((_d$tags = d.tags) !== null && _d$tags !== void 0 ? _d$tags : []).includes(t);
1146
+ }));
1147
+ }
1148
+ return docs;
1149
+ }
1150
+ async updateDocument(id, patch) {
1151
+ const model = this.requireDocuments();
1152
+ const data = {
1153
+ status: 'pending'
1154
+ };
1155
+ if (patch.title !== undefined) data.title = patch.title;
1156
+ if (patch.content !== undefined) data.content = patch.content;
1157
+ if (patch.source !== undefined) data.source = patch.source;
1158
+ if (patch.tags !== undefined) data.tags = patch.tags;
1159
+ if (patch.metadata !== undefined) data.metadata = patch.metadata;
1160
+ await model.update({
1161
+ where: {
1162
+ id
1163
+ },
1164
+ data
1165
+ });
1166
+ if (this.r.indexing.auto) {
1167
+ try {
1168
+ await this.index(id);
1169
+ } catch (err) {
1170
+ console.error(`[@nixxie-cms/ai-rag] Failed to re-index document ${id}:`, err);
1171
+ }
1172
+ }
1173
+ const doc = await this.getDocument(id);
1174
+ if (!doc) throw new Error(`[@nixxie-cms/ai-rag] Document not found after update: ${id}`);
1175
+ return doc;
1176
+ }
1177
+ async removeDocument(id) {
1178
+ await this.vectorStore.deleteByDocument(id);
1179
+ await this.requireDocuments().delete({
1180
+ where: {
1181
+ id
1182
+ }
1183
+ });
1184
+ }
1185
+
1186
+ /**
1187
+ * Delete a document's indexed chunks without touching the document row. Used by the
1188
+ * auto-index delete hook, where the KB row has already been removed by the CMS.
1189
+ */
1190
+ async purgeChunks(documentId) {
1191
+ await this.vectorStore.deleteByDocument(documentId);
1192
+ }
1193
+
1194
+ // ── Indexing ──
1195
+
1196
+ async index(documentId) {
1197
+ const model = this.requireDocuments();
1198
+ const row = await model.findUnique({
1199
+ where: {
1200
+ id: documentId
1201
+ }
1202
+ });
1203
+ if (!row) throw new Error(`[@nixxie-cms/ai-rag] Document not found: ${documentId}`);
1204
+ const doc = rowToDocument(row);
1205
+ if (doc.status === 'disabled') {
1206
+ await this.vectorStore.deleteByDocument(documentId);
1207
+ await model.update({
1208
+ where: {
1209
+ id: documentId
1210
+ },
1211
+ data: {
1212
+ chunkCount: 0
1213
+ }
1214
+ });
1215
+ return;
1216
+ }
1217
+ await model.update({
1218
+ where: {
1219
+ id: documentId
1220
+ },
1221
+ data: {
1222
+ status: 'indexing',
1223
+ error: null
1224
+ }
1225
+ });
1226
+ try {
1227
+ const text = [doc.title, doc.content].filter(Boolean).join('\n\n');
1228
+ const pieces = chunkText(text, {
1229
+ strategy: this.r.chunkStrategy,
1230
+ chunkSize: this.r.chunkSize,
1231
+ chunkOverlap: this.r.chunkOverlap
1232
+ });
1233
+ const embeddings = await this.embedBatched(pieces);
1234
+ const records = pieces.map((content, i) => ({
1235
+ id: `${documentId}:${i}`,
1236
+ documentId,
1237
+ content,
1238
+ embedding: embeddings[i],
1239
+ title: doc.title,
1240
+ source: doc.source,
1241
+ tags: doc.tags,
1242
+ metadata: doc.metadata
1243
+ }));
1244
+ await this.vectorStore.upsert(documentId, records);
1245
+ await model.update({
1246
+ where: {
1247
+ id: documentId
1248
+ },
1249
+ data: {
1250
+ status: 'indexed',
1251
+ chunkCount: records.length,
1252
+ error: null,
1253
+ indexedAt: new Date()
1254
+ }
1255
+ });
1256
+ } catch (err) {
1257
+ await model.update({
1258
+ where: {
1259
+ id: documentId
1260
+ },
1261
+ data: {
1262
+ status: 'error',
1263
+ error: err instanceof Error ? err.message : String(err)
1264
+ }
1265
+ });
1266
+ throw err;
1267
+ }
1268
+ }
1269
+ async reindex(options = {}) {
1270
+ const start = Date.now();
1271
+ const docs = await this.listDocuments({
1272
+ tags: options.tags
1273
+ });
1274
+ const targets = options.force ? docs : docs.filter(d => d.status !== 'indexed');
1275
+ let chunks = 0;
1276
+ let errors = 0;
1277
+ await mapWithConcurrency(targets, this.r.indexing.concurrency, async d => {
1278
+ try {
1279
+ var _refreshed$chunkCount;
1280
+ await this.index(d.id);
1281
+ const refreshed = await this.getDocument(d.id);
1282
+ chunks += (_refreshed$chunkCount = refreshed === null || refreshed === void 0 ? void 0 : refreshed.chunkCount) !== null && _refreshed$chunkCount !== void 0 ? _refreshed$chunkCount : 0;
1283
+ } catch {
1284
+ errors++;
1285
+ }
1286
+ });
1287
+ return {
1288
+ documents: targets.length,
1289
+ chunks,
1290
+ errors,
1291
+ durationMs: Date.now() - start
1292
+ };
1293
+ }
1294
+ async indexPending() {
1295
+ const start = Date.now();
1296
+ const pending = (await this.listDocuments()).filter(d => d.status === 'pending' || d.status === 'error');
1297
+ let chunks = 0;
1298
+ let errors = 0;
1299
+ await mapWithConcurrency(pending, this.r.indexing.concurrency, async d => {
1300
+ try {
1301
+ var _refreshed$chunkCount2;
1302
+ await this.index(d.id);
1303
+ const refreshed = await this.getDocument(d.id);
1304
+ chunks += (_refreshed$chunkCount2 = refreshed === null || refreshed === void 0 ? void 0 : refreshed.chunkCount) !== null && _refreshed$chunkCount2 !== void 0 ? _refreshed$chunkCount2 : 0;
1305
+ } catch {
1306
+ errors++;
1307
+ }
1308
+ });
1309
+ return {
1310
+ documents: pending.length,
1311
+ chunks,
1312
+ errors,
1313
+ durationMs: Date.now() - start
1314
+ };
1315
+ }
1316
+ async embedBatched(texts) {
1317
+ if (texts.length === 0) return [];
1318
+ const out = [];
1319
+ const size = this.r.embeddingBatchSize;
1320
+ for (let i = 0; i < texts.length; i += size) {
1321
+ const batch = texts.slice(i, i + size);
1322
+ out.push(...(await this.embedder.embed(batch, this.r.embeddingModel)));
1323
+ }
1324
+ return out;
1325
+ }
1326
+
1327
+ // ── Retrieval ──
1328
+
1329
+ async retrieve(query, options = {}) {
1330
+ var _options$topK, _options$minScore;
1331
+ const topK = (_options$topK = options.topK) !== null && _options$topK !== void 0 ? _options$topK : this.r.topK;
1332
+ const minScore = (_options$minScore = options.minScore) !== null && _options$minScore !== void 0 ? _options$minScore : this.r.minScore;
1333
+ const [embedding] = await this.embedder.embed([query], this.r.embeddingModel);
1334
+ if (!embedding) return [];
1335
+ const candidates = await this.vectorStore.query({
1336
+ embedding,
1337
+ topK: Math.max(topK, topK * this.r.candidateMultiplier),
1338
+ tags: options.tags,
1339
+ minScore
1340
+ });
1341
+ let chunks = candidates.map(c => ({
1342
+ id: c.id,
1343
+ documentId: c.documentId,
1344
+ title: c.title,
1345
+ source: c.source,
1346
+ content: c.content,
1347
+ score: c.score,
1348
+ tags: c.tags,
1349
+ metadata: c.metadata
1350
+ }));
1351
+ if (this.rerank) chunks = await this.rerank(query, chunks);
1352
+ return chunks.slice(0, topK);
1353
+ }
1354
+
1355
+ // ── Chat ──
1356
+
1357
+ async ask(question, options = {}) {
1358
+ return this.chat([{
1359
+ role: 'user',
1360
+ content: question
1361
+ }], options);
1362
+ }
1363
+ async chat(messages, options = {}) {
1364
+ const release = await this.chatGate.acquire();
1365
+ try {
1366
+ const prepared = await this.prepare(messages, options);
1367
+ if (prepared.refusal) {
1368
+ return {
1369
+ text: prepared.refusal,
1370
+ sources: [],
1371
+ grounded: true,
1372
+ refused: true,
1373
+ model: this.generation.defaultModel
1374
+ };
1375
+ }
1376
+ const result = await this.generation.generate(prepared.messages, prepared.genOptions);
1377
+ return this.finalize(result.text, result.model, result.usage, prepared.context, options);
1378
+ } finally {
1379
+ release();
1380
+ }
1381
+ }
1382
+ async *stream(messages, options = {}) {
1383
+ const release = await this.chatGate.acquire();
1384
+ try {
1385
+ const prepared = await this.prepare(messages, options);
1386
+ if (prepared.refusal) {
1387
+ const answer = {
1388
+ text: prepared.refusal,
1389
+ sources: [],
1390
+ grounded: true,
1391
+ refused: true,
1392
+ model: this.generation.defaultModel
1393
+ };
1394
+ yield {
1395
+ type: 'token',
1396
+ token: prepared.refusal
1397
+ };
1398
+ yield {
1399
+ type: 'done',
1400
+ answer
1401
+ };
1402
+ return;
1403
+ }
1404
+ yield {
1405
+ type: 'sources',
1406
+ sources: prepared.citations
1407
+ };
1408
+ let text = '';
1409
+ let model = this.generation.defaultModel;
1410
+ let usage;
1411
+ if (this.generation.stream) {
1412
+ for await (const part of this.generation.stream(prepared.messages, prepared.genOptions)) {
1413
+ if (part.delta) {
1414
+ text += part.delta;
1415
+ yield {
1416
+ type: 'token',
1417
+ token: part.delta
1418
+ };
1419
+ }
1420
+ if (part.done) {
1421
+ var _part$model;
1422
+ model = (_part$model = part.model) !== null && _part$model !== void 0 ? _part$model : model;
1423
+ usage = part.usage;
1424
+ }
1425
+ }
1426
+ } else {
1427
+ const result = await this.generation.generate(prepared.messages, prepared.genOptions);
1428
+ text = result.text;
1429
+ model = result.model;
1430
+ usage = result.usage;
1431
+ yield {
1432
+ type: 'token',
1433
+ token: text
1434
+ };
1435
+ }
1436
+ const answer = await this.finalize(text, model, usage, prepared.context, options);
1437
+ // If the grounding check rewrote the answer to a refusal, surface that token too.
1438
+ if (answer.refused && answer.text !== text) yield {
1439
+ type: 'token',
1440
+ token: answer.text
1441
+ };
1442
+ yield {
1443
+ type: 'done',
1444
+ answer
1445
+ };
1446
+ } catch (err) {
1447
+ yield {
1448
+ type: 'error',
1449
+ error: err instanceof Error ? err.message : String(err)
1450
+ };
1451
+ } finally {
1452
+ release();
1453
+ }
1454
+ }
1455
+
1456
+ /** Shared retrieve → guard → prompt pipeline for chat/stream. */
1457
+ async prepare(messages, options) {
1458
+ var _lastUser$content, _options$guard, _options$minScore2, _options$context, _options$model, _options$temperature, _options$maxTokens;
1459
+ const turns = messages.filter(m => m.role !== 'system');
1460
+ const lastUser = [...turns].reverse().find(m => m.role === 'user');
1461
+ const question = (_lastUser$content = lastUser === null || lastUser === void 0 ? void 0 : lastUser.content) !== null && _lastUser$content !== void 0 ? _lastUser$content : '';
1462
+ if (question.length > this.r.maxQueryChars) {
1463
+ throw new Error(`[@nixxie-cms/ai-rag] Query exceeds the ${this.r.maxQueryChars}-character limit.`);
1464
+ }
1465
+ const guardOn = (_options$guard = options.guard) !== null && _options$guard !== void 0 ? _options$guard : this.r.guard.enabled;
1466
+ const minScore = (_options$minScore2 = options.minScore) !== null && _options$minScore2 !== void 0 ? _options$minScore2 : this.r.minScore;
1467
+ const context = (_options$context = options.context) !== null && _options$context !== void 0 ? _options$context : question ? await this.retrieve(question, options) : [];
1468
+ if (guardOn && shouldRefuseForNoContext(context, this.r.guard, minScore)) {
1469
+ return {
1470
+ refusal: this.r.guard.refusal,
1471
+ context: [],
1472
+ citations: [],
1473
+ messages: [],
1474
+ genOptions: {}
1475
+ };
1476
+ }
1477
+ const history = turns.slice(0, -1).slice(-this.r.historyLimit).map(m => ({
1478
+ role: m.role,
1479
+ content: m.content
1480
+ }));
1481
+ const systemBase = options.systemSuffix ? `${this.r.generation.systemPrompt}\n\n${options.systemSuffix}` : this.r.generation.systemPrompt;
1482
+ const built = this.r.generation.buildPrompt ? this.r.generation.buildPrompt({
1483
+ question,
1484
+ history,
1485
+ context,
1486
+ systemPrompt: systemBase,
1487
+ requireCitations: guardOn && this.r.guard.requireCitations
1488
+ }) : buildRagPrompt({
1489
+ question,
1490
+ history,
1491
+ context,
1492
+ systemPrompt: systemBase,
1493
+ requireCitations: guardOn && this.r.guard.requireCitations
1494
+ }, {
1495
+ maxContextChars: this.r.maxContextChars
1496
+ });
1497
+ const genOptions = {
1498
+ system: built.system,
1499
+ model: (_options$model = options.model) !== null && _options$model !== void 0 ? _options$model : this.r.generation.model,
1500
+ temperature: (_options$temperature = options.temperature) !== null && _options$temperature !== void 0 ? _options$temperature : this.r.generation.temperature,
1501
+ maxTokens: (_options$maxTokens = options.maxTokens) !== null && _options$maxTokens !== void 0 ? _options$maxTokens : this.r.generation.maxTokens,
1502
+ topP: this.r.generation.topP
1503
+ };
1504
+ return {
1505
+ context,
1506
+ citations: toCitations(context),
1507
+ messages: built.messages,
1508
+ genOptions
1509
+ };
1510
+ }
1511
+
1512
+ /** Apply the grounding check and assemble the final answer with citations. */
1513
+ async finalize(text, model, usage, context, options) {
1514
+ var _options$guard2;
1515
+ const guardOn = (_options$guard2 = options.guard) !== null && _options$guard2 !== void 0 ? _options$guard2 : this.r.guard.enabled;
1516
+ let grounded = true;
1517
+ if (guardOn && this.r.guard.groundingCheck) {
1518
+ var _this$r$guard$groundi;
1519
+ const check = await checkGrounding(this.generation, text, context, (_this$r$guard$groundi = this.r.guard.groundingModel) !== null && _this$r$guard$groundi !== void 0 ? _this$r$guard$groundi : this.r.generation.model);
1520
+ grounded = check.grounded;
1521
+ if (!grounded && this.r.guard.refuseWhenUngrounded) {
1522
+ return {
1523
+ text: this.r.guard.refusal,
1524
+ sources: [],
1525
+ grounded: false,
1526
+ refused: true,
1527
+ model,
1528
+ usage
1529
+ };
1530
+ }
1531
+ }
1532
+ return {
1533
+ text,
1534
+ sources: toCitations(context),
1535
+ grounded,
1536
+ refused: false,
1537
+ model,
1538
+ usage
1539
+ };
1540
+ }
1541
+ async close() {
1542
+ // No long-lived resources to release; indexing runs are awaited by their callers.
1543
+ }
1544
+ }
1545
+ function toCitations(chunks) {
1546
+ return chunks.map(c => ({
1547
+ documentId: c.documentId,
1548
+ chunkId: c.id,
1549
+ title: c.title,
1550
+ source: c.source,
1551
+ score: c.score,
1552
+ snippet: snippet(c.content)
1553
+ }));
1554
+ }
1555
+ function rowToDocument(row) {
1556
+ var _row$title, _row$content, _row$source, _row$tags, _ref, _row$status, _row$chunkCount, _row$error, _row$createdAt, _ref2, _row$updatedAt, _row$indexedAt;
1557
+ return {
1558
+ id: String(row.id),
1559
+ title: (_row$title = row.title) !== null && _row$title !== void 0 ? _row$title : undefined,
1560
+ content: (_row$content = row.content) !== null && _row$content !== void 0 ? _row$content : '',
1561
+ source: (_row$source = row.source) !== null && _row$source !== void 0 ? _row$source : undefined,
1562
+ tags: Array.isArray(row.tags) ? row.tags : (_row$tags = row.tags) !== null && _row$tags !== void 0 ? _row$tags : undefined,
1563
+ metadata: (_ref = row.metadata) !== null && _ref !== void 0 ? _ref : undefined,
1564
+ status: (_row$status = row.status) !== null && _row$status !== void 0 ? _row$status : 'pending',
1565
+ chunkCount: (_row$chunkCount = row.chunkCount) !== null && _row$chunkCount !== void 0 ? _row$chunkCount : 0,
1566
+ error: (_row$error = row.error) !== null && _row$error !== void 0 ? _row$error : undefined,
1567
+ createdAt: (_row$createdAt = row.createdAt) !== null && _row$createdAt !== void 0 ? _row$createdAt : new Date(),
1568
+ updatedAt: (_ref2 = (_row$updatedAt = row.updatedAt) !== null && _row$updatedAt !== void 0 ? _row$updatedAt : row.createdAt) !== null && _ref2 !== void 0 ? _ref2 : new Date(),
1569
+ indexedAt: (_row$indexedAt = row.indexedAt) !== null && _row$indexedAt !== void 0 ? _row$indexedAt : undefined
1570
+ };
1571
+ }
1572
+
1573
+ /** Run `fn` over items with at most `limit` in flight at once. */
1574
+ async function mapWithConcurrency(items, limit, fn) {
1575
+ const queue = [...items];
1576
+ const workers = Array.from({
1577
+ length: Math.max(1, Math.min(limit, items.length))
1578
+ }, async () => {
1579
+ while (queue.length) {
1580
+ const item = queue.shift();
1581
+ await fn(item);
1582
+ }
1583
+ });
1584
+ await Promise.all(workers);
1585
+ }
1586
+
1587
+ /**
1588
+ * The knowledge-base source list the assistant is trained on. Users add rows here; each
1589
+ * row's `content` is chunked, embedded and made retrievable. `ragPlugin()` adds this
1590
+ * collection automatically, but you can register it yourself for full control over access.
1591
+ *
1592
+ * @example
1593
+ * config({
1594
+ * collections: { KnowledgeBase: knowledgeBaseCollection(), ...collections },
1595
+ * })
1596
+ */
1597
+ function knowledgeBaseCollection() {
1598
+ return {
1599
+ fields: {
1600
+ title: text({
1601
+ validation: {
1602
+ isRequired: true
1603
+ },
1604
+ isIndexed: true
1605
+ }),
1606
+ content: text({
1607
+ ui: {
1608
+ displayMode: 'textarea'
1609
+ },
1610
+ validation: {
1611
+ isRequired: true
1612
+ }
1613
+ }),
1614
+ source: text(),
1615
+ tags: json({
1616
+ defaultValue: []
1617
+ }),
1618
+ metadata: json(),
1619
+ status: select({
1620
+ type: 'string',
1621
+ options: [{
1622
+ label: 'Pending',
1623
+ value: 'pending'
1624
+ }, {
1625
+ label: 'Indexing',
1626
+ value: 'indexing'
1627
+ }, {
1628
+ label: 'Indexed',
1629
+ value: 'indexed'
1630
+ }, {
1631
+ label: 'Error',
1632
+ value: 'error'
1633
+ }, {
1634
+ label: 'Disabled',
1635
+ value: 'disabled'
1636
+ }],
1637
+ defaultValue: 'pending',
1638
+ isIndexed: true,
1639
+ ui: {
1640
+ displayMode: 'segmented-control'
1641
+ }
1642
+ }),
1643
+ chunkCount: integer({
1644
+ defaultValue: 0
1645
+ }),
1646
+ error: text(),
1647
+ indexedAt: timestamp(),
1648
+ createdAt: timestamp({
1649
+ defaultValue: {
1650
+ kind: 'now'
1651
+ },
1652
+ db: {
1653
+ isNullable: false
1654
+ }
1655
+ }),
1656
+ updatedAt: timestamp({
1657
+ db: {
1658
+ updatedAt: true
1659
+ }
1660
+ })
1661
+ },
1662
+ ui: {
1663
+ labelField: 'title',
1664
+ description: 'Knowledge base the AI assistant is trained on.',
1665
+ listView: {
1666
+ initialColumns: ['title', 'status', 'chunkCount', 'tags', 'updatedAt'],
1667
+ initialSort: {
1668
+ field: 'updatedAt',
1669
+ direction: 'DESC'
1670
+ }
1671
+ }
1672
+ }
1673
+ };
1674
+ }
1675
+
1676
+ /**
1677
+ * The chunk/embedding list backing retrieval. Managed entirely by ai-rag — rows are written
1678
+ * during indexing and deleted with their document. Hidden from create/delete in the Admin UI.
1679
+ * `ragPlugin()` adds it automatically.
1680
+ */
1681
+ function knowledgeChunkCollection() {
1682
+ return {
1683
+ fields: {
1684
+ documentId: text({
1685
+ validation: {
1686
+ isRequired: true
1687
+ },
1688
+ isIndexed: true
1689
+ }),
1690
+ content: text({
1691
+ ui: {
1692
+ displayMode: 'textarea'
1693
+ }
1694
+ }),
1695
+ embedding: json(),
1696
+ title: text(),
1697
+ source: text(),
1698
+ tags: json({
1699
+ defaultValue: []
1700
+ }),
1701
+ metadata: json(),
1702
+ createdAt: timestamp({
1703
+ defaultValue: {
1704
+ kind: 'now'
1705
+ },
1706
+ db: {
1707
+ isNullable: false
1708
+ }
1709
+ })
1710
+ },
1711
+ graphql: {
1712
+ omit: {
1713
+ create: true,
1714
+ update: true,
1715
+ delete: true
1716
+ }
1717
+ },
1718
+ ui: {
1719
+ isHidden: true,
1720
+ labelField: 'title',
1721
+ hideCreate: true,
1722
+ hideDelete: true,
1723
+ itemView: {
1724
+ defaultFieldMode: 'read'
1725
+ }
1726
+ }
1727
+ };
1728
+ }
1729
+
1730
+ function getService(context, options) {
1731
+ var _options$service;
1732
+ const svc = (_options$service = options.service) !== null && _options$service !== void 0 ? _options$service : context.services.aiRag;
1733
+ if (!svc) {
1734
+ throw new Error('[@nixxie-cms/ai-rag] No aiRag service configured. Pass `service` or set `config({ aiRag })`.');
1735
+ }
1736
+ return svc;
1737
+ }
1738
+
1739
+ /** Read a JSON body whether or not a body parser ran upstream. */
1740
+ async function readJson(req) {
1741
+ if (req.body && typeof req.body === 'object') return req.body;
1742
+ return new Promise(resolve => {
1743
+ let raw = '';
1744
+ req.on('data', c => raw += c);
1745
+ req.on('end', () => {
1746
+ try {
1747
+ resolve(raw ? JSON.parse(raw) : {});
1748
+ } catch {
1749
+ resolve({});
1750
+ }
1751
+ });
1752
+ req.on('error', () => resolve({}));
1753
+ });
1754
+ }
1755
+ function writeSse(res, event) {
1756
+ const type = event.type;
1757
+ res.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`);
1758
+ }
1759
+
1760
+ /**
1761
+ * Mount the assistant's HTTP API on an Express app:
1762
+ *
1763
+ * - `POST <path>/chat` — body `{ messages?, question?, topK?, tags?, stream? }`. When
1764
+ * `stream` is true (or `Accept: text/event-stream`), responds with SSE: `sources`,
1765
+ * then `token` events, then a final `done` event carrying the full answer.
1766
+ * - `GET <path>/documents` — list knowledge-base documents.
1767
+ * - `POST <path>/documents` — add a document `{ content, title?, source?, tags? }`.
1768
+ * - `DELETE <path>/documents/:id` — remove a document.
1769
+ * - `POST <path>/reindex` — `{ force? }` → index stats.
1770
+ *
1771
+ * Call from `server.extendExpressApp`, or use `ragPlugin()` to wire it automatically.
1772
+ */
1773
+ function installAiRagRoutes(app, context, options = {}) {
1774
+ var _options$path, _options$protectReads;
1775
+ const path = ((_options$path = options.path) !== null && _options$path !== void 0 ? _options$path : '/api/ai-rag').replace(/\/$/, '');
1776
+ const protectReads = (_options$protectReads = options.protectReads) !== null && _options$protectReads !== void 0 ? _options$protectReads : true;
1777
+ const authorize = async (req, res) => {
1778
+ var _options$isAuthorized;
1779
+ const requestContext = await context.withRequest(req, res);
1780
+ const check = (_options$isAuthorized = options.isAuthorized) !== null && _options$isAuthorized !== void 0 ? _options$isAuthorized : c => !!c.session;
1781
+ if (!(await check(requestContext))) {
1782
+ res.statusCode = 401;
1783
+ res.end('Unauthorized');
1784
+ return null;
1785
+ }
1786
+ return requestContext;
1787
+ };
1788
+ const wrap = (handler, gated) => (req, res) => {
1789
+ void (async () => {
1790
+ const ctx = gated ? await authorize(req, res) : await context.withRequest(req, res);
1791
+ if (!ctx) return;
1792
+ await handler(req, res, ctx);
1793
+ })().catch(err => {
1794
+ console.error('[@nixxie-cms/ai-rag] Route error:', err);
1795
+ if (!res.headersSent) {
1796
+ res.statusCode = 500;
1797
+ res.setHeader('Content-Type', 'application/json');
1798
+ res.end(JSON.stringify({
1799
+ error: err instanceof Error ? err.message : 'Internal error'
1800
+ }));
1801
+ } else {
1802
+ res.end();
1803
+ }
1804
+ });
1805
+ };
1806
+
1807
+ // ── Chat (streaming or one-shot) ──
1808
+ app.post(`${path}/chat`, wrap(async (req, res, ctx) => {
1809
+ var _req$headers$accept, _req$headers, _res$flushHeaders, _req$on;
1810
+ const svc = getService(ctx, options);
1811
+ const body = await readJson(req);
1812
+ const messages = Array.isArray(body.messages) && body.messages.length ? body.messages : body.question ? [{
1813
+ role: 'user',
1814
+ content: String(body.question)
1815
+ }] : [];
1816
+ if (!messages.length) {
1817
+ res.statusCode = 400;
1818
+ res.setHeader('Content-Type', 'application/json');
1819
+ res.end(JSON.stringify({
1820
+ error: 'Provide `messages` or `question`.'
1821
+ }));
1822
+ return;
1823
+ }
1824
+ const askOptions = {
1825
+ topK: body.topK,
1826
+ tags: body.tags,
1827
+ temperature: body.temperature,
1828
+ model: body.model
1829
+ };
1830
+ const wantsStream = body.stream === true || String((_req$headers$accept = (_req$headers = req.headers) === null || _req$headers === void 0 ? void 0 : _req$headers.accept) !== null && _req$headers$accept !== void 0 ? _req$headers$accept : '').includes('text/event-stream');
1831
+ if (!wantsStream) {
1832
+ const answer = await svc.chat(messages, askOptions);
1833
+ res.setHeader('Content-Type', 'application/json');
1834
+ res.end(JSON.stringify(answer));
1835
+ return;
1836
+ }
1837
+ res.setHeader('Content-Type', 'text/event-stream');
1838
+ res.setHeader('Cache-Control', 'no-cache');
1839
+ res.setHeader('Connection', 'keep-alive');
1840
+ (_res$flushHeaders = res.flushHeaders) === null || _res$flushHeaders === void 0 || _res$flushHeaders.call(res);
1841
+ let aborted = false;
1842
+ (_req$on = req.on) === null || _req$on === void 0 || _req$on.call(req, 'close', () => aborted = true);
1843
+ for await (const event of svc.stream(messages, askOptions)) {
1844
+ if (aborted) break;
1845
+ writeSse(res, event);
1846
+ }
1847
+ res.end();
1848
+ }, protectReads));
1849
+
1850
+ // ── Documents CRUD ──
1851
+ app.get(`${path}/documents`, wrap(async (req, res, ctx) => {
1852
+ var _req$query, _req$query2, _req$query3;
1853
+ const svc = getService(ctx, options);
1854
+ const take = (_req$query = req.query) !== null && _req$query !== void 0 && _req$query.take ? Number(req.query.take) : undefined;
1855
+ const skip = (_req$query2 = req.query) !== null && _req$query2 !== void 0 && _req$query2.skip ? Number(req.query.skip) : undefined;
1856
+ const docs = await svc.listDocuments({
1857
+ take,
1858
+ skip,
1859
+ search: (_req$query3 = req.query) === null || _req$query3 === void 0 ? void 0 : _req$query3.search
1860
+ });
1861
+ res.setHeader('Content-Type', 'application/json');
1862
+ res.end(JSON.stringify({
1863
+ documents: docs
1864
+ }));
1865
+ }, protectReads));
1866
+ app.post(`${path}/documents`, wrap(async (req, res, ctx) => {
1867
+ const svc = getService(ctx, options);
1868
+ const body = await readJson(req);
1869
+ if (!body.content) {
1870
+ res.statusCode = 400;
1871
+ res.setHeader('Content-Type', 'application/json');
1872
+ res.end(JSON.stringify({
1873
+ error: '`content` is required.'
1874
+ }));
1875
+ return;
1876
+ }
1877
+ const doc = await svc.addDocument({
1878
+ content: String(body.content),
1879
+ title: body.title,
1880
+ source: body.source,
1881
+ tags: body.tags,
1882
+ metadata: body.metadata
1883
+ });
1884
+ res.statusCode = 201;
1885
+ res.setHeader('Content-Type', 'application/json');
1886
+ res.end(JSON.stringify(doc));
1887
+ }, true));
1888
+ app.delete(`${path}/documents/:id`, wrap(async (req, res, ctx) => {
1889
+ const svc = getService(ctx, options);
1890
+ await svc.removeDocument(req.params.id);
1891
+ res.setHeader('Content-Type', 'application/json');
1892
+ res.end(JSON.stringify({
1893
+ ok: true
1894
+ }));
1895
+ }, true));
1896
+ app.post(`${path}/reindex`, wrap(async (req, res, ctx) => {
1897
+ const svc = getService(ctx, options);
1898
+ const body = await readJson(req);
1899
+ const stats = await svc.reindex({
1900
+ force: !!body.force
1901
+ });
1902
+ res.setHeader('Content-Type', 'application/json');
1903
+ res.end(JSON.stringify(stats));
1904
+ }, true));
1905
+ }
1906
+
1907
+ /** How the GraphQL resolvers obtain the service (defaults to `context.services.aiRag`). */
1908
+
1909
+ function serviceFrom(context, options) {
1910
+ var _options$getService;
1911
+ const svc = ((_options$getService = options.getService) !== null && _options$getService !== void 0 ? _options$getService : c => c.services.aiRag)(context);
1912
+ if (!svc) {
1913
+ throw new Error('[@nixxie-cms/ai-rag] No aiRag service is configured. Pass it to `config({ aiRag })` or `ragPlugin({ service })`.');
1914
+ }
1915
+ return svc;
1916
+ }
1917
+ async function assertAuthorized(context, options) {
1918
+ var _options$isAuthorized;
1919
+ const check = (_options$isAuthorized = options.isAuthorized) !== null && _options$isAuthorized !== void 0 ? _options$isAuthorized : c => !!c.session;
1920
+ if (!(await check(context))) {
1921
+ throw new Error('[@nixxie-cms/ai-rag] Not authorized.');
1922
+ }
1923
+ }
1924
+
1925
+ /**
1926
+ * Build the `extendGraphqlSchema` function that adds the assistant's queries and mutations:
1927
+ *
1928
+ * - `ragAsk(question, topK?, tags?)` → grounded answer with citations
1929
+ * - `ragRetrieve(query, topK?, tags?)` → raw retrieved chunks
1930
+ * - `ragAddDocument(...)` / `ragRemoveDocument(id)` / `ragReindex(force?)`
1931
+ *
1932
+ * `ragPlugin()` wires this automatically; use it directly only if you compose the schema yourself.
1933
+ */
1934
+ function ragGraphqlExtension(options = {}) {
1935
+ return g.extend(() => {
1936
+ const Citation = g.object()({
1937
+ name: 'RagCitation',
1938
+ fields: {
1939
+ documentId: g.field({
1940
+ type: g.nonNull(g.String),
1941
+ resolve: s => s.documentId
1942
+ }),
1943
+ chunkId: g.field({
1944
+ type: g.nonNull(g.String),
1945
+ resolve: s => s.chunkId
1946
+ }),
1947
+ title: g.field({
1948
+ type: g.String,
1949
+ resolve: s => {
1950
+ var _s$title;
1951
+ return (_s$title = s.title) !== null && _s$title !== void 0 ? _s$title : null;
1952
+ }
1953
+ }),
1954
+ source: g.field({
1955
+ type: g.String,
1956
+ resolve: s => {
1957
+ var _s$source;
1958
+ return (_s$source = s.source) !== null && _s$source !== void 0 ? _s$source : null;
1959
+ }
1960
+ }),
1961
+ score: g.field({
1962
+ type: g.nonNull(g.Float),
1963
+ resolve: s => s.score
1964
+ }),
1965
+ snippet: g.field({
1966
+ type: g.nonNull(g.String),
1967
+ resolve: s => s.snippet
1968
+ })
1969
+ }
1970
+ });
1971
+ const Chunk = g.object()({
1972
+ name: 'RagChunk',
1973
+ fields: {
1974
+ id: g.field({
1975
+ type: g.nonNull(g.String),
1976
+ resolve: s => s.id
1977
+ }),
1978
+ documentId: g.field({
1979
+ type: g.nonNull(g.String),
1980
+ resolve: s => s.documentId
1981
+ }),
1982
+ title: g.field({
1983
+ type: g.String,
1984
+ resolve: s => {
1985
+ var _s$title2;
1986
+ return (_s$title2 = s.title) !== null && _s$title2 !== void 0 ? _s$title2 : null;
1987
+ }
1988
+ }),
1989
+ source: g.field({
1990
+ type: g.String,
1991
+ resolve: s => {
1992
+ var _s$source2;
1993
+ return (_s$source2 = s.source) !== null && _s$source2 !== void 0 ? _s$source2 : null;
1994
+ }
1995
+ }),
1996
+ content: g.field({
1997
+ type: g.nonNull(g.String),
1998
+ resolve: s => s.content
1999
+ }),
2000
+ score: g.field({
2001
+ type: g.nonNull(g.Float),
2002
+ resolve: s => s.score
2003
+ })
2004
+ }
2005
+ });
2006
+ const Answer = g.object()({
2007
+ name: 'RagAnswer',
2008
+ fields: {
2009
+ text: g.field({
2010
+ type: g.nonNull(g.String),
2011
+ resolve: s => s.text
2012
+ }),
2013
+ grounded: g.field({
2014
+ type: g.nonNull(g.Boolean),
2015
+ resolve: s => s.grounded
2016
+ }),
2017
+ refused: g.field({
2018
+ type: g.nonNull(g.Boolean),
2019
+ resolve: s => s.refused
2020
+ }),
2021
+ model: g.field({
2022
+ type: g.nonNull(g.String),
2023
+ resolve: s => s.model
2024
+ }),
2025
+ sources: g.field({
2026
+ type: g.nonNull(g.list(g.nonNull(Citation))),
2027
+ resolve: s => s.sources
2028
+ })
2029
+ }
2030
+ });
2031
+ const Document = g.object()({
2032
+ name: 'RagDocument',
2033
+ fields: {
2034
+ id: g.field({
2035
+ type: g.nonNull(g.String),
2036
+ resolve: s => s.id
2037
+ }),
2038
+ title: g.field({
2039
+ type: g.String,
2040
+ resolve: s => {
2041
+ var _s$title3;
2042
+ return (_s$title3 = s.title) !== null && _s$title3 !== void 0 ? _s$title3 : null;
2043
+ }
2044
+ }),
2045
+ content: g.field({
2046
+ type: g.nonNull(g.String),
2047
+ resolve: s => s.content
2048
+ }),
2049
+ source: g.field({
2050
+ type: g.String,
2051
+ resolve: s => {
2052
+ var _s$source3;
2053
+ return (_s$source3 = s.source) !== null && _s$source3 !== void 0 ? _s$source3 : null;
2054
+ }
2055
+ }),
2056
+ tags: g.field({
2057
+ type: g.list(g.nonNull(g.String)),
2058
+ resolve: s => {
2059
+ var _s$tags;
2060
+ return (_s$tags = s.tags) !== null && _s$tags !== void 0 ? _s$tags : null;
2061
+ }
2062
+ }),
2063
+ status: g.field({
2064
+ type: g.nonNull(g.String),
2065
+ resolve: s => s.status
2066
+ }),
2067
+ chunkCount: g.field({
2068
+ type: g.nonNull(g.Int),
2069
+ resolve: s => s.chunkCount
2070
+ }),
2071
+ error: g.field({
2072
+ type: g.String,
2073
+ resolve: s => {
2074
+ var _s$error;
2075
+ return (_s$error = s.error) !== null && _s$error !== void 0 ? _s$error : null;
2076
+ }
2077
+ }),
2078
+ indexedAt: g.field({
2079
+ type: g.String,
2080
+ resolve: s => {
2081
+ var _s$indexedAt$toISOStr, _s$indexedAt;
2082
+ return (_s$indexedAt$toISOStr = (_s$indexedAt = s.indexedAt) === null || _s$indexedAt === void 0 ? void 0 : _s$indexedAt.toISOString()) !== null && _s$indexedAt$toISOStr !== void 0 ? _s$indexedAt$toISOStr : null;
2083
+ }
2084
+ }),
2085
+ createdAt: g.field({
2086
+ type: g.nonNull(g.String),
2087
+ resolve: s => s.createdAt.toISOString()
2088
+ })
2089
+ }
2090
+ });
2091
+ const IndexStats = g.object()({
2092
+ name: 'RagIndexStats',
2093
+ fields: {
2094
+ documents: g.field({
2095
+ type: g.nonNull(g.Int),
2096
+ resolve: s => s.documents
2097
+ }),
2098
+ chunks: g.field({
2099
+ type: g.nonNull(g.Int),
2100
+ resolve: s => s.chunks
2101
+ }),
2102
+ errors: g.field({
2103
+ type: g.nonNull(g.Int),
2104
+ resolve: s => s.errors
2105
+ }),
2106
+ durationMs: g.field({
2107
+ type: g.nonNull(g.Int),
2108
+ resolve: s => s.durationMs
2109
+ })
2110
+ }
2111
+ });
2112
+ return {
2113
+ query: {
2114
+ ragAsk: g.field({
2115
+ type: g.nonNull(Answer),
2116
+ args: {
2117
+ question: g.arg({
2118
+ type: g.nonNull(g.String)
2119
+ }),
2120
+ topK: g.arg({
2121
+ type: g.Int
2122
+ }),
2123
+ tags: g.arg({
2124
+ type: g.list(g.nonNull(g.String))
2125
+ })
2126
+ },
2127
+ resolve: async (_s, args, context) => {
2128
+ var _args$topK, _args$tags;
2129
+ return serviceFrom(context, options).ask(args.question, {
2130
+ topK: (_args$topK = args.topK) !== null && _args$topK !== void 0 ? _args$topK : undefined,
2131
+ tags: (_args$tags = args.tags) !== null && _args$tags !== void 0 ? _args$tags : undefined
2132
+ });
2133
+ }
2134
+ }),
2135
+ ragRetrieve: g.field({
2136
+ type: g.nonNull(g.list(g.nonNull(Chunk))),
2137
+ args: {
2138
+ query: g.arg({
2139
+ type: g.nonNull(g.String)
2140
+ }),
2141
+ topK: g.arg({
2142
+ type: g.Int
2143
+ }),
2144
+ tags: g.arg({
2145
+ type: g.list(g.nonNull(g.String))
2146
+ })
2147
+ },
2148
+ resolve: async (_s, args, context) => {
2149
+ var _args$topK2, _args$tags2;
2150
+ return serviceFrom(context, options).retrieve(args.query, {
2151
+ topK: (_args$topK2 = args.topK) !== null && _args$topK2 !== void 0 ? _args$topK2 : undefined,
2152
+ tags: (_args$tags2 = args.tags) !== null && _args$tags2 !== void 0 ? _args$tags2 : undefined
2153
+ });
2154
+ }
2155
+ })
2156
+ },
2157
+ mutation: {
2158
+ ragAddDocument: g.field({
2159
+ type: g.nonNull(Document),
2160
+ args: {
2161
+ content: g.arg({
2162
+ type: g.nonNull(g.String)
2163
+ }),
2164
+ title: g.arg({
2165
+ type: g.String
2166
+ }),
2167
+ source: g.arg({
2168
+ type: g.String
2169
+ }),
2170
+ tags: g.arg({
2171
+ type: g.list(g.nonNull(g.String))
2172
+ })
2173
+ },
2174
+ resolve: async (_s, args, context) => {
2175
+ var _args$title, _args$source, _args$tags3;
2176
+ await assertAuthorized(context, options);
2177
+ return serviceFrom(context, options).addDocument({
2178
+ content: args.content,
2179
+ title: (_args$title = args.title) !== null && _args$title !== void 0 ? _args$title : undefined,
2180
+ source: (_args$source = args.source) !== null && _args$source !== void 0 ? _args$source : undefined,
2181
+ tags: (_args$tags3 = args.tags) !== null && _args$tags3 !== void 0 ? _args$tags3 : undefined
2182
+ });
2183
+ }
2184
+ }),
2185
+ ragRemoveDocument: g.field({
2186
+ type: g.nonNull(g.Boolean),
2187
+ args: {
2188
+ id: g.arg({
2189
+ type: g.nonNull(g.String)
2190
+ })
2191
+ },
2192
+ resolve: async (_s, args, context) => {
2193
+ await assertAuthorized(context, options);
2194
+ await serviceFrom(context, options).removeDocument(args.id);
2195
+ return true;
2196
+ }
2197
+ }),
2198
+ ragReindex: g.field({
2199
+ type: g.nonNull(IndexStats),
2200
+ args: {
2201
+ force: g.arg({
2202
+ type: g.Boolean
2203
+ })
2204
+ },
2205
+ resolve: async (_s, args, context) => {
2206
+ var _args$force;
2207
+ await assertAuthorized(context, options);
2208
+ return serviceFrom(context, options).reindex({
2209
+ force: (_args$force = args.force) !== null && _args$force !== void 0 ? _args$force : false
2210
+ });
2211
+ }
2212
+ })
2213
+ }
2214
+ };
2215
+ });
2216
+ }
2217
+
2218
+ /** Re-index on CMS create/update, purge chunks on delete. Skips the service's own status writes. */
2219
+ function autoIndexHooks(service) {
2220
+ return {
2221
+ afterOperation: async args => {
2222
+ try {
2223
+ var _args$originalItem2, _args$originalItem3;
2224
+ if (args.operation === 'delete') {
2225
+ var _args$originalItem;
2226
+ const id = (_args$originalItem = args.originalItem) === null || _args$originalItem === void 0 ? void 0 : _args$originalItem.id;
2227
+ if (id != null) await service.purgeChunks(String(id));
2228
+ return;
2229
+ }
2230
+ const item = args.item;
2231
+ if (!(item !== null && item !== void 0 && item.id)) return;
2232
+ // Only (re)index when indexable content actually changed — the service's own
2233
+ // status/chunkCount writes go through raw Prisma and never reach this hook, but
2234
+ // this also avoids needless work on unrelated field edits.
2235
+ const changed = args.operation === 'create' || item.content !== ((_args$originalItem2 = args.originalItem) === null || _args$originalItem2 === void 0 ? void 0 : _args$originalItem2.content) || item.title !== ((_args$originalItem3 = args.originalItem) === null || _args$originalItem3 === void 0 ? void 0 : _args$originalItem3.title);
2236
+ if (changed) await service.index(String(item.id));
2237
+ } catch (err) {
2238
+ console.error('[@nixxie-cms/ai-rag] Auto-index hook failed:', err);
2239
+ }
2240
+ }
2241
+ };
2242
+ }
2243
+ function composeExtendExpress(previous, service, routes) {
2244
+ const routeOptions = typeof routes === 'object' ? routes : {};
2245
+ return async (app, context) => {
2246
+ await (previous === null || previous === void 0 ? void 0 : previous(app, context));
2247
+ if (routes !== false) installAiRagRoutes(app, context, {
2248
+ ...routeOptions,
2249
+ service
2250
+ });
2251
+ };
2252
+ }
2253
+ function composeExtendGraphql(previous, graphqlOption) {
2254
+ if (graphqlOption === false) return previous;
2255
+ const ext = ragGraphqlExtension(typeof graphqlOption === 'object' ? graphqlOption : {});
2256
+ return schema => ext(previous ? previous(schema) : schema);
2257
+ }
2258
+
2259
+ /**
2260
+ * One-call wiring for the RAG assistant. Registers the service, adds the knowledge-base
2261
+ * collections (with auto-indexing hooks), mounts the HTTP + GraphQL surfaces and schedules
2262
+ * re-indexing.
2263
+ *
2264
+ * @example
2265
+ * import { createAiRag, ragPlugin } from '@nixxie-cms/ai-rag'
2266
+ * const aiRag = createAiRag({
2267
+ * generation: { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY! },
2268
+ * embedding: { provider: 'openai', apiKey: process.env.OPENAI_API_KEY! },
2269
+ * indexing: { schedule: '0 3 * * *' },
2270
+ * })
2271
+ * export default config({
2272
+ * db: { ... },
2273
+ * plugins: [ragPlugin({ service: aiRag })],
2274
+ * })
2275
+ */
2276
+ function ragPlugin(options) {
2277
+ var _options$autoIndex, _options$jobName;
2278
+ const {
2279
+ service
2280
+ } = options;
2281
+ const {
2282
+ documents,
2283
+ chunks
2284
+ } = service.collections;
2285
+ const autoIndex = (_options$autoIndex = options.autoIndex) !== null && _options$autoIndex !== void 0 ? _options$autoIndex : true;
2286
+ const jobName = (_options$jobName = options.jobName) !== null && _options$jobName !== void 0 ? _options$jobName : 'ai-rag-reindex';
2287
+ const collections = options.collections === false ? undefined : {
2288
+ [documents]: autoIndex ? {
2289
+ ...knowledgeBaseCollection(),
2290
+ hooks: autoIndexHooks(service)
2291
+ } : knowledgeBaseCollection(),
2292
+ [chunks]: knowledgeChunkCollection()
2293
+ };
2294
+ return {
2295
+ name: 'nixxie-ai-rag',
2296
+ collections,
2297
+ extendConfig: config => {
2298
+ var _config$aiRag, _config$graphql, _config$server;
2299
+ return {
2300
+ ...config,
2301
+ // Register the service so core initialises it and it's reachable via context.services.aiRag.
2302
+ aiRag: (_config$aiRag = config.aiRag) !== null && _config$aiRag !== void 0 ? _config$aiRag : service,
2303
+ graphql: {
2304
+ ...config.graphql,
2305
+ extendGraphqlSchema: composeExtendGraphql((_config$graphql = config.graphql) === null || _config$graphql === void 0 ? void 0 : _config$graphql.extendGraphqlSchema, options.graphql)
2306
+ },
2307
+ server: {
2308
+ ...config.server,
2309
+ extendExpressApp: composeExtendExpress((_config$server = config.server) === null || _config$server === void 0 ? void 0 : _config$server.extendExpressApp, service, options.routes)
2310
+ }
2311
+ };
2312
+ },
2313
+ onConnect: async context => {
2314
+ // The service itself is initialised by core's service-init loop (because we set
2315
+ // config.aiRag above). Here we only schedule the periodic full reindex, if requested.
2316
+ const schedule = service.indexingSchedule;
2317
+ if (schedule && context.services.jobs) {
2318
+ context.services.jobs.define({
2319
+ name: jobName,
2320
+ schedule,
2321
+ handler: async () => {
2322
+ await service.reindex();
2323
+ }
2324
+ });
2325
+ } else if (schedule && !context.services.jobs) {
2326
+ console.warn(`[@nixxie-cms/ai-rag] indexing.schedule is set but no jobs service is configured — ` + `add \`jobs: createJobs()\` from @nixxie-cms/jobs to enable scheduled re-indexing.`);
2327
+ }
2328
+ }
2329
+ };
2330
+ }
2331
+
2332
+ /** Source of the self-contained Admin UI page (plain React + fetch, no extra deps). */
2333
+ function pageSource(apiPath, title) {
2334
+ return `// Generated by @nixxie-cms/ai-rag — a chat + knowledge-base console for the Admin UI.
2335
+ import React, { useState } from 'react'
2336
+
2337
+ const API = ${JSON.stringify(apiPath)}
2338
+
2339
+ export default function AiRagPage() {
2340
+ const [messages, setMessages] = useState([])
2341
+ const [input, setInput] = useState('')
2342
+ const [busy, setBusy] = useState(false)
2343
+ const [docTitle, setDocTitle] = useState('')
2344
+ const [docContent, setDocContent] = useState('')
2345
+
2346
+ async function send() {
2347
+ const question = input.trim()
2348
+ if (!question || busy) return
2349
+ setInput('')
2350
+ const next = [...messages, { role: 'user', content: question }]
2351
+ setMessages(next)
2352
+ setBusy(true)
2353
+ try {
2354
+ const res = await fetch(API + '/chat', {
2355
+ method: 'POST',
2356
+ headers: { 'Content-Type': 'application/json' },
2357
+ body: JSON.stringify({ messages: next, stream: false }),
2358
+ })
2359
+ const answer = await res.json()
2360
+ const sources = (answer.sources || [])
2361
+ .map(function (s, i) { return '[' + (i + 1) + '] ' + (s.title || s.source || s.documentId) })
2362
+ .join(' ')
2363
+ setMessages(function (m) {
2364
+ return m.concat([{ role: 'assistant', content: answer.text + (sources ? '\\n\\nSources: ' + sources : '') }])
2365
+ })
2366
+ } catch (err) {
2367
+ setMessages(function (m) { return m.concat([{ role: 'assistant', content: 'Error: ' + String(err) }]) })
2368
+ } finally {
2369
+ setBusy(false)
2370
+ }
2371
+ }
2372
+
2373
+ async function addDocument() {
2374
+ if (!docContent.trim()) return
2375
+ await fetch(API + '/documents', {
2376
+ method: 'POST',
2377
+ headers: { 'Content-Type': 'application/json' },
2378
+ body: JSON.stringify({ title: docTitle, content: docContent }),
2379
+ })
2380
+ setDocTitle('')
2381
+ setDocContent('')
2382
+ alert('Document added and queued for indexing.')
2383
+ }
2384
+
2385
+ return (
2386
+ React.createElement('div', { style: { maxWidth: 820, margin: '0 auto', padding: 24 } },
2387
+ React.createElement('h1', { style: { fontSize: 24, fontWeight: 700, marginBottom: 16 } }, ${JSON.stringify(title)}),
2388
+ React.createElement('div', { style: { border: '1px solid #e2e8f0', borderRadius: 8, padding: 16, minHeight: 240, marginBottom: 12 } },
2389
+ messages.length === 0
2390
+ ? React.createElement('p', { style: { color: '#94a3b8' } }, 'Ask the assistant a question about your knowledge base…')
2391
+ : messages.map(function (m, i) {
2392
+ return React.createElement('div', { key: i, style: { margin: '8px 0' } },
2393
+ React.createElement('strong', null, m.role === 'user' ? 'You: ' : 'Assistant: '),
2394
+ React.createElement('span', { style: { whiteSpace: 'pre-wrap' } }, m.content))
2395
+ })
2396
+ ),
2397
+ React.createElement('div', { style: { display: 'flex', gap: 8, marginBottom: 32 } },
2398
+ React.createElement('input', {
2399
+ value: input,
2400
+ onChange: function (e) { setInput(e.target.value) },
2401
+ onKeyDown: function (e) { if (e.key === 'Enter') send() },
2402
+ placeholder: 'Type your question…',
2403
+ style: { flex: 1, padding: 8, borderRadius: 6, border: '1px solid #cbd5e1' },
2404
+ }),
2405
+ React.createElement('button', { onClick: send, disabled: busy, style: { padding: '8px 16px' } }, busy ? '…' : 'Send')
2406
+ ),
2407
+ React.createElement('h2', { style: { fontSize: 18, fontWeight: 600, marginBottom: 8 } }, 'Add to knowledge base'),
2408
+ React.createElement('input', {
2409
+ value: docTitle,
2410
+ onChange: function (e) { setDocTitle(e.target.value) },
2411
+ placeholder: 'Title',
2412
+ style: { width: '100%', padding: 8, borderRadius: 6, border: '1px solid #cbd5e1', marginBottom: 8 },
2413
+ }),
2414
+ React.createElement('textarea', {
2415
+ value: docContent,
2416
+ onChange: function (e) { setDocContent(e.target.value) },
2417
+ placeholder: 'Content the assistant should learn from…',
2418
+ rows: 6,
2419
+ style: { width: '100%', padding: 8, borderRadius: 6, border: '1px solid #cbd5e1', marginBottom: 8 },
2420
+ }),
2421
+ React.createElement('button', { onClick: addDocument, style: { padding: '8px 16px' } }, 'Add document')
2422
+ )
2423
+ )
2424
+ }
2425
+ `;
2426
+ }
2427
+
2428
+ /**
2429
+ * Returns an `AdminFileToWrite` that adds a chat + knowledge-base console page to the
2430
+ * Admin UI. Spread it into `ui.getAdditionalFiles`:
2431
+ *
2432
+ * @example
2433
+ * import { ragAdminPage } from '@nixxie-cms/ai-rag'
2434
+ * export default config({
2435
+ * ui: { getAdditionalFiles: [async () => [ragAdminPage()]] },
2436
+ * })
2437
+ *
2438
+ * The page appears at `/<route>` (default `/ai-rag`) in the Admin UI.
2439
+ */
2440
+ function ragAdminPage(options = {}) {
2441
+ var _options$route, _options$apiPath, _options$title;
2442
+ const route = (_options$route = options.route) !== null && _options$route !== void 0 ? _options$route : 'ai-rag';
2443
+ const apiPath = ((_options$apiPath = options.apiPath) !== null && _options$apiPath !== void 0 ? _options$apiPath : '/api/ai-rag').replace(/\/$/, '');
2444
+ const title = (_options$title = options.title) !== null && _options$title !== void 0 ? _options$title : 'AI Assistant';
2445
+ return {
2446
+ mode: 'write',
2447
+ src: pageSource(apiPath, title),
2448
+ outputPath: `pages/${route}.js`
2449
+ };
2450
+ }
2451
+
2452
+ /**
2453
+ * Create a custom, RAG-trained assistant backed by a knowledge-base collection.
2454
+ *
2455
+ * Register it with `ragPlugin()` so the knowledge-base collections, HTTP + GraphQL surfaces
2456
+ * and scheduled indexing are wired automatically:
2457
+ *
2458
+ * @example
2459
+ * import { createAiRag, ragPlugin } from '@nixxie-cms/ai-rag'
2460
+ * const aiRag = createAiRag({
2461
+ * generation: { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY! },
2462
+ * embedding: { provider: 'openai', apiKey: process.env.OPENAI_API_KEY! },
2463
+ * retrieval: { topK: 6, minScore: 0.25 },
2464
+ * guard: { groundingCheck: true },
2465
+ * indexing: { schedule: '0 3 * * *' },
2466
+ * })
2467
+ * export default config({ db: { ... }, plugins: [ragPlugin({ service: aiRag })] })
2468
+ *
2469
+ * Then anywhere you have context:
2470
+ * await context.services.aiRag!.addDocument({ title, content })
2471
+ * const answer = await context.services.aiRag!.ask('How do refunds work?')
2472
+ */
2473
+ function createAiRag(config = {}) {
2474
+ return new AiRagService(config);
2475
+ }
2476
+
2477
+ // Config + value types
2478
+
2479
+ // Re-exported service types from core
2480
+
2481
+ export { AiRagService, AnthropicRagProvider, DEFAULT_SYSTEM_PROMPT, GeminiRagProvider, InMemoryVectorStore, OllamaRagProvider, OpenAiRagProvider, ServiceRagProvider, SqlVectorStore, buildRagPrompt, chunkText, cosineSimilarity, createAiRag, installAiRagRoutes, knowledgeBaseCollection, knowledgeChunkCollection, normalize, ragAdminPage, ragGraphqlExtension, ragPlugin, renderContext, resolveEmbeddingProvider, resolveGenerationProvider };