@retrivora-ai/rag-engine 1.7.9 → 1.8.1

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 (47) hide show
  1. package/dist/DocumentChunker-Dh9TvmGG.d.mts +45 -0
  2. package/dist/DocumentChunker-Dh9TvmGG.d.ts +45 -0
  3. package/dist/{index-wCRqMtdX.d.mts → ILLMProvider-BfRgI1Xh.d.mts} +59 -1
  4. package/dist/{index-wCRqMtdX.d.ts → ILLMProvider-BfRgI1Xh.d.ts} +59 -1
  5. package/dist/MultiTablePostgresProvider-YY7LPNJK.mjs +8 -0
  6. package/dist/{chunk-UHGYLTTY.mjs → chunk-BFYLQYQU.mjs} +846 -458
  7. package/dist/chunk-R3RGUMHE.mjs +218 -0
  8. package/dist/handlers/index.d.mts +2 -2
  9. package/dist/handlers/index.d.ts +2 -2
  10. package/dist/handlers/index.js +1033 -622
  11. package/dist/handlers/index.mjs +1 -1
  12. package/dist/{index-BDqz2_Yu.d.ts → index-1Z4GuYBi.d.ts} +7 -1
  13. package/dist/{index-DhsG2o5q.d.mts → index-BV0z5mb6.d.mts} +7 -1
  14. package/dist/index.d.mts +3 -3
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.js +507 -352
  17. package/dist/index.mjs +427 -253
  18. package/dist/server.d.mts +35 -5
  19. package/dist/server.d.ts +35 -5
  20. package/dist/server.js +1183 -795
  21. package/dist/server.mjs +163 -176
  22. package/package.json +4 -2
  23. package/src/app/page.tsx +1 -2
  24. package/src/components/DynamicChart.tsx +10 -35
  25. package/src/components/MessageBubble.tsx +223 -74
  26. package/src/components/ProductCard.tsx +2 -2
  27. package/src/components/VisualizationRenderer.tsx +347 -247
  28. package/src/config/RagConfig.ts +5 -0
  29. package/src/config/serverConfig.ts +3 -1
  30. package/src/core/Pipeline.ts +70 -209
  31. package/src/core/ProviderRegistry.ts +2 -2
  32. package/src/core/VectorPlugin.ts +9 -0
  33. package/src/handlers/index.ts +23 -7
  34. package/src/hooks/useRagChat.ts +2 -2
  35. package/src/llm/LLMFactory.ts +54 -2
  36. package/src/llm/providers/AnthropicProvider.ts +12 -8
  37. package/src/llm/providers/GeminiProvider.ts +188 -143
  38. package/src/llm/providers/OllamaProvider.ts +7 -3
  39. package/src/llm/providers/OpenAIProvider.ts +12 -8
  40. package/src/types/chat.ts +6 -0
  41. package/src/types/index.ts +81 -0
  42. package/src/utils/SchemaMapper.ts +129 -0
  43. package/src/utils/UITransformer.ts +524 -194
  44. package/dist/DocumentChunker-Bmscbh-X.d.ts +0 -93
  45. package/dist/DocumentChunker-DCuxrOdM.d.mts +0 -93
  46. package/dist/PostgreSQLProvider-BMOETDZA.mjs +0 -8
  47. package/dist/chunk-FLOSGE6A.mjs +0 -202
@@ -2,15 +2,13 @@
2
2
 
3
3
  import React from 'react';
4
4
  import { Bot, User, ChevronDown, ChevronRight } from 'lucide-react';
5
- import ReactMarkdown from 'react-markdown';
6
- import remarkGfm from 'remark-gfm';
7
- import { MessageBubbleProps, Product } from '../types';
5
+ import ReactMarkdown, { Components } from 'react-markdown';
6
+ import { MessageBubbleProps, Product, UITransformationResponse } from '../types';
8
7
  import { ChatViewportSize } from '../types/props';
9
8
  import { SourceCard } from './SourceCard';
10
9
  import { ProductCarousel } from './ProductCarousel';
