@retrivora-ai/rag-engine 1.8.0 → 1.8.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 (71) hide show
  1. package/dist/DocumentChunker-Dh9TvmGG.d.mts +45 -0
  2. package/dist/DocumentChunker-Dh9TvmGG.d.ts +45 -0
  3. package/dist/{index-DPsQodME.d.mts → ILLMProvider-BOJFz3Na.d.mts} +104 -1
  4. package/dist/{index-DPsQodME.d.ts → ILLMProvider-BOJFz3Na.d.ts} +104 -1
  5. package/dist/MultiTablePostgresProvider-ZLGSKTJR.mjs +8 -0
  6. package/dist/chunk-ICKRMZQK.mjs +76 -0
  7. package/dist/{chunk-PV3MFHWU.mjs → chunk-LZVVLSDN.mjs} +977 -516
  8. package/dist/chunk-OZFBG4BA.mjs +291 -0
  9. package/dist/handlers/index.d.mts +2 -2
  10. package/dist/handlers/index.d.ts +2 -2
  11. package/dist/handlers/index.js +1269 -656
  12. package/dist/handlers/index.mjs +4 -1
  13. package/dist/{index-Bb2yEopi.d.mts → index-BwpcaziY.d.ts} +10 -2
  14. package/dist/{index-CkbTzj9J.d.ts → index-D3V9Et2M.d.mts} +10 -2
  15. package/dist/index.d.mts +24 -4
  16. package/dist/index.d.ts +24 -4
  17. package/dist/index.js +1354 -826
  18. package/dist/index.mjs +1284 -795
  19. package/dist/server.d.mts +47 -27
  20. package/dist/server.d.ts +47 -27
  21. package/dist/server.js +1417 -829
  22. package/dist/server.mjs +164 -176
  23. package/package.json +6 -2
  24. package/src/app/api/upload/route.ts +4 -0
  25. package/src/app/constants.tsx +2 -2
  26. package/src/app/page.tsx +12 -322
  27. package/src/components/AmbientBackground.tsx +29 -0
  28. package/src/components/ArchitectureCard.tsx +17 -0
  29. package/src/components/ArchitectureCardsSection.tsx +15 -0
  30. package/src/components/ChatWindow.tsx +32 -0
  31. package/src/components/CodeViewer.tsx +51 -0
  32. package/src/components/ConfigProvider.tsx +1 -0
  33. package/src/components/DocViewer.tsx +37 -0
  34. package/src/components/DocumentUpload.tsx +44 -1
  35. package/src/components/Documentation.tsx +58 -0
  36. package/src/components/DynamicChart.tsx +27 -2
  37. package/src/components/Hero.tsx +59 -0
  38. package/src/components/HourglassLoader.tsx +87 -0
  39. package/src/components/Lifecycle.tsx +37 -0
  40. package/src/components/MarkdownComponents.tsx +140 -0
  41. package/src/components/MessageBubble.tsx +124 -904
  42. package/src/components/Navbar.tsx +55 -0
  43. package/src/components/ObservabilityPanel.tsx +374 -0
  44. package/src/components/ProductCard.tsx +5 -3
  45. package/src/components/UIDispatcher.tsx +344 -0
  46. package/src/components/VisualizationRenderer.tsx +372 -250
  47. package/src/config/RagConfig.ts +5 -0
  48. package/src/config/serverConfig.ts +3 -1
  49. package/src/core/Pipeline.ts +240 -271
  50. package/src/core/ProviderRegistry.ts +2 -2
  51. package/src/core/VectorPlugin.ts +9 -0
  52. package/src/handlers/index.ts +91 -15
  53. package/src/hooks/useRagChat.ts +21 -11
  54. package/src/index.ts +9 -1
  55. package/src/llm/LLMFactory.ts +54 -2
  56. package/src/llm/providers/AnthropicProvider.ts +12 -8
  57. package/src/llm/providers/GeminiProvider.ts +188 -143
  58. package/src/llm/providers/OllamaProvider.ts +7 -3
  59. package/src/llm/providers/OpenAIProvider.ts +12 -8
  60. package/src/providers/vectordb/MultiTablePostgresProvider.ts +150 -64
  61. package/src/types/chat.ts +8 -0
  62. package/src/types/index.ts +132 -0
  63. package/src/types/props.ts +9 -1
  64. package/src/utils/ProductExtractor.ts +347 -0
  65. package/src/utils/SchemaMapper.ts +129 -0
  66. package/src/utils/UITransformer.ts +470 -209
  67. package/src/utils/synonyms.ts +78 -0
  68. package/dist/DocumentChunker-C1GEEosY.d.ts +0 -93
  69. package/dist/DocumentChunker-CFEiRopR.d.mts +0 -93
  70. package/dist/PostgreSQLProvider-BMOETDZA.mjs +0 -8
  71. package/dist/chunk-FLOSGE6A.mjs +0 -202
@@ -1,650 +1,26 @@
1
1
  'use client';
2
2
 
3
3
  import React from 'react';
4
- import { Bot, User, ChevronDown, ChevronRight } from 'lucide-react';
4
+ import { Bot, User, ChevronDown, ChevronRight, Activity } from 'lucide-react';
5
5
  import ReactMarkdown from 'react-markdown';
6
- import remarkGfm from 'remark-gfm';
7
- import { MessageBubbleProps, Product } from '../types';
8
- import { ChatViewportSize } from '../types/props';
6
+ import { MessageBubbleProps, Product, UITransformationResponse } from '../types';
9
7
  import { SourceCard } from './SourceCard';
10
8
  import { ProductCarousel } from './ProductCarousel';
11
- import { DynamicChart } from './DynamicChart';
12
9
  import { VisualizationRenderer } from './VisualizationRenderer';
