@retrivora-ai/rag-engine 1.5.4 → 1.5.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.
- package/dist/{chunk-BLLMNP77.mjs → chunk-ID2Q4CF7.mjs} +58 -11
- package/dist/handlers/index.js +58 -11
- package/dist/handlers/index.mjs +1 -1
- package/dist/index.d.mts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +192 -62
- package/dist/index.mjs +192 -62
- package/dist/server.js +58 -11
- package/dist/server.mjs +1 -1
- package/package.json +1 -1
- package/src/app/api/chat/route.ts +2 -2
- package/src/components/ChatWindow.tsx +26 -0
- package/src/components/DynamicChart.tsx +113 -30
- package/src/components/MessageBubble.tsx +143 -26
- package/src/components/ProductCarousel.tsx +2 -2
- package/src/core/Pipeline.ts +58 -11
- package/src/types/props.ts +3 -0
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
|
|
6
6
|
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
|
|
7
7
|
} from 'recharts';
|
|
8
|
+
import { ChatViewportSize } from '../types/props';
|
|
8
9
|
|
|
9
10
|
export interface ChartConfig {
|
|
10
11
|
type: 'bar' | 'line' | 'pie';
|
|
@@ -18,13 +19,28 @@ interface DynamicChartProps {
|
|
|
18
19
|
config: ChartConfig;
|
|
19
20
|
primaryColor?: string;
|
|
20
21
|
accentColor?: string;
|
|
22
|
+
viewportSize?: ChatViewportSize;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
const DEFAULT_COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
|
24
26
|
|
|
25
|
-
export function DynamicChart({
|
|
27
|
+
export function DynamicChart({
|
|
28
|
+
config,
|
|
29
|
+
primaryColor = '#6366f1',
|
|
30
|
+
accentColor = '#8b5cf6',
|
|
31
|
+
viewportSize = 'large',
|
|
32
|
+
}: DynamicChartProps) {
|
|
26
33
|
const { type, data } = config;
|
|
27
34
|
const colors = config.colors || [primaryColor, accentColor, ...DEFAULT_COLORS];
|
|
35
|
+
const isCompact = viewportSize === 'compact';
|
|
36
|
+
const isMedium = viewportSize === 'medium';
|
|
37
|
+
const chartHeightClass = isCompact ? 'h-56 min-h-[220px]' : isMedium ? 'h-64 min-h-[250px]' : 'h-72 min-h-[280px]';
|
|
38
|
+
const pieOuterRadius = isCompact ? 56 : isMedium ? 68 : 80;
|
|
39
|
+
const pieLabel = ({ name, percent }: { name?: string; percent?: number }) =>
|
|
40
|
+
isCompact
|
|
41
|
+
? `${name}`
|
|
42
|
+
: `${name} ${((percent ?? 0) * 100).toFixed(0)}%`;
|
|
43
|
+
const axisTick = { fontSize: isCompact ? 10 : 12, fill: '#64748b' };
|
|
28
44
|
|
|
29
45
|
// CRITICAL: Sanitize the data array to remove any non-primitive values (objects/arrays)
|
|
30
46
|
// This prevents the "Objects are not valid as a React child" error in Tooltips/Legends
|
|
@@ -96,13 +112,87 @@ export function DynamicChart({ config, primaryColor = '#6366f1', accentColor = '
|
|
|
96
112
|
// Final sanity check: ensure all keys are strings
|
|
97
113
|
const finalXKey = String(resolvedXKey);
|
|
98
114
|
const finalDataKeys = resolvedDataKeys.map(String).filter(k => k !== 'undefined');
|
|
115
|
+
const pieDataKey = finalDataKeys[0] || 'value';
|
|
116
|
+
|
|
117
|
+
const stockAwareColor = (entry: Record<string, string | number | null>, index: number) => {
|
|
118
|
+
const stockStatus = String(
|
|
119
|
+
entry.stockStatus ??
|
|
120
|
+
entry.status ??
|
|
121
|
+
entry.availability ??
|
|
122
|
+
'',
|
|
123
|
+
).toLowerCase();
|
|
124
|
+
const inStockCount = typeof entry.inStockCount === 'number' ? entry.inStockCount : undefined;
|
|
125
|
+
const outOfStockCount = typeof entry.outOfStockCount === 'number' ? entry.outOfStockCount : undefined;
|
|
126
|
+
const inStockFlag = entry.inStock;
|
|
127
|
+
|
|
128
|
+
if (stockStatus.includes('in stock')) return '#10b981';
|
|
129
|
+
if (stockStatus.includes('out of stock')) return '#f97316';
|
|
130
|
+
if (typeof inStockFlag === 'string' && inStockFlag.toLowerCase() === 'true') return '#10b981';
|
|
131
|
+
if (inStockFlag === 1) return '#10b981';
|
|
132
|
+
if (inStockFlag === 0) return '#f97316';
|
|
133
|
+
if (typeof inStockCount === 'number' || typeof outOfStockCount === 'number') {
|
|
134
|
+
const inCount = inStockCount ?? 0;
|
|
135
|
+
const outCount = outOfStockCount ?? 0;
|
|
136
|
+
if (inCount > outCount) return '#10b981';
|
|
137
|
+
if (outCount > inCount) return '#f97316';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return colors[index % colors.length];
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const tooltipLabelMap: Record<string, string> = {
|
|
144
|
+
value: 'Value',
|
|
145
|
+
count: 'Count',
|
|
146
|
+
inStock: 'In stock',
|
|
147
|
+
inStockCount: 'In stock',
|
|
148
|
+
outOfStockCount: 'Out of stock',
|
|
149
|
+
stockStatus: 'Stock status',
|
|
150
|
+
category: 'Category',
|
|
151
|
+
label: 'Label',
|
|
152
|
+
name: 'Name',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const renderTooltip = (
|
|
156
|
+
row: Record<string, string | number | null> | null,
|
|
157
|
+
label?: string,
|
|
158
|
+
) => {
|
|
159
|
+
if (!row) return null;
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs shadow-xl dark:border-white/10 dark:bg-slate-950">
|
|
163
|
+
<p className="mb-2 font-semibold text-slate-800 dark:text-white/90">
|
|
164
|
+
{String(row[finalXKey] ?? label ?? 'Details')}
|
|
165
|
+
</p>
|
|
166
|
+
{Object.entries(row).map(([key, value]) => {
|
|
167
|
+
if (value === null || value === undefined || key === finalXKey) return null;
|
|
168
|
+
if (typeof value === 'object') return null;
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div key={key} className="flex items-center justify-between gap-3 text-slate-600 dark:text-white/70">
|
|
172
|
+
<span>{tooltipLabelMap[key] ?? key}</span>
|
|
173
|
+
<span className="font-medium text-slate-900 dark:text-white/90">{String(value)}</span>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const getTooltipRow = (payload: unknown): Record<string, string | number | null> | null => {
|
|
182
|
+
if (!Array.isArray(payload) || payload.length === 0) return null;
|
|
183
|
+
const first = payload[0];
|
|
184
|
+
if (!first || typeof first !== 'object' || !('payload' in first)) return null;
|
|
185
|
+
|
|
186
|
+
const row = (first as { payload?: unknown }).payload;
|
|
187
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return null;
|
|
188
|
+
|
|
189
|
+
return row as Record<string, string | number | null>;
|
|
190
|
+
};
|
|
99
191
|
|
|
100
192
|
// Handle Pie Chart separately as it has a different structure
|
|
101
193
|
if (type === 'pie') {
|
|
102
|
-
const pieDataKey = finalDataKeys[0] || 'value';
|
|
103
|
-
|
|
104
194
|
return (
|
|
105
|
-
<div className=
|
|
195
|
+
<div className={`w-full ${chartHeightClass} mt-4 mb-2 select-none overflow-hidden`}>
|
|
106
196
|
<ResponsiveContainer width="99%" height="100%">
|
|
107
197
|
<PieChart>
|
|
108
198
|
<Pie
|
|
@@ -111,17 +201,15 @@ export function DynamicChart({ config, primaryColor = '#6366f1', accentColor = '
|
|
|
111
201
|
nameKey={finalXKey}
|
|
112
202
|
cx="50%"
|
|
113
203
|
cy="50%"
|
|
114
|
-
outerRadius={
|
|
115
|
-
label={
|
|
204
|
+
outerRadius={pieOuterRadius}
|
|
205
|
+
label={pieLabel}
|
|
116
206
|
>
|
|
117
|
-
{
|
|
118
|
-
<Cell key={`cell-${index}`} fill={
|
|
207
|
+
{sanitizedData.map((entry, index) => (
|
|
208
|
+
<Cell key={`cell-${index}`} fill={stockAwareColor(entry, index)} />
|
|
119
209
|
))}
|
|
120
210
|
</Pie>
|
|
121
|
-
<Tooltip
|
|
122
|
-
|
|
123
|
-
/>
|
|
124
|
-
<Legend />
|
|
211
|
+
<Tooltip content={(props) => renderTooltip(getTooltipRow(props.payload), typeof props.label === 'string' ? props.label : undefined)} />
|
|
212
|
+
<Legend wrapperStyle={{ fontSize: isCompact ? 10 : 12 }} />
|
|
125
213
|
</PieChart>
|
|
126
214
|
</ResponsiveContainer>
|
|
127
215
|
</div>
|
|
@@ -130,46 +218,41 @@ export function DynamicChart({ config, primaryColor = '#6366f1', accentColor = '
|
|
|
130
218
|
|
|
131
219
|
// Bar and Line charts
|
|
132
220
|
return (
|
|
133
|
-
<div className=
|
|
221
|
+
<div className={`w-full ${chartHeightClass} mt-4 mb-2 select-none overflow-hidden`}>
|
|
134
222
|
<ResponsiveContainer width="99%" height="100%">
|
|
135
223
|
{type === 'bar' ? (
|
|
136
224
|
<BarChart data={sanitizedData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
|
137
225
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
|
|
138
|
-
<XAxis dataKey={finalXKey} tick={
|
|
139
|
-
<YAxis tick={
|
|
140
|
-
<Tooltip
|
|
141
|
-
|
|
142
|
-
cursor={{ fill: '#f1f5f9' }}
|
|
143
|
-
/>
|
|
144
|
-
<Legend wrapperStyle={{ fontSize: 12 }} />
|
|
226
|
+
<XAxis dataKey={finalXKey} tick={axisTick} tickLine={false} axisLine={{ stroke: '#cbd5e1' }} />
|
|
227
|
+
<YAxis tick={axisTick} tickLine={false} axisLine={false} />
|
|
228
|
+
<Tooltip content={(props) => renderTooltip(getTooltipRow(props.payload), typeof props.label === 'string' ? props.label : undefined)} cursor={{ fill: '#f1f5f9' }} />
|
|
229
|
+
<Legend wrapperStyle={{ fontSize: isCompact ? 10 : 12 }} />
|
|
145
230
|
{finalDataKeys.map((key, index) => (
|
|
146
231
|
<Bar
|
|
147
232
|
key={key}
|
|
148
233
|
dataKey={key}
|
|
149
234
|
fill={colors[index % colors.length]}
|
|
150
235
|
radius={[4, 4, 0, 0]}
|
|
151
|
-
barSize={Math.max(20, 60 / data.length)}
|
|
236
|
+
barSize={isCompact ? Math.max(14, 42 / data.length) : Math.max(20, 60 / data.length)}
|
|
152
237
|
/>
|
|
153
238
|
))}
|
|
154
239
|
</BarChart>
|
|
155
240
|
) : (
|
|
156
241
|
<LineChart data={sanitizedData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
|
157
242
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
|
|
158
|
-
<XAxis dataKey={finalXKey} tick={
|
|
159
|
-
<YAxis tick={
|
|
160
|
-
<Tooltip
|
|
161
|
-
|
|
162
|
-
/>
|
|
163
|
-
<Legend wrapperStyle={{ fontSize: 12 }} />
|
|
243
|
+
<XAxis dataKey={finalXKey} tick={axisTick} tickLine={false} axisLine={{ stroke: '#cbd5e1' }} />
|
|
244
|
+
<YAxis tick={axisTick} tickLine={false} axisLine={false} />
|
|
245
|
+
<Tooltip content={(props) => renderTooltip(getTooltipRow(props.payload), typeof props.label === 'string' ? props.label : undefined)} />
|
|
246
|
+
<Legend wrapperStyle={{ fontSize: isCompact ? 10 : 12 }} />
|
|
164
247
|
{finalDataKeys.map((key, index) => (
|
|
165
248
|
<Line
|
|
166
249
|
key={key}
|
|
167
250
|
type="monotone"
|
|
168
251
|
dataKey={key}
|
|
169
252
|
stroke={colors[index % colors.length]}
|
|
170
|
-
strokeWidth={3}
|
|
171
|
-
dot={{ r: 4, strokeWidth: 2 }}
|
|
172
|
-
activeDot={{ r: 6 }}
|
|
253
|
+
strokeWidth={isCompact ? 2 : 3}
|
|
254
|
+
dot={{ r: isCompact ? 3 : 4, strokeWidth: 2 }}
|
|
255
|
+
activeDot={{ r: isCompact ? 5 : 6 }}
|
|
173
256
|
/>
|
|
174
257
|
))}
|
|
175
258
|
</LineChart>
|
|
@@ -5,6 +5,7 @@ import { Bot, User, ChevronDown, ChevronRight } from 'lucide-react';
|
|
|
5
5
|
import ReactMarkdown from 'react-markdown';
|
|
6
6
|
import remarkGfm from 'remark-gfm';
|
|
7
7
|
import { MessageBubbleProps, Product } from '../types';
|
|
8
|
+
import { ChatViewportSize } from '../types/props';
|
|
8
9
|
import { SourceCard } from './SourceCard';
|
|
9
10
|
import { ProductCarousel } from './ProductCarousel';
|
|
10
11
|
import { DynamicChart } from './DynamicChart';
|
|
@@ -90,6 +91,17 @@ function sanitizeJson(raw: string): string {
|
|
|
90
91
|
return s;
|
|
91
92
|
}
|
|
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
|
+
|
|
93
105
|
// ─── Tiny helpers ─────────────────────────────────────────────────────────────
|
|
94
106
|
|
|
95
107
|
function resolveImage(data: Record<string, unknown>): string | undefined {
|
|
@@ -123,13 +135,16 @@ function normaliseChild(children: React.ReactNode): React.ReactNode {
|
|
|
123
135
|
interface TableConfig {
|
|
124
136
|
type: 'table';
|
|
125
137
|
title?: string;
|
|
138
|
+
description?: string;
|
|
126
139
|
columns?: string[]; // preferred: explicit ordered column labels
|
|
127
140
|
dataKeys?: string[]; // fallback column keys (may omit xAxisKey)
|
|
128
141
|
xAxisKey?: string; // row-label key to prepend when using dataKeys
|
|
129
142
|
data: Record<string, unknown>[];
|
|
130
143
|
}
|
|
131
144
|
|
|
132
|
-
function DataTable({ config }: { config: TableConfig }) {
|
|
145
|
+
function DataTable({ config, viewportSize = 'large' }: { config: TableConfig; viewportSize?: ChatViewportSize }) {
|
|
146
|
+
const isCompact = viewportSize === 'compact';
|
|
147
|
+
const isMedium = viewportSize === 'medium';
|
|
133
148
|
const keys = React.useMemo<string[]>(() => {
|
|
134
149
|
// Priority 1: explicit columns list
|
|
135
150
|
if (Array.isArray(config.columns) && config.columns.length) {
|
|
@@ -168,13 +183,13 @@ function DataTable({ config }: { config: TableConfig }) {
|
|
|
168
183
|
)}
|
|
169
184
|
|
|
170
185
|
<div className="overflow-x-auto">
|
|
171
|
-
<table className=
|
|
186
|
+
<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'}`}>
|
|
172
187
|
<thead className="bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10">
|
|
173
188
|
<tr>
|
|
174
189
|
{keys.map(k => (
|
|
175
190
|
<th
|
|
176
191
|
key={k}
|
|
177
|
-
className=
|
|
192
|
+
className={`${isCompact ? 'px-2.5 py-2' : 'px-4 py-3'} font-semibold text-slate-700 dark:text-white/90 whitespace-nowrap`}
|
|
178
193
|
>
|
|
179
194
|
{k}
|
|
180
195
|
</th>
|
|
@@ -190,7 +205,7 @@ function DataTable({ config }: { config: TableConfig }) {
|
|
|
190
205
|
{keys.map(k => (
|
|
191
206
|
<td
|
|
192
207
|
key={k}
|
|
193
|
-
className=
|
|
208
|
+
className={`${isCompact ? 'px-2.5 py-2' : 'px-4 py-3'} text-slate-600 dark:text-white/70 whitespace-nowrap`}
|
|
194
209
|
>
|
|
195
210
|
{formatCell(row[k])}
|
|
196
211
|
</td>
|
|
@@ -216,17 +231,57 @@ function DataTable({ config }: { config: TableConfig }) {
|
|
|
216
231
|
interface UIConfig {
|
|
217
232
|
view: 'chart' | 'carousel' | 'table';
|
|
218
233
|
chartType?: 'pie' | 'bar' | 'line';
|
|
234
|
+
xAxisKey?: string;
|
|
235
|
+
dataKeys?: string[];
|
|
236
|
+
columns?: string[];
|
|
237
|
+
colors?: string[];
|
|
219
238
|
data: Record<string, unknown>[];
|
|
220
239
|
title?: string;
|
|
240
|
+
description?: string;
|
|
241
|
+
insights?: string[];
|
|
221
242
|
type?: string;
|
|
222
243
|
items?: Record<string, unknown>[];
|
|
223
244
|
}
|
|
224
245
|
|
|
225
|
-
|
|
246
|
+
interface RawUIConfig extends Omit<UIConfig, 'data'> {
|
|
247
|
+
data: Array<Record<string, unknown> | unknown[]>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function normalizeTabularRows(
|
|
251
|
+
rows: Array<Record<string, unknown> | unknown[]> | undefined,
|
|
252
|
+
columns?: string[],
|
|
253
|
+
): Record<string, unknown>[] {
|
|
254
|
+
if (!Array.isArray(rows)) return [];
|
|
255
|
+
|
|
256
|
+
return rows
|
|
257
|
+
.map((row) => {
|
|
258
|
+
if (Array.isArray(row)) {
|
|
259
|
+
const keys = Array.isArray(columns) && columns.length > 0
|
|
260
|
+
? columns
|
|
261
|
+
: row.map((_, index) => `column_${index + 1}`);
|
|
262
|
+
|
|
263
|
+
return keys.reduce<Record<string, unknown>>((acc, key, index) => {
|
|
264
|
+
acc[key] = row[index];
|
|
265
|
+
return acc;
|
|
266
|
+
}, {});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (row && typeof row === 'object') {
|
|
270
|
+
return row as Record<string, unknown>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { value: row };
|
|
274
|
+
})
|
|
275
|
+
.filter((row) => Object.keys(row).length > 0);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAddToCart, viewportSize = 'large' }: {
|
|
226
279
|
rawContent: string;
|
|
227
280
|
primaryColor?: string;
|
|
228
281
|
accentColor?: string;
|
|
229
282
|
isStreaming?: boolean;
|
|
283
|
+
onAddToCart?: (product: Product) => void;
|
|
284
|
+
viewportSize?: ChatViewportSize;
|
|
230
285
|
}) {
|
|
231
286
|
const result = React.useMemo<{ config: UIConfig } | { error: string } | { loading: true }>(() => {
|
|
232
287
|
if (isStreaming) return { loading: true };
|
|
@@ -235,17 +290,26 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming }: {
|
|
|
235
290
|
.replace(/^```[a-z]*\s*/i, '')
|
|
236
291
|
.replace(/```\s*$/, '')
|
|
237
292
|
.trim();
|
|
238
|
-
const parsed = JSON.parse(sanitizeJson(clean));
|
|
239
|
-
const config = { ...parsed }
|
|
293
|
+
const parsed = JSON.parse(sanitizeJson(extractLikelyJsonBlock(clean)));
|
|
294
|
+
const config = { ...parsed } as Partial<RawUIConfig> & Record<string, unknown>;
|
|
240
295
|
|
|
241
296
|
// Smart Auto-Detection for legacy or simpler formats
|
|
242
297
|
if (!config.view) {
|
|
243
298
|
if (config.type === 'products' || Array.isArray(config.items)) {
|
|
244
299
|
config.view = 'carousel';
|
|
245
300
|
config.data = config.data || config.items || [];
|
|
246
|
-
} else if (
|
|
301
|
+
} else if (
|
|
302
|
+
(typeof config.type === 'string' && ['pie', 'bar', 'line'].includes(config.type)) ||
|
|
303
|
+
(typeof config.chartType === 'string' && ['pie', 'bar', 'line'].includes(config.chartType))
|
|
304
|
+
) {
|
|
305
|
+
const resolvedChartType =
|
|
306
|
+
config.chartType === 'pie' || config.chartType === 'bar' || config.chartType === 'line'
|
|
307
|
+
? config.chartType
|
|
308
|
+
: config.type === 'pie' || config.type === 'bar' || config.type === 'line'
|
|
309
|
+
? config.type
|
|
310
|
+
: 'bar';
|
|
247
311
|
config.view = 'chart';
|
|
248
|
-
config.chartType =
|
|
312
|
+
config.chartType = resolvedChartType;
|
|
249
313
|
config.data = config.data || [];
|
|
250
314
|
} else {
|
|
251
315
|
config.view = 'table';
|
|
@@ -253,7 +317,12 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming }: {
|
|
|
253
317
|
}
|
|
254
318
|
}
|
|
255
319
|
|
|
256
|
-
|
|
320
|
+
const normalizedConfig: UIConfig = {
|
|
321
|
+
...(config as Omit<UIConfig, 'data'>),
|
|
322
|
+
data: normalizeTabularRows(config.data, config.columns),
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
return { config: normalizedConfig };
|
|
257
326
|
} catch (err) {
|
|
258
327
|
return { error: String(err) };
|
|
259
328
|
}
|
|
@@ -273,36 +342,73 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming }: {
|
|
|
273
342
|
}
|
|
274
343
|
|
|
275
344
|
const { config } = result;
|
|
345
|
+
const isCompact = viewportSize === 'compact';
|
|
346
|
+
const hasInsights = Array.isArray(config.insights) && config.insights.length > 0;
|
|
347
|
+
const hasDescription = typeof config.description === 'string' && config.description.trim().length > 0;
|
|
276
348
|
|
|
277
349
|
switch (config.view) {
|
|
278
350
|
case 'chart':
|
|
279
351
|
return (
|
|
280
|
-
<div className=
|
|
281
|
-
{config.title && <h4 className=
|
|
352
|
+
<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`}>
|
|
353
|
+
{config.title && <h4 className={`${isCompact ? 'text-[11px] mb-3' : 'text-xs mb-4'} font-semibold text-slate-500 px-2`}>{config.title}</h4>}
|
|
354
|
+
{hasDescription && (
|
|
355
|
+
<p className={`px-2 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
|
|
356
|
+
)}
|
|
282
357
|
<DynamicChart
|
|
283
358
|
config={{
|
|
284
359
|
type: (config.chartType || 'bar') as 'bar' | 'line' | 'pie',
|
|
285
|
-
data: config.data as unknown as Record<string, string | number>[]
|
|
360
|
+
data: config.data as unknown as Record<string, string | number>[],
|
|
361
|
+
xAxisKey: config.xAxisKey,
|
|
362
|
+
dataKeys: config.dataKeys,
|
|
363
|
+
colors: config.colors,
|
|
286
364
|
}}
|
|
287
365
|
primaryColor={primaryColor}
|
|
288
366
|
accentColor={accentColor}
|
|
367
|
+
viewportSize={viewportSize}
|
|
289
368
|
/>
|
|
369
|
+
{hasInsights && (
|
|
370
|
+
<div className="mt-4 flex flex-wrap gap-2 px-2">
|
|
371
|
+
{config.insights?.map((insight) => (
|
|
372
|
+
<span
|
|
373
|
+
key={insight}
|
|
374
|
+
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`}
|
|
375
|
+
>
|
|
376
|
+
{insight}
|
|
377
|
+
</span>
|
|
378
|
+
))}
|
|
379
|
+
</div>
|
|
380
|
+
)}
|
|
290
381
|
</div>
|
|
291
382
|
);
|
|
292
383
|
case 'carousel':
|
|
293
384
|
return (
|
|
294
385
|
<div className="my-4">
|
|
386
|
+
{config.title && <h4 className={`${isCompact ? 'mb-1.5 text-[11px]' : 'mb-2 text-xs'} font-semibold text-slate-500`}>{config.title}</h4>}
|
|
387
|
+
{hasDescription && (
|
|
388
|
+
<p className={`mb-3 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
|
|
389
|
+
)}
|
|
295
390
|
<ProductCarousel products={(config.data as unknown as Product[]).map(item => ({
|
|
296
391
|
...item,
|
|
297
392
|
image: item.image ?? (typeof resolveImage === 'function' ? resolveImage(item as unknown as Record<string, unknown>) : undefined)
|
|
298
|
-
}))} />
|
|
393
|
+
}))} primaryColor={primaryColor} onAddToCart={onAddToCart} />
|
|
299
394
|
</div>
|
|
300
395
|
);
|
|
301
396
|
case 'table':
|
|
302
397
|
return (
|
|
303
|
-
<div className=
|
|
304
|
-
{config.title && <h4 className=
|
|
305
|
-
|
|
398
|
+
<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`}>
|
|
399
|
+
{config.title && <h4 className={`${isCompact ? 'text-[11px]' : 'text-xs'} font-semibold text-slate-500 mb-2`}>{config.title}</h4>}
|
|
400
|
+
{hasDescription && (
|
|
401
|
+
<p className={`mb-3 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
|
|
402
|
+
)}
|
|
403
|
+
<DataTable config={{
|
|
404
|
+
type: 'table',
|
|
405
|
+
title: config.title,
|
|
406
|
+
description: config.description,
|
|
407
|
+
columns: config.columns,
|
|
408
|
+
dataKeys: config.dataKeys,
|
|
409
|
+
xAxisKey: config.xAxisKey,
|
|
410
|
+
data: config.data,
|
|
411
|
+
}} viewportSize={viewportSize} />
|
|
306
412
|
</div>
|
|
307
413
|
);
|
|
308
414
|
default:
|
|
@@ -321,9 +427,16 @@ export function MessageBubble({
|
|
|
321
427
|
primaryColor = '#6366f1',
|
|
322
428
|
accentColor = '#8b5cf6',
|
|
323
429
|
onAddToCart,
|
|
430
|
+
viewportSize = 'large',
|
|
324
431
|
}: MessageBubbleProps) {
|
|
325
432
|
const isUser = message.role === 'user';
|
|
433
|
+
const isCompact = viewportSize === 'compact';
|
|
434
|
+
const isMedium = viewportSize === 'medium';
|
|
326
435
|
const [showSources, setShowSources] = React.useState(false);
|
|
436
|
+
const hasStructuredProductBlock = React.useMemo(
|
|
437
|
+
() => /```(?:ui|json|chart)\s*[\s\S]*?"(?:view|type)"\s*:\s*"(?:carousel|products)"/i.test(message.content),
|
|
438
|
+
[message.content],
|
|
439
|
+
);
|
|
327
440
|
|
|
328
441
|
// ── Products from sources ──────────────────────────────────────────────────
|
|
329
442
|
const productsFromSources = React.useMemo<Product[]>(() => {
|
|
@@ -500,6 +613,8 @@ export function MessageBubble({
|
|
|
500
613
|
primaryColor={primaryColor}
|
|
501
614
|
accentColor={accentColor}
|
|
502
615
|
isStreaming={isStreaming}
|
|
616
|
+
onAddToCart={onAddToCart}
|
|
617
|
+
viewportSize={viewportSize}
|
|
503
618
|
/>
|
|
504
619
|
);
|
|
505
620
|
}
|
|
@@ -513,6 +628,8 @@ export function MessageBubble({
|
|
|
513
628
|
primaryColor={primaryColor}
|
|
514
629
|
accentColor={accentColor}
|
|
515
630
|
isStreaming={isStreaming}
|
|
631
|
+
onAddToCart={onAddToCart}
|
|
632
|
+
viewportSize={viewportSize}
|
|
516
633
|
/>
|
|
517
634
|
);
|
|
518
635
|
}
|
|
@@ -538,27 +655,27 @@ export function MessageBubble({
|
|
|
538
655
|
);
|
|
539
656
|
},
|
|
540
657
|
}),
|
|
541
|
-
[primaryColor, accentColor, isStreaming],
|
|
658
|
+
[primaryColor, accentColor, isStreaming, onAddToCart, viewportSize],
|
|
542
659
|
);
|
|
543
660
|
|
|
544
661
|
// ── Render ─────────────────────────────────────────────────────────────────
|
|
545
662
|
return (
|
|
546
|
-
<div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'} items-start`}>
|
|
663
|
+
<div className={`flex ${isCompact ? 'gap-2' : 'gap-3'} ${isUser ? 'flex-row-reverse' : 'flex-row'} items-start`}>
|
|
547
664
|
{/* Avatar */}
|
|
548
665
|
<div
|
|
549
|
-
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center shadow-lg ${isUser
|
|
666
|
+
className={`flex-shrink-0 ${isCompact ? 'w-7 h-7' : 'w-8 h-8'} rounded-full flex items-center justify-center shadow-lg ${isUser
|
|
550
667
|
? 'text-white'
|
|
551
668
|
: 'bg-slate-100 dark:bg-white/10 text-slate-700 dark:text-white/80'
|
|
552
669
|
}`}
|
|
553
670
|
style={isUser ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` } : {}}
|
|
554
671
|
>
|
|
555
|
-
{isUser ? <User className=
|
|
672
|
+
{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'}`} />}
|
|
556
673
|
</div>
|
|
557
674
|
|
|
558
|
-
<div className={`flex flex-col gap-1 max-w-[90%] ${isUser ? 'items-end' : 'items-start'}`}>
|
|
675
|
+
<div className={`flex flex-col gap-1 ${isCompact ? 'max-w-[94%]' : isMedium ? 'max-w-[92%]' : 'max-w-[90%]'} ${isUser ? 'items-end' : 'items-start'}`}>
|
|
559
676
|
{/* Bubble */}
|
|
560
677
|
<div
|
|
561
|
-
className={`relative px-4 py-3 rounded-2xl
|
|
678
|
+
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
|
|
562
679
|
? 'text-white rounded-tr-sm'
|
|
563
680
|
: 'bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-800 dark:text-white/90 rounded-tl-sm'
|
|
564
681
|
}`}
|
|
@@ -573,7 +690,7 @@ export function MessageBubble({
|
|
|
573
690
|
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:300ms]" />
|
|
574
691
|
</span>
|
|
575
692
|
) : (
|
|
576
|
-
<div className={`prose prose-sm max-w-none ${isUser ? 'prose-invert' : 'dark:prose-invert'}`}>
|
|
693
|
+
<div className={`prose ${isCompact ? 'prose-xs' : 'prose-sm'} max-w-none break-words ${isUser ? 'prose-invert' : 'dark:prose-invert'}`}>
|
|
577
694
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
578
695
|
{processedMarkdown}
|
|
579
696
|
</ReactMarkdown>
|
|
@@ -586,7 +703,7 @@ export function MessageBubble({
|
|
|
586
703
|
</div>
|
|
587
704
|
|
|
588
705
|
{/* Product Carousel */}
|
|
589
|
-
{!isUser && allProducts.length > 0 && (
|
|
706
|
+
{!isUser && !hasStructuredProductBlock && allProducts.length > 0 && (
|
|
590
707
|
<div className="w-full mt-1">
|
|
591
708
|
<ProductCarousel
|
|
592
709
|
products={allProducts}
|
|
@@ -619,4 +736,4 @@ export function MessageBubble({
|
|
|
619
736
|
</div>
|
|
620
737
|
</div>
|
|
621
738
|
);
|
|
622
|
-
}
|
|
739
|
+
}
|
|
@@ -44,7 +44,7 @@ export function ProductCarousel({ products, primaryColor = '#6366f1', onAddToCar
|
|
|
44
44
|
// Single product: No carousel needed
|
|
45
45
|
if (products.length === 1) {
|
|
46
46
|
return (
|
|
47
|
-
<div className="my-4 w-[
|
|
47
|
+
<div className="my-4 w-[min(88%,18rem)] min-w-0 max-w-full animate-fade-in-up">
|
|
48
48
|
<ProductCard product={products[0]} primaryColor={primaryColor} onAddToCart={onAddToCart} />
|
|
49
49
|
</div>
|
|
50
50
|
);
|
|
@@ -86,7 +86,7 @@ export function ProductCarousel({ products, primaryColor = '#6366f1', onAddToCar
|
|
|
86
86
|
{products.map((product) => (
|
|
87
87
|
<div
|
|
88
88
|
key={product.id}
|
|
89
|
-
className="snap-start flex-shrink-0 w-[
|
|
89
|
+
className="snap-start flex-shrink-0 w-[min(88%,18rem)] min-w-[11rem] max-w-full"
|
|
90
90
|
>
|
|
91
91
|
<ProductCard product={product} primaryColor={primaryColor} onAddToCart={onAddToCart} />
|
|
92
92
|
</div>
|
package/src/core/Pipeline.ts
CHANGED
|
@@ -101,21 +101,68 @@ 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_V5 -->';
|
|
105
105
|
const chartInstruction = `\n\n${CHART_MARKER}
|
|
106
|
-
### UNIVERSAL UI PROTOCOL
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
- "
|
|
111
|
-
- "
|
|
112
|
-
|
|
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.
|
|
108
|
+
|
|
109
|
+
1. VIEW SELECTION
|
|
110
|
+
- Use "chart" for distributions, percentages, counts, comparisons, trends, or "show me in a chart".
|
|
111
|
+
- Use "carousel" for browseable product recommendations or when the user asks to see products/items/cards.
|
|
112
|
+
- Use "table" for tabular/raw detail, explicit table/list requests, or when there are many attributes to compare.
|
|
113
|
+
|
|
114
|
+
2. REQUIRED JSON SHAPE
|
|
113
115
|
{
|
|
114
116
|
"view": "chart" | "carousel" | "table",
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
+
"title": "Short heading",
|
|
118
|
+
"description": "One sentence describing the view",
|
|
119
|
+
"chartType": "pie" | "bar" | "line",
|
|
120
|
+
"xAxisKey": "label",
|
|
121
|
+
"dataKeys": ["value"],
|
|
122
|
+
"columns": ["name", "category", "price", "stockStatus"],
|
|
123
|
+
"insights": ["Short takeaway 1", "Short takeaway 2"],
|
|
124
|
+
"data": []
|
|
117
125
|
}
|
|
118
|
-
|
|
126
|
+
|
|
127
|
+
3. DATA RULES
|
|
128
|
+
- Build the UI payload from retrieved context only. Do not invent products, categories, or stock values.
|
|
129
|
+
- Aggregate raw product rows when the user asks for a chart.
|
|
130
|
+
- For charts, every row in "data" must be flat JSON with primitive values only.
|
|
131
|
+
- Prefer { "label": "...", "value": number } for pie charts.
|
|
132
|
+
- If stock information exists, preserve it in each row with fields like "inStock", "inStockCount", "outOfStockCount", or "stockStatus".
|
|
133
|
+
- If the user asks to highlight what is in stock, include those stock-related fields in the chart data and mention the pattern in "insights".
|
|
134
|
+
- For carousel rows, include product-friendly fields such as id, name, brand, price, image, link, category, stockStatus.
|
|
135
|
+
- For tables, keep "data" row-oriented and include "columns" when possible.
|
|
136
|
+
|
|
137
|
+
4. FORMAT RULES
|
|
138
|
+
- Never use ASCII art, markdown tables as the primary answer, or text-only fake charts when a UI block is requested.
|
|
139
|
+
- Put any short natural-language explanation before or after the \`\`\`ui\`\`\` block, but keep it concise.
|
|
140
|
+
- Return valid JSON inside the \`\`\`ui\`\`\` block.
|
|
141
|
+
- If the user explicitly asks for a pie chart, return "view": "chart" with "chartType": "pie", not a table fallback.
|
|
142
|
+
- Do not prefix the JSON with labels like "Table View", "Alternative", or explanatory text inside the code block.
|
|
143
|
+
|
|
144
|
+
5. EXAMPLE
|
|
145
|
+
User: "provide me the distribution of products across categories and highlight which ones are in stock in a pie chart"
|
|
146
|
+
Assistant:
|
|
147
|
+
\`\`\`ui
|
|
148
|
+
{
|
|
149
|
+
"view": "chart",
|
|
150
|
+
"title": "Product Distribution Across Categories",
|
|
151
|
+
"description": "Pie chart showing product count by category with stock signals preserved per slice.",
|
|
152
|
+
"chartType": "pie",
|
|
153
|
+
"xAxisKey": "label",
|
|
154
|
+
"dataKeys": ["value"],
|
|
155
|
+
"insights": [
|
|
156
|
+
"Beauty has the highest product count.",
|
|
157
|
+
"Electronics has the largest number of in-stock products."
|
|
158
|
+
],
|
|
159
|
+
"data": [
|
|
160
|
+
{ "label": "Beauty", "value": 12, "inStockCount": 9, "outOfStockCount": 3 },
|
|
161
|
+
{ "label": "Electronics", "value": 8, "inStockCount": 7, "outOfStockCount": 1 },
|
|
162
|
+
{ "label": "Home", "value": 5, "inStockCount": 2, "outOfStockCount": 3 }
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
\`\`\``;
|
|
119
166
|
|
|
120
167
|
if (!this.config.llm.systemPrompt?.includes(CHART_MARKER)) {
|
|
121
168
|
this.config.llm.systemPrompt = (this.config.llm.systemPrompt || '') + chartInstruction;
|