@retrivora-ai/rag-engine 1.8.9 → 1.9.1

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 (44) hide show
  1. package/dist/{ILLMProvider-CbUtvWAW.d.mts → ILLMProvider-BQyKZLnd.d.mts} +10 -2
  2. package/dist/{ILLMProvider-CbUtvWAW.d.ts → ILLMProvider-BQyKZLnd.d.ts} +10 -2
  3. package/dist/handlers/index.d.mts +2 -2
  4. package/dist/handlers/index.d.ts +2 -2
  5. package/dist/handlers/index.js +1726 -522
  6. package/dist/handlers/index.mjs +1725 -521
  7. package/dist/{index-DgzcnqZs.d.ts → index-A0GqPsaG.d.ts} +1 -1
  8. package/dist/{index-c_y_5qdw.d.mts → index-CDftK3qs.d.mts} +1 -1
  9. package/dist/index.css +26 -0
  10. package/dist/index.d.mts +2 -2
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.js +246 -76
  13. package/dist/index.mjs +248 -76
  14. package/dist/server.d.mts +32 -5
  15. package/dist/server.d.ts +32 -5
  16. package/dist/server.js +1735 -733
  17. package/dist/server.mjs +1733 -731
  18. package/package.json +1 -1
  19. package/src/components/MarkdownComponents.tsx +3 -3
  20. package/src/components/MessageBubble.tsx +49 -2
  21. package/src/components/ProductCard.tsx +33 -6
  22. package/src/components/UIDispatcher.tsx +1 -0
  23. package/src/components/VisualizationRenderer.tsx +143 -11
  24. package/src/config/EmbeddingStrategy.ts +5 -4
  25. package/src/config/RagConfig.ts +10 -2
  26. package/src/config/serverConfig.ts +16 -1
  27. package/src/core/LLMRouter.ts +79 -0
  28. package/src/core/Pipeline.ts +262 -45
  29. package/src/core/ProviderRegistry.ts +6 -0
  30. package/src/core/QueryProcessor.ts +98 -0
  31. package/src/handlers/index.ts +8 -5
  32. package/src/hooks/useRagChat.ts +53 -17
  33. package/src/llm/providers/UniversalLLMAdapter.ts +110 -13
  34. package/src/providers/vectordb/ChromaDBProvider.ts +13 -6
  35. package/src/providers/vectordb/MilvusProvider.ts +18 -2
  36. package/src/providers/vectordb/MultiTablePostgresProvider.ts +16 -29
  37. package/src/providers/vectordb/PostgreSQLProvider.ts +4 -15
  38. package/src/providers/vectordb/QdrantProvider.ts +2 -5
  39. package/src/providers/vectordb/RedisProvider.ts +3 -4
  40. package/src/providers/vectordb/WeaviateProvider.ts +41 -3
  41. package/src/types/index.ts +26 -0
  42. package/src/utils/ProductExtractor.ts +5 -3
  43. package/src/utils/UITransformer.ts +1345 -534
  44. package/src/utils/synonyms.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retrivora-ai/rag-engine",
3
- "version": "1.8.9",
3
+ "version": "1.9.1",
4
4
  "description": "Retrivora AI is a plug-and-play AI engine for RAG chat experiences — generic vector DB + LLM provider, embeddable or standalone.",
5
5
  "author": "Abhinav Alkuchi",
6
6
  "license": "MIT",
