@retrivora-ai/rag-engine 1.7.0 → 1.7.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.
@@ -32,8 +32,8 @@ function sanitizeJson(raw: string): string {
32
32
  },
33
33
  );
34
34
 
35
- // 2. Strip JS-style single-line comments, but avoid URL protocols like https://
36
- s = s.replace(/(^|[^\:])\/\/[^\n]*/g, '$1');
35
+ // 2. Strip JS-style single-line comments
36
+ s = s.replace(/\/\/[^\n]*/g, '');
37
37
 
38
38
  // 3. Quote bare (unquoted) object keys
39
39
  s = s.replace(/([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g, '$1"$2":');
@@ -103,7 +103,7 @@ function extractLikelyJsonBlock(raw: string): string {
103
103
  }
104
104
 
105
105
  function looksLikeStructuredPayload(raw: string): boolean {
106
- return /"?(view|chartType|type|data|items|rows|products|results|columns|xAxisKey)"?\s*:/.test(raw);
106
+ return /"?(view|chartType|type|data|items|rows|products|results)"?\s*:/.test(raw);
107
107
  }
108
108
 
109
109
  function stripMarkdownTables(raw: string): string {
@@ -204,23 +204,12 @@ function isLikelyUiOnlyMessage(text: string): boolean {
204
204
  // ─── Tiny helpers ─────────────────────────────────────────────────────────────
205
205
 
206
206
  function resolveImage(data: Record<string, unknown>): string | undefined {
207
- const candidates = ['image', 'img', 'thumbnail', 'url', 'picture', 'link'];
208
- for (const key of candidates) {
207
+ for (const key of ['image', 'img', 'thumbnail']) {
209
208
  const v = data[key];
210
- if (typeof v === 'string' && v.startsWith('http')) return v;
211
- if (v && typeof v === 'object' && !Array.isArray(v)) {
212
- const subVal = (v as Record<string, unknown>).url || (v as Record<string, unknown>).link || (v as Record<string, unknown>).src;
213
- if (typeof subVal === 'string' && subVal.startsWith('http')) return subVal;
214
- }
209
+ if (typeof v === 'string' && v) return v;
215
210
  }
216
- if (Array.isArray(data.images)) {
217
- for (const item of data.images) {
218
- if (typeof item === 'string' && item.startsWith('http')) return item;
219
- if (item && typeof item === 'object') {
220
- const url = item.url || item.link || item.src;
221
- if (typeof url === 'string' && url.startsWith('http')) return url;
222
- }
223
- }
211
+ if (Array.isArray(data.images) && typeof data.images[0] === 'string') {
212
+ return data.images[0];
224
213
  }
225
214
  return undefined;
226
215
  }
@@ -436,21 +425,16 @@ function resolveStructuredRows(config: Partial<RawUIConfig> & Record<string, unk
436
425
  return [];
437
426
  }
438
427
 
439
- function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, viewportSize = 'large' }: {
428
+ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAddToCart, viewportSize = 'large' }: {
440
429
  rawContent: string;
441
430
  primaryColor?: string;
442
431
  accentColor?: string;
443
432
  isStreaming?: boolean;
433
+ onAddToCart?: (product: Product) => void;
444
434
  viewportSize?: ChatViewportSize;
445
435
  }) {
446
- const result = React.useMemo<{ config: UIConfig } | { error: string } | { loading: true; silent?: boolean }>(() => {
447
- if (isStreaming) {
448
- const looksLikeCarousel = rawContent.includes('"view": "carousel"') ||
449
- rawContent.includes('"view":"carousel"') ||
450
- rawContent.includes('"type": "products"') ||
451
- rawContent.includes('"type":"products"');
452
- return { loading: true, silent: looksLikeCarousel };
453
- }
436
+ const result = React.useMemo<{ config: UIConfig } | { error: string } | { loading: true }>(() => {
437
+ if (isStreaming) return { loading: true };
454
438
  try {
455
439
  const clean = rawContent
456
440
  .replace(/^```[a-z]*\s*/i, '')
@@ -461,18 +445,18 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, view
461
445
  const resolvedRows = resolveStructuredRows(config);
462
446
 
463
447
  // ─── Intent Healing & Auto-Detection ───────────────────────────────────
464
-
465
- const hasProductLikeData = resolvedRows.length > 0 &&
466
- typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
448
+
449
+ const hasProductLikeData = resolvedRows.length > 0 &&
450
+ typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
467
451
  !Array.isArray(resolvedRows[0]) &&
468
- (['price', 'brand', 'image', 'link', 'thumbnail'].some(key => key in resolvedRows[0]) ||
469
- Object.keys(resolvedRows[0]).length >= 4);
452
+ (['price', 'brand', 'image', 'link', 'thumbnail'].some(key => key in resolvedRows[0]) ||
453
+ Object.keys(resolvedRows[0]).length >= 4);
470
454
 
471
- const hasAggregationLikeData = resolvedRows.length > 0 &&
472
- typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
455
+ const hasAggregationLikeData = resolvedRows.length > 0 &&
456
+ typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
473
457
  !Array.isArray(resolvedRows[0]) &&
474
- (['value', 'count', 'total', 'percentage'].some(key => key in resolvedRows[0]) ||
475
- (Object.keys(resolvedRows[0]).length <= 3 && Object.values(resolvedRows[0]).some(v => typeof v === 'number')));
458
+ (['value', 'count', 'total', 'percentage'].some(key => key in resolvedRows[0]) ||
459
+ (Object.keys(resolvedRows[0]).length <= 3 && Object.values(resolvedRows[0]).some(v => typeof v === 'number')));
476
460
 
477
461
  // 1. Healing: Override explicit view if it contradicts the data shape
478
462
  if (config.view === 'chart' && hasProductLikeData && !hasAggregationLikeData) {
@@ -492,8 +476,8 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, view
492
476
  (typeof config.chartType === 'string' && ['pie', 'bar', 'line'].includes(config.chartType))
493
477
  ) {
494
478
  config.view = 'chart';
495
- config.chartType = (config.chartType as 'pie' | 'bar' | 'line') ||
496
- (config.type as 'pie' | 'bar' | 'line') || 'bar';
479
+ config.chartType = (config.chartType as 'pie' | 'bar' | 'line') ||
480
+ (config.type as 'pie' | 'bar' | 'line') || 'bar';
497
481
  } else {
498
482
  config.view = 'table';
499
483
  }
@@ -511,7 +495,6 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, view
511
495
  }, [rawContent, isStreaming]);
512
496
 
513
497
  if ('loading' in result) {
514
- if (result.silent) return null;
515
498
  return (
516
499
  <div className="my-4 p-8 bg-slate-50 dark:bg-white/5 rounded-xl border border-dashed border-slate-300 dark:border-white/10 flex flex-col items-center justify-center gap-3 animate-pulse">
517
500
  <div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
@@ -564,14 +547,16 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, view
564
547
  </div>
565
548
  );
566
549
  case 'carousel':
567
- // We only render title/description here. The actual carousel is rendered separately
568
- // at the bottom of the MessageBubble to avoid being trapped in the streaming loading box.
569
550
  return (
570
551
  <div className="my-4">
571
552
  {config.title && <h4 className={`${isCompact ? 'mb-1.5 text-[11px]' : 'mb-2 text-xs'} font-semibold text-slate-500`}>{config.title}</h4>}
572
553
  {hasDescription && (
573
554
  <p className={`mb-3 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
574
555
  )}
556
+ <ProductCarousel products={(config.data as unknown as Product[]).map(item => ({
557
+ ...item,
558
+ image: item.image ?? (typeof resolveImage === 'function' ? resolveImage(item as unknown as Record<string, unknown>) : undefined)
559
+ }))} primaryColor={primaryColor} onAddToCart={onAddToCart} />
575
560
  </div>
576
561
  );
577
562
  case 'table':
@@ -614,6 +599,11 @@ export function MessageBubble({
614
599
  const isCompact = viewportSize === 'compact';
615
600
  const isMedium = viewportSize === 'medium';
616
601
  const [showSources, setShowSources] = React.useState(false);
602
+ const hasStructuredProductBlock = React.useMemo(
603
+ () => /```(?:ui|json|chart)\s*[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products|table)"?/i.test(message.content) ||
604
+ (/\{[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products)"?[\s\S]*\}/i.test(message.content) && looksLikeStructuredPayload(message.content)),
605
+ [message.content],
606
+ );
617
607
  const structuredContent = React.useMemo(
618
608
  () => isUser ? { payload: null, text: message.content } : extractStructuredPayload(message.content),
619
609
  [isUser, message.content],
@@ -728,23 +718,17 @@ export function MessageBubble({
728
718
  .replace(/(\|[^\n]*\|)\n\n+(?=\|)/g, '$1\n'); // Remove blank lines between rows
729
719
 
730
720
  // 2. HEALING: Detect naked JSON charts/products and wrap them if markers are missing
731
- // This handles models (like Llama 3.2) that often omit the ```ui markers.
732
- if (!isStreaming && !hasStructuredUiBlock) {
721
+ // This handles models (like Llama 3.2) that often omit the ```chart markers.
722
+ if (!isStreaming) {
733
723
  // Look for objects that look like chart or product data
734
- raw = raw.replace(/(?:^|\n)\s*(\{[\s\S]+?\})(?:\s*\n|$)/g, (match, body) => {
724
+ raw = raw.replace(/(?:^|\n)\s*\{[\s\S]*\}\s*(?:\n|$)/g, (match) => {
735
725
  // Only wrap if not already in a code block
736
726
  if (match.includes('```')) return match;
737
- const trimmedBody = body.trim();
738
- if (!looksLikeStructuredPayload(trimmedBody)) return match;
739
-
740
- const type = trimmedBody.includes('"view"') ||
741
- trimmedBody.includes('"chartType"') ||
742
- trimmedBody.includes('"type"') ||
743
- trimmedBody.includes('"xAxisKey"')
727
+ if (!looksLikeStructuredPayload(match)) return match;
728
+ const type = match.includes('"view"') || match.includes('"chartType"') || match.includes('"type": "pie"') || match.includes('"type":"pie"') || match.includes('"type": "bar"') || match.includes('"type":"bar"') || match.includes('"type": "line"') || match.includes('"type":"line"')
744
729
  ? 'ui'
745
730
  : 'json';
746
-
747
- return `\n\n\`\`\`${type}\n${trimmedBody}\n\`\`\`\n\n`;
731
+ return `\n\n\`\`\`${type}\n${match.trim()}\n\`\`\`\n\n`;
748
732
  });
749
733
  }
750
734
 
@@ -840,6 +824,7 @@ export function MessageBubble({
840
824
  primaryColor={primaryColor}
841
825
  accentColor={accentColor}
842
826
  isStreaming={isStreaming}
827
+ onAddToCart={onAddToCart}
843
828
  viewportSize={viewportSize}
844
829
  />
845
830
  );
@@ -854,6 +839,7 @@ export function MessageBubble({
854
839
  primaryColor={primaryColor}
855
840
  accentColor={accentColor}
856
841
  isStreaming={isStreaming}
842
+ onAddToCart={onAddToCart}
857
843
  viewportSize={viewportSize}
858
844
  />
859
845
  );
@@ -869,6 +855,7 @@ export function MessageBubble({
869
855
  primaryColor={primaryColor}
870
856
  accentColor={accentColor}
871
857
  isStreaming={isStreaming}
858
+ onAddToCart={onAddToCart}
872
859
  viewportSize={viewportSize}
873
860
  />
874
861
  );
@@ -895,7 +882,7 @@ export function MessageBubble({
895
882
  );
896
883
  },
897
884
  }),
898
- [primaryColor, accentColor, isStreaming, viewportSize],
885
+ [primaryColor, accentColor, isStreaming, onAddToCart, viewportSize],
899
886
  );
900
887
 
901
888
  // ── Render ─────────────────────────────────────────────────────────────────
@@ -937,6 +924,7 @@ export function MessageBubble({
937
924
  primaryColor={primaryColor}
938
925
  accentColor={accentColor}
939
926
  isStreaming={isStreaming}
927
+ onAddToCart={onAddToCart}
940
928
  viewportSize={viewportSize}
941
929
  />
942
930
  )}
@@ -955,7 +943,7 @@ export function MessageBubble({
955
943
  </div>
956
944
 
957
945
  {/* Product Carousel */}
958
- {!isUser && allProducts.length > 0 && (
946
+ {!isUser && !hasStructuredProductBlock && allProducts.length > 0 && (
959
947
  <div className="w-full mt-1">
960
948
  <ProductCarousel
961
949
  products={allProducts}
@@ -0,0 +1,341 @@
1
+ 'use client';
2
+
3
+ import Image from 'next/image';
4
+ import {
5
+ UITransformationResponse,
6
+ VisualizationType,
7
+ PieChartData,
8
+ BarChartData,
9
+ LineChartDataPoint,
10
+ TableData,
11
+ CarouselProduct,
12
+ } from '@/utils/UITransformer';
13
+
14
+ /**
15
+ * Props for the VisualizationRenderer component
16
+ */
17
+ interface VisualizationRendererProps {
18
+ data: UITransformationResponse;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * VisualizationRenderer — Renders UI transformations as React components
24
+ *
25
+ * Dynamically selects the appropriate visualization component based on the
26
+ * transformation type returned by UITransformer.
27
+ *
28
+ * @example
29
+ * const transformation = UITransformer.transform(query, results);
30
+ * return <VisualizationRenderer data={transformation} />;
31
+ */
32
+ export function VisualizationRenderer({
33
+ data,
34
+ className = '',
35
+ }: VisualizationRendererProps) {
36
+ if (!data) {
37
+ return (
38
+ <div className={`p-4 text-gray-500 ${className}`}>
39
+ No data available for visualization
40
+ </div>
41
+ );
42
+ }
43
+
44
+ return (
45
+ <div className={`space-y-4 ${className}`}>
46
+ <div className="space-y-2">
47
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
48
+ {data.title}
49
+ </h3>
50
+ {data.description && (
51
+ <p className="text-sm text-gray-600 dark:text-gray-400">
52
+ {data.description}
53
+ </p>
54
+ )}
55
+ </div>
56
+
57
+ {renderVisualization(data.type, data.data)}
58
+ </div>
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Internal helper to render the appropriate visualization
64
+ */
65
+ function renderVisualization(type: VisualizationType, data: unknown): React.ReactNode {
66
+ switch (type) {
67
+ case 'pie_chart':
68
+ return renderPieChart(data as PieChartData[]);
69
+ case 'bar_chart':
70
+ return renderBarChart(data as BarChartData[]);
71
+ case 'line_chart':
72
+ return renderLineChart(data as LineChartDataPoint[]);
73
+ case 'table':
74
+ return renderTable(data as TableData);
75
+ case 'product_carousel':
76
+ return renderProductCarousel(data as CarouselProduct[]);
77
+ case 'text':
78
+ default:
79
+ return renderText(data);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Render pie chart
85
+ */
86
+ function renderPieChart(data: PieChartData[]): React.ReactNode {
87
+ if (!Array.isArray(data)) {
88
+ return <div className="text-red-500">Invalid pie chart data</div>;
89
+ }
90
+
91
+ return (
92
+ <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
93
+ <div className="flex flex-wrap gap-2">
94
+ {data.map((item: PieChartData, idx: number) => (
95
+ <div
96
+ key={idx}
97
+ className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700 rounded"
98
+ >
99
+ <div
100
+ className="w-3 h-3 rounded-full"
101
+ style={{ backgroundColor: generateColor(idx) }}
102
+ />
103
+ <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
104
+ {item.label}: {item.value}
105
+ </span>
106
+ {item.inStock !== undefined && (
107
+ <span
108
+ className={`text-xs px-2 py-1 rounded ${
109
+ item.inStock
110
+ ? 'bg-green-100 text-green-800'
111
+ : 'bg-red-100 text-red-800'
112
+ }`}
113
+ >
114
+ {item.inStock ? 'In Stock' : 'Out of Stock'}
115
+ </span>
116
+ )}
117
+ </div>
118
+ ))}
119
+ </div>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Render bar chart
126
+ */
127
+ function renderBarChart(data: BarChartData[]): React.ReactNode {
128
+ if (!Array.isArray(data)) {
129
+ return <div className="text-red-500">Invalid bar chart data</div>;
130
+ }
131
+
132
+ return (
133
+ <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow overflow-x-auto">
134
+ <div className="space-y-3">
135
+ {data.map((item: BarChartData, idx: number) => (
136
+ <div key={idx} className="space-y-1">
137
+ <div className="flex justify-between items-center">
138
+ <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
139
+ {item.category}
140
+ </span>
141
+ <span className="text-sm text-gray-600 dark:text-gray-400">
142
+ {item.value}
143
+ </span>
144
+ </div>
145
+ <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
146
+ <div
147
+ className="h-full rounded-full transition-all"
148
+ style={{
149
+ width: `${(item.value / Math.max(...data.map((d: BarChartData) => d.value))) * 100}%`,
150
+ backgroundColor: generateColor(idx),
151
+ }}
152
+ />
153
+ </div>
154
+ {item.inStock !== undefined && (
155
+ <span
156
+ className={`text-xs px-2 py-1 rounded inline-block ${
157
+ item.inStock
158
+ ? 'bg-green-100 text-green-800'
159
+ : 'bg-red-100 text-red-800'
160
+ }`}
161
+ >
162
+ {item.inStock ? 'In Stock' : 'Out of Stock'}
163
+ </span>
164
+ )}
165
+ </div>
166
+ ))}
167
+ </div>
168
+ </div>
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Render line chart (simplified text representation)
174
+ */
175
+ function renderLineChart(data: LineChartDataPoint[]): React.ReactNode {
176
+ if (!Array.isArray(data)) {
177
+ return <div className="text-red-500">Invalid line chart data</div>;
178
+ }
179
+
180
+ return (
181
+ <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow overflow-x-auto">
182
+ <div className="space-y-2">
183
+ {data.map((point: LineChartDataPoint, idx: number) => (
184
+ <div
185
+ key={idx}
186
+ className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-700 rounded"
187
+ >
188
+ <span className="text-sm text-gray-600 dark:text-gray-400">
189
+ {point.label || point.timestamp}
190
+ </span>
191
+ <span className="text-sm font-semibold text-gray-900 dark:text-white">
192
+ {typeof point.value === 'number' ? point.value.toFixed(2) : point.value}
193
+ </span>
194
+ </div>
195
+ ))}
196
+ </div>
197
+ </div>
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Render table
203
+ */
204
+ function renderTable(data: TableData): React.ReactNode {
205
+ if (!data.columns || !data.rows) {
206
+ return <div className="text-red-500">Invalid table data</div>;
207
+ }
208
+
209
+ return (
210
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
211
+ <table className="w-full text-sm">
212
+ <thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
213
+ <tr>
214
+ {data.columns.map((col: string, idx: number) => (
215
+ <th
216
+ key={idx}
217
+ className="px-4 py-2 text-left font-semibold text-gray-900 dark:text-white"
218
+ >
219
+ {col}
220
+ </th>
221
+ ))}
222
+ </tr>
223
+ </thead>
224
+ <tbody className="divide-y divide-gray-200 dark:divide-gray-600">
225
+ {data.rows.map((row: (string | number | boolean)[], rowIdx: number) => (
226
+ <tr
227
+ key={rowIdx}
228
+ className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
229
+ >
230
+ {row.map((cell: string | number | boolean, cellIdx: number) => (
231
+ <td
232
+ key={cellIdx}
233
+ className="px-4 py-2 text-gray-700 dark:text-gray-300"
234
+ >
235
+ {String(cell)}
236
+ </td>
237
+ ))}
238
+ </tr>
239
+ ))}
240
+ </tbody>
241
+ </table>
242
+ </div>
243
+ );
244
+ }
245
+
246
+ /**
247
+ * Render product carousel
248
+ */
249
+ function renderProductCarousel(data: CarouselProduct[]): React.ReactNode {
250
+ if (!Array.isArray(data)) {
251
+ return <div className="text-red-500">Invalid carousel data</div>;
252
+ }
253
+
254
+ return (
255
+ <div className="overflow-x-auto">
256
+ <div className="flex gap-4 pb-4">
257
+ {data.map((product: CarouselProduct) => (
258
+ <div
259
+ key={product.id}
260
+ className="flex-shrink-0 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
261
+ >
262
+ {product.image && (
263
+ <div className="relative w-full h-40 bg-gray-200">
264
+ <Image
265
+ src={product.image}
266
+ alt={product.name}
267
+ fill
268
+ className="object-cover"
269
+ sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
270
+ />
271
+ </div>
272
+ )}
273
+ <div className="p-4 space-y-2">
274
+ <h4 className="font-semibold text-gray-900 dark:text-white line-clamp-2">
275
+ {product.name}
276
+ </h4>
277
+ {product.brand && (
278
+ <p className="text-xs text-gray-500 dark:text-gray-400">
279
+ Brand: {product.brand}
280
+ </p>
281
+ )}
282
+ {product.price && (
283
+ <p className="text-lg font-bold text-gray-900 dark:text-white">
284
+ ${typeof product.price === 'number' ? product.price.toFixed(2) : product.price}
285
+ </p>
286
+ )}
287
+ {product.description && (
288
+ <p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
289
+ {product.description}
290
+ </p>
291
+ )}
292
+ <div className="flex gap-2 pt-2">
293
+ <span
294
+ className={`text-xs px-2 py-1 rounded flex-1 text-center ${
295
+ product.inStock
296
+ ? 'bg-green-100 text-green-800'
297
+ : 'bg-red-100 text-red-800'
298
+ }`}
299
+ >
300
+ {product.inStock ? 'In Stock' : 'Out of Stock'}
301
+ </span>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ ))}
306
+ </div>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ /**
312
+ * Render text
313
+ */
314
+ function renderText(data: unknown): React.ReactNode {
315
+ const content = (data as { content?: string })?.content || String(data);
316
+
317
+ return (
318
+ <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow prose dark:prose-invert max-w-none">
319
+ <p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
320
+ {content}
321
+ </p>
322
+ </div>
323
+ );
324
+ }
325
+
326
+ /**
327
+ * Utility to generate consistent colors for charts
328
+ */
329
+ function generateColor(index: number): string {
330
+ const colors = [
331
+ '#3B82F6', // blue
332
+ '#10B981', // green
333
+ '#F59E0B', // amber
334
+ '#EF4444', // red
335
+ '#8B5CF6', // purple
336
+ '#EC4899', // pink
337
+ '#06B6D4', // cyan
338
+ '#F97316', // orange
339
+ ];
340
+ return colors[index % colors.length];
341
+ }
@@ -101,16 +101,15 @@ export class Pipeline {
101
101
 
102
102
  // Augment system prompt with a Universal Visualization Protocol
103
103
  // We use a unique marker to ensure this is only injected once but is ALWAYS present.
104
- const CHART_MARKER = '<!-- UI_PROTOCOL_V7 -->';
104
+ const CHART_MARKER = '<!-- UI_PROTOCOL_V6 -->';
105
105
  const chartInstruction = `\n\n${CHART_MARKER}
106
- ### UNIVERSAL UI PROTOCOL (MANDATORY)
107
- CRITICAL: When providing visual data (carousels, charts, tables), you MUST wrap the JSON payload in a \`\`\`ui\`\`\` code block.
108
- NEVER output naked JSON. If you provide a visualization, do not also provide a markdown table of the same data unless explicitly asked.
106
+ ### UNIVERSAL UI PROTOCOL
107
+ When the user asks for a visual representation, comparison, distribution, breakdown, table, list, catalog, products, carousel, stock view, or category summary, you MUST include exactly one \`\`\`ui\`\`\` block.
109
108
 
110
109
  1. VIEW SELECTION
111
- - Use "carousel" for browsing products, items, or catalogs. (e.g., "show me beauty products").
112
- - Use "chart" ONLY for numerical aggregations (counts, sums, percentages).
113
- - Use "table" for technical specs or row-heavy comparisons.
110
+ - Use "carousel" for browsing specific products, items, or a catalog. This is the DEFAULT for "show me", "what are", or "find me" requests for a specific category (e.g. "beauty").
111
+ - Use "chart" ONLY for numerical aggregations: distributions, percentages, counts, comparisons over time, or trends (e.g. "how many beauty products?").
112
+ - Use "table" for detailed technical specifications, row-heavy comparisons, or when the user explicitly asks for a list/table format.
114
113
 
115
114
  2. REQUIRED JSON SHAPE
116
115
  {
@@ -118,31 +117,52 @@ NEVER output naked JSON. If you provide a visualization, do not also provide a m
118
117
  "title": "Short heading",
119
118
  "description": "One sentence describing the view",
120
119
  "chartType": "pie" | "bar" | "line", // Required if view is "chart"
121
- "data": [] // Array of objects
120
+ "xAxisKey": "label",
121
+ "dataKeys": ["value"],
122
+ "columns": ["dynamicFieldKey1", "dynamicFieldKey2"],
123
+ "insights": ["Short takeaway 1", "Short takeaway 2"],
124
+ "data": []
122
125
  }
123
126
 
124
127
  3. DATA RULES
125
- - Build the UI payload from retrieved context and metadata.
126
- - For CAROUSEL: "data" items MUST have { id, name, brand, price, image, link }.
127
- - IMPORTANT: Extract "image" and "price" from Source metadata. Image URLs are often in fields like "images", "thumbnail", or "img".
128
+ - Build the UI payload from retrieved context only. Do not invent products or values.
129
+ - For CAROUSEL: "data" must be a list of product objects (id, name, brand, price, image, link).
130
+ - For CHART: "data" must be aggregated counts or sums. Never put raw product lists in a chart view.
131
+ - If the user asks for "products in beauty", do NOT return a pie chart of categories; return a carousel of the beauty products themselves.
128
132
 
129
133
  4. FORMAT RULES
130
- - Wrap the JSON in \`\`\`ui ... \`\`\` blocks.
131
- - Place natural language text OUTSIDE the block.
134
+ - Return valid JSON inside the \`\`\`ui\`\`\` block.
135
+ - Put natural language explanation before or after the block, not inside.
132
136
 
133
- 5. EXAMPLE
134
- User: "find me beauty products"
135
- Assistant: "I found several beauty products in our catalog:"
137
+ 5. EXAMPLES
138
+ User: "show me beauty products"
139
+ Assistant:
136
140
  \`\`\`ui
137
141
  {
138
142
  "view": "carousel",
139
143
  "title": "Beauty Products",
140
- "description": "Latest skincare and makeup items.",
144
+ "description": "Browse our latest skincare and makeup collection.",
141
145
  "data": [
142
- { "id": "1", "name": "Mascara", "brand": "Essence", "price": "$9.99", "image": "https://placehold.co/600x400?text=Product", "link": "https://example.com/product" }
146
+ { "id": "1", "name": "Mascara", "brand": "Essence", "price": "$9.99", "image": "...", "link": "..." },
147
+ { "id": "2", "name": "Lipstick", "brand": "Chic", "price": "$12.99", "image": "...", "link": "..." }
143
148
  ]
144
149
  }
145
150
  \`\`\`
151
+
152
+ User: "breakdown of products by category"
153
+ Assistant:
154
+ \`\`\`ui
155
+ {
156
+ "view": "chart",
157
+ "title": "Category Breakdown",
158
+ "chartType": "pie",
159
+ "xAxisKey": "label",
160
+ "dataKeys": ["value"],
161
+ "data": [
162
+ { "label": "Beauty", "value": 15 },
163
+ { "label": "Fragrances", "value": 8 }
164
+ ]
165
+ }
146
166
  \`\`\``;
147
167
 
148
168
  if (!this.config.llm.systemPrompt?.includes(CHART_MARKER)) {
@@ -387,7 +407,7 @@ Assistant: "I found several beauty products in our catalog:"
387
407
 
388
408
  // 4. Context Augmentation
389
409
  let context = sources.length
390
- ? sources.map((m, i) => `[Source ${i + 1}]\nContent: ${m.content}\nMetadata: ${JSON.stringify(m.metadata)}`).join('\n\n---\n\n')
410
+ ? sources.map((m, i) => `[Source ${i + 1}]\n${m.content}`).join('\n\n---\n\n')
391
411
  : 'No relevant context found.';
392
412
 
393
413
  if (graphData && graphData.nodes.length > 0) {