@retrivora-ai/rag-engine 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,8 @@ import remarkGfm from 'remark-gfm';
7
7
  import { ChatMessage } from '../llm/ILLMProvider';
8
8
  import { VectorMatch } from '../types';
9
9
  import { SourceCard } from './SourceCard';
10
+ import { ProductCarousel } from './ProductCarousel';
11
+ import { Product } from './ProductCard';
10
12
 
11
13
  interface MessageBubbleProps {
12
14
  message: ChatMessage;
@@ -26,6 +28,81 @@ export function MessageBubble({
26
28
  const isUser = message.role === 'user';
27
29
  const [showSources, setShowSources] = React.useState(false);
28
30
 
31
+ // Helper to extract image from metadata or object
32
+ const resolveImage = (data: Record<string, unknown>): string | undefined => {
33
+ if (!data) return undefined;
34
+
35
+ // Check single string fields
36
+ const singleFields = ['image', 'img', 'thumbnail'];
37
+ for (const field of singleFields) {
38
+ const val = data[field];
39
+ if (typeof val === 'string' && val) return val;
40
+ }
41
+
42
+ // Check images array
43
+ if (Array.isArray(data.images) && data.images.length > 0) {
44
+ if (typeof data.images[0] === 'string') return data.images[0];
45
+ }
46
+
47
+ return undefined;
48
+ };
49
+
50
+ // Extract products from sources
51
+ const productsFromSources = React.useMemo(() => {
52
+ if (isUser || !sources) return [];
53
+ return sources
54
+ .filter(s => {
55
+ const m = s.metadata || {};
56
+ return m.price || m.image || m.img || m.thumbnail || m.images || m.brand || m.type === 'product';
57
+ })
58
+ .map(s => {
59
+ const m = (s.metadata || {}) as Record<string, unknown>;
60
+ return {
61
+ id: s.id,
62
+ name: (m.name || m.title || s.content.split('\n')[0] || 'Unknown Product') as string,
63
+ brand: m.brand as string,
64
+ price: m.price as string | number,
65
+ image: resolveImage(m),
66
+ link: m.link as string,
67
+ description: s.content
68
+ };
69
+ });
70
+ }, [sources, isUser]);
71
+
72
+ // Extract products from content (structured JSON)
73
+ const { productsFromContent, cleanContent } = React.useMemo(() => {
74
+ if (isUser) return { productsFromContent: [], cleanContent: message.content };
75
+
76
+ const jsonRegex = /```json\s*([\s\S]*?)\s*```/g;
77
+ const products: Product[] = [];
78
+ let content = message.content;
79
+
80
+ // Only process if it looks like it might contain product data
81
+ if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
82
+ const matches = Array.from(content.matchAll(jsonRegex));
83
+ for (const m of matches) {
84
+ try {
85
+ const data = JSON.parse(m[1]);
86
+ if (data.type === 'products' && Array.isArray(data.items)) {
87
+ const formattedItems = data.items.map((item: Record<string, unknown>) => ({
88
+ ...item,
89
+ image: (item.image as string) || resolveImage(item)
90
+ })) as Product[];
91
+ products.push(...formattedItems);
92
+ content = content.replace(m[0], '');
93
+ }
94
+ } catch {
95
+ // Skip invalid JSON
96
+ }
97
+ }
98
+ }
99
+
100
+ return { productsFromContent: products, cleanContent: content.trim() };
101
+ }, [message.content, isUser]);
102
+
103
+ const allProducts = [...productsFromSources, ...productsFromContent];
104
+ const hasProducts = allProducts.length > 0;
105
+
29
106
  return (
30
107
  <div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'} items-start`}>
31
108
  {/* Avatar */}
@@ -41,7 +118,7 @@ export function MessageBubble({
41
118
  }
42
119
  </div>
43
120
 
44
- <div className={`flex flex-col gap-1 max-w-[75%] ${isUser ? 'items-end' : 'items-start'}`}>
121
+ <div className={`flex flex-col gap-1 max-w-[90%] ${isUser ? 'items-end' : 'items-start'}`}>
45
122
  {/* Bubble */}
46
123
  <div
47
124
  className={`relative px-4 py-3 rounded-2xl text-sm leading-relaxed shadow-sm dark:shadow-lg ${
@@ -64,8 +141,9 @@ export function MessageBubble({
64
141
  ) : (
65
142
  <div className={`prose prose-sm max-w-none ${isUser ? 'prose-invert' : 'dark:prose-invert'}`}>
66
143
  <ReactMarkdown remarkPlugins={[remarkGfm]}>
67
- {message.content}
144
+ {cleanContent || message.content}
68
145
  </ReactMarkdown>
146
+
69
147
  {isStreaming && message.content && (
70
148
  <span className="inline-block w-1.5 h-3.5 ml-1 bg-emerald-500 animate-pulse align-middle" />
71
149
  )}
@@ -73,6 +151,13 @@ export function MessageBubble({
73
151
  )}
74
152
  </div>
75
153
 
154
+ {/* Product Carousel (Outside the bubble) */}
155
+ {!isUser && hasProducts && (
156
+ <div className="w-full mt-1">
157
+ <ProductCarousel products={allProducts} primaryColor={primaryColor} />
158
+ </div>
159
+ )}
160
+
76
161
  {/* Sources toggle */}
77
162
  {!isUser && sources && sources.length > 0 && (
78
163
  <div className="w-full">
@@ -0,0 +1,90 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import Image from 'next/image';
5
+ import { ShoppingCart, ExternalLink } from 'lucide-react';
6
+
7
+ export interface Product {
8
+ id: string | number;
9
+ name: string;
10
+ brand?: string;
11
+ price?: string | number;
12
+ image?: string;
13
+ link?: string;
14
+ description?: string;
15
+ }
16
+
17
+ interface ProductCardProps {
18
+ product: Product;
19
+ primaryColor?: string;
20
+ }
21
+
22
+ export function ProductCard({ product, primaryColor = '#6366f1' }: ProductCardProps) {
23
+ return (
24
+ <div className="flex-shrink-0 w-full bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-300 group">
25
+ {/* Product Image */}
26
+ <div className="relative h-40 w-full bg-slate-100 dark:bg-white/5 flex items-center justify-center overflow-hidden">
27
+ {product.image ? (
28
+ <Image
29
+ src={product.image}
30
+ alt={product.name}
31
+ fill
32
+ unoptimized
33
+ className="object-cover group-hover:scale-105 transition-transform duration-500"
34
+ />
35
+ ) : (
36
+ <div className="text-slate-400 dark:text-white/20">
37
+ <ShoppingCart className="w-12 h-12" />
38
+ </div>
39
+ )}
40
+
41
+ {/* Brand Badge */}
42
+ {product.brand && (
43
+ <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">
44
+ {product.brand}
45
+ </div>
46
+ )}
47
+ </div>
48
+
49
+ {/* Product Info */}
50
+ <div className="p-4 flex flex-col gap-2">
51
+ <div className="flex justify-between items-start gap-2">
52
+ <h3 className="text-sm font-semibold text-slate-800 dark:text-white/90 line-clamp-1">
53
+ {product.name}
54
+ </h3>
55
+ {product.price && (
56
+ <span className="text-sm font-bold" style={{ color: primaryColor }}>
57
+ {typeof product.price === 'number' ? `$${product.price}` : product.price}
58
+ </span>
59
+ )}
60
+ </div>
61
+
62
+ {product.description && (
63
+ <p className="text-[11px] text-slate-500 dark:text-white/50 line-clamp-2 leading-relaxed">
64
+ {product.description}
65
+ </p>
66
+ )}
67
+
68
+ <div className="mt-2 flex gap-2">
69
+ <button
70
+ className="flex-1 py-2 px-3 rounded-lg text-[11px] font-medium text-white shadow-sm hover:opacity-90 transition-opacity flex items-center justify-center gap-1.5"
71
+ style={{ background: primaryColor }}
72
+ >
73
+ <ShoppingCart className="w-3 h-3" />
74
+ Add to Cart
75
+ </button>
76
+ {product.link && (
77
+ <a
78
+ href={product.link}
79
+ target="_blank"
80
+ rel="noopener noreferrer"
81
+ className="p-2 rounded-lg bg-slate-100 dark:bg-white/10 text-slate-600 dark:text-white/70 hover:bg-slate-200 dark:hover:bg-white/20 transition-colors"
82
+ >
83
+ <ExternalLink className="w-3.5 h-3.5" />
84
+ </a>
85
+ )}
86
+ </div>
87
+ </div>
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import React, { useRef } from 'react';
4
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
5
+ import { Product, ProductCard } from './ProductCard';
6
+
7
+ interface ProductCarouselProps {
8
+ products: Product[];
9
+ primaryColor?: string;
10
+ }
11
+
12
+ export function ProductCarousel({ products, primaryColor = '#6366f1' }: ProductCarouselProps) {
13
+ const scrollRef = useRef<HTMLDivElement>(null);
14
+
15
+ const scroll = (direction: 'left' | 'right') => {
16
+ if (scrollRef.current) {
17
+ const firstCard = scrollRef.current.firstElementChild as HTMLElement;
18
+ if (firstCard) {
19
+ // Use actual rendered width + gap
20
+ const cardWidth = firstCard.offsetWidth + 16;
21
+ const scrollAmount = direction === 'left' ? -cardWidth : cardWidth;
22
+ scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
23
+ }
24
+ }
25
+ };
26
+
27
+ if (!products || products.length === 0) return null;
28
+
29
+ return (
30
+ <div className="relative w-full my-4 group/carousel">
31
+ {/* Scroll Buttons */}
32
+ <div className="absolute -left-4 top-1/2 -translate-y-1/2 z-20 opacity-100 md:opacity-0 md:group-hover/carousel:opacity-100 transition-opacity">
33
+ <button
34
+ onClick={(e) => { e.stopPropagation(); scroll('left'); }}
35
+ className="p-2 rounded-full bg-white dark:bg-slate-800 shadow-xl border border-slate-200 dark:border-white/10 text-slate-600 dark:text-white/80 hover:text-indigo-500 dark:hover:text-indigo-400 transition-all hover:scale-110 active:scale-95"
36
+ aria-label="Scroll left"
37
+ >
38
+ <ChevronLeft className="w-5 h-5" />
39
+ </button>
40
+ </div>
41
+
42
+ <div className="absolute -right-4 top-1/2 -translate-y-1/2 z-20 opacity-100 md:opacity-0 md:group-hover/carousel:opacity-100 transition-opacity">
43
+ <button
44
+ onClick={(e) => { e.stopPropagation(); scroll('right'); }}
45
+ className="p-2 rounded-full bg-white dark:bg-slate-800 shadow-xl border border-slate-200 dark:border-white/10 text-slate-600 dark:text-white/80 hover:text-indigo-500 dark:hover:text-indigo-400 transition-all hover:scale-110 active:scale-95"
46
+ aria-label="Scroll right"
47
+ >
48
+ <ChevronRight className="w-5 h-5" />
49
+ </button>
50
+ </div>
51
+
52
+ {/* Carousel Container */}
53
+ <div
54
+ ref={scrollRef}
55
+ className="flex gap-4 overflow-x-auto pb-4 px-1 scrollbar-hide snap-x snap-mandatory"
56
+ style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
57
+ >
58
+ {products.map((product) => (
59
+ <div
60
+ key={product.id}
61
+ className="snap-start flex-shrink-0 w-[85%] sm:w-[48%] md:w-[31%] min-w-[200px] max-w-[300px]"
62
+ >
63
+ <ProductCard product={product} primaryColor={primaryColor} />
64
+ </div>
65
+ ))}
66
+ </div>
67
+ </div>
68
+ );
69
+ }