@@ -20,9 +20,9 @@ export function createMarkdownComponents({
20
20
  viewportSize,
21
21
  }: MarkdownComponentsProps): Components {
22
22
  return {
23
- p: ({ ...props }: React.HTMLAttributes<HTMLParagraphElement>) => (
24
- <p className="whitespace-pre-line" {...props} />
25
- ),
23
+ // p: ({ ...props }: React.HTMLAttributes<HTMLParagraphElement>) => (
24
+ // <p className="whitespace-pre-line" {...props} />
25
+ // ),
26
26
  table: ({ ...props }: React.HTMLAttributes<HTMLTableElement>) => {
27
27
  // Fix: intercept table rendering during streaming instead of using sentinel
28
28
  if (isStreaming) {
@@ -22,6 +22,28 @@ import {
22
22
  import { createMarkdownComponents } from './MarkdownComponents';
23
23
  import { UIDispatcher } from './UIDispatcher';
24
24
 
25
+ function normalizePlusSeparatedLists(raw: string): string {
26
+ return raw
27
+ .split('\n')
28
+ .map((line) => {
29
+ const productListLine =
30
+ /[—:]\s*\d+\s+\+\s+[A-Za-z]/.test(line) ||
31
+ /\b(products?|categories?|in stock|out of stock)\b/i.test(line);
32
+ if (!productListLine) return line;
33
+
34
+ return line.replace(/\s+\+\s+(?=[A-Za-z0-9])/g, ', ');
35
+ })
36
+ .join('\n');
37
+ }
38
+
39
+ function stripLeakedAnswerWrappers(raw: string): string {
40
+ return raw
41
+ .replace(/<prose\s+answer(?:\s+in\s+[^>]*)?>\s*([\s\S]*?)\s*<\/prose>/gi, '$1')
42
+ .replace(/<\/?prose(?:\s+answer)?[^>]*>/gi, '')
43
+ .replace(/\n{3,}/g, '\n\n')
44
+ .trim();
45
+ }
46
+
25
47
  // ─── Main component ───────────────────────────────────────────────────────────
26
48
 
27
49
  export function MessageBubble({
@@ -56,6 +78,11 @@ export function MessageBubble({
56
78
  return false;
57
79
  }, [message.uiTransformation, structuredContent.payload]);
58
80
 
81
+ const uiTransformationType = (message.uiTransformation as UITransformationResponse | undefined)?.type;
82
+ const shouldSuppressProductCarousel = Boolean(
83
+ uiTransformationType && !['text', 'product_carousel', 'carousel'].includes(uiTransformationType),
84
+ );
85
+
59
86
  const shouldRenderStructuredOnly = !isUser && hasRichUI && isLikelyUiOnlyMessage(structuredContent.text);
60
87
 
61
88
  // ── Extraction ─────────────────────────────────────────────────────────────
@@ -89,6 +116,26 @@ export function MessageBubble({
89
116
 
90
117
  const processedMarkdown = React.useMemo<string>(() => {
91
118
  let raw = structuredContent.payload ? structuredContent.text : (cleanContent || message.content);
119
+
120
+ // 0. Pre-sanitize: strip $ from non-monetary whole integers.
121
+ // Real prices always have a decimal (e.g. $257.00). A bare $168 or $9680811891595 is NOT a price.
122
+ raw = raw.replace(/\$\s*(\d+)(?!\.\d)/g, '$1');
123
+ raw = normalizePlusSeparatedLists(raw);
124
+ raw = stripLeakedAnswerWrappers(raw);
125
+
126
+ // 0a. Strip bold (**...**) from inline field labels.
127
+ // The LLM wraps field names like **Stock** — 168 or **EAN** — 9680811891595 in bold.
128
+ // We want to strip the ** so it renders as plain text, not bold.
129
+ raw = raw.replace(/\*\*([^*\n]+)\*\*(\s*[—\-–:]\s*)/g, '$1$2');
130
+
131
+ // 0b. Remove duplicate lines (LLM sometimes repeats fields like Stock, EAN twice)
132
+ raw = raw
133
+ .split('\n')
134
+ .filter((line, idx, arr) => {
135
+ const trimmed = line.trim();
136
+ return !trimmed || arr.findIndex(l => l.trim() === trimmed) === idx;
137
+ })
138
+ .join('\n');
92
139
  const hasStructuredUiBlock = Boolean(structuredContent.payload) || /```(?:ui|json|chart)\s*[\s\S]*?"(?:view|chartType|type|data|items|rows|products|results)"\s*:/i.test(raw);
93
140
 
94
141
  // 1. Structural fixes
@@ -194,7 +241,7 @@ export function MessageBubble({
194
241
  const ui = message.uiTransformation as UITransformationResponse;
195
242
  const textContent = (ui.data as { content?: string })?.content ?? '';
196
243
  const shouldShow =
197
- (ui.type === 'table' && allProducts.length === 0) ||
244
+ ui.type === 'table' ||
198
245
  !['text', 'table'].includes(ui.type) ||
199
246
  (ui.type === 'text' && !hasRichUI && allProducts.length === 0 && textContent &&
200
247
  !processedMarkdown.toLowerCase().includes(textContent.trim().toLowerCase()));
@@ -213,7 +260,7 @@ export function MessageBubble({
213
260
  })()}
214
261
 
215
262
  {/* Product Carousel */}
216
- {!isUser && !hasRichUI && allProducts.length > 0 && (
263
+ {!isUser && !hasRichUI && !shouldSuppressProductCarousel && allProducts.length > 0 && (
217
264
  <div className="w-full mt-1">
218
265
  <ProductCarousel
219
266
  products={allProducts}
@@ -6,7 +6,34 @@ import { ShoppingCart, ExternalLink } from 'lucide-react';
6
6
 
7
7
  import { ProductCardProps } from '../types';
8
8
 
9
+ function cleanProductDescription(raw: unknown): string {
10
+ if (raw === null || raw === undefined) return '';
11
+
12
+ const source = String(raw);
13
+ const bodyMatch = source.match(
14
+ /\bBody\s*\(HTML\)\s*:\s*([\s\S]*?)(?=\s*[,.]?\s+\b(?:Handle|Title|Vendor|Type|Tags|Published|Option\d*\s+Name|Option\d*\s+Value|Option|Variant|Image|SKU|Price|Status|Category)\b\s*:|$)/i,
15
+ );
16
+ const descriptionMatch = bodyMatch ?? source.match(
17
+ /\b(?:Description|Summary|Details|Body)\s*:\s*([\s\S]*?)(?=\s*[,.]?\s+\b(?:Handle|Title|Vendor|Type|Tags|Published|Option\d*\s+Name|Option\d*\s+Value|Option|Variant|Image|SKU|Price|Status|Category)\b\s*:|$)/i,
18
+ );
19
+
20
+ return (descriptionMatch?.[1] ?? source)
21
+ .replace(/<[^>]*>/g, ' ')
22
+ .replace(/&nbsp;/gi, ' ')
23
+ .replace(/&amp;/gi, '&')
24
+ .replace(/&quot;/gi, '"')
25
+ .replace(/&#39;/gi, "'")
26
+ .replace(/\[[^\]]*TYPE[^\]]*\]/gi, ' ')
27
+ .replace(/\b(?:Handle|Title|Vendor|Type|Tags|Published|Option\d*\s+Name|Option\d*\s+Value|Option|Variant|Image|SKU|Price|Status|Category)\s*:\s*[^,.\n:]+[,.]?/gi, ' ')
28
+ .replace(/\bBody\s*\(HTML\)\s*:\s*/gi, ' ')
29
+ .replace(/\b(?:Description|Summary|Details|Body|Content)\s*:\s*/gi, ' ')
30
+ .replace(/\s+/g, ' ')
31
+ .trim();
32
+ }
33
+
9
34
  export function ProductCard({ product, primaryColor = '#6366f1', onAddToCart }: ProductCardProps) {
35
+ const description = cleanProductDescription(product.description);
36
+
10
37
  return (
11
38
  <div className="flex-shrink-0 w-full bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-300 group">
12
39
  {/* Product Image */}
@@ -20,7 +47,7 @@ export function ProductCard({ product, primaryColor = '#6366f1', onAddToCart }:
20
47
  className="object-cover group-hover:scale-105 transition-transform duration-500"
21
48
  />
22
49
  ) : (
23
- <div className="text-slate-400 dark:text-white/20">
50
+ <div className="text-slate-400 dark:text-white/20 cursor-pointer" onClick={() => onAddToCart?.(product)}>
24
51
  <ShoppingCart className="w-12 h-12" />
25
52
  </div>
26
53
  )}
@@ -41,23 +68,23 @@ export function ProductCard({ product, primaryColor = '#6366f1', onAddToCart }:
41
68
  </h3>
42
69
  {product.price && (
43
70
  <span className="text-sm font-bold" style={{ color: primaryColor }}>
44
- {typeof product.price === 'number'
45
- ? `$${product.price}`
71
+ {typeof product.price === 'number'
72
+ ? `$${product.price}`
46
73
  : (String(product.price).match(/^[\d.]/) ? `$${product.price}` : product.price)}
47
74
  </span>
48
75
  )}
49
76
  </div>
50
77
 
51
- {product.description && (
78
+ {description && (
52
79
  <p className="text-[11px] text-slate-500 dark:text-white/50 line-clamp-2 leading-relaxed">
53
- {product.description}
80
+ {description}
54
81
  </p>
55
82
  )}
56
83
 
57
84
  <div className="mt-2 flex gap-2">
58
85
  <button
59
86
  onClick={() => onAddToCart?.(product)}
60
- className="flex-1 py-2 px-3 rounded-lg text-[11px] font-medium text-white shadow-sm hover:opacity-90 transition-opacity flex items-center justify-center gap-1.5"
87
+ className="flex-1 py-2 px-3 rounded-lg text-[11px] font-medium text-white shadow-sm hover:opacity-90 transition-opacity flex items-center justify-center gap-1.5 cursor-pointer"
61
88
  style={{ background: primaryColor }}
62
89
  >
63
90
  <ShoppingCart className="w-3 h-3" />
@@ -166,6 +166,7 @@ export function UIDispatcher({ rawContent, primaryColor, accentColor, isStreamin
166
166
  onAddToCart?: (product: Product) => void;
167
167
  viewportSize?: ChatViewportSize;
168
168
  }) {
169
+ console.log('rawContent', rawContent);
169
170
  const result = React.useMemo<{ config: UIConfig } | { error: string } | { loading: true }>(() => {
170
171
  try {
171
172
  const clean = rawContent
@@ -9,6 +9,8 @@ import {
9
9
  Bar,
10
10
  LineChart,
11
11
  Line,
12
+ ScatterChart,
13
+ Scatter,
12
14
  XAxis,
13
15
  YAxis,
14
16
  CartesianGrid,
@@ -22,7 +24,18 @@ import {
22
24
  PolarRadiusAxis,
23
25
  } from 'recharts';
24
26
  import { ProductCarousel } from './ProductCarousel';
25
- import { Product, UITransformationResponse, VisualizationType, PieChartData, BarChartData, LineChartDataPoint, TableData, CarouselProduct } from '../types';
27
+ import {
28
+ Product,
29
+ UITransformationResponse,
30
+ VisualizationType,
31
+ PieChartData,
32
+ BarChartData,
33
+ LineChartDataPoint,
34
+ ScatterPlotDataPoint,
35
+ MetricCardData,
36
+ TableData,
37
+ CarouselProduct,
38
+ } from '../types';
26
39
 
27
40
  import { CHART_COLORS } from './DynamicChart';
28
41
 
@@ -30,6 +43,13 @@ function getColor(index: number): string {
30
43
  return CHART_COLORS[index % CHART_COLORS.length];
31
44
  }
32
45
 
46
+ function stripLeakedAnswerWrappers(raw: string): string {
47
+ return raw
48
+ .replace(/<prose\s+answer(?:\s+in\s+[^>]*)?>\s*([\s\S]*?)\s*<\/prose>/gi, '$1')
49
+ .replace(/<\/?prose(?:\s+answer)?[^>]*>/gi, '')
50
+ .trim();
51
+ }
52
+
33
53
  // ─── Custom Tooltip ───────────────────────────────────────────────────────────
34
54
 
35
55
  interface TooltipPayloadItem {
@@ -145,10 +165,20 @@ function renderVisualization(
145
165
  return <PieChartView data={data as PieChartData[]} isStreaming={isStreaming} />;
146
166
  case 'bar_chart':
147
167
  return <BarChartView data={data as BarChartData[]} primaryColor={primaryColor} isStreaming={isStreaming} />;
168
+ case 'histogram':
169
+ return <BarChartView data={data as BarChartData[]} primaryColor={primaryColor} isStreaming={isStreaming} />;
170
+ case 'horizontal_bar':
171
+ return <BarChartView data={data as BarChartData[]} primaryColor={primaryColor} isStreaming={isStreaming} layout="vertical" />;
148
172
  case 'line_chart':
149
173
  return <LineChartView data={data as LineChartDataPoint[]} primaryColor={primaryColor} isStreaming={isStreaming} />;
174
+ case 'scatter_plot':
175
+ return <ScatterPlotView data={data as ScatterPlotDataPoint[]} primaryColor={primaryColor} isStreaming={isStreaming} />;
150
176
  case 'radar_chart':
151
177
  return <RadarChartView data={data as Record<string, unknown>[]} isStreaming={isStreaming} />;
178
+ case 'metric_card':
179
+ return <MetricCardView data={data as MetricCardData} isStreaming={isStreaming} />;
180
+ case 'geo_map':
181
+ return <BarChartView data={data as BarChartData[]} primaryColor={primaryColor} isStreaming={isStreaming} />;
152
182
  case 'table':
153
183
  return <TableView data={data as TableData} isStreaming={isStreaming} />;
154
184
  case 'product_carousel':
@@ -204,7 +234,12 @@ function PieChartView({ data, isStreaming }: { data: PieChartData[]; isStreaming
204
234
  const chartData = data.map((item) => ({
205
235
  name: item.label,
206
236
  value: item.value,
237
+ inStockCount: item.inStockCount,
238
+ outOfStockCount: item.outOfStockCount,
207
239
  }));
240
+ const hasStockBreakdown = chartData.some(
241
+ (item) => item.inStockCount !== undefined || item.outOfStockCount !== undefined,
242
+ );
208
243
 
209
244
  return (
210
245
  <div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 p-4 shadow-sm">
@@ -228,13 +263,52 @@ function PieChartView({ data, isStreaming }: { data: PieChartData[]; isStreaming
228
263
  <Legend wrapperStyle={{ fontSize: 12 }} verticalAlign="bottom" />
229
264
  </PieChart>
230
265
  </ResponsiveContainer>
266
+ {hasStockBreakdown && (
267
+ <div className="mt-3 grid gap-2 sm:grid-cols-2">
268
+ {chartData.map((item, index) => {
269
+ const inStock = Number(item.inStockCount ?? 0);
270
+ const outOfStock = Number(item.outOfStockCount ?? 0);
271
+ const status = inStock > 0 ? `${inStock} in stock` : 'Out of stock';
272
+ const mutedStatus = outOfStock > 0 ? `${outOfStock} out` : null;
273
+
274
+ return (
275
+ <div
276
+ key={item.name}
277
+ className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-slate-100 px-2.5 py-2 text-xs dark:border-white/10"
278
+ >
279
+ <span className="flex min-w-0 items-center gap-2 text-slate-700 dark:text-white/75">
280
+ <span
281
+ className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
282
+ style={{ background: getColor(index) }}
283
+ />
284
+ <span className="truncate">{item.name}</span>
285
+ </span>
286
+ <span className={inStock > 0 ? 'whitespace-nowrap font-medium text-emerald-600' : 'whitespace-nowrap font-medium text-rose-600'}>
287
+ {status}
288
+ {mutedStatus && <span className="ml-1 text-slate-400">({mutedStatus})</span>}
289
+ </span>
290
+ </div>
291
+ );
292
+ })}
293
+ </div>
294
+ )}
231
295
  </div>
232
296
  );
233
297
  }
234
298
 
235
299
  // ─── Bar Chart ────────────────────────────────────────────────────────────────
236
300
 
237
- function BarChartView({ data, primaryColor, isStreaming }: { data: BarChartData[]; primaryColor?: string; isStreaming?: boolean }) {
301
+ function BarChartView({
302
+ data,
303
+ primaryColor,
304
+ isStreaming,
305
+ layout = 'horizontal',
306
+ }: {
307
+ data: BarChartData[];
308
+ primaryColor?: string;
309
+ isStreaming?: boolean;
310
+ layout?: 'horizontal' | 'vertical';
311
+ }) {
238
312
  if (isStreaming) return <ChartSkeleton type="bar" />;
239
313
  if (!Array.isArray(data) || data.length === 0) {
240
314
  return <div className="text-xs text-red-500">Invalid bar chart data</div>;
@@ -253,15 +327,28 @@ function BarChartView({ data, primaryColor, isStreaming }: { data: BarChartData[
253
327
  return (
254
328
  <div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 p-4 shadow-sm overflow-x-auto">
255
329
  <ResponsiveContainer width="100%" height={260}>
256
- <BarChart data={chartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
330
+ <BarChart
331
+ data={chartData}
332
+ layout={layout}
333
+ margin={{ top: 10, right: 10, left: layout === 'vertical' ? 20 : -20, bottom: 0 }}
334
+ >
257
335
  <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
258
- <XAxis
259
- dataKey="category"
260
- tick={{ fontSize: 11, fill: '#64748b' }}
261
- tickLine={false}
262
- axisLine={{ stroke: '#cbd5e1' }}
263
- />
264
- <YAxis tick={{ fontSize: 11, fill: '#64748b' }} tickLine={false} axisLine={false} />
336
+ {layout === 'vertical' ? (
337
+ <>
338
+ <XAxis type="number" tick={{ fontSize: 11, fill: '#64748b' }} tickLine={false} axisLine={{ stroke: '#cbd5e1' }} />
339
+ <YAxis type="category" dataKey="category" width={110} tick={{ fontSize: 11, fill: '#64748b' }} tickLine={false} axisLine={false} />
340
+ </>
341
+ ) : (
342
+ <>
343
+ <XAxis
344
+ dataKey="category"
345
+ tick={{ fontSize: 11, fill: '#64748b' }}
346
+ tickLine={false}
347
+ axisLine={{ stroke: '#cbd5e1' }}
348
+ />
349
+ <YAxis tick={{ fontSize: 11, fill: '#64748b' }} tickLine={false} axisLine={false} />
350
+ </>
351
+ )}
265
352
  <Tooltip content={<CustomTooltip />} cursor={{ fill: '#f1f5f9' }} />
266
353
  <Legend wrapperStyle={{ fontSize: 12 }} />
267
354
  {hasStock ? (
@@ -278,6 +365,51 @@ function BarChartView({ data, primaryColor, isStreaming }: { data: BarChartData[
278
365
  );
279
366
  }
280
367
 
368
+ // ─── Scatter Plot ─────────────────────────────────────────────────────────────
369
+
370
+ function ScatterPlotView({ data, primaryColor, isStreaming }: { data: ScatterPlotDataPoint[]; primaryColor?: string; isStreaming?: boolean }) {
371
+ if (isStreaming) return <ChartSkeleton type="bar" />;
372
+ if (!Array.isArray(data) || data.length === 0) {
373
+ return <div className="text-xs text-red-500">Invalid scatter plot data</div>;
374
+ }
375
+
376
+ return (
377
+ <div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 p-4 shadow-sm overflow-x-auto">
378
+ <ResponsiveContainer width="100%" height={260}>
379
+ <ScatterChart margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
380
+ <CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
381
+ <XAxis type="number" dataKey="x" tick={{ fontSize: 11, fill: '#64748b' }} tickLine={false} axisLine={{ stroke: '#cbd5e1' }} />
382
+ <YAxis type="number" dataKey="y" tick={{ fontSize: 11, fill: '#64748b' }} tickLine={false} axisLine={false} />
383
+ <Tooltip content={<CustomTooltip />} cursor={{ strokeDasharray: '3 3' }} />
384
+ <Scatter name="Value" data={data} fill={primaryColor ?? getColor(0)} />
385
+ </ScatterChart>
386
+ </ResponsiveContainer>
387
+ </div>
388
+ );
389
+ }
390
+
391
+ // ─── Metric Card ──────────────────────────────────────────────────────────────
392
+
393
+ function MetricCardView({ data, isStreaming }: { data: MetricCardData; isStreaming?: boolean }) {
394
+ if (isStreaming) return <ChartSkeleton type="bar" />;
395
+ if (!data || typeof data.value !== 'number') {
396
+ return <div className="text-xs text-red-500">Invalid metric data</div>;
397
+ }
398
+
399
+ return (
400
+ <div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-slate-900">
401
+ <div className="text-xs font-medium uppercase text-slate-500 dark:text-slate-400">{data.label}</div>
402
+ <div className="mt-2 text-3xl font-semibold text-slate-900 dark:text-white">
403
+ {data.value.toLocaleString()}
404
+ {data.unit && <span className="ml-1 text-base text-slate-500">{data.unit}</span>}
405
+ </div>
406
+ {data.operation && (
407
+ <div className="mt-1 text-xs text-slate-500 dark:text-slate-400">{data.operation}</div>
408
+ )}
409
+ </div>
410
+ );
411
+ }
412
+
281
413
  // ─── Line Chart ───────────────────────────────────────────────────────────────
282
414
 
283
415
  function LineChartView({ data, primaryColor, isStreaming }: { data: LineChartDataPoint[]; primaryColor?: string; isStreaming?: boolean }) {
@@ -451,7 +583,7 @@ function CarouselView({
451
583
  // ─── Text / Prose ─────────────────────────────────────────────────────────────
452
584
 
453
585
  function TextView({ data }: { data: unknown }) {
454
- const content = (data as { content?: string })?.content || String(data ?? '');
586
+ const content = stripLeakedAnswerWrappers((data as { content?: string })?.content || String(data ?? ''));
455
587
 
456
588
  return (
457
589
  <div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 p-4 shadow-sm">
@@ -51,24 +51,25 @@ export class EmbeddingStrategyResolver {
51
51
  llmConfig: LLMConfig,
52
52
  embeddingConfig?: EmbeddingConfig
53
53
  ): EmbeddingStrategy {
54
- // If embedding config not provided, use integrated strategy
54
+ // No explicit embedding config LLM provider must handle embeddings itself.
55
55
  if (!embeddingConfig) {
56
56
  return this.supportsEmbedding(llmConfig.provider)
57
57
  ? EmbeddingStrategy.INTEGRATED
58
58
  : EmbeddingStrategy.SEPARATE;
59
59
  }
60
60
 
61
- // If embedding config differs from LLM provider, use separate
61
+ // Different provider for embeddings (e.g. Anthropic LLM + OpenAI embeddings).
62
62
  if (embeddingConfig.provider !== (llmConfig.provider as EmbeddingProvider)) {
63
63
  return EmbeddingStrategy.SEPARATE;
64
64
  }
65
65
 
66
- // If same provider but different models/configs, use external
66
+ // Same provider, different model (e.g. OpenAI gpt-4o + text-embedding-3-small).
67
+ // Use EXTERNAL so the factory creates a dedicated embedding-model client.
67
68
  if (embeddingConfig.model !== llmConfig.model) {
68
69
  return EmbeddingStrategy.EXTERNAL;
69
70
  }
70
71
 
71
- // Default to integrated
72
+ // Same provider, same model — fully integrated.
72
73
  return EmbeddingStrategy.INTEGRATED;
73
74
  }
74
75
 
@@ -44,8 +44,6 @@ export interface VectorDBConfig {
44
44
  * - searchFields?: string[] | string // optional override for which columns receive exact-match boosts
45
45
  */
46
46
  options: Record<string, unknown>;
47
- /** Optional distance metric (defaults to cosine if not specified and supported by provider) */
48
- distanceMetric?: 'cosine' | 'euclidean' | 'dotproduct';
49
47
  }
50
48
 
51
49
  // ---------------------------------------------------------------------------
@@ -189,6 +187,16 @@ export interface RAGConfig {
189
187
  * e.g., { "name": "ProductName", "price": "SalesPrice", "image": "ThumbnailUrl" }
190
188
  */
191
189
  uiMapping?: Record<string, string>;
190
+ /**
191
+ * Custom keywords that signal graph-based retrieval should be used.
192
+ * Defaults to built-in list (relationship, connect, hierarchy, etc.).
193
+ */
194
+ graphKeywords?: string[];
195
+ /**
196
+ * Custom keywords that signal vector-based retrieval should be used.
197
+ * Defaults to built-in list (find, search, tell me about, etc.).
198
+ */
199
+ vectorKeywords?: string[];
192
200
  }
193
201
 
194
202
  // ---------------------------------------------------------------------------
@@ -126,12 +126,27 @@ export function getEnvConfig(env: Record<string, string | undefined> = process.e
126
126
 
127
127
  const embeddingApiKeyByProvider: Record<string, string | undefined> = {
128
128
  openai: readString(env, 'OPENAI_API_KEY'),
129
+ anthropic: readString(env, 'ANTHROPIC_API_KEY'), // Anthropic needs a separate embedding provider; key kept for completeness
129
130
  gemini: readString(env, 'GEMINI_API_KEY'),
130
131
  ollama: undefined,
131
132
  universal_rest: readString(env, 'EMBEDDING_API_KEY') ?? readString(env, 'OPENAI_API_KEY'),
132
133
  custom: readString(env, 'EMBEDDING_API_KEY') ?? readString(env, 'OPENAI_API_KEY'),
133
134
  };
134
135
 
136
+ /**
137
+ * Provider-aware default models.
138
+ * Applied when LLM_MODEL env var is not explicitly set.
139
+ */
140
+ const DEFAULT_MODEL_BY_PROVIDER: Record<string, string> = {
141
+ openai: 'gpt-4o',
142
+ anthropic: 'claude-3-5-sonnet-20241022',
143
+ gemini: 'gemini-2.0-flash',
144
+ ollama: 'llama3',
145
+ universal_rest: 'default',
146
+ rest: 'default',
147
+ custom: 'default',
148
+ };
149
+
135
150
  return {
136
151
  projectId,
137
152
  vectorDb: {
@@ -144,7 +159,7 @@ export function getEnvConfig(env: Record<string, string | undefined> = process.e
144
159
  },
145
160
  llm: {
146
161
  provider: llmProvider,
147
- model: readString(env, 'LLM_MODEL') ?? 'gpt-4o',
162
+ model: readString(env, 'LLM_MODEL') ?? DEFAULT_MODEL_BY_PROVIDER[llmProvider] ?? 'gpt-4o',
148
163
  apiKey: llmApiKeyByProvider[llmProvider] ?? '',
149
164
  baseUrl: readString(env, 'LLM_BASE_URL'),
150
165
  systemPrompt: readString(env, 'LLM_SYSTEM_PROMPT'),
@@ -0,0 +1,79 @@
1
+ import { ILLMProvider } from '../llm/ILLMProvider';
2
+ import { LLMFactory } from '../llm/LLMFactory';
3
+ import { RagConfig, LLMConfig } from '../config/RagConfig';
4
+
5
+ /**
6
+ * Provider-aware fast model defaults.
7
+ * Maps an LLM provider name to a sensible lightweight model for routing tasks.
8
+ */
9
+ const FAST_MODEL_DEFAULTS: Record<string, string> = {
10
+ openai: 'gpt-4o-mini',
11
+ gemini: 'gemini-2.0-flash',
12
+ anthropic: 'claude-3-haiku-20240307',
13
+ ollama: '', // Ollama has no universal lightweight default — reuse main model
14
+ rest: '',
15
+ universal_rest: '',
16
+ custom: '',
17
+ };
18
+
19
+ /**
20
+ * LLMRouter — manages multiple LLM instances for different task roles.
21
+ *
22
+ * Roles:
23
+ * - 'default' : the main configured LLM (used for chat generation)
24
+ * - 'fast' : a lightweight model for classification/routing tasks (UI decisions, strategy)
25
+ * - 'powerful': reserved for future use (defaults to main model)
26
+ *
27
+ * Provider-agnostic: will never mix provider SDKs (e.g., won't set a Gemini model name on an
28
+ * OpenAI client). If FAST_LLM_MODEL is set in the environment it is applied to the *same* provider.
29
+ */
30
+ export class LLMRouter {
31
+ private models = new Map<string, ILLMProvider>();
32
+
33
+ constructor(private config: RagConfig) {}
34
+
35
+ /**
36
+ * Initialize all LLM roles.
37
+ *
38
+ * @param prebuiltDefault - optional pre-built provider (from EmbeddingStrategyResolver).
39
+ * When provided it is used directly as the 'default' role without re-constructing.
40
+ */
41
+ async initialize(prebuiltDefault?: ILLMProvider): Promise<void> {
42
+ // 1. Seed 'default' — prefer the pre-built provider so we don't double-construct it.
43
+ const defaultModel = prebuiltDefault ?? LLMFactory.create(this.config.llm, this.config.embedding);
44
+ this.models.set('default', defaultModel);
45
+
46
+ // 2. Resolve 'fast' model — always stays on the SAME provider to avoid SDK mismatches.
47
+ const envFastModel = process.env.FAST_LLM_MODEL;
48
+ const providerFastDefault = FAST_MODEL_DEFAULTS[this.config.llm.provider] ?? '';
49
+
50
+ const fastModelName = envFastModel || providerFastDefault;
51
+
52
+ if (fastModelName && fastModelName !== this.config.llm.model) {
53
+ console.log(`[LLMRouter] Fast role → provider="${this.config.llm.provider}" model="${fastModelName}"`);
54
+ const fastConfig: LLMConfig = {
55
+ ...this.config.llm,
56
+ model: fastModelName,
57
+ };
58
+ this.models.set('fast', LLMFactory.create(fastConfig, this.config.embedding));
59
+ } else {
60
+ console.log(`[LLMRouter] Fast role → reusing default model (no lightweight alternative configured).`);
61
+ this.models.set('fast', defaultModel);
62
+ }
63
+
64
+ // 3. 'powerful' defaults to main model (reserved for future use)
65
+ this.models.set('powerful', defaultModel);
66
+ }
67
+
68
+ /**
69
+ * Retrieve a model provider by its task role.
70
+ * Falls back to 'default' if the requested role is not registered.
71
+ */
72
+ get(role: 'fast' | 'powerful' | 'default'): ILLMProvider {
73
+ const provider = this.models.get(role) ?? this.models.get('default');
74
+ if (!provider) {
75
+ throw new Error(`[LLMRouter] No provider registered for role: "${role}". Did you call initialize()?`);
76
+ }
77
+ return provider;
78
+ }
79
+ }