@retrivora-ai/rag-engine 1.9.0 → 1.9.2
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/dist/{ILLMProvider-BOJFz3Na.d.mts → ILLMProvider-Bw2A28nU.d.mts} +12 -0
- package/dist/{ILLMProvider-BOJFz3Na.d.ts → ILLMProvider-Bw2A28nU.d.ts} +12 -0
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +1874 -542
- package/dist/handlers/index.mjs +1873 -541
- package/dist/{index-D3V9Et2M.d.mts → index-B70ZLkfG.d.mts} +1 -1
- package/dist/{index-BwpcaziY.d.ts → index-DVu-mkAM.d.ts} +1 -1
- package/dist/index.css +83 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +330 -106
- package/dist/index.mjs +333 -107
- package/dist/server.d.mts +32 -5
- package/dist/server.d.ts +32 -5
- package/dist/server.js +1871 -736
- package/dist/server.mjs +1870 -735
- package/package.json +1 -1
- package/src/components/ChatWindow.tsx +24 -14
- package/src/components/MarkdownComponents.tsx +3 -3
- package/src/components/MessageBubble.tsx +89 -7
- package/src/components/ProductCard.tsx +29 -2
- package/src/components/UIDispatcher.tsx +1 -0
- package/src/components/VisualizationRenderer.tsx +143 -11
- package/src/config/EmbeddingStrategy.ts +5 -4
- package/src/config/RagConfig.ts +10 -0
- package/src/config/serverConfig.ts +16 -1
- package/src/core/LLMRouter.ts +79 -0
- package/src/core/Pipeline.ts +295 -51
- package/src/core/ProviderRegistry.ts +6 -0
- package/src/core/QueryProcessor.ts +108 -9
- package/src/handlers/index.ts +37 -11
- package/src/hooks/useRagChat.ts +77 -17
- package/src/llm/providers/UniversalLLMAdapter.ts +110 -13
- package/src/providers/vectordb/ChromaDBProvider.ts +13 -2
- package/src/providers/vectordb/MilvusProvider.ts +18 -2
- package/src/providers/vectordb/MultiTablePostgresProvider.ts +48 -16
- package/src/providers/vectordb/PostgreSQLProvider.ts +1 -1
- package/src/providers/vectordb/QdrantProvider.ts +1 -1
- package/src/providers/vectordb/RedisProvider.ts +3 -4
- package/src/providers/vectordb/WeaviateProvider.ts +41 -3
- package/src/types/chat.ts +2 -0
- package/src/types/index.ts +26 -0
- package/src/utils/ProductExtractor.ts +5 -3
- package/src/utils/SchemaMapper.ts +6 -4
- package/src/utils/UITransformer.ts +1350 -490
- package/src/utils/synonyms.ts +6 -4
|
@@ -12,10 +12,17 @@ export interface QueryFieldHint {
|
|
|
12
12
|
value: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface NumericPredicate {
|
|
16
|
+
field?: string;
|
|
17
|
+
operator: 'gt' | 'gte' | 'lt' | 'lte' | 'eq';
|
|
18
|
+
value: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
export interface QueryFilter {
|
|
16
22
|
metadata?: Record<string, string>;
|
|
17
23
|
keywords?: string[];
|
|
18
24
|
queryText?: string;
|
|
25
|
+
__numericPredicates?: NumericPredicate[];
|
|
19
26
|
[key: string]: unknown;
|
|
20
27
|
}
|
|
21
28
|
|
|
@@ -149,6 +156,7 @@ export class QueryProcessor {
|
|
|
149
156
|
new RegExp(`\\b${fieldPattern}\\s*(?:=|:)\\s*["']?${valuePattern}["']?(?=[?.!,]|$)`, 'gi'),
|
|
150
157
|
new RegExp(`\\b${fieldPattern}\\s+(?:is|are|was|were|equals?|equal to|named|called)\\s+["']?${valuePattern}["']?(?=[?.!,]|$)`, 'gi'),
|
|
151
158
|
new RegExp(`\\bwith\\s+${fieldPattern}\\s+["']?${valuePattern}["']?(?=[?.!,]|$)`, 'gi'),
|
|
159
|
+
new RegExp(`\\b(?:under|in|for|from|of)\\s+${fieldPattern}\\s+["']?${valuePattern}["']?(?=[?.!,]|$)`, 'gi'),
|
|
152
160
|
];
|
|
153
161
|
|
|
154
162
|
for (const pattern of fieldValuePatterns) {
|
|
@@ -172,6 +180,92 @@ export class QueryProcessor {
|
|
|
172
180
|
return [...hints.values()];
|
|
173
181
|
}
|
|
174
182
|
|
|
183
|
+
static extractNumericPredicates(question: string, validFields: string[] = []): NumericPredicate[] {
|
|
184
|
+
const predicates: NumericPredicate[] = [];
|
|
185
|
+
const seen = new Set<string>();
|
|
186
|
+
const comparatorPattern = [
|
|
187
|
+
'greater than or equal to',
|
|
188
|
+
'more than or equal to',
|
|
189
|
+
'less than or equal to',
|
|
190
|
+
'greater than',
|
|
191
|
+
'more than',
|
|
192
|
+
'less than',
|
|
193
|
+
'equal to',
|
|
194
|
+
'at least',
|
|
195
|
+
'at most',
|
|
196
|
+
'above',
|
|
197
|
+
'over',
|
|
198
|
+
'below',
|
|
199
|
+
'under',
|
|
200
|
+
'equals?',
|
|
201
|
+
'>=',
|
|
202
|
+
'<=',
|
|
203
|
+
'>',
|
|
204
|
+
'<',
|
|
205
|
+
'=',
|
|
206
|
+
].join('|');
|
|
207
|
+
|
|
208
|
+
const addPredicate = (rawField: string | undefined, rawOperator: string, rawValue: string) => {
|
|
209
|
+
const value = Number(rawValue.replace(/,/g, ''));
|
|
210
|
+
if (!Number.isFinite(value)) return;
|
|
211
|
+
|
|
212
|
+
const operator = this.normalizeNumericOperator(rawOperator);
|
|
213
|
+
const field = rawField ? this.normalizePredicateField(rawField, validFields) : undefined;
|
|
214
|
+
const key = `${field ?? '*'}::${operator}::${value}`;
|
|
215
|
+
if (seen.has(key)) return;
|
|
216
|
+
seen.add(key);
|
|
217
|
+
predicates.push({ ...(field ? { field } : {}), operator, value });
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const scopedPatterns = [
|
|
221
|
+
new RegExp(`\\b(?:whose|with|having|where|that\\s+have|which\\s+have)\\s+([a-zA-Z][a-zA-Z0-9_\\s\\-/]{1,80}?)\\s+(?:is|are|was|were)?\\s*(?:${comparatorPattern})\\s+([\\d,]+(?:\\.\\d+)?)`, 'gi'),
|
|
222
|
+
new RegExp(`\\b([a-zA-Z][a-zA-Z0-9_\\s\\-/]{1,80}?)\\s+(?:is|are|was|were)?\\s*(?:${comparatorPattern})\\s+([\\d,]+(?:\\.\\d+)?)`, 'gi'),
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
for (const pattern of scopedPatterns) {
|
|
226
|
+
for (const match of question.matchAll(pattern)) {
|
|
227
|
+
const full = match[0];
|
|
228
|
+
const operatorMatch = full.match(new RegExp(`(${comparatorPattern})`, 'i'));
|
|
229
|
+
if (!operatorMatch) continue;
|
|
230
|
+
addPredicate(match[1], operatorMatch[1], match[2]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const match of question.matchAll(/\b([a-zA-Z][a-zA-Z0-9_\s\-/]{1,80}?)\s*(>=|<=|>|<|=)\s*([\d,]+(?:\.\d+)?)/g)) {
|
|
235
|
+
addPredicate(match[1], match[2], match[3]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return predicates;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private static normalizeNumericOperator(operator: string): NumericPredicate['operator'] {
|
|
242
|
+
const op = operator.toLowerCase().trim();
|
|
243
|
+
if (op === '>' || /\b(greater than|more than|above|over)\b/.test(op)) return 'gt';
|
|
244
|
+
if (op === '>=' || /\b(greater than or equal to|more than or equal to|at least)\b/.test(op)) return 'gte';
|
|
245
|
+
if (op === '<' || /\b(less than|below|under)\b/.test(op)) return 'lt';
|
|
246
|
+
if (op === '<=' || /\b(less than or equal to|at most)\b/.test(op)) return 'lte';
|
|
247
|
+
return 'eq';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private static normalizePredicateField(field: string, validFields: string[]): string {
|
|
251
|
+
const cleaned = this.normalizeHintValue(field)
|
|
252
|
+
.replace(/\b(?:provide|show|get|give|list|all|the|a|an|of|organizations?|companies?|records?|items?|whose|with|having|where|that|which|have|has|is|are|was|were)\b/gi, ' ')
|
|
253
|
+
.replace(/\s+/g, ' ')
|
|
254
|
+
.trim();
|
|
255
|
+
|
|
256
|
+
if (validFields.length === 0) return cleaned;
|
|
257
|
+
const comparable = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
258
|
+
const cleanedComparable = comparable(cleaned);
|
|
259
|
+
const matchedField = validFields.find(fieldName => {
|
|
260
|
+
const candidate = comparable(fieldName);
|
|
261
|
+
return candidate === cleanedComparable ||
|
|
262
|
+
candidate.includes(cleanedComparable) ||
|
|
263
|
+
cleanedComparable.includes(candidate);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return matchedField ?? cleaned;
|
|
267
|
+
}
|
|
268
|
+
|
|
175
269
|
/**
|
|
176
270
|
* Constructs a QueryFilter object from extracted hints.
|
|
177
271
|
*
|
|
@@ -196,6 +290,11 @@ export class QueryProcessor {
|
|
|
196
290
|
if (Object.keys(filter.metadata || {}).length === 0) delete filter.metadata;
|
|
197
291
|
if (filter.keywords && filter.keywords.length === 0) delete filter.keywords;
|
|
198
292
|
|
|
293
|
+
const numericPredicates = this.extractNumericPredicates(question);
|
|
294
|
+
if (numericPredicates.length > 0) {
|
|
295
|
+
filter.__numericPredicates = numericPredicates;
|
|
296
|
+
}
|
|
297
|
+
|
|
199
298
|
return filter;
|
|
200
299
|
}
|
|
201
300
|
|
|
@@ -203,12 +302,12 @@ export class QueryProcessor {
|
|
|
203
302
|
* Determines the retrieval strategy based on the query content.
|
|
204
303
|
*/
|
|
205
304
|
static async determineRetrievalStrategy(
|
|
206
|
-
query: string,
|
|
305
|
+
query: string,
|
|
207
306
|
llm?: import('../llm/ILLMProvider').ILLMProvider,
|
|
208
307
|
customGraphKeywords?: string[],
|
|
209
308
|
customVectorKeywords?: string[]
|
|
210
309
|
): Promise<'vector' | 'graph' | 'both'> {
|
|
211
|
-
|
|
310
|
+
|
|
212
311
|
if (llm) {
|
|
213
312
|
try {
|
|
214
313
|
const prompt = `You are a routing expert in a RAG system.
|
|
@@ -238,29 +337,29 @@ Return ONLY 'vector', 'graph', or 'both'. No explanation.`;
|
|
|
238
337
|
|
|
239
338
|
// Fallback to keywords
|
|
240
339
|
const normalized = query.toLowerCase();
|
|
241
|
-
|
|
340
|
+
|
|
242
341
|
// Graph keywords
|
|
243
342
|
const graphKeywords = customGraphKeywords || [
|
|
244
|
-
'relationship', 'connect', 'link', 'network', 'friend', 'colleague',
|
|
343
|
+
'relationship', 'connect', 'link', 'network', 'friend', 'colleague',
|
|
245
344
|
'manager', 'hierarchy', 'multi-hop', 'shortest path', 'between',
|
|
246
345
|
'related to', 'associated with', 'belongs to', 'part of'
|
|
247
346
|
];
|
|
248
|
-
|
|
347
|
+
|
|
249
348
|
const hasGraphKeyword = graphKeywords.some(kw => normalized.includes(kw));
|
|
250
|
-
|
|
349
|
+
|
|
251
350
|
// Vector keywords (usually generic search or content specific)
|
|
252
351
|
const vectorKeywords = customVectorKeywords || [
|
|
253
352
|
'find', 'search', 'tell me about', 'what is', 'how to', 'documents about'
|
|
254
353
|
];
|
|
255
|
-
|
|
354
|
+
|
|
256
355
|
const hasVectorKeyword = vectorKeywords.some(kw => normalized.includes(kw));
|
|
257
|
-
|
|
356
|
+
|
|
258
357
|
if (hasGraphKeyword && !hasVectorKeyword) {
|
|
259
358
|
return 'graph';
|
|
260
359
|
} else if (hasGraphKeyword && hasVectorKeyword) {
|
|
261
360
|
return 'both';
|
|
262
361
|
}
|
|
263
|
-
|
|
362
|
+
|
|
264
363
|
return 'vector';
|
|
265
364
|
}
|
|
266
365
|
}
|
package/src/handlers/index.ts
CHANGED
|
@@ -142,15 +142,24 @@ export function createStreamHandler(configOrPlugin?: Partial<RagConfig> | Vector
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
const encoder = new TextEncoder();
|
|
145
|
+
let isActive = true;
|
|
145
146
|
|
|
146
147
|
const stream = new ReadableStream({
|
|
147
148
|
async start(controller) {
|
|
148
|
-
const enqueue = (text: string) =>
|
|
149
|
+
const enqueue = (text: string) => {
|
|
150
|
+
if (!isActive) return;
|
|
151
|
+
try {
|
|
152
|
+
controller.enqueue(encoder.encode(text));
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.warn('[createStreamHandler] Failed to enqueue (stream already closed):', err);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
149
157
|
|
|
150
158
|
try {
|
|
151
159
|
const pipelineStream = plugin.chatStream(message, history, namespace);
|
|
152
160
|
|
|
153
161
|
for await (const chunk of pipelineStream) {
|
|
162
|
+
if (!isActive) break;
|
|
154
163
|
if (typeof chunk === 'string') {
|
|
155
164
|
enqueue(sseTextFrame(chunk));
|
|
156
165
|
} else {
|
|
@@ -168,11 +177,12 @@ export function createStreamHandler(configOrPlugin?: Partial<RagConfig> | Vector
|
|
|
168
177
|
|
|
169
178
|
if (sources.length > 0) {
|
|
170
179
|
try {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
180
|
+
// The pipeline already computed ui_transformation inside askStream().
|
|
181
|
+
// We ONLY fall back to the cheap heuristic transform (no LLM call) when
|
|
182
|
+
// the pipeline didn't produce one — never call analyzeAndDecide() here.
|
|
183
|
+
const uiTransformation =
|
|
184
|
+
responseChunk?.ui_transformation ??
|
|
185
|
+
UITransformer.transform(message, sources, plugin.getConfig());
|
|
176
186
|
|
|
177
187
|
if (uiTransformation) {
|
|
178
188
|
enqueue(sseUIFrame(uiTransformation));
|
|
@@ -188,14 +198,28 @@ export function createStreamHandler(configOrPlugin?: Partial<RagConfig> | Vector
|
|
|
188
198
|
}
|
|
189
199
|
}
|
|
190
200
|
} catch (streamError) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
201
|
+
// Do not log or enqueue errors if the client intentionally cancelled/aborted
|
|
202
|
+
if (isActive) {
|
|
203
|
+
const errorMessage =
|
|
204
|
+
streamError instanceof Error ? streamError.message : String(streamError);
|
|
205
|
+
console.error('[createStreamHandler] Stream error:', streamError);
|
|
206
|
+
try {
|
|
207
|
+
enqueue(sseErrorFrame(errorMessage));
|
|
208
|
+
} catch { /* silent */ }
|
|
209
|
+
}
|
|
195
210
|
} finally {
|
|
196
|
-
|
|
211
|
+
if (isActive) {
|
|
212
|
+
isActive = false;
|
|
213
|
+
try {
|
|
214
|
+
controller.close();
|
|
215
|
+
} catch { /* silent */ }
|
|
216
|
+
}
|
|
197
217
|
}
|
|
198
218
|
},
|
|
219
|
+
cancel(reason) {
|
|
220
|
+
isActive = false;
|
|
221
|
+
console.log('[createStreamHandler] Stream connection closed by client:', reason);
|
|
222
|
+
}
|
|
199
223
|
});
|
|
200
224
|
|
|
201
225
|
return new Response(stream, { headers: SSE_HEADERS });
|
|
@@ -289,6 +313,7 @@ export function createUploadHandler(configOrPlugin?: Partial<RagConfig> | Vector
|
|
|
289
313
|
if (parsed.data && parsed.data.length > 0) {
|
|
290
314
|
let i = 0;
|
|
291
315
|
let lastRowData: Record<string, string> | null = null;
|
|
316
|
+
const csvCols = Object.keys(parsed.data[0] as Record<string, string>);
|
|
292
317
|
|
|
293
318
|
for (const row of parsed.data) {
|
|
294
319
|
i++;
|
|
@@ -325,6 +350,7 @@ export function createUploadHandler(configOrPlugin?: Partial<RagConfig> | Vector
|
|
|
325
350
|
fileType: file.type,
|
|
326
351
|
uploadedAt: new Date().toISOString(),
|
|
327
352
|
...(dimension ? { dimension } : {}),
|
|
353
|
+
csvHeaders: csvCols,
|
|
328
354
|
...rowData
|
|
329
355
|
}
|
|
330
356
|
});
|
package/src/hooks/useRagChat.ts
CHANGED
|
@@ -74,6 +74,7 @@ export function useRagChat(
|
|
|
74
74
|
const [error, setError] = useState<string | null>(null);
|
|
75
75
|
const lastInputRef = useRef<string>('');
|
|
76
76
|
const messagesRef = useRef<RagMessage[]>(messages);
|
|
77
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
77
78
|
|
|
78
79
|
useEffect(() => {
|
|
79
80
|
messagesRef.current = messages;
|
|
@@ -100,12 +101,21 @@ export function useRagChat(
|
|
|
100
101
|
setError(null);
|
|
101
102
|
setIsLoading(true);
|
|
102
103
|
|
|
104
|
+
// Abort any existing call
|
|
105
|
+
if (abortControllerRef.current) {
|
|
106
|
+
abortControllerRef.current.abort();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
abortControllerRef.current = controller;
|
|
111
|
+
|
|
103
112
|
try {
|
|
104
113
|
const history = messagesRef.current.map(({ role, content }) => ({ role, content }));
|
|
105
114
|
|
|
106
115
|
const response = await fetch(apiUrl, {
|
|
107
116
|
method: 'POST',
|
|
108
117
|
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
signal: controller.signal,
|
|
109
119
|
body: JSON.stringify({
|
|
110
120
|
message: trimmed,
|
|
111
121
|
history,
|
|
@@ -128,10 +138,36 @@ export function useRagChat(
|
|
|
128
138
|
let sources: VectorMatch[] = [];
|
|
129
139
|
let uiTransformation: unknown = null;
|
|
130
140
|
let trace: ObservabilityTrace | undefined;
|
|
131
|
-
// Buffer for partial SSE frames that arrive split across reads
|
|
132
141
|
let buffer = '';
|
|
133
142
|
|
|
134
|
-
//
|
|
143
|
+
// ── RAF-based render batching ─────────────────────────────────────
|
|
144
|
+
// Accumulate text chunks in a ref and flush to React state at ~60fps
|
|
145
|
+
// instead of calling setMessages on every SSE frame. This eliminates
|
|
146
|
+
// O(N) ReactMarkdown re-parses where N = number of token chunks.
|
|
147
|
+
let pendingFlush = false;
|
|
148
|
+
const pendingContentRef = { current: '' };
|
|
149
|
+
|
|
150
|
+
const flushTextUpdate = (msgId: string) => {
|
|
151
|
+
pendingFlush = false;
|
|
152
|
+
const snapshot = pendingContentRef.current;
|
|
153
|
+
setMessages((prev) =>
|
|
154
|
+
prev.map((msg) =>
|
|
155
|
+
msg.id === msgId
|
|
156
|
+
? { ...msg, content: snapshot }
|
|
157
|
+
: msg
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const scheduleFlush = (msgId: string) => {
|
|
163
|
+
if (!pendingFlush) {
|
|
164
|
+
pendingFlush = true;
|
|
165
|
+
requestAnimationFrame(() => flushTextUpdate(msgId));
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
// ─────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
// Add a placeholder assistant message
|
|
135
171
|
const assistantMessageId = `assistant_${Date.now()}`;
|
|
136
172
|
setMessages((prev) => [
|
|
137
173
|
...prev,
|
|
@@ -149,40 +185,50 @@ export function useRagChat(
|
|
|
149
185
|
|
|
150
186
|
buffer += decoder.decode(value, { stream: true });
|
|
151
187
|
|
|
152
|
-
// Only process complete frames (ending with \n\n)
|
|
153
188
|
const lastBoundary = buffer.lastIndexOf('\n\n');
|
|
154
|
-
if (lastBoundary === -1) continue;
|
|
189
|
+
if (lastBoundary === -1) continue;
|
|
155
190
|
|
|
156
191
|
const complete = buffer.slice(0, lastBoundary + 2);
|
|
157
192
|
buffer = buffer.slice(lastBoundary + 2);
|
|
158
193
|
|
|
194
|
+
let hasNonTextFrame = false;
|
|
195
|
+
|
|
159
196
|
for (const frame of parseSseChunk(complete)) {
|
|
160
197
|
if (frame.type === 'text' && frame.text) {
|
|
161
198
|
assistantContent += frame.text;
|
|
199
|
+
pendingContentRef.current = assistantContent;
|
|
200
|
+
// Schedule a batched RAF flush instead of immediate setMessages
|
|
201
|
+
scheduleFlush(assistantMessageId);
|
|
162
202
|
} else if (frame.type === 'metadata') {
|
|
163
203
|
sources = frame.sources ?? [];
|
|
204
|
+
hasNonTextFrame = true;
|
|
164
205
|
} else if (frame.type === 'ui_transformation') {
|
|
165
206
|
uiTransformation = frame.data;
|
|
207
|
+
hasNonTextFrame = true;
|
|
166
208
|
} else if (frame.type === 'observability') {
|
|
167
209
|
trace = (frame as SseObservabilityFrame).data;
|
|
210
|
+
hasNonTextFrame = true;
|
|
168
211
|
} else if (frame.type === 'error') {
|
|
169
212
|
setError(frame.error || 'Stream error');
|
|
170
213
|
}
|
|
171
214
|
}
|
|
172
215
|
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
216
|
+
// Flush non-text metadata immediately (sources, UI transform, trace)
|
|
217
|
+
if (hasNonTextFrame) {
|
|
218
|
+
pendingFlush = false; // cancel pending text flush — we'll do a full update
|
|
219
|
+
setMessages((prev) =>
|
|
220
|
+
prev.map((msg) =>
|
|
221
|
+
msg.id === assistantMessageId
|
|
222
|
+
? {
|
|
223
|
+
...msg,
|
|
224
|
+
content: assistantContent,
|
|
225
|
+
sources: sources.length > 0 ? sources : msg.sources,
|
|
226
|
+
uiTransformation: uiTransformation || (msg as RagMessage).uiTransformation,
|
|
227
|
+
}
|
|
228
|
+
: msg
|
|
229
|
+
)
|
|
230
|
+
);
|
|
231
|
+
}
|
|
186
232
|
}
|
|
187
233
|
|
|
188
234
|
// Process any remaining buffered data
|
|
@@ -209,11 +255,16 @@ export function useRagChat(
|
|
|
209
255
|
);
|
|
210
256
|
onReply?.(finalMsg);
|
|
211
257
|
} catch (err) {
|
|
258
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
259
|
+
console.log('[useRagChat] Streaming stopped by user.');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
212
262
|
const msg = err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
213
263
|
setError(msg);
|
|
214
264
|
onError?.(msg);
|
|
215
265
|
} finally {
|
|
216
266
|
setIsLoading(false);
|
|
267
|
+
abortControllerRef.current = null;
|
|
217
268
|
}
|
|
218
269
|
},
|
|
219
270
|
[apiUrl, isLoading, namespace, onError, onReply, projectId, setMessages]
|
|
@@ -234,6 +285,14 @@ export function useRagChat(
|
|
|
234
285
|
}
|
|
235
286
|
}, [sendMessage]);
|
|
236
287
|
|
|
288
|
+
const stop = useCallback(() => {
|
|
289
|
+
if (abortControllerRef.current) {
|
|
290
|
+
abortControllerRef.current.abort();
|
|
291
|
+
abortControllerRef.current = null;
|
|
292
|
+
}
|
|
293
|
+
setIsLoading(false);
|
|
294
|
+
}, []);
|
|
295
|
+
|
|
237
296
|
return {
|
|
238
297
|
messages,
|
|
239
298
|
isLoading,
|
|
@@ -242,5 +301,6 @@ export function useRagChat(
|
|
|
242
301
|
clear,
|
|
243
302
|
retry,
|
|
244
303
|
setMessages,
|
|
304
|
+
stop,
|
|
245
305
|
};
|
|
246
306
|
}
|
|
@@ -25,6 +25,9 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
25
25
|
private readonly systemPrompt: string;
|
|
26
26
|
private readonly maxTokens: number;
|
|
27
27
|
private readonly temperature: number;
|
|
28
|
+
private readonly baseUrl: string;
|
|
29
|
+
private readonly apiKey?: string;
|
|
30
|
+
private readonly resolvedHeaders: Record<string, string>;
|
|
28
31
|
|
|
29
32
|
constructor(config: LLMConfig | EmbeddingConfig) {
|
|
30
33
|
this.model = config.model;
|
|
@@ -46,19 +49,22 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
46
49
|
this.systemPrompt = llmConfig.systemPrompt ?? 'You are a helpful AI assistant. Use the provided context to answer the user.';
|
|
47
50
|
this.maxTokens = llmConfig.maxTokens ?? 1024;
|
|
48
51
|
this.temperature = llmConfig.temperature ?? 0;
|
|
52
|
+
this.apiKey = config.apiKey;
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
if (!baseUrl) {
|
|
54
|
+
this.baseUrl = llmConfig.baseUrl ?? this.opts.baseUrl ?? '';
|
|
55
|
+
if (!this.baseUrl) {
|
|
52
56
|
throw new Error('[UniversalLLMAdapter] baseUrl is required in config or config.options');
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
this.resolvedHeaders = {
|
|
60
|
+
'Content-Type': 'application/json',
|
|
61
|
+
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
|
|
62
|
+
...(this.opts.headers || {}),
|
|
63
|
+
};
|
|
64
|
+
|
|
55
65
|
this.http = axios.create({
|
|
56
|
-
baseURL: baseUrl,
|
|
57
|
-
headers:
|
|
58
|
-
'Content-Type': 'application/json',
|
|
59
|
-
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
|
|
60
|
-
...(this.opts.headers || {}),
|
|
61
|
-
},
|
|
66
|
+
baseURL: this.baseUrl,
|
|
67
|
+
headers: this.resolvedHeaders,
|
|
62
68
|
timeout: this.opts.timeout ?? 60_000,
|
|
63
69
|
});
|
|
64
70
|
}
|
|
@@ -73,7 +79,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
73
79
|
|
|
74
80
|
let payload: unknown;
|
|
75
81
|
if (this.opts.chatPayloadTemplate) {
|
|
76
|
-
// The user wants to map messages and parameters to their unique JSON shape
|
|
77
82
|
payload = buildPayload(this.opts.chatPayloadTemplate, {
|
|
78
83
|
model: this.model,
|
|
79
84
|
messages: formattedMessages,
|
|
@@ -81,7 +86,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
81
86
|
temperature: this.temperature,
|
|
82
87
|
});
|
|
83
88
|
} else {
|
|
84
|
-
// Fallback to OpenAI standard which covers 90% of open-source models (vLLM, LMStudio, Ollama, etc.)
|
|
85
89
|
payload = {
|
|
86
90
|
model: this.model,
|
|
87
91
|
messages: formattedMessages,
|
|
@@ -102,6 +106,102 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
102
106
|
return String(result);
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Streaming chat using native fetch + ReadableStream.
|
|
111
|
+
* Parses OpenAI-compatible SSE frames: `data: {...}\n\n`
|
|
112
|
+
* Works with vLLM, LMStudio, Together AI, Fireworks, and any OpenAI-compatible API.
|
|
113
|
+
*/
|
|
114
|
+
async *chatStream(messages: ChatMessage[], context?: string): AsyncIterable<string> {
|
|
115
|
+
const path = this.opts.chatPath ?? '/chat/completions';
|
|
116
|
+
const url = `${this.baseUrl.replace(/\/$/, '')}${path}`;
|
|
117
|
+
// Delta extract path — same root as chat but with delta instead of message
|
|
118
|
+
const extractPath = (this.opts.responseExtractPath ?? 'choices[0].message.content')
|
|
119
|
+
.replace('message.content', 'delta.content');
|
|
120
|
+
|
|
121
|
+
const formattedMessages = [
|
|
122
|
+
{ role: 'system', content: `${this.systemPrompt}\n\nContext:\n${context ?? 'None'}` },
|
|
123
|
+
...messages,
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
let payload: unknown;
|
|
127
|
+
if (this.opts.chatPayloadTemplate) {
|
|
128
|
+
payload = buildPayload(this.opts.chatPayloadTemplate, {
|
|
129
|
+
model: this.model,
|
|
130
|
+
messages: formattedMessages,
|
|
131
|
+
maxTokens: this.maxTokens,
|
|
132
|
+
temperature: this.temperature,
|
|
133
|
+
});
|
|
134
|
+
// Inject stream: true into the payload if it's an object
|
|
135
|
+
if (typeof payload === 'object' && payload !== null) {
|
|
136
|
+
(payload as Record<string, unknown>).stream = true;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
payload = {
|
|
140
|
+
model: this.model,
|
|
141
|
+
messages: formattedMessages,
|
|
142
|
+
max_tokens: this.maxTokens,
|
|
143
|
+
temperature: this.temperature,
|
|
144
|
+
stream: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const response = await fetch(url, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: this.resolvedHeaders,
|
|
151
|
+
body: JSON.stringify(payload),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
const errorText = await response.text().catch(() => response.statusText);
|
|
156
|
+
throw new Error(`[UniversalLLMAdapter] Streaming request failed (${response.status}): ${errorText}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!response.body) {
|
|
160
|
+
throw new Error('[UniversalLLMAdapter] Response body is null — server did not send a streaming response.');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const reader = response.body.getReader();
|
|
164
|
+
const decoder = new TextDecoder('utf-8');
|
|
165
|
+
let buffer = '';
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
while (true) {
|
|
169
|
+
const { done, value } = await reader.read();
|
|
170
|
+
if (done) break;
|
|
171
|
+
|
|
172
|
+
buffer += decoder.decode(value, { stream: true });
|
|
173
|
+
const lines = buffer.split('\n');
|
|
174
|
+
buffer = lines.pop() ?? '';
|
|
175
|
+
|
|
176
|
+
for (const line of lines) {
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
|
179
|
+
if (!trimmed.startsWith('data:')) continue;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const json = JSON.parse(trimmed.slice(5).trim());
|
|
183
|
+
const text = resolvePath(json, extractPath);
|
|
184
|
+
if (text && typeof text === 'string') yield text;
|
|
185
|
+
} catch {
|
|
186
|
+
// Malformed SSE line — skip
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Flush remaining buffer
|
|
192
|
+
if (buffer.trim() && buffer.trim() !== 'data: [DONE]') {
|
|
193
|
+
const jsonStr = buffer.replace(/^data:\s*/, '').trim();
|
|
194
|
+
try {
|
|
195
|
+
const json = JSON.parse(jsonStr);
|
|
196
|
+
const text = resolvePath(json, extractPath);
|
|
197
|
+
if (text && typeof text === 'string') yield text;
|
|
198
|
+
} catch { /* ignore final partial line */ }
|
|
199
|
+
}
|
|
200
|
+
} finally {
|
|
201
|
+
reader.releaseLock();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
105
205
|
async embed(text: string): Promise<number[]> {
|
|
106
206
|
const path = this.opts.embedPath ?? '/embeddings';
|
|
107
207
|
|
|
@@ -112,7 +212,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
112
212
|
input: text,
|
|
113
213
|
});
|
|
114
214
|
} else {
|
|
115
|
-
// OpenAI standard fallback
|
|
116
215
|
payload = {
|
|
117
216
|
model: this.model,
|
|
118
217
|
input: text,
|
|
@@ -133,8 +232,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
133
232
|
|
|
134
233
|
async batchEmbed(texts: string[]): Promise<number[][]> {
|
|
135
234
|
const vectors: number[][] = [];
|
|
136
|
-
// Sequential fallback if the provider doesn't have a specific batch template
|
|
137
|
-
// In production, we should add a batchEmbedPayloadTemplate to UniversalLLMOptions
|
|
138
235
|
for (const text of texts) {
|
|
139
236
|
vectors.push(await this.embed(text));
|
|
140
237
|
}
|
|
@@ -70,12 +70,23 @@ export class ChromaDBProvider extends BaseVectorProvider {
|
|
|
70
70
|
await this.http.post(`/api/v1/collections/${this.collectionId}/add`, payload);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
74
73
|
async query(vector: number[], topK: number, namespace?: string, _filter?: Record<string, unknown>): Promise<VectorMatch[]> {
|
|
74
|
+
const sanitizedFilter = this.sanitizeFilter(_filter);
|
|
75
|
+
const whereClauses: Record<string, unknown>[] = [];
|
|
76
|
+
if (namespace) whereClauses.push({ namespace: { $eq: namespace } });
|
|
77
|
+
Object.entries(sanitizedFilter).forEach(([key, value]) => {
|
|
78
|
+
if (key === 'namespace') return;
|
|
79
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
80
|
+
whereClauses.push({ [key]: { $eq: value } });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
75
84
|
const payload = {
|
|
76
85
|
query_embeddings: [vector],
|
|
77
86
|
n_results: topK,
|
|
78
|
-
where:
|
|
87
|
+
where: whereClauses.length > 1
|
|
88
|
+
? { $and: whereClauses }
|
|
89
|
+
: whereClauses[0],
|
|
79
90
|
};
|
|
80
91
|
const { data } = await this.http.post(`/api/v1/collections/${this.collectionId}/query`, payload);
|
|
81
92
|
|
|
@@ -68,13 +68,29 @@ export class MilvusProvider extends BaseVectorProvider {
|
|
|
68
68
|
await this.http.post('/v1/vector/upsert', payload);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
72
71
|
async query(vector: number[], topK: number, namespace?: string, _filter?: Record<string, unknown>): Promise<VectorMatch[]> {
|
|
72
|
+
const sanitizedFilter = this.sanitizeFilter(_filter);
|
|
73
|
+
|
|
74
|
+
// Build Milvus boolean expression from filter fields.
|
|
75
|
+
// Namespace filtering is expressed as a field equality check.
|
|
76
|
+
const filterParts: string[] = [];
|
|
77
|
+
if (namespace) {
|
|
78
|
+
filterParts.push(`namespace == "${namespace}"`);
|
|
79
|
+
}
|
|
80
|
+
for (const [key, value] of Object.entries(sanitizedFilter)) {
|
|
81
|
+
if (key === 'queryText' || key === 'keywords') continue;
|
|
82
|
+
if (typeof value === 'string') {
|
|
83
|
+
filterParts.push(`metadata["${key}"] == "${value.replace(/"/g, '\\"')}"`);
|
|
84
|
+
} else if (typeof value === 'number') {
|
|
85
|
+
filterParts.push(`metadata["${key}"] == ${value}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
const payload = {
|
|
74
90
|
collectionName: this.indexName,
|
|
75
91
|
vector: vector,
|
|
76
92
|
limit: topK,
|
|
77
|
-
filter:
|
|
93
|
+
filter: filterParts.length > 0 ? filterParts.join(' && ') : undefined,
|
|
78
94
|
outputFields: ['content', 'metadata'],
|
|
79
95
|
searchParams: {
|
|
80
96
|
nprobe: (this.config.options.nprobe as number) || 16,
|