@retrivora-ai/rag-engine 1.7.1 → 1.7.3

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],
@@ -703,34 +693,20 @@ export function MessageBubble({
703
693
 
704
694
  // ── Merge & deduplicate products ───────────────────────────────────────────
705
695
  const allProducts = React.useMemo<Product[]>(() => {
706
- const seen = new Set<string>();
707
- const merged: Product[] = [];
708
-
709
- // 1. Add products extracted from the LLM content (highest priority)
710
- for (const p of productsFromContent) {
711
- const id = String(p.id || p.name);
712
- if (!seen.has(id)) {
713
- seen.add(id);
714
- merged.push(p);
715
- }
716
- }
696
+ if (productsFromContent.length > 0) return productsFromContent;
717
697
 
718
- // 2. Add products from sources that were mentioned in the text
698
+ const seen = new Set<string>();
719
699
  const contentLower = message.content.toLowerCase();
720
- for (const p of productsFromSources) {
721
- const id = String(p.id || p.name);
722
- if (seen.has(id)) continue;
723
-
724
- const mentioned = contentLower.includes(p.name.toLowerCase()) ||
725
- (p.brand ? contentLower.includes(p.brand.toLowerCase()) : false);
726
-
727
- if (mentioned) {
728
- seen.add(id);
729
- merged.push(p);
730
- }
731
- }
732
700
 
733
- return merged;
701
+ return productsFromSources.filter(p => {
702
+ const id = String(p.id ?? p.name);
703
+ if (seen.has(id)) return false;
704
+ const mentioned =
705
+ contentLower.includes(p.name.toLowerCase()) ||
706
+ (p.brand ? contentLower.includes(p.brand.toLowerCase()) : false);
707
+ if (mentioned) { seen.add(id); return true; }
708
+ return false;
709
+ });
734
710
  }, [productsFromSources, productsFromContent, message.content]);
735
711
 
736
712
  const processedMarkdown = React.useMemo(() => {
@@ -742,23 +718,17 @@ export function MessageBubble({
742
718
  .replace(/(\|[^\n]*\|)\n\n+(?=\|)/g, '$1\n'); // Remove blank lines between rows
743
719
 
744
720
  // 2. HEALING: Detect naked JSON charts/products and wrap them if markers are missing
745
- // This handles models (like Llama 3.2) that often omit the ```ui markers.
746
- if (!isStreaming && !hasStructuredUiBlock) {
721
+ // This handles models (like Llama 3.2) that often omit the ```chart markers.
722
+ if (!isStreaming) {
747
723
  // Look for objects that look like chart or product data
748
- raw = raw.replace(/(?:^|\n)\s*(\{[\s\S]+?\})(?:\s*\n|$)/g, (match, body) => {
724
+ raw = raw.replace(/(?:^|\n)\s*\{[\s\S]*\}\s*(?:\n|$)/g, (match) => {
749
725
  // Only wrap if not already in a code block
750
726
  if (match.includes('```')) return match;
751
- const trimmedBody = body.trim();
752
- if (!looksLikeStructuredPayload(trimmedBody)) return match;
753
-
754
- const type = trimmedBody.includes('"view"') ||
755
- trimmedBody.includes('"chartType"') ||
756
- trimmedBody.includes('"type"') ||
757
- 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"')
758
729
  ? 'ui'
759
730
  : 'json';
760
-
761
- return `\n\n\`\`\`${type}\n${trimmedBody}\n\`\`\`\n\n`;
731
+ return `\n\n\`\`\`${type}\n${match.trim()}\n\`\`\`\n\n`;
762
732
  });
763
733
  }
764
734
 
@@ -854,6 +824,7 @@ export function MessageBubble({
854
824
  primaryColor={primaryColor}
855
825
  accentColor={accentColor}
856
826
  isStreaming={isStreaming}
827
+ onAddToCart={onAddToCart}
857
828
  viewportSize={viewportSize}
858
829
  />
859
830
  );
@@ -868,6 +839,7 @@ export function MessageBubble({
868
839
  primaryColor={primaryColor}
869
840
  accentColor={accentColor}
870
841
  isStreaming={isStreaming}
842
+ onAddToCart={onAddToCart}
871
843
  viewportSize={viewportSize}
872
844
  />
873
845
  );
@@ -883,6 +855,7 @@ export function MessageBubble({
883
855
  primaryColor={primaryColor}
884
856
  accentColor={accentColor}
885
857
  isStreaming={isStreaming}
858
+ onAddToCart={onAddToCart}
886
859
  viewportSize={viewportSize}
887
860
  />
888
861
  );
@@ -909,7 +882,7 @@ export function MessageBubble({
909
882
  );
910
883
  },
911
884
  }),
912
- [primaryColor, accentColor, isStreaming, viewportSize],
885
+ [primaryColor, accentColor, isStreaming, onAddToCart, viewportSize],
913
886
  );
914
887
 
915
888
  // ── Render ─────────────────────────────────────────────────────────────────
@@ -951,6 +924,7 @@ export function MessageBubble({
951
924
  primaryColor={primaryColor}
952
925
  accentColor={accentColor}
953
926
  isStreaming={isStreaming}
927
+ onAddToCart={onAddToCart}
954
928
  viewportSize={viewportSize}
955
929
  />
956
930
  )}
@@ -969,7 +943,7 @@ export function MessageBubble({
969
943
  </div>
970
944
 
971
945
  {/* Product Carousel */}
972
- {!isUser && allProducts.length > 0 && (
946
+ {!isUser && !hasStructuredProductBlock && allProducts.length > 0 && (
973
947
  <div className="w-full mt-1">
974
948
  <ProductCarousel
975
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
+ }