@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retrivora-ai/rag-engine",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
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",
@@ -71,7 +71,7 @@ export function ChatWindow({
71
71
  const windowRef = useRef<HTMLDivElement>(null);
72
72
  const bottomRef = useRef<HTMLDivElement>(null);
73
73
  const inputRef = useRef<HTMLTextAreaElement>(null);
74
- const { messages, send, clear, isLoading, error } = useRagChat(projectId, {
74
+ const { messages, send, clear, isLoading, error, stop } = useRagChat(projectId, {
75
75
  namespace: projectId,
76
76
  });
77
77
 
@@ -492,19 +492,29 @@ export function ChatWindow({
492
492
  </button>
493
493
  )}
494
494
 
495
- <button
496
- onClick={sendMessage}
497
- disabled={!input.trim() || isLoading}
498
- className="flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center transition-all disabled:opacity-30 disabled:cursor-not-allowed hover:scale-105 active:scale-95 shadow-md"
499
- style={{
500
- background:
501
- input.trim() && !isLoading
502
- ? `linear-gradient(135deg, ${ui.primaryColor}, ${ui.accentColor})`
503
- : 'rgba(148, 163, 184, 0.2)', // slate-400/20 for light mode disabled
504
- }}
505
- >
506
- <ArrowUp className="w-4 h-4 text-white" />
507
- </button>
495
+ {isLoading ? (
496
+ <button
497
+ onClick={stop}
498
+ className="flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center transition-all hover:scale-105 active:scale-95 shadow-md bg-rose-500 hover:bg-rose-600 text-white cursor-pointer"
499
+ title="Stop generating"
500
+ >
501
+ <div className="w-3.5 h-3.5 bg-white rounded-[2px]" />
502
+ </button>
503
+ ) : (
504
+ <button
505
+ onClick={sendMessage}
506
+ disabled={!input.trim()}
507
+ className="flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center transition-all disabled:opacity-30 disabled:cursor-not-allowed hover:scale-105 active:scale-95 shadow-md"
508
+ style={{
509
+ background:
510
+ input.trim()
511
+ ? `linear-gradient(135deg, ${ui.primaryColor}, ${ui.accentColor})`
512
+ : 'rgba(148, 163, 184, 0.2)', // slate-400/20 for light mode disabled
513
+ }}
514
+ >
515
+ <ArrowUp className="w-4 h-4 text-white" />
516
+ </button>
517
+ )}
508
518
  </div>
509
519
  <p className="text-center text-[10px] text-slate-400 dark:text-white/20 mt-2">
510
520
  Press Enter to send · Shift+Enter for new line
@@ -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) {
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import React from 'react';
4
- import { Bot, User, ChevronDown, ChevronRight, Activity } from 'lucide-react';
4
+ import { Bot, User, ChevronDown, ChevronRight, Activity, Copy, Check } from 'lucide-react';
5
5
  import ReactMarkdown from 'react-markdown';
6
6
  import { MessageBubbleProps, Product, UITransformationResponse } from '../types';
7
7
  import { SourceCard } from './SourceCard';
@@ -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({
@@ -39,6 +61,17 @@ export function MessageBubble({
39
61
 
40
62
  const [showSources, setShowSources] = React.useState(false);
41
63
  const [showTrace, setShowTrace] = React.useState(false);
64
+ const [copied, setCopied] = React.useState(false);
65
+
66
+ const handleCopy = React.useCallback(async () => {
67
+ try {
68
+ await navigator.clipboard.writeText(message.content);
69
+ setCopied(true);
70
+ setTimeout(() => setCopied(false), 2000);
71
+ } catch (err) {
72
+ console.error('Failed to copy text:', err);
73
+ }
74
+ }, [message.content]);
42
75
 
43
76
  const structuredContent = React.useMemo(
44
77
  () => isUser ? { payload: null, text: message.content } : extractStructuredPayload(message.content),
@@ -56,6 +89,11 @@ export function MessageBubble({
56
89
  return false;
57
90
  }, [message.uiTransformation, structuredContent.payload]);
58
91
 
92
+ const uiTransformationType = (message.uiTransformation as UITransformationResponse | undefined)?.type;
93
+ const shouldSuppressProductCarousel = Boolean(
94
+ uiTransformationType && !['text', 'product_carousel', 'carousel'].includes(uiTransformationType),
95
+ );
96
+
59
97
  const shouldRenderStructuredOnly = !isUser && hasRichUI && isLikelyUiOnlyMessage(structuredContent.text);
60
98
 
61
99
  // ── Extraction ─────────────────────────────────────────────────────────────
@@ -89,6 +127,26 @@ export function MessageBubble({
89
127
 
90
128
  const processedMarkdown = React.useMemo<string>(() => {
91
129
  let raw = structuredContent.payload ? structuredContent.text : (cleanContent || message.content);
130
+
131
+ // 0. Pre-sanitize: strip $ from non-monetary whole integers.
132
+ // Real prices always have a decimal (e.g. $257.00). A bare $168 or $9680811891595 is NOT a price.
133
+ raw = raw.replace(/\$\s*(\d+)(?!\.\d)/g, '$1');
134
+ raw = normalizePlusSeparatedLists(raw);
135
+ raw = stripLeakedAnswerWrappers(raw);
136
+
137
+ // 0a. Strip bold (**...**) from inline field labels.
138
+ // The LLM wraps field names like **Stock** — 168 or **EAN** — 9680811891595 in bold.
139
+ // We want to strip the ** so it renders as plain text, not bold.
140
+ raw = raw.replace(/\*\*([^*\n]+)\*\*(\s*[—\-–:]\s*)/g, '$1$2');
141
+
142
+ // 0b. Remove duplicate lines (LLM sometimes repeats fields like Stock, EAN twice)
143
+ raw = raw
144
+ .split('\n')
145
+ .filter((line, idx, arr) => {
146
+ const trimmed = line.trim();
147
+ return !trimmed || arr.findIndex(l => l.trim() === trimmed) === idx;
148
+ })
149
+ .join('\n');
92
150
  const hasStructuredUiBlock = Boolean(structuredContent.payload) || /```(?:ui|json|chart)\s*[\s\S]*?"(?:view|chartType|type|data|items|rows|products|results)"\s*:/i.test(raw);
93
151
 
94
152
  // 1. Structural fixes
@@ -140,7 +198,7 @@ export function MessageBubble({
140
198
  <div className={`flex flex-col gap-1 min-w-0 ${isCompact ? 'max-w-[94%]' : isMedium ? 'max-w-[92%]' : 'max-w-[90%]'} ${isUser ? 'items-end' : 'items-start'}`}>
141
199
  {/* Bubble */}
142
200
  <div
143
- className={`relative ${isCompact ? 'px-3 py-2.5 text-[13px]' : 'px-4 py-3 text-sm'} rounded-2xl leading-relaxed shadow-sm dark:shadow-lg ${isUser
201
+ className={`relative group ${isCompact ? 'px-3 py-2.5 text-[13px]' : 'px-4 py-3 text-sm'} rounded-2xl leading-relaxed shadow-sm dark:shadow-lg ${isUser
144
202
  ? 'text-white rounded-tr-sm'
145
203
  : 'bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-800 dark:text-white/90 rounded-tl-sm'
146
204
  }`}
@@ -148,6 +206,20 @@ export function MessageBubble({
148
206
  isUser ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` } : {}
149
207
  }
150
208
  >
209
+ {/* Floating Copy Button for completed assistant messages */}
210
+ {!isUser && !isStreaming && message.content && (
211
+ <button
212
+ onClick={handleCopy}
213
+ className={`absolute top-2 right-2 p-1.5 rounded-lg border transition-all duration-200 cursor-pointer ${
214
+ copied
215
+ ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-500 dark:text-emerald-400'
216
+ : 'bg-slate-500/5 hover:bg-slate-500/10 border-slate-200 dark:border-white/10 text-slate-400 dark:text-white/30 hover:text-slate-600 dark:hover:text-white/60'
217
+ } opacity-0 group-hover:opacity-100 backdrop-blur-sm shadow-sm scale-95 group-hover:scale-100`}
218
+ title={copied ? "Copied!" : "Copy response"}
219
+ >
220
+ {copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
221
+ </button>
222
+ )}
151
223
  {isStreaming && !message.content ? (
152
224
  <span className="flex items-center gap-1 py-1">
153
225
  <span className="w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:0ms]" />
@@ -193,11 +265,21 @@ export function MessageBubble({
193
265
  if (isUser || structuredContent.payload || !message.uiTransformation) return null;
194
266
  const ui = message.uiTransformation as UITransformationResponse;
195
267
  const textContent = (ui.data as { content?: string })?.content ?? '';
268
+
269
+ const isNegativeOrFallbackText =
270
+ textContent.toLowerCase().includes('cannot answer') ||
271
+ textContent.toLowerCase().includes('no relevant data') ||
272
+ textContent.toLowerCase().includes('no data available') ||
273
+ (typeof ui.title === 'string' && ui.title.toLowerCase().includes('no data')) ||
274
+ (typeof ui.description === 'string' && ui.description.toLowerCase().includes('no relevant data'));
275
+
196
276
  const shouldShow =
197
- (ui.type === 'table' && allProducts.length === 0) ||
198
- !['text', 'table'].includes(ui.type) ||
199
- (ui.type === 'text' && !hasRichUI && allProducts.length === 0 && textContent &&
200
- !processedMarkdown.toLowerCase().includes(textContent.trim().toLowerCase()));
277
+ !isNegativeOrFallbackText &&
278
+ (ui.type === 'table' ||
279
+ !['text', 'table'].includes(ui.type) ||
280
+ (ui.type === 'text' && !hasRichUI && allProducts.length === 0 && textContent &&
281
+ !processedMarkdown.toLowerCase().includes(textContent.trim().toLowerCase())));
282
+
201
283
  if (!shouldShow) return null;
202
284
  return (
203
285
  <div className="w-full mt-3">
@@ -213,7 +295,7 @@ export function MessageBubble({
213
295
  })()}
214
296
 
215
297
  {/* Product Carousel */}
216
- {!isUser && !hasRichUI && allProducts.length > 0 && (
298
+ {!isUser && !hasRichUI && !shouldSuppressProductCarousel && allProducts.length > 0 && (
217
299
  <div className="w-full mt-1">
218
300
  <ProductCarousel
219
301
  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 */}
@@ -48,9 +75,9 @@ export function ProductCard({ product, primaryColor = '#6366f1', onAddToCart }:
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
 
@@ -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
 
@@ -187,6 +187,16 @@ export interface RAGConfig {
187
187
  * e.g., { "name": "ProductName", "price": "SalesPrice", "image": "ThumbnailUrl" }
188
188
  */
189
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[];
190
200
  }
191
201
 
192
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'),