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