11
10
  import { DynamicChart } from './DynamicChart';
12
11
  import { VisualizationRenderer } from './VisualizationRenderer';
13
- import { UITransformationResponse } from '../utils/UITransformer';
14
12
 
15
13
  // ─── JSON sanitization ────────────────────────────────────────────────────────
16
14
  // Order matters: each step is a targeted fix, no step undoes a previous one.
@@ -126,50 +124,52 @@ function stripStructuredUiLabels(raw: string): string {
126
124
  }
127
125
 
128
126
  function extractBareJsonObject(raw: string): string | null {
129
- const start = raw.indexOf('{');
127
+ // Find the first occurrence of '{'
128
+ let start = raw.indexOf('{');
130
129
  if (start === -1) return null;
131
130
 
132
- let depth = 0;
133
- let inString = false;
134
- let escape = false;
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;
135
136
 
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
- }
137
+ while (start !== -1) {
138
+ let depth = 0;
139
+ let inString = false;
140
+ let escape = false;
153
141
 
154
- if (inString) continue;
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;
155
148
 
156
- if (ch === '{') depth += 1;
157
- if (ch === '}') {
158
- depth -= 1;
159
- if (depth === 0) {
160
- return raw.slice(start, i + 1);
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
+ }
161
160
  }
162
161
  }
162
+ start = raw.indexOf('{', start + 1);
163
163
  }
164
164
 
165
- return null;
165
+ return bestJson;
166
166
  }
167
167
 