13
- import { UITransformationResponse } from '../utils/UITransformer';
14
-
15
- // ─── JSON sanitization ────────────────────────────────────────────────────────
16
- // Order matters: each step is a targeted fix, no step undoes a previous one.
17
-
18
- function sanitizeJson(raw: string): string {
19
- let s = raw.trim();
20
-
21
- // 1. Evaluate simple arithmetic expressions in values e.g. "v": 495 / 1000
22
- s = s.replace(
23
- /:\s*(-?\d+(?:\.\d+)?)\s*([+\-*/])\s*(-?\d+(?:\.\d+)?)/g,
24
- (_, a, op, b) => {
25
- const n1 = parseFloat(a);
26
- const n2 = parseFloat(b);
27
- const result =
28
- op === '+' ? n1 + n2
29
- : op === '-' ? n1 - n2
30
- : op === '*' ? n1 * n2
31
- : n2 !== 0 ? n1 / n2
32
- : n1;
33
- return `: ${result}`;
34
- },
35
- );
36
-
37
- // 2. Strip JS-style comments while preserving strings (e.g. URLs)
38
- s = s.replace(/("(?:\\.|[^\\"])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, g1) => g1 || '');
39
-
40
- // 3. Quote bare (unquoted) object keys
41
- s = s.replace(/([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g, '$1"$2":');
42
-
43
- // 4. Replace single-quoted strings with double-quoted ones
44
- s = s.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"');
45
-
46
- // 5. Remove trailing commas before ] or }
47
- s = s.replace(/,\s*([}\]])/g, '$1');
48
-
49
- // 6. Remove completely empty key-value pairs left by prior cleanup
50
- s = s.replace(/([{,])\s*""\s*(?=[,}])/g, '$1');
51
-
52
- // 7. Stack-based structure balancer.
53
- // Walk every character, maintain a stack of openers so we close each
54
- // unclosed structure with its exact matching closer — in the right order.
55
- // A flat counter approach appends all ]'s then all }'s, which corrupts
56
- // nested structures like {..., [..., {...}]}.
57
- {
58
- const stack: ('{' | '[')[] = [];
59
- let inString = false;
60
- let escape = false;
61
-
62
- for (const ch of s) {
63
- if (escape) { escape = false; continue; }
64
- if (ch === '\\' && inString) { escape = true; continue; }
65
- if (ch === '"') { inString = !inString; continue; }
66
- if (inString) continue;
67
-
68
- if (ch === '{' || ch === '[') {
69
- stack.push(ch);
70
- } else if (ch === '}') {
71
- if (stack[stack.length - 1] === '{') stack.pop();
72
- } else if (ch === ']') {
73
- if (stack[stack.length - 1] === '[') stack.pop();
74
- }
75
- }
76
-
77
- // Close any unterminated string literal first
78
- if (inString) s += '"';
79
-
80
- // Close every unclosed opener in reverse (innermost first)
81
- for (let i = stack.length - 1; i >= 0; i--) {
82
- s += stack[i] === '{' ? '}' : ']';
83
- }
84
- }
85
-
86
- // 8. Trim trailing garbage that appears after the outermost closing char.
87
- // Only fires when something non-whitespace follows the last } or ].
88
- const lastClose = Math.max(s.lastIndexOf('}'), s.lastIndexOf(']'));
89
- if (lastClose !== -1 && lastClose < s.length - 1 && /\S/.test(s.slice(lastClose + 1))) {
90
- s = s.substring(0, lastClose + 1);
91
- }
92
-
93
- return s;
94
- }
95
-
96
- function extractLikelyJsonBlock(raw: string): string {
97
- const trimmed = raw.trim();
98
- const objectStart = trimmed.indexOf('{');
99
- const arrayStart = trimmed.indexOf('[');
100
-
101
- const starts = [objectStart, arrayStart].filter((index) => index >= 0);
102
- if (starts.length === 0) return trimmed;
103
-
104
- return trimmed.slice(Math.min(...starts));
105
- }
106
-
107
- function looksLikeStructuredPayload(raw: string): boolean {
108
- return /"?(view|chartType|type|data|items|rows|products|results)"?\s*:/.test(raw);
109
- }
110
-
111
- function stripMarkdownTables(raw: string): string {
112
- return raw.replace(
113
- /(?:^|\n)\|[^\n]+\|\n\|(?:\s*:?-+:?\s*\|)+\n(?:\|[^\n]*\|\n?)*/g,
114
- '\n\n',
115
- );
116
- }
117
-
118
- function stripStructuredUiLabels(raw: string): string {
119
- return raw.replace(
120
- /(?:^|\n)\*\*(?:Chart Data|Table Data|Visualization|Visual Representation)\*\:\s*(?=\n|$)/gi,
121
- '\n',
122
- ).replace(
123
- /(?:^|\n)(?:Chart Data|Table Data|Visualization|Visual Representation)\s*\:\s*(?=\n|$)/gi,
124
- '\n',
125
- );
126
- }
127
-
128
- function extractBareJsonObject(raw: string): string | null {
129
- const start = raw.indexOf('{');
130
- if (start === -1) return null;
131
-
132
- let depth = 0;
133
- let inString = false;
134
- let escape = false;
135
-
136
- for (let i = start; i < raw.length; i += 1) {
137
- const ch = raw[i];
138
-
139
- if (escape) {
140
- escape = false;
141
- continue;
142
- }
143
-
144
- if (ch === '\\' && inString) {
145
- escape = true;
146
- continue;
147
- }
148
-
149
- if (ch === '"') {
150
- inString = !inString;
151
- continue;
152
- }
153
-
154
- if (inString) continue;
155
-
156
- if (ch === '{') depth += 1;
157
- if (ch === '}') {
158
- depth -= 1;
159
- if (depth === 0) {
160
- return raw.slice(start, i + 1);
161
- }
162
- }
163
- }
164
-
165
- return null;
166
- }
167
-
168
- function extractStructuredPayload(raw: string): { payload: string | null; text: string } {
169
- let working = raw;
170
- const fenceRegex = /```(?:[a-z]+)?\s*([\s\S]*?)```/gi;
171
-
172
- for (const match of raw.matchAll(fenceRegex)) {
173
- const body = match[1]?.trim() ?? '';
174
- if (!looksLikeStructuredPayload(body)) continue;
175
-
176
- working = working.replace(match[0], '');
177
- return {
178
- payload: body,
179
- text: stripStructuredUiLabels(working).replace(/\n{3,}/g, '\n\n').trim(),
180
- };
181
- }
182
-
183
- const bareJson = extractBareJsonObject(raw);
184
- if (bareJson && looksLikeStructuredPayload(bareJson)) {
185
- working = working.replace(bareJson, '');
186
- return {
187
- payload: bareJson,
188
- text: stripStructuredUiLabels(working).replace(/\n{3,}/g, '\n\n').trim(),
189
- };
190
- }
191
-
192
- return {
193
- payload: null,
194
- text: stripStructuredUiLabels(raw).replace(/\n{3,}/g, '\n\n').trim(),
195
- };
196
- }
197
-
198
- function isLikelyUiOnlyMessage(text: string): boolean {
199
- const trimmed = text.trim();
200
- return (
201
- trimmed.length === 0 ||
202
- /^(chart data|table data|visualization|visual representation|product catalog|product recommendations|catalog summary)\s*:?\s*$/i.test(trimmed)
203
- );
204
- }
205
-
206
- // ─── Tiny helpers ─────────────────────────────────────────────────────────────
207
-
208
- function resolveImage(data: Record<string, unknown>): string | undefined {
209
- const isPlaceholder = (val: unknown) =>
210
- typeof val === 'string' && (val === '...' || val === '' || val.toLowerCase() === 'null' || val.toLowerCase() === 'undefined');
211
-
212
- for (const key of ['image', 'img', 'thumbnail']) {
213
- const v = data[key];
214
- if (typeof v === 'string' && v && !isPlaceholder(v)) return v;
215
- }
216
- if (Array.isArray(data.images) && typeof data.images[0] === 'string' && !isPlaceholder(data.images[0])) {
217
- return data.images[0];
218
- }
219
- return undefined;
220
- }
221
-
222
- /** Safely render table cell children that might be plain objects. */
223
- function normaliseChild(children: React.ReactNode): React.ReactNode {
224
- if (
225
- typeof children === 'object' &&
226
- children !== null &&
227
- !Array.isArray(children) &&
228
- !React.isValidElement(children)
229
- ) {
230
- return JSON.stringify(children);
231
- }
232
- return children;
233
- }
234
-
235
- function normaliseFieldName(value: string): string {
236
- return value.toLowerCase().replace(/[^a-z0-9]/g, '');
237
- }
238
-
239
- function formatColumnLabel(value: string): string {
240
- return value
241
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
242
- .replace(/[_-]+/g, ' ')
243
- .replace(/\s+/g, ' ')
244
- .trim()
245
- .replace(/\b\w/g, (char) => char.toUpperCase());
246
- }
247
-
248
- // ─── Inline data table renderer ───────────────────────────────────────────────
249
- // Handles chart configs where type === "table". The LLM may emit any of:
250
- // columns, dataKeys (+optional xAxisKey), or no key hints (auto-detect from row).
251
-
252
- interface TableConfig {
253
- type: 'table';
254
- title?: string;
255
- description?: string;
256
- columns?: string[]; // preferred: explicit ordered column labels
257
- dataKeys?: string[]; // fallback column keys (may omit xAxisKey)
258
- xAxisKey?: string; // row-label key to prepend when using dataKeys
259
- data: Record<string, unknown>[];
260
- }
261
-
262
- function DataTable({ config, viewportSize = 'large' }: { config: TableConfig; viewportSize?: ChatViewportSize }) {
263
- const isCompact = viewportSize === 'compact';
264
- const isMedium = viewportSize === 'medium';
265
- const columns = React.useMemo<Array<{ label: string; accessor: string }>>(() => {
266
- const rowKeys = Array.from(
267
- new Set(
268
- config.data.flatMap((row) => Object.keys(row)),
269
- ),
270
- );
271
-
272
- const findAccessor = (label: string) => {
273
- const exact = rowKeys.find((key) => key === label);
274
- if (exact) return exact;
275
-
276
- const normalisedLabel = normaliseFieldName(label);
277
- const normalisedMatch = rowKeys.find((key) => normaliseFieldName(key) === normalisedLabel);
278
- if (normalisedMatch) return normalisedMatch;
279
-
280
- return label;
281
- };
282
-
283
- if (Array.isArray(config.columns) && config.columns.length) {
284
- return config.columns.map((label) => ({
285
- label: formatColumnLabel(label),
286
- accessor: findAccessor(label),
287
- }));
288
- }
289
-
290
- if (Array.isArray(config.dataKeys) && config.dataKeys.length) {
291
- const keys = config.xAxisKey && !config.dataKeys.includes(config.xAxisKey)
292
- ? [config.xAxisKey, ...config.dataKeys]
293
- : config.dataKeys;
294
-
295
- return keys.map((key) => ({
296
- label: formatColumnLabel(key),
297
- accessor: findAccessor(key),
298
- }));
299
- }
300
-
301
- return rowKeys.map((key) => ({ label: formatColumnLabel(key), accessor: key }));
302
- }, [config]);
303
-
304
- if (!config.data.length || !columns.length) {
305
- return <p className="text-xs text-slate-400 italic my-2">No data to display.</p>;
306
- }
307
-
308
- const formatCell = (val: unknown): string => {
309
- if (val === null || val === undefined) return '—';
310
- if (typeof val === 'boolean') return val ? '✓' : '✗';
311
- return String(val);
312
- };
313
-
314
- return (
315
- <div className="my-4 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden">
316
- {config.title && (
317
- <div className="px-4 py-2.5 bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10">
318
- <p className="text-xs font-semibold text-slate-600 dark:text-white/70 uppercase tracking-wide">
319
- {config.title}
320
- </p>
321
- </div>
322
- )}
323
-
324
- <div className="overflow-x-auto">
325
- <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'}`}>
326
- <thead className="bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10">
327
- <tr>
328
- {columns.map((column) => (
329
- <th
330
- key={column.label}
331
- className={`${isCompact ? 'px-2.5 py-2' : 'px-4 py-3'} font-semibold text-slate-700 dark:text-white/90 whitespace-nowrap`}
332
- >
333
- {column.label}
334
- </th>
335
- ))}
336
- </tr>
337
- </thead>
338
- <tbody>
339
- {config.data.map((row, ri) => (
340
- <tr
341
- key={ri}
342
- className="border-b border-slate-100 dark:border-white/5 last:border-0 hover:bg-slate-50/60 dark:hover:bg-white/5 transition-colors"
343
- >
344
- {columns.map((column) => (
345
- <td
346
- key={column.label}
347
- className={`${isCompact ? 'px-2.5 py-2' : 'px-4 py-3'} text-slate-600 dark:text-white/70 ${column.accessor.toLowerCase().includes('description') ? 'whitespace-normal break-words' : 'whitespace-nowrap'}`}
348
- >
349
- {formatCell(row[column.accessor])}
350
- </td>
351
- ))}
352
- </tr>
353
- ))}
354
- </tbody>
355
- </table>
356
- </div>
357
-
358
- <div className="px-4 py-2 bg-slate-50 dark:bg-white/5 border-t border-slate-100 dark:border-white/5">
359
- <p className="text-[11px] text-slate-400 dark:text-white/30">
360
- {config.data.length} row{config.data.length !== 1 ? 's' : ''}
361
- </p>
362
- </div>
363
- </div>
364
- );
365
- }
366
-
367
- // ─── Universal UI Dispatcher ────────────────────────────────────────────────
368
- // Handles charts, product carousels, and data tables via a single protocol.
369
-
370
- interface UIConfig {
371
- view: 'chart' | 'carousel' | 'table';
372
- chartType?: 'pie' | 'bar' | 'line';
373
- xAxisKey?: string;
374
- dataKeys?: string[];
375
- columns?: string[];
376
- colors?: string[];
377
- data: Record<string, unknown>[];
378
- title?: string;
379
- description?: string;
380
- insights?: string[];
381
- type?: string;
382
- items?: Record<string, unknown>[];
383
- rows?: Record<string, unknown>[];
384
- products?: Record<string, unknown>[];
385
- results?: Record<string, unknown>[];
386
-
387
- // V7 Protocol Fields
388
- carousel?: {
389
- items: Record<string, unknown>[];
390
- };
391
- chart?: {
392
- type: 'pie' | 'bar' | 'line';
393
- xKey: string;
394
- yKeys: string[];
395
- data: Record<string, unknown>[];
396
- };
397
- table?: {
398
- columns: string[];
399
- rows?: Record<string, unknown>[];
400
- data?: Record<string, unknown>[];
401
- };
402
- }
403
-
404
- interface RawUIConfig extends Omit<UIConfig, 'data' | 'items' | 'rows' | 'products' | 'results' | 'carousel' | 'chart' | 'table'> {
405
- data: Array<Record<string, unknown> | unknown[]>;
406
- items?: Array<Record<string, unknown> | unknown[]>;
407
- rows?: Array<Record<string, unknown> | unknown[]>;
408
- products?: Array<Record<string, unknown> | unknown[]>;
409
- results?: Array<Record<string, unknown> | unknown[]>;
410
-
411
- // V7 Protocol Fields (Raw)
412
- carousel?: {
413
- items: Array<Record<string, unknown> | unknown[]>;
414
- };
415
- chart?: {
416
- type: 'pie' | 'bar' | 'line';
417
- xKey: string;
418
- yKeys: string[];
419
- data: Array<Record<string, unknown> | unknown[]>;
420
- };
421
- table?: {
422
- columns: string[];
423
- rows?: Array<Record<string, unknown> | unknown[]>;
424
- data?: Array<Record<string, unknown> | unknown[]>;
425
- };
426
- }
427
-
428
- function normalizeTabularRows(
429
- rows: Array<Record<string, unknown> | unknown[]> | undefined,
430
- columns?: string[],
431
- ): Record<string, unknown>[] {
432
- if (!Array.isArray(rows)) return [];
433
-
434
- return rows
435
- .map((row) => {
436
- if (Array.isArray(row)) {
437
- const keys = Array.isArray(columns) && columns.length > 0
438
- ? columns
439
- : row.map((_, index) => `column_${index + 1}`);
440
-
441
- return keys.reduce<Record<string, unknown>>((acc, key, index) => {
442
- acc[key] = row[index];
443
- return acc;
444
- }, {});
445
- }
446
-
447
- if (row && typeof row === 'object') {
448
- return row as Record<string, unknown>;
449
- }
450
-
451
- return { value: row };
452
- })
453
- .filter((row) => Object.keys(row).length > 0);
454
- }
455
-
456
- function resolveStructuredRows(config: Partial<RawUIConfig> & Record<string, unknown>) {
457
- // V7 Nested Structures
458
- if (config.carousel?.items && Array.isArray(config.carousel.items)) return config.carousel.items;
459
- if (config.chart?.data && Array.isArray(config.chart.data)) return config.chart.data;
460
- if (config.table?.rows && Array.isArray(config.table.rows)) return config.table.rows;
461
- if (config.table?.data && Array.isArray(config.table.data)) return config.table.data;
462
-
463
- // V6 Flat Structures
464
- if (Array.isArray(config.data) && config.data.length > 0) return config.data;
465
- if (Array.isArray(config.items) && config.items.length > 0) return config.items;
466
- if (Array.isArray(config.products) && config.products.length > 0) return config.products;
467
- if (Array.isArray(config.rows) && config.rows.length > 0) return config.rows;
468
- if (Array.isArray(config.results) && config.results.length > 0) return config.results;
469
- return [];
470
- }
471
-
472
- function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAddToCart, viewportSize = 'large' }: {
473
- rawContent: string;
474
- primaryColor?: string;
475
- accentColor?: string;
476
- isStreaming?: boolean;
477
- onAddToCart?: (product: Product) => void;
478
- viewportSize?: ChatViewportSize;
479
- }) {
480
- const result = React.useMemo<{ config: UIConfig } | { error: string } | { loading: true }>(() => {
481
- if (isStreaming) return { loading: true };
482
- try {
483
- const clean = rawContent
484
- .replace(/^```[a-z]*\s*/i, '')
485
- .replace(/```\s*$/, '')
486
- .trim();
487
- const parsed = JSON.parse(sanitizeJson(extractLikelyJsonBlock(clean)));
488
- const config = { ...parsed } as Partial<RawUIConfig> & Record<string, unknown>;
489
-
490
- // V7 Field Mapping
491
- if (config.chart) {
492
- if (!config.chartType) config.chartType = config.chart.type;
493
- if (!config.xAxisKey) config.xAxisKey = config.chart.xKey;
494
- if (!config.dataKeys) config.dataKeys = config.chart.yKeys;
495
- }
496
- if (config.table) {
497
- if (!config.columns) config.columns = config.table.columns;
498
- }
499
-
500
- const resolvedRows = resolveStructuredRows(config);
501
-
502
- // ─── Intent Healing & Auto-Detection ───────────────────────────────────
503
-
504
- const hasProductLikeData = resolvedRows.length > 0 &&
505
- typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
506
- !Array.isArray(resolvedRows[0]) &&
507
- (['price', 'brand', 'image', 'link', 'thumbnail'].some(key => key in resolvedRows[0]) ||
508
- Object.keys(resolvedRows[0]).length >= 4);
509
-
510
- const hasAggregationLikeData = resolvedRows.length > 0 &&
511
- typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
512
- !Array.isArray(resolvedRows[0]) &&
513
- (['value', 'count', 'total', 'percentage'].some(key => key in resolvedRows[0]) ||
514
- (Object.keys(resolvedRows[0]).length <= 3 && Object.values(resolvedRows[0]).some(v => typeof v === 'number')));
515
-
516
- // 1. Healing: Override explicit view if it contradicts the data shape
517
- if (config.view === 'chart' && hasProductLikeData && !hasAggregationLikeData) {
518
- config.view = 'carousel';
519
- } else if (config.view === 'carousel' && hasAggregationLikeData && !hasProductLikeData) {
520
- config.view = 'chart';
521
- config.chartType = (config.chartType as 'pie' | 'bar' | 'line') || 'bar';
522
- }
523
-
524
- // 2. Auto-Detection: Fill in missing or dynamic view
525
- if (!config.view || ['pie', 'bar', 'line', 'pie_chart', 'bar_chart', 'line_chart'].includes(String(config.view))) {
526
- const viewStr = String(config.view || '').toLowerCase();
527
-
528
- if (viewStr.includes('carousel') || config.type === 'products' || Array.isArray(config.items) || hasProductLikeData) {
529
- config.view = 'carousel';
530
- } else if (
531
- viewStr.includes('chart') ||
532
- viewStr.includes('pie') || viewStr.includes('bar') || viewStr.includes('line') ||
533
- hasAggregationLikeData ||
534
- (typeof config.type === 'string' && ['pie', 'bar', 'line'].includes(config.type)) ||
535
- (typeof config.chartType === 'string' && ['pie', 'bar', 'line'].includes(config.chartType))
536
- ) {
537
- config.view = 'chart';
538
- config.chartType = (config.chartType as 'pie' | 'bar' | 'line') ||
539
- (viewStr.replace('_chart', '') as 'pie' | 'bar' | 'line') ||
540
- (config.type as 'pie' | 'bar' | 'line') || 'bar';
541
- } else if (viewStr.includes('table')) {
542
- config.view = 'table';
543
- } else {
544
- config.view = 'table';
545
- }
546
- }
547
-
548
- const normalizedConfig: UIConfig = {
549
- ...(config as Omit<UIConfig, 'data'>),
550
- data: normalizeTabularRows(resolvedRows, config.columns),
551
- };
552
-
553
- return { config: normalizedConfig };
554
- } catch (err) {
555
- return { error: String(err) };
556
- }
557
- }, [rawContent, isStreaming]);
558
-
559
- if ('loading' in result) {
560
- return (
561
- <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">
562
- <div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
563
- <p className="text-xs text-slate-500 font-medium italic">Preparing view...</p>
564
- </div>
565
- );
566
- }
567
-
568
- if ('error' in result) {
569
- return <pre className="p-4 my-2 bg-slate-900 text-slate-100 rounded-lg text-[10px] overflow-auto max-h-40"><code>{rawContent}</code></pre>;
570
- }
571
-
572
- const { config } = result;
573
- const isCompact = viewportSize === 'compact';
574
- const hasInsights = Array.isArray(config.insights) && config.insights.length > 0;
575
- const hasDescription = typeof config.description === 'string' && config.description.trim().length > 0;
576
-
577
- switch (config.view) {
578
- case 'chart':
579
- return (
580
- <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`}>
581
- {config.title && <h4 className={`${isCompact ? 'text-[11px] mb-3' : 'text-xs mb-4'} font-semibold text-slate-500 px-2`}>{config.title}</h4>}
582
- {hasDescription && (
583
- <p className={`px-2 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
584
- )}
585
- <DynamicChart
586
- config={{
587
- type: (config.chartType || 'bar') as 'bar' | 'line' | 'pie',
588
- data: config.data as unknown as Record<string, string | number>[],
589
- xAxisKey: config.xAxisKey,
590
- dataKeys: config.dataKeys,
591
- colors: config.colors,
592
- }}
593
- primaryColor={primaryColor}
594
- accentColor={accentColor}
595
- viewportSize={viewportSize}
596
- />
597
- {hasInsights && (
598
- <div className="mt-4 flex flex-wrap gap-2 px-2">
599
- {config.insights?.map((insight, i) => (
600
- <span
601
- key={`insight-${i}`}
602
- 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`}
603
- >
604
- {String(insight)}
605
- </span>
606
- ))}
607
- </div>
608
- )}
609
- </div>
610
- );
611
- case 'carousel':
612
- return (
613
- <div className="my-4">
614
- {config.title && <h4 className={`${isCompact ? 'mb-1.5 text-[11px]' : 'mb-2 text-xs'} font-semibold text-slate-500`}>{config.title}</h4>}
615
- {hasDescription && (
616
- <p className={`mb-3 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
617
- )}
618
- <ProductCarousel products={(config.data as unknown as Product[]).map(item => ({
619
- ...item,
620
- image: item.image ?? (typeof resolveImage === 'function' ? resolveImage(item as unknown as Record<string, unknown>) : undefined)
621
- }))} primaryColor={primaryColor} onAddToCart={onAddToCart} />
622
- </div>
623
- );
624
- case 'table':
625
- return (
626
- <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`}>
627
- {config.title && <h4 className={`${isCompact ? 'text-[11px]' : 'text-xs'} font-semibold text-slate-500 mb-2`}>{config.title}</h4>}
628
- {hasDescription && (
629
- <p className={`mb-3 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
630
- )}
631
- <DataTable config={{
632
- type: 'table',
633
- title: config.title,
634
- description: config.description,
635
- columns: config.columns,
636
- dataKeys: config.dataKeys,
637
- xAxisKey: config.xAxisKey,
638
- data: config.data,
639
- }} viewportSize={viewportSize} />
640
- </div>
641
- );
642
- default:
643
- return null;
644
- }
645
- }
646
-
647
-
10
+ import { ObservabilityPanel } from './ObservabilityPanel';
11
+ import HourglassLoader from './HourglassLoader';
12
+ import {
13
+ extractStructuredPayload,
14
+ isLikelyUiOnlyMessage,
15
+ extractProductsFromSources,
16
+ extractProductsFromContent,
17
+ deduplicateProducts,
18
+ looksLikeStructuredPayload,
19
+ stripStructuredUiLabels,
20
+ stripMarkdownTables
21
+ } from '../utils/ProductExtractor';
22
+ import { createMarkdownComponents } from './MarkdownComponents';
23
+ import { UIDispatcher } from './UIDispatcher';
648
24
 
