@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.
Files changed (47) hide show
  1. package/dist/{ILLMProvider-BOJFz3Na.d.mts → ILLMProvider-Bw2A28nU.d.mts} +12 -0
  2. package/dist/{ILLMProvider-BOJFz3Na.d.ts → ILLMProvider-Bw2A28nU.d.ts} +12 -0
  3. package/dist/handlers/index.d.mts +2 -2
  4. package/dist/handlers/index.d.ts +2 -2
  5. package/dist/handlers/index.js +1874 -542
  6. package/dist/handlers/index.mjs +1873 -541
  7. package/dist/{index-D3V9Et2M.d.mts → index-B70ZLkfG.d.mts} +1 -1
  8. package/dist/{index-BwpcaziY.d.ts → index-DVu-mkAM.d.ts} +1 -1
  9. package/dist/index.css +83 -0
  10. package/dist/index.d.mts +2 -2
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.js +330 -106
  13. package/dist/index.mjs +333 -107
  14. package/dist/server.d.mts +32 -5
  15. package/dist/server.d.ts +32 -5
  16. package/dist/server.js +1871 -736
  17. package/dist/server.mjs +1870 -735
  18. package/package.json +1 -1
  19. package/src/components/ChatWindow.tsx +24 -14
  20. package/src/components/MarkdownComponents.tsx +3 -3
  21. package/src/components/MessageBubble.tsx +89 -7
  22. package/src/components/ProductCard.tsx +29 -2
  23. package/src/components/UIDispatcher.tsx +1 -0
  24. package/src/components/VisualizationRenderer.tsx +143 -11
  25. package/src/config/EmbeddingStrategy.ts +5 -4
  26. package/src/config/RagConfig.ts +10 -0
  27. package/src/config/serverConfig.ts +16 -1
  28. package/src/core/LLMRouter.ts +79 -0
  29. package/src/core/Pipeline.ts +295 -51
  30. package/src/core/ProviderRegistry.ts +6 -0
  31. package/src/core/QueryProcessor.ts +108 -9
  32. package/src/handlers/index.ts +37 -11
  33. package/src/hooks/useRagChat.ts +77 -17
  34. package/src/llm/providers/UniversalLLMAdapter.ts +110 -13
  35. package/src/providers/vectordb/ChromaDBProvider.ts +13 -2
  36. package/src/providers/vectordb/MilvusProvider.ts +18 -2
  37. package/src/providers/vectordb/MultiTablePostgresProvider.ts +48 -16
  38. package/src/providers/vectordb/PostgreSQLProvider.ts +1 -1
  39. package/src/providers/vectordb/QdrantProvider.ts +1 -1
  40. package/src/providers/vectordb/RedisProvider.ts +3 -4
  41. package/src/providers/vectordb/WeaviateProvider.ts +41 -3
  42. package/src/types/chat.ts +2 -0
  43. package/src/types/index.ts +26 -0
  44. package/src/utils/ProductExtractor.ts +5 -3
  45. package/src/utils/SchemaMapper.ts +6 -4
  46. package/src/utils/UITransformer.ts +1350 -490
  47. 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
  }
@@ -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) => controller.enqueue(encoder.encode(text));
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
- const llmProvider = plugin.getLLMProvider?.();
172
- const uiTransformation = responseChunk?.ui_transformation ??
173
- (llmProvider
174
- ? await UITransformer.analyzeAndDecide(message, sources, llmProvider)
175
- : UITransformer.transform(message, sources, plugin.getConfig()));
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
- const errorMessage =
192
- streamError instanceof Error ? streamError.message : String(streamError);
193
- console.error('[createStreamHandler] Stream error:', streamError);
194
- enqueue(sseErrorFrame(errorMessage));
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
- controller.close();
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
  });
@@ -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
- // Add a placeholder assistant message that we will update incrementally
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; // Wait for more data
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
- // Update the assistant message with accumulated content
174
- setMessages((prev) =>
175
- prev.map((msg) =>
176
- msg.id === assistantMessageId
177
- ? {
178
- ...msg,
179
- content: assistantContent,
180
- sources: sources.length > 0 ? sources : msg.sources,
181
- uiTransformation: uiTransformation || (msg as RagMessage).uiTransformation
182
- }
183
- : msg
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
- const baseUrl = llmConfig.baseUrl ?? this.opts.baseUrl;
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: namespace ? { namespace: { $eq: namespace } } : undefined,
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: namespace ? `namespace == "${namespace}"` : undefined,
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,