168
168
  function extractStructuredPayload(raw: string): { payload: string | null; text: string } {
169
169
  let working = raw;
170
170
  const fenceRegex = /```(?:[a-z]+)?\s*([\s\S]*?)```/gi;
171
171
 
172
- for (const match of raw.matchAll(fenceRegex)) {
172
+ for (const match of Array.from(raw.matchAll(fenceRegex))) {
173
173
  const body = match[1]?.trim() ?? '';
174
174
  if (!looksLikeStructuredPayload(body)) continue;
175
175
 
@@ -199,28 +199,44 @@ function isLikelyUiOnlyMessage(text: string): boolean {
199
199
  const trimmed = text.trim();
200
200
  return (
201
201
  trimmed.length === 0 ||
202
- /^(chart data|table data|visualization|visual representation|product catalog|product recommendations|catalog summary)\s*:?\s*$/i.test(trimmed)
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)
203
204
  );
204
205
  }
205
206
 
206
207
  // ─── Tiny helpers ─────────────────────────────────────────────────────────────
207
208
 
208
209
  function resolveImage(data: Record<string, unknown>): string | undefined {
209
- const isPlaceholder = (val: unknown) =>
210
+ const isPlaceholder = (val: unknown) =>
210
211
  typeof val === 'string' && (val === '...' || val === '' || val.toLowerCase() === 'null' || val.toLowerCase() === 'undefined');
211
212
 
212
- for (const key of ['image', 'img', 'thumbnail']) {
213
+ for (const key of ['image', 'img', 'thumbnail', 'images', 'product_image', 'Image', 'Thumbnail']) {
213
214
  const v = data[key];
214
215
  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];
216
+ if (Array.isArray(v) && typeof v[0] === 'string' && v[0] && !isPlaceholder(v[0])) return v[0];
218
217
  }
219
218
  return undefined;
220
219
  }
221
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
+
222
238
  /** Safely render table cell children that might be plain objects. */
223
- function normaliseChild(children: React.ReactNode): React.ReactNode {
239
+ function normaliseChild(children: unknown): React.ReactNode {
224
240
  if (
225
241
  typeof children === 'object' &&
226
242
  children !== null &&
@@ -229,7 +245,7 @@ function normaliseChild(children: React.ReactNode): React.ReactNode {
229
245
  ) {
230
246
  return JSON.stringify(children);
231
247
  }
232
- return children;
248
+ return children as React.ReactNode;
233
249
  }
234
250
 
235
251
  function normaliseFieldName(value: string): string {
@@ -431,6 +447,15 @@ function normalizeTabularRows(
431
447
  ): Record<string, unknown>[] {
432
448
  if (!Array.isArray(rows)) return [];
433
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
+
434
459
  return rows
435
460
  .map((row) => {
436
461
  if (Array.isArray(row)) {
@@ -445,7 +470,46 @@ function normalizeTabularRows(
445
470
  }
446
471
 
447
472
  if (row && typeof row === 'object') {
448
- return row as Record<string, unknown>;
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;
449
513
  }
450
514
 
451
515
  return { value: row };
@@ -486,7 +550,7 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAd
486
550
  .trim();
487
551
  const parsed = JSON.parse(sanitizeJson(extractLikelyJsonBlock(clean)));
488
552
  const config = { ...parsed } as Partial<RawUIConfig> & Record<string, unknown>;
489
-
553
+
490
554
  // V7 Field Mapping
491
555
  if (config.chart) {
492
556
  if (!config.chartType) config.chartType = config.chart.type;
@@ -521,18 +585,25 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAd
521
585
  config.chartType = (config.chartType as 'pie' | 'bar' | 'line') || 'bar';
522
586
  }
523
587
 
524
- // 2. Auto-Detection: Fill in missing view
525
- if (!config.view) {
526
- if (config.type === 'products' || Array.isArray(config.items) || hasProductLikeData) {
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) {
527
593
  config.view = 'carousel';
528
594
  } else if (
595
+ viewStr.includes('chart') ||
596
+ viewStr.includes('pie') || viewStr.includes('bar') || viewStr.includes('line') ||
529
597
  hasAggregationLikeData ||
530
598
  (typeof config.type === 'string' && ['pie', 'bar', 'line'].includes(config.type)) ||
531
599
  (typeof config.chartType === 'string' && ['pie', 'bar', 'line'].includes(config.chartType))
532
600
  ) {
533
601
  config.view = 'chart';
534
602
  config.chartType = (config.chartType as 'pie' | 'bar' | 'line') ||
603
+ (viewStr.replace('_chart', '') as 'pie' | 'bar' | 'line') ||
535
604
  (config.type as 'pie' | 'bar' | 'line') || 'bar';
605
+ } else if (viewStr.includes('table')) {
606
+ config.view = 'table';
536
607
  } else {
537
608
  config.view = 'table';
538
609
  }
@@ -589,12 +660,12 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAd
589
660
  />
590
661
  {hasInsights && (
591
662
  <div className="mt-4 flex flex-wrap gap-2 px-2">
592
- {config.insights?.map((insight) => (
663
+ {config.insights?.map((insight, i) => (
593
664
  <span
594
- key={insight}
665
+ key={`insight-${i}`}
595
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`}
596
667
  >
597
- {insight}
668
+ {String(insight)}
598
669
  </span>
599
670
  ))}
600
671
  </div>
@@ -654,16 +725,23 @@ export function MessageBubble({
654
725
  const isCompact = viewportSize === 'compact';
655
726
  const isMedium = viewportSize === 'medium';
656
727
  const [showSources, setShowSources] = React.useState(false);
657
- const hasStructuredProductBlock = React.useMemo(
658
- () => /```(?:ui|json|chart)\s*[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products|table)"?/i.test(message.content) ||
659
- (/\{[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products)"?[\s\S]*\}/i.test(message.content) && looksLikeStructuredPayload(message.content)),
660
- [message.content],
661
- );
662
728
  const structuredContent = React.useMemo(
663
729
  () => isUser ? { payload: null, text: message.content } : extractStructuredPayload(message.content),
664
730
  [isUser, message.content],
665
731
  );
666
- const shouldRenderStructuredOnly = !isUser && Boolean(structuredContent.payload) && isLikelyUiOnlyMessage(structuredContent.text);
732
+ const hasRichUI = React.useMemo(() => {
733
+ if (message.uiTransformation) {
734
+ const type = (message.uiTransformation as UITransformationResponse).type;
735
+ // Table is not considered "Rich enough" to block a product carousel
736
+ if (type && !['text', 'table'].includes(type)) return true;
737
+ }
738
+ if (structuredContent.payload) {
739
+ return /"?(?:view|type|chartType)"?\s*:\s*"?(?:chart|carousel|pie|bar|line|product_carousel)"?/i.test(structuredContent.payload);
740
+ }
741
+ return false;
742
+ }, [message.uiTransformation, structuredContent.payload]);
743
+
744
+ const shouldRenderStructuredOnly = !isUser && hasRichUI && isLikelyUiOnlyMessage(structuredContent.text);
667
745
 
668
746
  // ── Products from sources ──────────────────────────────────────────────────
669
747
  const productsFromSources = React.useMemo<Product[]>(() => {
@@ -671,17 +749,26 @@ export function MessageBubble({
671
749
  return sources
672
750
  .filter(s => {
673
751
  const m = s.metadata ?? {};
674
- return m.price || m.image || m.img || m.thumbnail || m.images || m.brand || m.type === 'product';
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';
675
758
  })
676
759
  .map(s => {
677
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;
764
+
678
765
  return {
679
766
  id: s.id,
680
- name: (m.name ?? m.title ?? s.content.split('\n')[0] ?? 'Unknown Product') as string,
681
- brand: m.brand as string,
682
- price: m.price as string | number,
767
+ name: name ?? s.content.split('\n')[0] ?? 'Unknown Product',
768
+ brand: brand,
769
+ price: price,
683
770
  image: resolveImage(m),
684
- link: m.link as string,
771
+ link: getMetadataValue(m, ['link', 'url', 'product_url']) as string,
685
772
  description: s.content,
686
773
  };
687
774
  });
@@ -697,7 +784,7 @@ export function MessageBubble({
697
784
  const products: Product[] = [];
698
785
  let content = structuredContent.text;
699
786
 
700
- const payloadCandidates = [structuredContent.payload, ...content.matchAll(jsonRegex).map((match) => match[1])].filter(Boolean) as string[];
787
+ const payloadCandidates = [structuredContent.payload, ...Array.from(content.matchAll(jsonRegex)).map((match) => match[1])].filter(Boolean) as string[];
701
788
 
702
789
  for (const candidate of payloadCandidates) {
703
790
  try {
@@ -725,7 +812,7 @@ export function MessageBubble({
725
812
  }
726
813
 
727
814
  if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
728
- for (const match of content.matchAll(jsonRegex)) {
815
+ for (const match of Array.from(content.matchAll(jsonRegex))) {
729
816
  try {
730
817
  const data = JSON.parse(sanitizeJson(match[1]));
731
818
  if (data.type === 'products' && Array.isArray(data.items)) {
@@ -743,8 +830,52 @@ export function MessageBubble({
743
830
  }
744
831
  }
745
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
+
746
877
  return { productsFromContent: products, cleanContent: content.trim() };
747
- }, [message.content, isUser, structuredContent]);
878
+ }, [message.content, isUser, structuredContent, productsFromSources]);
748
879
 
749
880
  // ── Merge & deduplicate products ───────────────────────────────────────────
750
881
  const allProducts = React.useMemo<Product[]>(() => {
@@ -764,7 +895,7 @@ export function MessageBubble({
764
895
  });
765
896
  }, [productsFromSources, productsFromContent, message.content]);
766
897
 
767
- const processedMarkdown = React.useMemo(() => {
898
+ const processedMarkdown = React.useMemo<string>(() => {
768
899
  let raw = structuredContent.payload ? structuredContent.text : (cleanContent || message.content);
769
900
  const hasStructuredUiBlock = Boolean(structuredContent.payload) || /```(?:ui|json|chart)\s*[\s\S]*?"(?:view|chartType|type|data|items|rows|products|results)"\s*:/i.test(raw);
770
901
 
@@ -806,7 +937,7 @@ export function MessageBubble({
806
937
  }, [cleanContent, message.content, isStreaming, structuredContent]);
807
938
 
808
939
  // ── Markdown component overrides ───────────────────────────────────────────
809
- const markdownComponents = React.useMemo(
940
+ const markdownComponents: Components = React.useMemo(
810
941
  () => ({
811
942
  // Wrap in not-prose so Tailwind Typography resets don't clobber our styles.
812
943
  // prose applies display:block and padding:0 to table/th/td — not-prose opts out.
@@ -985,18 +1116,12 @@ export function MessageBubble({
985
1116
  )}
986
1117
 
987
1118
  {!shouldRenderStructuredOnly && (
988
- <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
1119
+ <ReactMarkdown components={markdownComponents}>
989
1120
  {processedMarkdown}
990
1121
  </ReactMarkdown>
991
1122
  )}
992
1123
 
993
1124
  {/* Automated UI Fallback (if LLM failed to output structured block but UITransformer found data) */}
994
- {!isUser && !structuredContent.payload && !!message.uiTransformation && (
995
- <div className="mt-4 border-t border-slate-100 dark:border-white/5 pt-4">
996
- <VisualizationRenderer data={message.uiTransformation as UITransformationResponse} />
997
- </div>
998
- )}
999
-
1000
1125
  {isStreaming && message.content && (
1001
1126
  <span className="inline-block w-1.5 h-3.5 ml-1 bg-emerald-500 animate-pulse align-middle" />
1002
1127
  )}
@@ -1004,8 +1129,32 @@ export function MessageBubble({
1004
1129
  )}
1005
1130
  </div>
1006
1131
 
1007
- {/* Product Carousel */}
1008
- {!isUser && !hasStructuredProductBlock && allProducts.length > 0 && (
1132
+ {/* Auto-generated visualization fallback (only if no inline UI payload and not a redundant text/table block) */}
1133
+ {(() => {
1134
+ if (isUser || structuredContent.payload || !message.uiTransformation) return null;
1135
+ const ui = message.uiTransformation as UITransformationResponse;
1136
+ const textContent = (ui.data as { content?: string })?.content ?? '';
1137
+ const shouldShow =
1138
+ (ui.type === 'table' && allProducts.length === 0) ||
1139
+ !['text', 'table'].includes(ui.type) ||
1140
+ (ui.type === 'text' && !hasRichUI && allProducts.length === 0 && textContent &&
1141
+ !processedMarkdown.toLowerCase().includes(textContent.trim().toLowerCase()));
1142
+ if (!shouldShow) return null;
1143
+ return (
1144
+ <div className="w-full mt-3">
1145
+ <VisualizationRenderer
1146
+ data={ui}
1147
+ primaryColor={primaryColor}
1148
+ onAddToCart={onAddToCart}
1149
+ className="rounded-3xl border border-slate-200/60 dark:border-white/10 bg-white/90 dark:bg-slate-900/80 p-4"
1150
+ />
1151
+ </div>
1152
+ );
1153
+ })()}
1154
+
1155
+
1156
+ {/* Product Carousel (only if no other rich UI is present) */}
1157
+ {!isUser && !hasRichUI && allProducts.length > 0 && (
1009
1158
  <div className="w-full mt-1">
1010
1159
  <ProductCarousel
1011
1160
  products={allProducts}
@@ -24,7 +24,7 @@ export function ProductCard({ product, primaryColor = '#6366f1', onAddToCart }:
24
24
  <ShoppingCart className="w-12 h-12" />
25
25
  </div>
26
26
  )}
27
-
27
+
28
28
  {/* Brand Badge */}
29
29
  {product.brand && (
30
30
  <div className="absolute top-2 left-2 px-2 py-1 bg-white/90 dark:bg-black/60 backdrop-blur-md rounded-md text-[10px] font-bold uppercase tracking-wider text-slate-700 dark:text-white/90 shadow-sm">
@@ -45,7 +45,7 @@ export function ProductCard({ product, primaryColor = '#6366f1', onAddToCart }:
45
45
  </span>
46
46
  )}
47
47
  </div>
48
-
48
+
49
49
  {product.description && (
50
50
  <p className="text-[11px] text-slate-500 dark:text-white/50 line-clamp-2 leading-relaxed">
51
51
  {product.description}