649
25
  // ─── Main component ───────────────────────────────────────────────────────────
650
26
 
@@ -660,118 +36,57 @@ export function MessageBubble({
660
36
  const isUser = message.role === 'user';
661
37
  const isCompact = viewportSize === 'compact';
662
38
  const isMedium = viewportSize === 'medium';
39
+
663
40
  const [showSources, setShowSources] = React.useState(false);
664
- const hasStructuredProductBlock = React.useMemo(
665
- () => /```(?:ui|json|chart)\s*[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products|table)"?/i.test(message.content) ||
666
- (/\{[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products)"?[\s\S]*\}/i.test(message.content) && looksLikeStructuredPayload(message.content)),
667
- [message.content],
668
- );
41
+ const [showTrace, setShowTrace] = React.useState(false);
42
+
669
43
  const structuredContent = React.useMemo(
670
44
  () => isUser ? { payload: null, text: message.content } : extractStructuredPayload(message.content),
671
45
  [isUser, message.content],
672
46
  );
673
- const shouldRenderStructuredOnly = !isUser && Boolean(structuredContent.payload) && isLikelyUiOnlyMessage(structuredContent.text);
674
47
 
675
- // ── Products from sources ──────────────────────────────────────────────────
48
+ const hasRichUI = React.useMemo(() => {
49
+ if (message.uiTransformation) {
50
+ const type = (message.uiTransformation as UITransformationResponse).type;
51
+ if (type && !['text', 'table'].includes(type)) return true;
52
+ }
53
+ if (structuredContent.payload) {
54
+ return /"?(?:view|type|chartType)"?\s*:\s*"?(?:chart|carousel|pie|bar|line|product_carousel)"?/i.test(structuredContent.payload);
55
+ }
56
+ return false;
57
+ }, [message.uiTransformation, structuredContent.payload]);
58
+
59
+ const shouldRenderStructuredOnly = !isUser && hasRichUI && isLikelyUiOnlyMessage(structuredContent.text);
60
+
61
+ // ── Extraction ─────────────────────────────────────────────────────────────
62
+
676
63
  const productsFromSources = React.useMemo<Product[]>(() => {
677
- if (isUser || !sources) return [];
678
- return sources
679
- .filter(s => {
680
- const m = s.metadata ?? {};
681
- return m.price || m.image || m.img || m.thumbnail || m.images || m.brand || m.type === 'product';
682
- })
683
- .map(s => {
684
- const m = (s.metadata ?? {}) as Record<string, unknown>;
685
- return {
686
- id: s.id,
687
- name: (m.name ?? m.title ?? s.content.split('\n')[0] ?? 'Unknown Product') as string,
688
- brand: m.brand as string,
689
- price: m.price as string | number,
690
- image: resolveImage(m),
691
- link: m.link as string,
692
- description: s.content,
693
- };
694
- });
64
+ return extractProductsFromSources(sources, isUser);
695
65
  }, [sources, isUser]);
696
66
 
697
- // ── Products + cleaned content from structured JSON blocks in the message ──
698
67
  const { productsFromContent, cleanContent } = React.useMemo(() => {
699
68
  if (isUser) {
700
69
  return { productsFromContent: [] as Product[], cleanContent: message.content };
701
70
  }
702
71
 
703
- const jsonRegex = /```(?:json|ui)?\s*([\s\S]*?)\s*```/g;
704
- const products: Product[] = [];
705
- let content = structuredContent.text;
706
-
707
- const payloadCandidates = [structuredContent.payload, ...content.matchAll(jsonRegex).map((match) => match[1])].filter(Boolean) as string[];
72
+ const payloadCandidates = [
73
+ structuredContent.payload,
74
+ ...Array.from(structuredContent.text.matchAll(/```(?:json|ui)?\s*([\s\S]*?)\s*```/g)).map(m => m[1])
75
+ ].filter(Boolean) as string[];
708
76
 
709
- for (const candidate of payloadCandidates) {
710
- try {
711
- const data = JSON.parse(sanitizeJson(candidate));
712
- const itemSet =
713
- Array.isArray(data.data) ? data.data
714
- : Array.isArray(data.items) ? data.items
715
- : Array.isArray(data.products) ? data.products
716
- : null;
717
-
718
- if (
719
- (data.type === 'products' || data.view === 'carousel' || data.view === 'table' || Array.isArray(data.products)) &&
720
- itemSet
721
- ) {
722
- products.push(
723
- ...itemSet.map((item: Record<string, unknown>) => ({
724
- ...item,
725
- image: (item.image as string) ?? resolveImage(item),
726
- })) as Product[],
727
- );
728
- }
729
- } catch {
730
- // Ignore malformed product payloads
731
- }
732
- }
733
-
734
- if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
735
- for (const match of content.matchAll(jsonRegex)) {
736
- try {
737
- const data = JSON.parse(sanitizeJson(match[1]));
738
- if (data.type === 'products' && Array.isArray(data.items)) {
739
- products.push(
740
- ...data.items.map((item: Record<string, unknown>) => ({
741
- ...item,
742
- image: (item.image as string) ?? resolveImage(item),
743
- })) as Product[],
744
- );
745
- content = content.replace(match[0], '');
746
- }
747
- } catch {
748
- // Malformed product block — skip silently
749
- }
750
- }
751
- }
752
-
753
- return { productsFromContent: products, cleanContent: content.trim() };
754
- }, [message.content, isUser, structuredContent]);
77
+ return extractProductsFromContent(
78
+ message.content,
79
+ structuredContent.text,
80
+ payloadCandidates,
81
+ productsFromSources
82
+ );
83
+ }, [message.content, isUser, structuredContent, productsFromSources]);
755
84
 
756
- // ── Merge & deduplicate products ───────────────────────────────────────────
757
85
  const allProducts = React.useMemo<Product[]>(() => {
758
- if (productsFromContent.length > 0) return productsFromContent;
759
-
760
- const seen = new Set<string>();
761
- const contentLower = message.content.toLowerCase();
762
-
763
- return productsFromSources.filter(p => {
764
- const id = String(p.id ?? p.name);
765
- if (seen.has(id)) return false;
766
- const mentioned =
767
- contentLower.includes(p.name.toLowerCase()) ||
768
- (p.brand ? contentLower.includes(p.brand.toLowerCase()) : false);
769
- if (mentioned) { seen.add(id); return true; }
770
- return false;
771
- });
86
+ return deduplicateProducts(productsFromSources, productsFromContent, message.content);
772
87
  }, [productsFromSources, productsFromContent, message.content]);
773
88
 
774
- const processedMarkdown = React.useMemo(() => {
89
+ const processedMarkdown = React.useMemo<string>(() => {
775
90
  let raw = structuredContent.payload ? structuredContent.text : (cleanContent || message.content);
776
91
  const hasStructuredUiBlock = Boolean(structuredContent.payload) || /```(?:ui|json|chart)\s*[\s\S]*?"(?:view|chartType|type|data|items|rows|products|results)"\s*:/i.test(raw);
777
92
 
@@ -779,12 +94,9 @@ export function MessageBubble({
779
94
  raw = raw.replace(/([^\n])\n\|/g, '$1\n\n|') // Ensure blank line before table
780
95
  .replace(/(\|[^\n]*\|)\n\n+(?=\|)/g, '$1\n'); // Remove blank lines between rows
781
96
 
782
- // 2. HEALING: Detect naked JSON charts/products and wrap them if markers are missing
783
- // This handles models (like Llama 3.2) that often omit the ```chart markers.
97
+ // 2. HEALING: Wrap naked JSON blocks
784
98
  if (!isStreaming) {
785
- // Look for objects that look like chart or product data
786
99
  raw = raw.replace(/(?:^|\n)\s*\{[\s\S]*\}\s*(?:\n|$)/g, (match) => {
787
- // Only wrap if not already in a code block
788
100
  if (match.includes('```')) return match;
789
101
  if (!looksLikeStructuredPayload(match)) return match;
790
102
  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"')
@@ -794,156 +106,19 @@ export function MessageBubble({
794
106
  });
795
107
  }
796
108
 
797
- // 2b. If the model already provided a structured UI block, suppress duplicated
798
- // markdown tables that often follow as a fallback explanation.
109
+ // 2b. Suppress duplicated markdown tables if a UI block is present
799
110
  if (hasStructuredUiBlock) {
800
111
  raw = stripStructuredUiLabels(stripMarkdownTables(raw))
801
112
  .replace(/\n{3,}/g, '\n\n')
802
113
  .trim();
803
114
  }
804
115
 
805
- // 3. HIJACKING: If streaming, hide table-like content to prevent raw markdown flashing.
806
- if (isStreaming) {
807
- raw = raw.replace(/((?:^|\n)\|.*)+/g, (match) => {
808
- return `\n\n\`\`\`table-loading\n${match.trim()}\n\`\`\`\n\n`;
809
- });
810
- }
811
-
812
116
  return raw;
813
117
  }, [cleanContent, message.content, isStreaming, structuredContent]);
814
118
 
815
119
  // ── Markdown component overrides ───────────────────────────────────────────
816
120
  const markdownComponents = React.useMemo(
817
- () => ({
818
- // Wrap in not-prose so Tailwind Typography resets don't clobber our styles.
819
- // prose applies display:block and padding:0 to table/th/td — not-prose opts out.
820
- table: ({ ...props }: React.HTMLAttributes<HTMLTableElement>) => {
821
- if (isStreaming) {
822
- return (
823
- <div className="my-5 p-6 bg-slate-50 dark:bg-white/5 rounded-xl border border-dashed border-slate-300 dark:border-white/10 flex items-center justify-center gap-3 select-none">
824
- <div className="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
825
- <p className="text-xs text-slate-500 font-medium italic animate-pulse">
826
- Generating data table...
827
- </p>
828
- </div>
829
- );
830
- }
831
-
832
- return (
833
- <div className="not-prose overflow-hidden my-5 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm bg-white dark:bg-slate-900/50">
834
- <div className="overflow-x-auto">
835
- <table
836
- className="!table w-full text-left border-collapse min-w-[400px] text-sm"
837
- {...props}
838
- />
839
- </div>
840
- </div>
841
- );
842
- },
843
- thead: ({ ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
844
- <thead
845
- className="!table-header-group bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10"
846
- {...props}
847
- />
848
- ),
849
- tbody: ({ ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
850
- <tbody className="!table-row-group divide-y divide-slate-100 dark:divide-white/5" {...props} />
851
- ),
852
- tr: ({ ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
853
- <tr
854
- className="!table-row hover:bg-slate-50/70 dark:hover:bg-white/5 transition-colors"
855
- {...props}
856
- />
857
- ),
858
- th: ({ children, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
859
- <th
860
- className="!table-cell px-4 py-3 font-bold text-slate-700 dark:text-white/90 whitespace-nowrap text-[11px] uppercase tracking-wider text-left"
861
- {...props}
862
- >
863
- {normaliseChild(children)}
864
- </th>
865
- ),
866
- td: ({ children, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
867
- <td
868
- className="!table-cell px-4 py-3 text-slate-600 dark:text-white/70 whitespace-nowrap text-sm"
869
- {...props}
870
- >
871
- {normaliseChild(children)}
872
- </td>
873
- ),
874
- code({
875
- inline,
876
- className,
877
- children,
878
- ...props
879
- }: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
880
- const lang = /language-(\w+)/.exec(className ?? '')?.[1];
881
-
882
- if (!inline && (lang === 'ui' || lang === 'chart')) {
883
- return (
884
- <UIDispatcher
885
- rawContent={String(children ?? '').trim()}
886
- primaryColor={primaryColor}
887
- accentColor={accentColor}
888
- isStreaming={isStreaming}
889
- onAddToCart={onAddToCart}
890
- viewportSize={viewportSize}
891
- />
892
- );
893
- }
894
-
895
- if (!inline && lang === 'json') {
896
- const content = String(children ?? '').trim();
897
- if (looksLikeStructuredPayload(content)) {
898
- return (
899
- <UIDispatcher
900
- rawContent={content}
901
- primaryColor={primaryColor}
902
- accentColor={accentColor}
903
- isStreaming={isStreaming}
904
- onAddToCart={onAddToCart}
905
- viewportSize={viewportSize}
906
- />
907
- );
908
- }
909
- }
910
-
911
- if (!inline) {
912
- const content = String(children ?? '').trim();
913
- if (looksLikeStructuredPayload(content)) {
914
- return (
915
- <UIDispatcher
916
- rawContent={content}
917
- primaryColor={primaryColor}
918
- accentColor={accentColor}
919
- isStreaming={isStreaming}
920
- onAddToCart={onAddToCart}
921
- viewportSize={viewportSize}
922
- />
923
- );
924
- }
925
- }
926
-
927
- if (!inline && lang === 'table-loading') {
928
- return (
929
- <div className="my-5 p-6 bg-slate-50 dark:bg-white/5 rounded-xl border border-dashed border-slate-300 dark:border-white/10 flex items-center justify-center gap-3 select-none">
930
- <div className="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
931
- <p className="text-xs text-slate-500 font-medium italic animate-pulse">
932
- Generating data table...
933
- </p>
934
- </div>
935
- );
936
- }
937
-
938
- return (
939
- <code className={className} {...props}>
940
- {typeof children === 'string' || typeof children === 'number'
941
- ? children
942
- : JSON.stringify(children)}
943
- </code>
944
- );
945
- },
946
- }),
121
+ () => createMarkdownComponents({ primaryColor, accentColor, isStreaming, onAddToCart, viewportSize }),
947
122
  [primaryColor, accentColor, isStreaming, onAddToCart, viewportSize],
948
123
  );
949
124
 
@@ -961,7 +136,7 @@ export function MessageBubble({
961
136
  {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'}`} />}
962
137
  </div>
963
138
 
964
- <div className={`flex flex-col gap-1 ${isCompact ? 'max-w-[94%]' : isMedium ? 'max-w-[92%]' : 'max-w-[90%]'} ${isUser ? 'items-end' : 'items-start'}`}>
139
+ <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'}`}>
965
140
  {/* Bubble */}
966
141
  <div
967
142
  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
@@ -992,27 +167,52 @@ export function MessageBubble({
992
167
  )}
993
168
 
994
169
  {!shouldRenderStructuredOnly && (
995
- <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
170
+ <ReactMarkdown components={markdownComponents}>
996
171
  {processedMarkdown}
997
172
  </ReactMarkdown>
998
173
  )}
999
174
 
1000
- {/* Automated UI Fallback (if LLM failed to output structured block but UITransformer found data) */}
1001
- {!isUser && !structuredContent.payload && !!message.uiTransformation && (
1002
- <div className="mt-4 border-t border-slate-100 dark:border-white/5 pt-4">
1003
- <VisualizationRenderer data={message.uiTransformation as UITransformationResponse} />
1004
- </div>
1005
- )}
1006
-
1007
- {isStreaming && message.content && (
1008
- <span className="inline-block w-1.5 h-3.5 ml-1 bg-emerald-500 animate-pulse align-middle" />
175
+ {/* Fix: Always show streaming cursor when isStreaming is true */}
176
+ {isStreaming && (
177
+ <span className="inline-flex items-center gap-1.5 ml-2 text-emerald-500 animate-pulse align-middle text-xs font-medium">
178
+ <HourglassLoader
179
+ size={18}
180
+ primaryColor={primaryColor}
181
+ accentColor={accentColor}
182
+ glow={false} />
183
+ Thinking...
184
+ </span>
1009
185
  )}
1010
186
  </div>
1011
187
  )}
1012
188
  </div>
1013
189
 
190
+ {/* Auto-generated visualization fallback */}
191
+ {(() => {
192
+ if (isUser || structuredContent.payload || !message.uiTransformation) return null;
193
+ const ui = message.uiTransformation as UITransformationResponse;
194
+ const textContent = (ui.data as { content?: string })?.content ?? '';
195
+ const shouldShow =
196
+ (ui.type === 'table' && allProducts.length === 0) ||
197
+ !['text', 'table'].includes(ui.type) ||
198
+ (ui.type === 'text' && !hasRichUI && allProducts.length === 0 && textContent &&
199
+ !processedMarkdown.toLowerCase().includes(textContent.trim().toLowerCase()));
200
+ if (!shouldShow) return null;
201
+ return (
202
+ <div className="w-full mt-3">
203
+ <VisualizationRenderer
204
+ data={ui}
205
+ primaryColor={primaryColor}
206
+ onAddToCart={onAddToCart}
207
+ className="rounded-3xl border border-slate-200/60 dark:border-white/10 bg-white/90 dark:bg-slate-900/80 p-4"
208
+ isStreaming={isStreaming}
209
+ />
210
+ </div>
211
+ );
212
+ })()}
213
+
1014
214
  {/* Product Carousel */}
1015
- {!isUser && !hasStructuredProductBlock && allProducts.length > 0 && (
215
+ {!isUser && !hasRichUI && allProducts.length > 0 && (
1016
216
  <div className="w-full mt-1">
1017
217
  <ProductCarousel
1018
218
  products={allProducts}
@@ -1022,26 +222,46 @@ export function MessageBubble({
1022
222
  </div>
1023
223
  )}
1024
224
 
1025
- {/* Sources */}
1026
- {!isUser && sources && sources.length > 0 && (
1027
- <div className="w-full">
1028
- <button
1029
- onClick={() => setShowSources(s => !s)}
1030
- className="text-[11px] text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1 mt-1"
1031
- >
1032
- {showSources ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
1033
- {sources.length} source{sources.length !== 1 ? 's' : ''} used
1034
- </button>
225
+ {/* Footer actions: Sources and Trace */}
226
+ {!isUser && (
227
+ <div className="flex items-center gap-4 mt-1 w-full flex-wrap">
228
+ {sources && sources.length > 0 && (
229
+ <button
230
+ onClick={() => setShowSources(s => !s)}
231
+ className="text-[11px] text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1"
232
+ >
233
+ {showSources ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
234
+ {sources.length} source{sources.length !== 1 ? 's' : ''} used
235
+ </button>
236
+ )}
1035
237
 
1036
- {showSources && (
1037
- <div className="mt-2 flex flex-col gap-2">
1038
- {sources.map((src, i) => (
1039
- <SourceCard key={src.id} source={src} index={i} />
1040
- ))}
1041
- </div>
238
+ {message.trace && (
239
+ <button
240
+ onClick={() => setShowTrace(t => !t)}
241
+ className="text-[11px] text-emerald-500 hover:text-emerald-400 transition-colors flex items-center gap-1"
242
+ >
243
+ <Activity className="w-3 h-3" />
244
+ {showTrace ? 'Hide Trace' : 'Trace'}
245
+ </button>
1042
246
  )}
1043
247
  </div>
1044
248
  )}
249
+
250
+ {/* Expandable areas */}
251
+ <div className="w-full flex flex-col gap-2 min-w-0">
252
+ {showSources && sources && sources.length > 0 && (
253
+ <div className="mt-1 flex flex-col gap-2">
254
+ {sources.map((src, i) => (
255
+ <SourceCard key={src.id} source={src} index={i} />
256
+ ))}
257
+ </div>
258
+ )}
259
+
260
+ {showTrace && message.trace && (
261
+ <ObservabilityPanel trace={message.trace} primaryColor={primaryColor} />
262
+ )}
263
+ </div>
264
+
1045
265
  </div>
1046
266
  </div>
1047
267
  );