@retrivora-ai/rag-engine 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-OHXRQTQH.mjs → chunk-M32ZXLTT.mjs} +399 -20
- package/dist/handlers/index.d.mts +1 -1
- package/dist/handlers/index.d.ts +1 -1
- package/dist/handlers/index.js +402 -22
- package/dist/handlers/index.mjs +5 -3
- package/dist/{index-kUXnRvuI.d.mts → index-BejNscWZ.d.mts} +4 -2
- package/dist/{index-B67KQ9NN.d.ts → index-CbkMJvu5.d.ts} +4 -2
- package/dist/index.js +29 -32
- package/dist/index.mjs +29 -32
- package/dist/server.d.mts +2 -2
- package/dist/server.d.ts +2 -2
- package/dist/server.js +400 -20
- package/dist/server.mjs +1 -1
- package/package.json +1 -1
- package/src/components/MessageBubble.tsx +43 -55
- package/src/components/VisualizationRenderer.tsx +341 -0
- package/src/core/Pipeline.ts +39 -19
- package/src/handlers/index.ts +21 -3
- package/src/hooks/useUITransform.ts +54 -0
- package/src/utils/UITransformer.ts +535 -0
|
@@ -32,8 +32,8 @@ function sanitizeJson(raw: string): string {
|
|
|
32
32
|
},
|
|
33
33
|
);
|
|
34
34
|
|
|
35
|
-
// 2. Strip JS-style single-line comments
|
|
36
|
-
s = s.replace(
|
|
35
|
+
// 2. Strip JS-style single-line comments
|
|
36
|
+
s = s.replace(/\/\/[^\n]*/g, '');
|
|
37
37
|
|
|
38
38
|
// 3. Quote bare (unquoted) object keys
|
|
39
39
|
s = s.replace(/([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g, '$1"$2":');
|
|
@@ -103,7 +103,7 @@ function extractLikelyJsonBlock(raw: string): string {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function looksLikeStructuredPayload(raw: string): boolean {
|
|
106
|
-
return /"?(view|chartType|type|data|items|rows|products|results
|
|
106
|
+
return /"?(view|chartType|type|data|items|rows|products|results)"?\s*:/.test(raw);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
function stripMarkdownTables(raw: string): string {
|
|
@@ -204,23 +204,12 @@ function isLikelyUiOnlyMessage(text: string): boolean {
|
|
|
204
204
|
// ─── Tiny helpers ─────────────────────────────────────────────────────────────
|
|
205
205
|
|
|
206
206
|
function resolveImage(data: Record<string, unknown>): string | undefined {
|
|
207
|
-
const
|
|
208
|
-
for (const key of candidates) {
|
|
207
|
+
for (const key of ['image', 'img', 'thumbnail']) {
|
|
209
208
|
const v = data[key];
|
|
210
|
-
if (typeof v === 'string' && v
|
|
211
|
-
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
212
|
-
const subVal = (v as Record<string, unknown>).url || (v as Record<string, unknown>).link || (v as Record<string, unknown>).src;
|
|
213
|
-
if (typeof subVal === 'string' && subVal.startsWith('http')) return subVal;
|
|
214
|
-
}
|
|
209
|
+
if (typeof v === 'string' && v) return v;
|
|
215
210
|
}
|
|
216
|
-
if (Array.isArray(data.images)) {
|
|
217
|
-
|
|
218
|
-
if (typeof item === 'string' && item.startsWith('http')) return item;
|
|
219
|
-
if (item && typeof item === 'object') {
|
|
220
|
-
const url = item.url || item.link || item.src;
|
|
221
|
-
if (typeof url === 'string' && url.startsWith('http')) return url;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
211
|
+
if (Array.isArray(data.images) && typeof data.images[0] === 'string') {
|
|
212
|
+
return data.images[0];
|
|
224
213
|
}
|
|
225
214
|
return undefined;
|
|
226
215
|
}
|
|
@@ -436,21 +425,16 @@ function resolveStructuredRows(config: Partial<RawUIConfig> & Record<string, unk
|
|
|
436
425
|
return [];
|
|
437
426
|
}
|
|
438
427
|
|
|
439
|
-
function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, viewportSize = 'large' }: {
|
|
428
|
+
function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAddToCart, viewportSize = 'large' }: {
|
|
440
429
|
rawContent: string;
|
|
441
430
|
primaryColor?: string;
|
|
442
431
|
accentColor?: string;
|
|
443
432
|
isStreaming?: boolean;
|
|
433
|
+
onAddToCart?: (product: Product) => void;
|
|
444
434
|
viewportSize?: ChatViewportSize;
|
|
445
435
|
}) {
|
|
446
|
-
const result = React.useMemo<{ config: UIConfig } | { error: string } | { loading: true
|
|
447
|
-
if (isStreaming) {
|
|
448
|
-
const looksLikeCarousel = rawContent.includes('"view": "carousel"') ||
|
|
449
|
-
rawContent.includes('"view":"carousel"') ||
|
|
450
|
-
rawContent.includes('"type": "products"') ||
|
|
451
|
-
rawContent.includes('"type":"products"');
|
|
452
|
-
return { loading: true, silent: looksLikeCarousel };
|
|
453
|
-
}
|
|
436
|
+
const result = React.useMemo<{ config: UIConfig } | { error: string } | { loading: true }>(() => {
|
|
437
|
+
if (isStreaming) return { loading: true };
|
|
454
438
|
try {
|
|
455
439
|
const clean = rawContent
|
|
456
440
|
.replace(/^```[a-z]*\s*/i, '')
|
|
@@ -461,18 +445,18 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, view
|
|
|
461
445
|
const resolvedRows = resolveStructuredRows(config);
|
|
462
446
|
|
|
463
447
|
// ─── Intent Healing & Auto-Detection ───────────────────────────────────
|
|
464
|
-
|
|
465
|
-
const hasProductLikeData = resolvedRows.length > 0 &&
|
|
466
|
-
typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
|
|
448
|
+
|
|
449
|
+
const hasProductLikeData = resolvedRows.length > 0 &&
|
|
450
|
+
typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
|
|
467
451
|
!Array.isArray(resolvedRows[0]) &&
|
|
468
|
-
(['price', 'brand', 'image', 'link', 'thumbnail'].some(key => key in resolvedRows[0]) ||
|
|
469
|
-
|
|
452
|
+
(['price', 'brand', 'image', 'link', 'thumbnail'].some(key => key in resolvedRows[0]) ||
|
|
453
|
+
Object.keys(resolvedRows[0]).length >= 4);
|
|
470
454
|
|
|
471
|
-
const hasAggregationLikeData = resolvedRows.length > 0 &&
|
|
472
|
-
typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
|
|
455
|
+
const hasAggregationLikeData = resolvedRows.length > 0 &&
|
|
456
|
+
typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
|
|
473
457
|
!Array.isArray(resolvedRows[0]) &&
|
|
474
|
-
(['value', 'count', 'total', 'percentage'].some(key => key in resolvedRows[0]) ||
|
|
475
|
-
|
|
458
|
+
(['value', 'count', 'total', 'percentage'].some(key => key in resolvedRows[0]) ||
|
|
459
|
+
(Object.keys(resolvedRows[0]).length <= 3 && Object.values(resolvedRows[0]).some(v => typeof v === 'number')));
|
|
476
460
|
|
|
477
461
|
// 1. Healing: Override explicit view if it contradicts the data shape
|
|
478
462
|
if (config.view === 'chart' && hasProductLikeData && !hasAggregationLikeData) {
|
|
@@ -492,8 +476,8 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, view
|
|
|
492
476
|
(typeof config.chartType === 'string' && ['pie', 'bar', 'line'].includes(config.chartType))
|
|
493
477
|
) {
|
|
494
478
|
config.view = 'chart';
|
|
495
|
-
config.chartType = (config.chartType as 'pie' | 'bar' | 'line') ||
|
|
496
|
-
|
|
479
|
+
config.chartType = (config.chartType as 'pie' | 'bar' | 'line') ||
|
|
480
|
+
(config.type as 'pie' | 'bar' | 'line') || 'bar';
|
|
497
481
|
} else {
|
|
498
482
|
config.view = 'table';
|
|
499
483
|
}
|
|
@@ -511,7 +495,6 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, view
|
|
|
511
495
|
}, [rawContent, isStreaming]);
|
|
512
496
|
|
|
513
497
|
if ('loading' in result) {
|
|
514
|
-
if (result.silent) return null;
|
|
515
498
|
return (
|
|
516
499
|
<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">
|
|
517
500
|
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
|
@@ -564,14 +547,16 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, view
|
|
|
564
547
|
</div>
|
|
565
548
|
);
|
|
566
549
|
case 'carousel':
|
|
567
|
-
// We only render title/description here. The actual carousel is rendered separately
|
|
568
|
-
// at the bottom of the MessageBubble to avoid being trapped in the streaming loading box.
|
|
569
550
|
return (
|
|
570
551
|
<div className="my-4">
|
|
571
552
|
{config.title && <h4 className={`${isCompact ? 'mb-1.5 text-[11px]' : 'mb-2 text-xs'} font-semibold text-slate-500`}>{config.title}</h4>}
|
|
572
553
|
{hasDescription && (
|
|
573
554
|
<p className={`mb-3 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
|
|
574
555
|
)}
|
|
556
|
+
<ProductCarousel products={(config.data as unknown as Product[]).map(item => ({
|
|
557
|
+
...item,
|
|
558
|
+
image: item.image ?? (typeof resolveImage === 'function' ? resolveImage(item as unknown as Record<string, unknown>) : undefined)
|
|
559
|
+
}))} primaryColor={primaryColor} onAddToCart={onAddToCart} />
|
|
575
560
|
</div>
|
|
576
561
|
);
|
|
577
562
|
case 'table':
|
|
@@ -614,6 +599,11 @@ export function MessageBubble({
|
|
|
614
599
|
const isCompact = viewportSize === 'compact';
|
|
615
600
|
const isMedium = viewportSize === 'medium';
|
|
616
601
|
const [showSources, setShowSources] = React.useState(false);
|
|
602
|
+
const hasStructuredProductBlock = React.useMemo(
|
|
603
|
+
() => /```(?:ui|json|chart)\s*[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products|table)"?/i.test(message.content) ||
|
|
604
|
+
(/\{[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products)"?[\s\S]*\}/i.test(message.content) && looksLikeStructuredPayload(message.content)),
|
|
605
|
+
[message.content],
|
|
606
|
+
);
|
|
617
607
|
const structuredContent = React.useMemo(
|
|
618
608
|
() => isUser ? { payload: null, text: message.content } : extractStructuredPayload(message.content),
|
|
619
609
|
[isUser, message.content],
|
|
@@ -728,23 +718,17 @@ export function MessageBubble({
|
|
|
728
718
|
.replace(/(\|[^\n]*\|)\n\n+(?=\|)/g, '$1\n'); // Remove blank lines between rows
|
|
729
719
|
|
|
730
720
|
// 2. HEALING: Detect naked JSON charts/products and wrap them if markers are missing
|
|
731
|
-
// This handles models (like Llama 3.2) that often omit the ```
|
|
732
|
-
if (!isStreaming
|
|
721
|
+
// This handles models (like Llama 3.2) that often omit the ```chart markers.
|
|
722
|
+
if (!isStreaming) {
|
|
733
723
|
// Look for objects that look like chart or product data
|
|
734
|
-
raw = raw.replace(/(?:^|\n)\s
|
|
724
|
+
raw = raw.replace(/(?:^|\n)\s*\{[\s\S]*\}\s*(?:\n|$)/g, (match) => {
|
|
735
725
|
// Only wrap if not already in a code block
|
|
736
726
|
if (match.includes('```')) return match;
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const type = trimmedBody.includes('"view"') ||
|
|
741
|
-
trimmedBody.includes('"chartType"') ||
|
|
742
|
-
trimmedBody.includes('"type"') ||
|
|
743
|
-
trimmedBody.includes('"xAxisKey"')
|
|
727
|
+
if (!looksLikeStructuredPayload(match)) return match;
|
|
728
|
+
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"')
|
|
744
729
|
? 'ui'
|
|
745
730
|
: 'json';
|
|
746
|
-
|
|
747
|
-
return `\n\n\`\`\`${type}\n${trimmedBody}\n\`\`\`\n\n`;
|
|
731
|
+
return `\n\n\`\`\`${type}\n${match.trim()}\n\`\`\`\n\n`;
|
|
748
732
|
});
|
|
749
733
|
}
|
|
750
734
|
|
|
@@ -840,6 +824,7 @@ export function MessageBubble({
|
|
|
840
824
|
primaryColor={primaryColor}
|
|
841
825
|
accentColor={accentColor}
|
|
842
826
|
isStreaming={isStreaming}
|
|
827
|
+
onAddToCart={onAddToCart}
|
|
843
828
|
viewportSize={viewportSize}
|
|
844
829
|
/>
|
|
845
830
|
);
|
|
@@ -854,6 +839,7 @@ export function MessageBubble({
|
|
|
854
839
|
primaryColor={primaryColor}
|
|
855
840
|
accentColor={accentColor}
|
|
856
841
|
isStreaming={isStreaming}
|
|
842
|
+
onAddToCart={onAddToCart}
|
|
857
843
|
viewportSize={viewportSize}
|
|
858
844
|
/>
|
|
859
845
|
);
|
|
@@ -869,6 +855,7 @@ export function MessageBubble({
|
|
|
869
855
|
primaryColor={primaryColor}
|
|
870
856
|
accentColor={accentColor}
|
|
871
857
|
isStreaming={isStreaming}
|
|
858
|
+
onAddToCart={onAddToCart}
|
|
872
859
|
viewportSize={viewportSize}
|
|
873
860
|
/>
|
|
874
861
|
);
|
|
@@ -895,7 +882,7 @@ export function MessageBubble({
|
|
|
895
882
|
);
|
|
896
883
|
},
|
|
897
884
|
}),
|
|
898
|
-
[primaryColor, accentColor, isStreaming, viewportSize],
|
|
885
|
+
[primaryColor, accentColor, isStreaming, onAddToCart, viewportSize],
|
|
899
886
|
);
|
|
900
887
|
|
|
901
888
|
// ── Render ─────────────────────────────────────────────────────────────────
|
|
@@ -937,6 +924,7 @@ export function MessageBubble({
|
|
|
937
924
|
primaryColor={primaryColor}
|
|
938
925
|
accentColor={accentColor}
|
|
939
926
|
isStreaming={isStreaming}
|
|
927
|
+
onAddToCart={onAddToCart}
|
|
940
928
|
viewportSize={viewportSize}
|
|
941
929
|
/>
|
|
942
930
|
)}
|
|
@@ -955,7 +943,7 @@ export function MessageBubble({
|
|
|
955
943
|
</div>
|
|
956
944
|
|
|
957
945
|
{/* Product Carousel */}
|
|
958
|
-
{!isUser && allProducts.length > 0 && (
|
|
946
|
+
{!isUser && !hasStructuredProductBlock && allProducts.length > 0 && (
|
|
959
947
|
<div className="w-full mt-1">
|
|
960
948
|
<ProductCarousel
|
|
961
949
|
products={allProducts}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Image from 'next/image';
|
|
4
|
+
import {
|
|
5
|
+
UITransformationResponse,
|
|
6
|
+
VisualizationType,
|
|
7
|
+
PieChartData,
|
|
8
|
+
BarChartData,
|
|
9
|
+
LineChartDataPoint,
|
|
10
|
+
TableData,
|
|
11
|
+
CarouselProduct,
|
|
12
|
+
} from '@/utils/UITransformer';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Props for the VisualizationRenderer component
|
|
16
|
+
*/
|
|
17
|
+
interface VisualizationRendererProps {
|
|
18
|
+
data: UITransformationResponse;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* VisualizationRenderer — Renders UI transformations as React components
|
|
24
|
+
*
|
|
25
|
+
* Dynamically selects the appropriate visualization component based on the
|
|
26
|
+
* transformation type returned by UITransformer.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* const transformation = UITransformer.transform(query, results);
|
|
30
|
+
* return <VisualizationRenderer data={transformation} />;
|
|
31
|
+
*/
|
|
32
|
+
export function VisualizationRenderer({
|
|
33
|
+
data,
|
|
34
|
+
className = '',
|
|
35
|
+
}: VisualizationRendererProps) {
|
|
36
|
+
if (!data) {
|
|
37
|
+
return (
|
|
38
|
+
<div className={`p-4 text-gray-500 ${className}`}>
|
|
39
|
+
No data available for visualization
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className={`space-y-4 ${className}`}>
|
|
46
|
+
<div className="space-y-2">
|
|
47
|
+
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
48
|
+
{data.title}
|
|
49
|
+
</h3>
|
|
50
|
+
{data.description && (
|
|
51
|
+
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
52
|
+
{data.description}
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{renderVisualization(data.type, data.data)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Internal helper to render the appropriate visualization
|
|
64
|
+
*/
|
|
65
|
+
function renderVisualization(type: VisualizationType, data: unknown): React.ReactNode {
|
|
66
|
+
switch (type) {
|
|
67
|
+
case 'pie_chart':
|
|
68
|
+
return renderPieChart(data as PieChartData[]);
|
|
69
|
+
case 'bar_chart':
|
|
70
|
+
return renderBarChart(data as BarChartData[]);
|
|
71
|
+
case 'line_chart':
|
|
72
|
+
return renderLineChart(data as LineChartDataPoint[]);
|
|
73
|
+
case 'table':
|
|
74
|
+
return renderTable(data as TableData);
|
|
75
|
+
case 'product_carousel':
|
|
76
|
+
return renderProductCarousel(data as CarouselProduct[]);
|
|
77
|
+
case 'text':
|
|
78
|
+
default:
|
|
79
|
+
return renderText(data);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Render pie chart
|
|
85
|
+
*/
|
|
86
|
+
function renderPieChart(data: PieChartData[]): React.ReactNode {
|
|
87
|
+
if (!Array.isArray(data)) {
|
|
88
|
+
return <div className="text-red-500">Invalid pie chart data</div>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
|
93
|
+
<div className="flex flex-wrap gap-2">
|
|
94
|
+
{data.map((item: PieChartData, idx: number) => (
|
|
95
|
+
<div
|
|
96
|
+
key={idx}
|
|
97
|
+
className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700 rounded"
|
|
98
|
+
>
|
|
99
|
+
<div
|
|
100
|
+
className="w-3 h-3 rounded-full"
|
|
101
|
+
style={{ backgroundColor: generateColor(idx) }}
|
|
102
|
+
/>
|
|
103
|
+
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
104
|
+
{item.label}: {item.value}
|
|
105
|
+
</span>
|
|
106
|
+
{item.inStock !== undefined && (
|
|
107
|
+
<span
|
|
108
|
+
className={`text-xs px-2 py-1 rounded ${
|
|
109
|
+
item.inStock
|
|
110
|
+
? 'bg-green-100 text-green-800'
|
|
111
|
+
: 'bg-red-100 text-red-800'
|
|
112
|
+
}`}
|
|
113
|
+
>
|
|
114
|
+
{item.inStock ? 'In Stock' : 'Out of Stock'}
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render bar chart
|
|
126
|
+
*/
|
|
127
|
+
function renderBarChart(data: BarChartData[]): React.ReactNode {
|
|
128
|
+
if (!Array.isArray(data)) {
|
|
129
|
+
return <div className="text-red-500">Invalid bar chart data</div>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow overflow-x-auto">
|
|
134
|
+
<div className="space-y-3">
|
|
135
|
+
{data.map((item: BarChartData, idx: number) => (
|
|
136
|
+
<div key={idx} className="space-y-1">
|
|
137
|
+
<div className="flex justify-between items-center">
|
|
138
|
+
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
139
|
+
{item.category}
|
|
140
|
+
</span>
|
|
141
|
+
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
142
|
+
{item.value}
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
146
|
+
<div
|
|
147
|
+
className="h-full rounded-full transition-all"
|
|
148
|
+
style={{
|
|
149
|
+
width: `${(item.value / Math.max(...data.map((d: BarChartData) => d.value))) * 100}%`,
|
|
150
|
+
backgroundColor: generateColor(idx),
|
|
151
|
+
}}
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
{item.inStock !== undefined && (
|
|
155
|
+
<span
|
|
156
|
+
className={`text-xs px-2 py-1 rounded inline-block ${
|
|
157
|
+
item.inStock
|
|
158
|
+
? 'bg-green-100 text-green-800'
|
|
159
|
+
: 'bg-red-100 text-red-800'
|
|
160
|
+
}`}
|
|
161
|
+
>
|
|
162
|
+
{item.inStock ? 'In Stock' : 'Out of Stock'}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render line chart (simplified text representation)
|
|
174
|
+
*/
|
|
175
|
+
function renderLineChart(data: LineChartDataPoint[]): React.ReactNode {
|
|
176
|
+
if (!Array.isArray(data)) {
|
|
177
|
+
return <div className="text-red-500">Invalid line chart data</div>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow overflow-x-auto">
|
|
182
|
+
<div className="space-y-2">
|
|
183
|
+
{data.map((point: LineChartDataPoint, idx: number) => (
|
|
184
|
+
<div
|
|
185
|
+
key={idx}
|
|
186
|
+
className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-700 rounded"
|
|
187
|
+
>
|
|
188
|
+
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
189
|
+
{point.label || point.timestamp}
|
|
190
|
+
</span>
|
|
191
|
+
<span className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
192
|
+
{typeof point.value === 'number' ? point.value.toFixed(2) : point.value}
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Render table
|
|
203
|
+
*/
|
|
204
|
+
function renderTable(data: TableData): React.ReactNode {
|
|
205
|
+
if (!data.columns || !data.rows) {
|
|
206
|
+
return <div className="text-red-500">Invalid table data</div>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
|
|
211
|
+
<table className="w-full text-sm">
|
|
212
|
+
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
|
213
|
+
<tr>
|
|
214
|
+
{data.columns.map((col: string, idx: number) => (
|
|
215
|
+
<th
|
|
216
|
+
key={idx}
|
|
217
|
+
className="px-4 py-2 text-left font-semibold text-gray-900 dark:text-white"
|
|
218
|
+
>
|
|
219
|
+
{col}
|
|
220
|
+
</th>
|
|
221
|
+
))}
|
|
222
|
+
</tr>
|
|
223
|
+
</thead>
|
|
224
|
+
<tbody className="divide-y divide-gray-200 dark:divide-gray-600">
|
|
225
|
+
{data.rows.map((row: (string | number | boolean)[], rowIdx: number) => (
|
|
226
|
+
<tr
|
|
227
|
+
key={rowIdx}
|
|
228
|
+
className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
229
|
+
>
|
|
230
|
+
{row.map((cell: string | number | boolean, cellIdx: number) => (
|
|
231
|
+
<td
|
|
232
|
+
key={cellIdx}
|
|
233
|
+
className="px-4 py-2 text-gray-700 dark:text-gray-300"
|
|
234
|
+
>
|
|
235
|
+
{String(cell)}
|
|
236
|
+
</td>
|
|
237
|
+
))}
|
|
238
|
+
</tr>
|
|
239
|
+
))}
|
|
240
|
+
</tbody>
|
|
241
|
+
</table>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Render product carousel
|
|
248
|
+
*/
|
|
249
|
+
function renderProductCarousel(data: CarouselProduct[]): React.ReactNode {
|
|
250
|
+
if (!Array.isArray(data)) {
|
|
251
|
+
return <div className="text-red-500">Invalid carousel data</div>;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<div className="overflow-x-auto">
|
|
256
|
+
<div className="flex gap-4 pb-4">
|
|
257
|
+
{data.map((product: CarouselProduct) => (
|
|
258
|
+
<div
|
|
259
|
+
key={product.id}
|
|
260
|
+
className="flex-shrink-0 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
|
|
261
|
+
>
|
|
262
|
+
{product.image && (
|
|
263
|
+
<div className="relative w-full h-40 bg-gray-200">
|
|
264
|
+
<Image
|
|
265
|
+
src={product.image}
|
|
266
|
+
alt={product.name}
|
|
267
|
+
fill
|
|
268
|
+
className="object-cover"
|
|
269
|
+
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
|
270
|
+
/>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
<div className="p-4 space-y-2">
|
|
274
|
+
<h4 className="font-semibold text-gray-900 dark:text-white line-clamp-2">
|
|
275
|
+
{product.name}
|
|
276
|
+
</h4>
|
|
277
|
+
{product.brand && (
|
|
278
|
+
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
279
|
+
Brand: {product.brand}
|
|
280
|
+
</p>
|
|
281
|
+
)}
|
|
282
|
+
{product.price && (
|
|
283
|
+
<p className="text-lg font-bold text-gray-900 dark:text-white">
|
|
284
|
+
${typeof product.price === 'number' ? product.price.toFixed(2) : product.price}
|
|
285
|
+
</p>
|
|
286
|
+
)}
|
|
287
|
+
{product.description && (
|
|
288
|
+
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
|
289
|
+
{product.description}
|
|
290
|
+
</p>
|
|
291
|
+
)}
|
|
292
|
+
<div className="flex gap-2 pt-2">
|
|
293
|
+
<span
|
|
294
|
+
className={`text-xs px-2 py-1 rounded flex-1 text-center ${
|
|
295
|
+
product.inStock
|
|
296
|
+
? 'bg-green-100 text-green-800'
|
|
297
|
+
: 'bg-red-100 text-red-800'
|
|
298
|
+
}`}
|
|
299
|
+
>
|
|
300
|
+
{product.inStock ? 'In Stock' : 'Out of Stock'}
|
|
301
|
+
</span>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Render text
|
|
313
|
+
*/
|
|
314
|
+
function renderText(data: unknown): React.ReactNode {
|
|
315
|
+
const content = (data as { content?: string })?.content || String(data);
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow prose dark:prose-invert max-w-none">
|
|
319
|
+
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
|
320
|
+
{content}
|
|
321
|
+
</p>
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Utility to generate consistent colors for charts
|
|
328
|
+
*/
|
|
329
|
+
function generateColor(index: number): string {
|
|
330
|
+
const colors = [
|
|
331
|
+
'#3B82F6', // blue
|
|
332
|
+
'#10B981', // green
|
|
333
|
+
'#F59E0B', // amber
|
|
334
|
+
'#EF4444', // red
|
|
335
|
+
'#8B5CF6', // purple
|
|
336
|
+
'#EC4899', // pink
|
|
337
|
+
'#06B6D4', // cyan
|
|
338
|
+
'#F97316', // orange
|
|
339
|
+
];
|
|
340
|
+
return colors[index % colors.length];
|
|
341
|
+
}
|
package/src/core/Pipeline.ts
CHANGED
|
@@ -101,16 +101,15 @@ export class Pipeline {
|
|
|
101
101
|
|
|
102
102
|
// Augment system prompt with a Universal Visualization Protocol
|
|
103
103
|
// We use a unique marker to ensure this is only injected once but is ALWAYS present.
|
|
104
|
-
const CHART_MARKER = '<!--
|
|
104
|
+
const CHART_MARKER = '<!-- UI_PROTOCOL_V6 -->';
|
|
105
105
|
const chartInstruction = `\n\n${CHART_MARKER}
|
|
106
|
-
### UNIVERSAL UI PROTOCOL
|
|
107
|
-
|
|
108
|
-
NEVER output naked JSON. If you provide a visualization, do not also provide a markdown table of the same data unless explicitly asked.
|
|
106
|
+
### UNIVERSAL UI PROTOCOL
|
|
107
|
+
When the user asks for a visual representation, comparison, distribution, breakdown, table, list, catalog, products, carousel, stock view, or category summary, you MUST include exactly one \`\`\`ui\`\`\` block.
|
|
109
108
|
|
|
110
109
|
1. VIEW SELECTION
|
|
111
|
-
- Use "carousel" for browsing products, items, or
|
|
112
|
-
- Use "chart" ONLY for numerical aggregations
|
|
113
|
-
- Use "table" for technical
|
|
110
|
+
- Use "carousel" for browsing specific products, items, or a catalog. This is the DEFAULT for "show me", "what are", or "find me" requests for a specific category (e.g. "beauty").
|
|
111
|
+
- Use "chart" ONLY for numerical aggregations: distributions, percentages, counts, comparisons over time, or trends (e.g. "how many beauty products?").
|
|
112
|
+
- Use "table" for detailed technical specifications, row-heavy comparisons, or when the user explicitly asks for a list/table format.
|
|
114
113
|
|
|
115
114
|
2. REQUIRED JSON SHAPE
|
|
116
115
|
{
|
|
@@ -118,31 +117,52 @@ NEVER output naked JSON. If you provide a visualization, do not also provide a m
|
|
|
118
117
|
"title": "Short heading",
|
|
119
118
|
"description": "One sentence describing the view",
|
|
120
119
|
"chartType": "pie" | "bar" | "line", // Required if view is "chart"
|
|
121
|
-
"
|
|
120
|
+
"xAxisKey": "label",
|
|
121
|
+
"dataKeys": ["value"],
|
|
122
|
+
"columns": ["dynamicFieldKey1", "dynamicFieldKey2"],
|
|
123
|
+
"insights": ["Short takeaway 1", "Short takeaway 2"],
|
|
124
|
+
"data": []
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
3. DATA RULES
|
|
125
|
-
- Build the UI payload from retrieved context
|
|
126
|
-
- For CAROUSEL: "data"
|
|
127
|
-
-
|
|
128
|
+
- Build the UI payload from retrieved context only. Do not invent products or values.
|
|
129
|
+
- For CAROUSEL: "data" must be a list of product objects (id, name, brand, price, image, link).
|
|
130
|
+
- For CHART: "data" must be aggregated counts or sums. Never put raw product lists in a chart view.
|
|
131
|
+
- If the user asks for "products in beauty", do NOT return a pie chart of categories; return a carousel of the beauty products themselves.
|
|
128
132
|
|
|
129
133
|
4. FORMAT RULES
|
|
130
|
-
-
|
|
131
|
-
-
|
|
134
|
+
- Return valid JSON inside the \`\`\`ui\`\`\` block.
|
|
135
|
+
- Put natural language explanation before or after the block, not inside.
|
|
132
136
|
|
|
133
|
-
5.
|
|
134
|
-
User: "
|
|
135
|
-
Assistant:
|
|
137
|
+
5. EXAMPLES
|
|
138
|
+
User: "show me beauty products"
|
|
139
|
+
Assistant:
|
|
136
140
|
\`\`\`ui
|
|
137
141
|
{
|
|
138
142
|
"view": "carousel",
|
|
139
143
|
"title": "Beauty Products",
|
|
140
|
-
"description": "
|
|
144
|
+
"description": "Browse our latest skincare and makeup collection.",
|
|
141
145
|
"data": [
|
|
142
|
-
{ "id": "1", "name": "Mascara", "brand": "Essence", "price": "$9.99", "image": "
|
|
146
|
+
{ "id": "1", "name": "Mascara", "brand": "Essence", "price": "$9.99", "image": "...", "link": "..." },
|
|
147
|
+
{ "id": "2", "name": "Lipstick", "brand": "Chic", "price": "$12.99", "image": "...", "link": "..." }
|
|
143
148
|
]
|
|
144
149
|
}
|
|
145
150
|
\`\`\`
|
|
151
|
+
|
|
152
|
+
User: "breakdown of products by category"
|
|
153
|
+
Assistant:
|
|
154
|
+
\`\`\`ui
|
|
155
|
+
{
|
|
156
|
+
"view": "chart",
|
|
157
|
+
"title": "Category Breakdown",
|
|
158
|
+
"chartType": "pie",
|
|
159
|
+
"xAxisKey": "label",
|
|
160
|
+
"dataKeys": ["value"],
|
|
161
|
+
"data": [
|
|
162
|
+
{ "label": "Beauty", "value": 15 },
|
|
163
|
+
{ "label": "Fragrances", "value": 8 }
|
|
164
|
+
]
|
|
165
|
+
}
|
|
146
166
|
\`\`\``;
|
|
147
167
|
|
|
148
168
|
if (!this.config.llm.systemPrompt?.includes(CHART_MARKER)) {
|
|
@@ -387,7 +407,7 @@ Assistant: "I found several beauty products in our catalog:"
|
|
|
387
407
|
|
|
388
408
|
// 4. Context Augmentation
|
|
389
409
|
let context = sources.length
|
|
390
|
-
? sources.map((m, i) => `[Source ${i + 1}]\
|
|
410
|
+
? sources.map((m, i) => `[Source ${i + 1}]\n${m.content}`).join('\n\n---\n\n')
|
|
391
411
|
: 'No relevant context found.';
|
|
392
412
|
|
|
393
413
|
if (graphData && graphData.nodes.length > 0) {
|