@retrivora-ai/rag-engine 1.1.4 → 1.1.5
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.
- package/dist/index.js +223 -95
- package/dist/index.mjs +199 -71
- package/package.json +1 -1
- package/src/app/globals.css +9 -0
- package/src/components/MessageBubble.tsx +84 -1
- package/src/components/ProductCard.tsx +90 -0
- package/src/components/ProductCarousel.tsx +59 -0
|
@@ -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 */}
|
|
@@ -64,8 +141,14 @@ 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
|
+
|
|
147
|
+
{/* Product Carousel */}
|
|
148
|
+
{hasProducts && (
|
|
149
|
+
<ProductCarousel products={allProducts} primaryColor={primaryColor} />
|
|
150
|
+
)}
|
|
151
|
+
|
|
69
152
|
{isStreaming && message.content && (
|
|
70
153
|
<span className="inline-block w-1.5 h-3.5 ml-1 bg-emerald-500 animate-pulse align-middle" />
|
|
71
154
|
)}
|
|
@@ -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-64 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,59 @@
|
|
|
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 scrollAmount = direction === 'left' ? -280 : 280;
|
|
18
|
+
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
if (!products || products.length === 0) return null;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="relative w-full my-4 group/carousel">
|
|
26
|
+
{/* Scroll Buttons */}
|
|
27
|
+
<div className="absolute -left-3 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover/carousel:opacity-100 transition-opacity">
|
|
28
|
+
<button
|
|
29
|
+
onClick={() => scroll('left')}
|
|
30
|
+
className="p-1.5 rounded-full bg-white dark:bg-slate-800 shadow-lg 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-colors"
|
|
31
|
+
>
|
|
32
|
+
<ChevronLeft className="w-4 h-4" />
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div className="absolute -right-3 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover/carousel:opacity-100 transition-opacity">
|
|
37
|
+
<button
|
|
38
|
+
onClick={() => scroll('right')}
|
|
39
|
+
className="p-1.5 rounded-full bg-white dark:bg-slate-800 shadow-lg 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-colors"
|
|
40
|
+
>
|
|
41
|
+
<ChevronRight className="w-4 h-4" />
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{/* Carousel Container */}
|
|
46
|
+
<div
|
|
47
|
+
ref={scrollRef}
|
|
48
|
+
className="flex gap-4 overflow-x-auto pb-4 px-1 scrollbar-hide snap-x snap-mandatory"
|
|
49
|
+
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
50
|
+
>
|
|
51
|
+
{products.map((product) => (
|
|
52
|
+
<div key={product.id} className="snap-start">
|
|
53
|
+
<ProductCard product={product} primaryColor={primaryColor} />
|
|
54
|
+
</div>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|