@retrivora-ai/rag-engine 1.5.4 → 1.5.6

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.
@@ -5,6 +5,7 @@ import {
5
5
  BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
6
6
  XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
7
7
  } from 'recharts';
8
+ import { ChatViewportSize } from '../types/props';
8
9
 
9
10
  export interface ChartConfig {
10
11
  type: 'bar' | 'line' | 'pie';
@@ -18,13 +19,28 @@ interface DynamicChartProps {
18
19
  config: ChartConfig;
19
20
  primaryColor?: string;
20
21
  accentColor?: string;
22
+ viewportSize?: ChatViewportSize;
21
23
  }
22
24
 
23
25
  const DEFAULT_COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
24
26
 
25
- export function DynamicChart({ config, primaryColor = '#6366f1', accentColor = '#8b5cf6' }: DynamicChartProps) {
27
+ export function DynamicChart({
28
+ config,
29
+ primaryColor = '#6366f1',
30
+ accentColor = '#8b5cf6',
31
+ viewportSize = 'large',
32
+ }: DynamicChartProps) {
26
33
  const { type, data } = config;
27
34
  const colors = config.colors || [primaryColor, accentColor, ...DEFAULT_COLORS];
35
+ const isCompact = viewportSize === 'compact';
36
+ const isMedium = viewportSize === 'medium';
37
+ const chartHeightClass = isCompact ? 'h-56 min-h-[220px]' : isMedium ? 'h-64 min-h-[250px]' : 'h-72 min-h-[280px]';
38
+ const pieOuterRadius = isCompact ? 56 : isMedium ? 68 : 80;
39
+ const pieLabel = ({ name, percent }: { name?: string; percent?: number }) =>
40
+ isCompact
41
+ ? `${name}`
42
+ : `${name} ${((percent ?? 0) * 100).toFixed(0)}%`;
43
+ const axisTick = { fontSize: isCompact ? 10 : 12, fill: '#64748b' };
28
44
 
29
45
  // CRITICAL: Sanitize the data array to remove any non-primitive values (objects/arrays)
30
46
  // This prevents the "Objects are not valid as a React child" error in Tooltips/Legends
@@ -96,13 +112,87 @@ export function DynamicChart({ config, primaryColor = '#6366f1', accentColor = '
96
112
  // Final sanity check: ensure all keys are strings
97
113
  const finalXKey = String(resolvedXKey);
98
114
  const finalDataKeys = resolvedDataKeys.map(String).filter(k => k !== 'undefined');
115
+ const pieDataKey = finalDataKeys[0] || 'value';
116
+
117
+ const stockAwareColor = (entry: Record<string, string | number | null>, index: number) => {
118
+ const stockStatus = String(
119
+ entry.stockStatus ??
120
+ entry.status ??
121
+ entry.availability ??
122
+ '',
123
+ ).toLowerCase();
124
+ const inStockCount = typeof entry.inStockCount === 'number' ? entry.inStockCount : undefined;
125
+ const outOfStockCount = typeof entry.outOfStockCount === 'number' ? entry.outOfStockCount : undefined;
126
+ const inStockFlag = entry.inStock;
127
+
128
+ if (stockStatus.includes('in stock')) return '#10b981';
129
+ if (stockStatus.includes('out of stock')) return '#f97316';
130
+ if (typeof inStockFlag === 'string' && inStockFlag.toLowerCase() === 'true') return '#10b981';
131
+ if (inStockFlag === 1) return '#10b981';
132
+ if (inStockFlag === 0) return '#f97316';
133
+ if (typeof inStockCount === 'number' || typeof outOfStockCount === 'number') {
134
+ const inCount = inStockCount ?? 0;
135
+ const outCount = outOfStockCount ?? 0;
136
+ if (inCount > outCount) return '#10b981';
137
+ if (outCount > inCount) return '#f97316';
138
+ }
139
+
140
+ return colors[index % colors.length];
141
+ };
142
+
143
+ const tooltipLabelMap: Record<string, string> = {
144
+ value: 'Value',
145
+ count: 'Count',
146
+ inStock: 'In stock',
147
+ inStockCount: 'In stock',
148
+ outOfStockCount: 'Out of stock',
149
+ stockStatus: 'Stock status',
150
+ category: 'Category',
151
+ label: 'Label',
152
+ name: 'Name',
153
+ };
154
+
155
+ const renderTooltip = (
156
+ row: Record<string, string | number | null> | null,
157
+ label?: string,
158
+ ) => {
159
+ if (!row) return null;
160
+
161
+ return (
162
+ <div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs shadow-xl dark:border-white/10 dark:bg-slate-950">
163
+ <p className="mb-2 font-semibold text-slate-800 dark:text-white/90">
164
+ {String(row[finalXKey] ?? label ?? 'Details')}
165
+ </p>
166
+ {Object.entries(row).map(([key, value]) => {
167
+ if (value === null || value === undefined || key === finalXKey) return null;
168
+ if (typeof value === 'object') return null;
169
+
170
+ return (
171
+ <div key={key} className="flex items-center justify-between gap-3 text-slate-600 dark:text-white/70">
172
+ <span>{tooltipLabelMap[key] ?? key}</span>
173
+ <span className="font-medium text-slate-900 dark:text-white/90">{String(value)}</span>
174
+ </div>
175
+ );
176
+ })}
177
+ </div>
178
+ );
179
+ };
180
+
181
+ const getTooltipRow = (payload: unknown): Record<string, string | number | null> | null => {
182
+ if (!Array.isArray(payload) || payload.length === 0) return null;
183
+ const first = payload[0];
184
+ if (!first || typeof first !== 'object' || !('payload' in first)) return null;
185
+
186
+ const row = (first as { payload?: unknown }).payload;
187
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return null;
188
+
189
+ return row as Record<string, string | number | null>;
190
+ };
99
191
 
100
192
  // Handle Pie Chart separately as it has a different structure
101
193
  if (type === 'pie') {
102
- const pieDataKey = finalDataKeys[0] || 'value';
103
-
104
194
  return (
105
- <div className="w-full h-72 min-h-[280px] mt-4 mb-2 select-none overflow-hidden">
195
+ <div className={`w-full ${chartHeightClass} mt-4 mb-2 select-none overflow-hidden`}>
106
196
  <ResponsiveContainer width="99%" height="100%">
107
197
  <PieChart>
108
198
  <Pie
@@ -111,17 +201,15 @@ export function DynamicChart({ config, primaryColor = '#6366f1', accentColor = '
111
201
  nameKey={finalXKey}
112
202
  cx="50%"
113
203
  cy="50%"
114
- outerRadius={80}
115
- label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
204
+ outerRadius={pieOuterRadius}
205
+ label={pieLabel}
116
206
  >
117
- {data.map((entry, index) => (
118
- <Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
207
+ {sanitizedData.map((entry, index) => (
208
+ <Cell key={`cell-${index}`} fill={stockAwareColor(entry, index)} />
119
209
  ))}
120
210
  </Pie>
121
- <Tooltip
122
- contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
123
- />
124
- <Legend />
211
+ <Tooltip content={(props) => renderTooltip(getTooltipRow(props.payload), typeof props.label === 'string' ? props.label : undefined)} />
212
+ <Legend wrapperStyle={{ fontSize: isCompact ? 10 : 12 }} />
125
213
  </PieChart>
126
214
  </ResponsiveContainer>
127
215
  </div>
@@ -130,46 +218,41 @@ export function DynamicChart({ config, primaryColor = '#6366f1', accentColor = '
130
218
 
131
219
  // Bar and Line charts
132
220
  return (
133
- <div className="w-full h-72 min-h-[280px] mt-4 mb-2 select-none overflow-hidden">
221
+ <div className={`w-full ${chartHeightClass} mt-4 mb-2 select-none overflow-hidden`}>
134
222
  <ResponsiveContainer width="99%" height="100%">
135
223
  {type === 'bar' ? (
136
224
  <BarChart data={sanitizedData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
137
225
  <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
138
- <XAxis dataKey={finalXKey} tick={{ fontSize: 12, fill: '#64748b' }} tickLine={false} axisLine={{ stroke: '#cbd5e1' }} />
139
- <YAxis tick={{ fontSize: 12, fill: '#64748b' }} tickLine={false} axisLine={false} />
140
- <Tooltip
141
- contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
142
- cursor={{ fill: '#f1f5f9' }}
143
- />
144
- <Legend wrapperStyle={{ fontSize: 12 }} />
226
+ <XAxis dataKey={finalXKey} tick={axisTick} tickLine={false} axisLine={{ stroke: '#cbd5e1' }} />
227
+ <YAxis tick={axisTick} tickLine={false} axisLine={false} />
228
+ <Tooltip content={(props) => renderTooltip(getTooltipRow(props.payload), typeof props.label === 'string' ? props.label : undefined)} cursor={{ fill: '#f1f5f9' }} />
229
+ <Legend wrapperStyle={{ fontSize: isCompact ? 10 : 12 }} />
145
230
  {finalDataKeys.map((key, index) => (
146
231
  <Bar
147
232
  key={key}
148
233
  dataKey={key}
149
234
  fill={colors[index % colors.length]}
150
235
  radius={[4, 4, 0, 0]}
151
- barSize={Math.max(20, 60 / data.length)}
236
+ barSize={isCompact ? Math.max(14, 42 / data.length) : Math.max(20, 60 / data.length)}
152
237
  />
153
238
  ))}
154
239
  </BarChart>
155
240
  ) : (
156
241
  <LineChart data={sanitizedData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
157
242
  <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
158
- <XAxis dataKey={finalXKey} tick={{ fontSize: 12, fill: '#64748b' }} tickLine={false} axisLine={{ stroke: '#cbd5e1' }} />
159
- <YAxis tick={{ fontSize: 12, fill: '#64748b' }} tickLine={false} axisLine={false} />
160
- <Tooltip
161
- contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
162
- />
163
- <Legend wrapperStyle={{ fontSize: 12 }} />
243
+ <XAxis dataKey={finalXKey} tick={axisTick} tickLine={false} axisLine={{ stroke: '#cbd5e1' }} />
244
+ <YAxis tick={axisTick} tickLine={false} axisLine={false} />
245
+ <Tooltip content={(props) => renderTooltip(getTooltipRow(props.payload), typeof props.label === 'string' ? props.label : undefined)} />
246
+ <Legend wrapperStyle={{ fontSize: isCompact ? 10 : 12 }} />
164
247
  {finalDataKeys.map((key, index) => (
165
248
  <Line
166
249
  key={key}
167
250
  type="monotone"
168
251
  dataKey={key}
169
252
  stroke={colors[index % colors.length]}
170
- strokeWidth={3}
171
- dot={{ r: 4, strokeWidth: 2 }}
172
- activeDot={{ r: 6 }}
253
+ strokeWidth={isCompact ? 2 : 3}
254
+ dot={{ r: isCompact ? 3 : 4, strokeWidth: 2 }}
255
+ activeDot={{ r: isCompact ? 5 : 6 }}
173
256
  />
174
257
  ))}
175
258
  </LineChart>
@@ -5,6 +5,7 @@ import { Bot, User, ChevronDown, ChevronRight } from 'lucide-react';
5
5
  import ReactMarkdown from 'react-markdown';
6
6
  import remarkGfm from 'remark-gfm';
7
7
  import { MessageBubbleProps, Product } from '../types';
8
+ import { ChatViewportSize } from '../types/props';
8
9
  import { SourceCard } from './SourceCard';
9
10
  import { ProductCarousel } from './ProductCarousel';
10
11
  import { DynamicChart } from './DynamicChart';
@@ -90,6 +91,17 @@ function sanitizeJson(raw: string): string {
90
91
  return s;
91
92
  }
92
93
 
94
+ function extractLikelyJsonBlock(raw: string): string {
95
+ const trimmed = raw.trim();
96
+ const objectStart = trimmed.indexOf('{');
97
+ const arrayStart = trimmed.indexOf('[');
98
+
99
+ const starts = [objectStart, arrayStart].filter((index) => index >= 0);
100
+ if (starts.length === 0) return trimmed;
101
+
102
+ return trimmed.slice(Math.min(...starts));
103
+ }
104
+
93
105
  // ─── Tiny helpers ─────────────────────────────────────────────────────────────
94
106
 
95
107
  function resolveImage(data: Record<string, unknown>): string | undefined {
@@ -123,13 +135,16 @@ function normaliseChild(children: React.ReactNode): React.ReactNode {
123
135
  interface TableConfig {
124
136
  type: 'table';
125
137
  title?: string;
138
+ description?: string;
126
139
  columns?: string[]; // preferred: explicit ordered column labels
127
140
  dataKeys?: string[]; // fallback column keys (may omit xAxisKey)
128
141
  xAxisKey?: string; // row-label key to prepend when using dataKeys
129
142
  data: Record<string, unknown>[];
130
143
  }
131
144
 
132
- function DataTable({ config }: { config: TableConfig }) {
145
+ function DataTable({ config, viewportSize = 'large' }: { config: TableConfig; viewportSize?: ChatViewportSize }) {
146
+ const isCompact = viewportSize === 'compact';
147
+ const isMedium = viewportSize === 'medium';
133
148
  const keys = React.useMemo<string[]>(() => {
134
149
  // Priority 1: explicit columns list
135
150
  if (Array.isArray(config.columns) && config.columns.length) {
@@ -168,13 +183,13 @@ function DataTable({ config }: { config: TableConfig }) {
168
183
  )}
169
184
 
170
185
  <div className="overflow-x-auto">
171
- <table className="w-full text-left border-collapse min-w-[320px] text-sm">
186
+ <table className={`w-full text-left border-collapse ${isCompact ? 'min-w-[240px] text-xs' : isMedium ? 'min-w-[280px] text-[13px]' : 'min-w-[320px] text-sm'}`}>
172
187
  <thead className="bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10">
173
188
  <tr>
174
189
  {keys.map(k => (
175
190
  <th
176
191
  key={k}
177
- className="px-4 py-3 font-semibold text-slate-700 dark:text-white/90 whitespace-nowrap"
192
+ className={`${isCompact ? 'px-2.5 py-2' : 'px-4 py-3'} font-semibold text-slate-700 dark:text-white/90 whitespace-nowrap`}
178
193
  >
179
194
  {k}
180
195
  </th>
@@ -190,7 +205,7 @@ function DataTable({ config }: { config: TableConfig }) {
190
205
  {keys.map(k => (
191
206
  <td
192
207
  key={k}
193
- className="px-4 py-3 text-slate-600 dark:text-white/70 whitespace-nowrap"
208
+ className={`${isCompact ? 'px-2.5 py-2' : 'px-4 py-3'} text-slate-600 dark:text-white/70 whitespace-nowrap`}
194
209
  >
195
210
  {formatCell(row[k])}
196
211
  </td>
@@ -216,17 +231,57 @@ function DataTable({ config }: { config: TableConfig }) {
216
231
  interface UIConfig {
217
232
  view: 'chart' | 'carousel' | 'table';
218
233
  chartType?: 'pie' | 'bar' | 'line';
234
+ xAxisKey?: string;
235
+ dataKeys?: string[];
236
+ columns?: string[];
237
+ colors?: string[];
219
238
  data: Record<string, unknown>[];
220
239
  title?: string;
240
+ description?: string;
241
+ insights?: string[];
221
242
  type?: string;
222
243
  items?: Record<string, unknown>[];
223
244
  }
224
245
 
225
- function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming }: {
246
+ interface RawUIConfig extends Omit<UIConfig, 'data'> {
247
+ data: Array<Record<string, unknown> | unknown[]>;
248
+ }
249
+
250
+ function normalizeTabularRows(
251
+ rows: Array<Record<string, unknown> | unknown[]> | undefined,
252
+ columns?: string[],
253
+ ): Record<string, unknown>[] {
254
+ if (!Array.isArray(rows)) return [];
255
+
256
+ return rows
257
+ .map((row) => {
258
+ if (Array.isArray(row)) {
259
+ const keys = Array.isArray(columns) && columns.length > 0
260
+ ? columns
261
+ : row.map((_, index) => `column_${index + 1}`);
262
+
263
+ return keys.reduce<Record<string, unknown>>((acc, key, index) => {
264
+ acc[key] = row[index];
265
+ return acc;
266
+ }, {});
267
+ }
268
+
269
+ if (row && typeof row === 'object') {
270
+ return row as Record<string, unknown>;
271
+ }
272
+
273
+ return { value: row };
274
+ })
275
+ .filter((row) => Object.keys(row).length > 0);
276
+ }
277
+
278
+ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAddToCart, viewportSize = 'large' }: {
226
279
  rawContent: string;
227
280
  primaryColor?: string;
228
281
  accentColor?: string;
229
282
  isStreaming?: boolean;
283
+ onAddToCart?: (product: Product) => void;
284
+ viewportSize?: ChatViewportSize;
230
285
  }) {
231
286
  const result = React.useMemo<{ config: UIConfig } | { error: string } | { loading: true }>(() => {
232
287
  if (isStreaming) return { loading: true };
@@ -235,17 +290,26 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming }: {
235
290
  .replace(/^```[a-z]*\s*/i, '')
236
291
  .replace(/```\s*$/, '')
237
292
  .trim();
238
- const parsed = JSON.parse(sanitizeJson(clean));
239
- const config = { ...parsed };
293
+ const parsed = JSON.parse(sanitizeJson(extractLikelyJsonBlock(clean)));
294
+ const config = { ...parsed } as Partial<RawUIConfig> & Record<string, unknown>;
240
295
 
241
296
  // Smart Auto-Detection for legacy or simpler formats
242
297
  if (!config.view) {
243
298
  if (config.type === 'products' || Array.isArray(config.items)) {
244
299
  config.view = 'carousel';
245
300
  config.data = config.data || config.items || [];
246
- } else if (['pie', 'bar', 'line'].includes(config.type) || ['pie', 'bar', 'line'].includes(config.chartType)) {
301
+ } else if (
302
+ (typeof config.type === 'string' && ['pie', 'bar', 'line'].includes(config.type)) ||
303
+ (typeof config.chartType === 'string' && ['pie', 'bar', 'line'].includes(config.chartType))
304
+ ) {
305
+ const resolvedChartType =
306
+ config.chartType === 'pie' || config.chartType === 'bar' || config.chartType === 'line'
307
+ ? config.chartType
308
+ : config.type === 'pie' || config.type === 'bar' || config.type === 'line'
309
+ ? config.type
310
+ : 'bar';
247
311
  config.view = 'chart';
248
- config.chartType = config.chartType || config.type || 'bar';
312
+ config.chartType = resolvedChartType;
249
313
  config.data = config.data || [];
250
314
  } else {
251
315
  config.view = 'table';
@@ -253,7 +317,12 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming }: {
253
317
  }
254
318
  }
255
319
 
256
- return { config: config as UIConfig };
320
+ const normalizedConfig: UIConfig = {
321
+ ...(config as Omit<UIConfig, 'data'>),
322
+ data: normalizeTabularRows(config.data, config.columns),
323
+ };
324
+
325
+ return { config: normalizedConfig };
257
326
  } catch (err) {
258
327
  return { error: String(err) };
259
328
  }
@@ -273,36 +342,73 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming }: {
273
342
  }
274
343
 
275
344
  const { config } = result;
345
+ const isCompact = viewportSize === 'compact';
346
+ const hasInsights = Array.isArray(config.insights) && config.insights.length > 0;
347
+ const hasDescription = typeof config.description === 'string' && config.description.trim().length > 0;
276
348
 
277
349
  switch (config.view) {
278
350
  case 'chart':
279
351
  return (
280
- <div className="my-6 p-4 bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden">
281
- {config.title && <h4 className="text-xs font-semibold text-slate-500 mb-4 px-2">{config.title}</h4>}
352
+ <div className={`${isCompact ? 'my-4 p-3' : 'my-6 p-4'} bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden`}>
353
+ {config.title && <h4 className={`${isCompact ? 'text-[11px] mb-3' : 'text-xs mb-4'} font-semibold text-slate-500 px-2`}>{config.title}</h4>}
354
+ {hasDescription && (
355
+ <p className={`px-2 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
356
+ )}
282
357
  <DynamicChart
283
358
  config={{
284
359
  type: (config.chartType || 'bar') as 'bar' | 'line' | 'pie',
285
- data: config.data as unknown as Record<string, string | number>[]
360
+ data: config.data as unknown as Record<string, string | number>[],
361
+ xAxisKey: config.xAxisKey,
362
+ dataKeys: config.dataKeys,
363
+ colors: config.colors,
286
364
  }}
287
365
  primaryColor={primaryColor}
288
366
  accentColor={accentColor}
367
+ viewportSize={viewportSize}
289
368
  />
369
+ {hasInsights && (
370
+ <div className="mt-4 flex flex-wrap gap-2 px-2">
371
+ {config.insights?.map((insight) => (
372
+ <span
373
+ key={insight}
374
+ className={`rounded-full border border-emerald-200 bg-emerald-50 ${isCompact ? 'px-2.5 py-1 text-[10px]' : 'px-3 py-1 text-[11px]'} font-medium text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300`}
375
+ >
376
+ {insight}
377
+ </span>
378
+ ))}
379
+ </div>
380
+ )}
290
381
  </div>
291
382
  );
292
383
  case 'carousel':
293
384
  return (
294
385
  <div className="my-4">
386
+ {config.title && <h4 className={`${isCompact ? 'mb-1.5 text-[11px]' : 'mb-2 text-xs'} font-semibold text-slate-500`}>{config.title}</h4>}
387
+ {hasDescription && (
388
+ <p className={`mb-3 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
389
+ )}
295
390
  <ProductCarousel products={(config.data as unknown as Product[]).map(item => ({
296
391
  ...item,
297
392
  image: item.image ?? (typeof resolveImage === 'function' ? resolveImage(item as unknown as Record<string, unknown>) : undefined)
298
- }))} />
393
+ }))} primaryColor={primaryColor} onAddToCart={onAddToCart} />
299
394
  </div>
300
395
  );
301
396
  case 'table':
302
397
  return (
303
- <div className="my-4 p-4 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm overflow-auto max-h-[400px]">
304
- {config.title && <h4 className="text-xs font-semibold text-slate-500 mb-2">{config.title}</h4>}
305
- <DataTable config={{ type: 'table', data: config.data } as TableConfig} />
398
+ <div className={`${isCompact ? 'my-3 p-3 max-h-[320px]' : 'my-4 p-4 max-h-[400px]'} bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm overflow-auto`}>
399
+ {config.title && <h4 className={`${isCompact ? 'text-[11px]' : 'text-xs'} font-semibold text-slate-500 mb-2`}>{config.title}</h4>}
400
+ {hasDescription && (
401
+ <p className={`mb-3 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
402
+ )}
403
+ <DataTable config={{
404
+ type: 'table',
405
+ title: config.title,
406
+ description: config.description,
407
+ columns: config.columns,
408
+ dataKeys: config.dataKeys,
409
+ xAxisKey: config.xAxisKey,
410
+ data: config.data,
411
+ }} viewportSize={viewportSize} />
306
412
  </div>
307
413
  );
308
414
  default:
@@ -321,9 +427,16 @@ export function MessageBubble({
321
427
  primaryColor = '#6366f1',
322
428
  accentColor = '#8b5cf6',
323
429
  onAddToCart,
430
+ viewportSize = 'large',
324
431
  }: MessageBubbleProps) {
325
432
  const isUser = message.role === 'user';
433
+ const isCompact = viewportSize === 'compact';
434
+ const isMedium = viewportSize === 'medium';
326
435
  const [showSources, setShowSources] = React.useState(false);
436
+ const hasStructuredProductBlock = React.useMemo(
437
+ () => /```(?:ui|json|chart)\s*[\s\S]*?"(?:view|type)"\s*:\s*"(?:carousel|products)"/i.test(message.content),
438
+ [message.content],
439
+ );
327
440
 
328
441
  // ── Products from sources ──────────────────────────────────────────────────
329
442
  const productsFromSources = React.useMemo<Product[]>(() => {
@@ -500,6 +613,8 @@ export function MessageBubble({
500
613
  primaryColor={primaryColor}
501
614
  accentColor={accentColor}
502
615
  isStreaming={isStreaming}
616
+ onAddToCart={onAddToCart}
617
+ viewportSize={viewportSize}
503
618
  />
504
619
  );
505
620
  }
@@ -513,6 +628,8 @@ export function MessageBubble({
513
628
  primaryColor={primaryColor}
514
629
  accentColor={accentColor}
515
630
  isStreaming={isStreaming}
631
+ onAddToCart={onAddToCart}
632
+ viewportSize={viewportSize}
516
633
  />
517
634
  );
518
635
  }
@@ -538,27 +655,27 @@ export function MessageBubble({
538
655
  );
539
656
  },
540
657
  }),
541
- [primaryColor, accentColor, isStreaming],
658
+ [primaryColor, accentColor, isStreaming, onAddToCart, viewportSize],
542
659
  );
543
660
 
544
661
  // ── Render ─────────────────────────────────────────────────────────────────
545
662
  return (
546
- <div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'} items-start`}>
663
+ <div className={`flex ${isCompact ? 'gap-2' : 'gap-3'} ${isUser ? 'flex-row-reverse' : 'flex-row'} items-start`}>
547
664
  {/* Avatar */}
548
665
  <div
549
- className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center shadow-lg ${isUser
666
+ className={`flex-shrink-0 ${isCompact ? 'w-7 h-7' : 'w-8 h-8'} rounded-full flex items-center justify-center shadow-lg ${isUser
550
667
  ? 'text-white'
551
668
  : 'bg-slate-100 dark:bg-white/10 text-slate-700 dark:text-white/80'
552
669
  }`}
553
670
  style={isUser ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` } : {}}
554
671
  >
555
- {isUser ? <User className="w-4 h-4 text-white" /> : <Bot className="w-4 h-4" />}
672
+ {isUser ? <User className={`${isCompact ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-white`} /> : <Bot className={`${isCompact ? 'w-3.5 h-3.5' : 'w-4 h-4'}`} />}
556
673
  </div>
557
674
 
558
- <div className={`flex flex-col gap-1 max-w-[90%] ${isUser ? 'items-end' : 'items-start'}`}>
675
+ <div className={`flex flex-col gap-1 ${isCompact ? 'max-w-[94%]' : isMedium ? 'max-w-[92%]' : 'max-w-[90%]'} ${isUser ? 'items-end' : 'items-start'}`}>
559
676
  {/* Bubble */}
560
677
  <div
561
- className={`relative px-4 py-3 rounded-2xl text-sm leading-relaxed shadow-sm dark:shadow-lg ${isUser
678
+ 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
562
679
  ? 'text-white rounded-tr-sm'
563
680
  : 'bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-800 dark:text-white/90 rounded-tl-sm'
564
681
  }`}
@@ -573,7 +690,7 @@ export function MessageBubble({
573
690
  <span className="w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:300ms]" />
574
691
  </span>
575
692
  ) : (
576
- <div className={`prose prose-sm max-w-none ${isUser ? 'prose-invert' : 'dark:prose-invert'}`}>
693
+ <div className={`prose ${isCompact ? 'prose-xs' : 'prose-sm'} max-w-none break-words ${isUser ? 'prose-invert' : 'dark:prose-invert'}`}>
577
694
  <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
578
695
  {processedMarkdown}
579
696
  </ReactMarkdown>
@@ -586,7 +703,7 @@ export function MessageBubble({
586
703
  </div>
587
704
 
588
705
  {/* Product Carousel */}
589
- {!isUser && allProducts.length > 0 && (
706
+ {!isUser && !hasStructuredProductBlock && allProducts.length > 0 && (
590
707
  <div className="w-full mt-1">
591
708
  <ProductCarousel
592
709
  products={allProducts}
@@ -619,4 +736,4 @@ export function MessageBubble({
619
736
  </div>
620
737
  </div>
621
738
  );
622
- }
739
+ }
@@ -44,7 +44,7 @@ export function ProductCarousel({ products, primaryColor = '#6366f1', onAddToCar
44
44
  // Single product: No carousel needed
45
45
  if (products.length === 1) {
46
46
  return (
47
- <div className="my-4 w-[85%] sm:w-[48%] md:w-[31%] min-w-[200px] max-w-[300px] animate-fade-in-up">
47
+ <div className="my-4 w-[min(88%,18rem)] min-w-0 max-w-full animate-fade-in-up">
48
48
  <ProductCard product={products[0]} primaryColor={primaryColor} onAddToCart={onAddToCart} />
49
49
  </div>
50
50
  );
@@ -86,7 +86,7 @@ export function ProductCarousel({ products, primaryColor = '#6366f1', onAddToCar
86
86
  {products.map((product) => (
87
87
  <div
88
88
  key={product.id}
89
- className="snap-start flex-shrink-0 w-[85%] sm:w-[48%] md:w-[31%] min-w-[200px] max-w-[300px]"
89
+ className="snap-start flex-shrink-0 w-[min(88%,18rem)] min-w-[11rem] max-w-full"
90
90
  >
91
91
  <ProductCard product={product} primaryColor={primaryColor} onAddToCart={onAddToCart} />
92
92
  </div>
@@ -101,21 +101,68 @@ 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_V3 -->';
104
+ const CHART_MARKER = '<!-- UI_PROTOCOL_V5 -->';
105
105
  const chartInstruction = `\n\n${CHART_MARKER}
106
- ### UNIVERSAL UI PROTOCOL:
107
- You have a specialized UI system. For any structured data (charts, product lists, or tables), you MUST use a single \`\`\`ui\`\`\` block.
108
- 1. DECIDE the best "view" for the user's query:
109
- - "chart": For statistics, distributions, or comparisons. (Requires "chartType": "pie" | "bar" | "line")
110
- - "carousel": For browsing specific product items.
111
- - "table": For detailed raw data comparisons.
112
- 2. OUTPUT a \`\`\`ui\`\`\` block with this schema:
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.
108
+
109
+ 1. VIEW SELECTION
110
+ - Use "chart" for distributions, percentages, counts, comparisons, trends, or "show me in a chart".
111
+ - Use "carousel" for browseable product recommendations or when the user asks to see products/items/cards.
112
+ - Use "table" for tabular/raw detail, explicit table/list requests, or when there are many attributes to compare.
113
+
114
+ 2. REQUIRED JSON SHAPE
113
115
  {
114
116
  "view": "chart" | "carousel" | "table",
115
- "chartType": "pie" | "bar" | "line" (only for view: "chart"),
116
- "data": [ ... objects ... ]
117
+ "title": "Short heading",
118
+ "description": "One sentence describing the view",
119
+ "chartType": "pie" | "bar" | "line",
120
+ "xAxisKey": "label",
121
+ "dataKeys": ["value"],
122
+ "columns": ["name", "category", "price", "stockStatus"],
123
+ "insights": ["Short takeaway 1", "Short takeaway 2"],
124
+ "data": []
117
125
  }
118
- 3. RULES: Valid JSON only. Data must be FLAT. Never draw charts/tables with text. Choose ONLY the most useful view.`;
126
+
127
+ 3. DATA RULES
128
+ - Build the UI payload from retrieved context only. Do not invent products, categories, or stock values.
129
+ - Aggregate raw product rows when the user asks for a chart.
130
+ - For charts, every row in "data" must be flat JSON with primitive values only.
131
+ - Prefer { "label": "...", "value": number } for pie charts.
132
+ - If stock information exists, preserve it in each row with fields like "inStock", "inStockCount", "outOfStockCount", or "stockStatus".
133
+ - If the user asks to highlight what is in stock, include those stock-related fields in the chart data and mention the pattern in "insights".
134
+ - For carousel rows, include product-friendly fields such as id, name, brand, price, image, link, category, stockStatus.
135
+ - For tables, keep "data" row-oriented and include "columns" when possible.
136
+
137
+ 4. FORMAT RULES
138
+ - Never use ASCII art, markdown tables as the primary answer, or text-only fake charts when a UI block is requested.
139
+ - Put any short natural-language explanation before or after the \`\`\`ui\`\`\` block, but keep it concise.
140
+ - Return valid JSON inside the \`\`\`ui\`\`\` block.
141
+ - If the user explicitly asks for a pie chart, return "view": "chart" with "chartType": "pie", not a table fallback.
142
+ - Do not prefix the JSON with labels like "Table View", "Alternative", or explanatory text inside the code block.
143
+
144
+ 5. EXAMPLE
145
+ User: "provide me the distribution of products across categories and highlight which ones are in stock in a pie chart"
146
+ Assistant:
147
+ \`\`\`ui
148
+ {
149
+ "view": "chart",
150
+ "title": "Product Distribution Across Categories",
151
+ "description": "Pie chart showing product count by category with stock signals preserved per slice.",
152
+ "chartType": "pie",
153
+ "xAxisKey": "label",
154
+ "dataKeys": ["value"],
155
+ "insights": [
156
+ "Beauty has the highest product count.",
157
+ "Electronics has the largest number of in-stock products."
158
+ ],
159
+ "data": [
160
+ { "label": "Beauty", "value": 12, "inStockCount": 9, "outOfStockCount": 3 },
161
+ { "label": "Electronics", "value": 8, "inStockCount": 7, "outOfStockCount": 1 },
162
+ { "label": "Home", "value": 5, "inStockCount": 2, "outOfStockCount": 3 }
163
+ ]
164
+ }
165
+ \`\`\``;
119
166
 
120
167
  if (!this.config.llm.systemPrompt?.includes(CHART_MARKER)) {
121
168
  this.config.llm.systemPrompt = (this.config.llm.systemPrompt || '') + chartInstruction;