@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.
- package/LICENSE +23 -0
- package/README.md +163 -0
- package/dist/declarations/src/AiRagService.d.ts +50 -0
- package/dist/declarations/src/AiRagService.d.ts.map +1 -0
- package/dist/declarations/src/admin-page.d.ts +29 -0
- package/dist/declarations/src/admin-page.d.ts.map +1 -0
- package/dist/declarations/src/chunking.d.ts +8 -0
- package/dist/declarations/src/chunking.d.ts.map +1 -0
- package/dist/declarations/src/collection.d.ts +18 -0
- package/dist/declarations/src/collection.d.ts.map +1 -0
- package/dist/declarations/src/express.d.ts +36 -0
- package/dist/declarations/src/express.d.ts.map +1 -0
- package/dist/declarations/src/graphql.d.ts +23 -0
- package/dist/declarations/src/graphql.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +39 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/plugin.d.ts +53 -0
- package/dist/declarations/src/plugin.d.ts.map +1 -0
- package/dist/declarations/src/prompt.d.ts +14 -0
- package/dist/declarations/src/prompt.d.ts.map +1 -0
- package/dist/declarations/src/providers/AnthropicRagProvider.d.ts +16 -0
- package/dist/declarations/src/providers/AnthropicRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/GeminiRagProvider.d.ts +19 -0
- package/dist/declarations/src/providers/GeminiRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/OllamaRagProvider.d.ts +23 -0
- package/dist/declarations/src/providers/OllamaRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/OpenAiRagProvider.d.ts +17 -0
- package/dist/declarations/src/providers/OpenAiRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/ServiceRagProvider.d.ts +17 -0
- package/dist/declarations/src/providers/ServiceRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/index.d.ts +14 -0
- package/dist/declarations/src/providers/index.d.ts.map +1 -0
- package/dist/declarations/src/providers/types.d.ts +45 -0
- package/dist/declarations/src/providers/types.d.ts.map +1 -0
- package/dist/declarations/src/similarity.d.ts +12 -0
- package/dist/declarations/src/similarity.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +319 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/declarations/src/vector-store.d.ts +34 -0
- package/dist/declarations/src/vector-store.d.ts.map +1 -0
- package/dist/nixxie-cms-ai-rag.cjs.d.ts +2 -0
- package/dist/nixxie-cms-ai-rag.cjs.js +2507 -0
- package/dist/nixxie-cms-ai-rag.esm.js +2481 -0
- package/package.json +37 -0
- package/src/AiRagService.ts +640 -0
- package/src/admin-page.ts +135 -0
- package/src/chunking.ts +78 -0
- package/src/collection.ts +79 -0
- package/src/express.ts +212 -0
- package/src/graphql.ts +196 -0
- package/src/guard.ts +75 -0
- package/src/index.ts +102 -0
- package/src/plugin.ts +162 -0
- package/src/prompt.ts +62 -0
- package/src/providers/AnthropicRagProvider.ts +91 -0
- package/src/providers/GeminiRagProvider.ts +147 -0
- package/src/providers/OllamaRagProvider.ts +157 -0
- package/src/providers/OpenAiRagProvider.ts +108 -0
- package/src/providers/ServiceRagProvider.ts +44 -0
- package/src/providers/index.ts +67 -0
- package/src/providers/types.ts +44 -0
- package/src/semaphore.ts +26 -0
- package/src/similarity.ts +31 -0
- package/src/types.ts +346 -0
- 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 };
|