@retrivora-ai/rag-engine 1.3.1 → 1.3.3

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.
@@ -9,6 +9,132 @@ import { SourceCard } from './SourceCard';
9
9
  import { ProductCarousel } from './ProductCarousel';
10
10
  import { DynamicChart, ChartConfig } from './DynamicChart';
11
11
 
12
+ // ─── JSON sanitization ────────────────────────────────────────────────────────
13
+ // Order matters: each step is a targeted fix, no step undoes a previous one.
14
+
15
+ function sanitizeJson(raw: string): string {
16
+ let s = raw.trim();
17
+
18
+ // 1. Evaluate simple arithmetic expressions in values e.g. "v": 495 / 1000
19
+ s = s.replace(
20
+ /:\s*(-?\d+(?:\.\d+)?)\s*([+\-*/])\s*(-?\d+(?:\.\d+)?)/g,
21
+ (_, a, op, b) => {
22
+ const n1 = parseFloat(a);
23
+ const n2 = parseFloat(b);
24
+ const result =
25
+ op === '+' ? n1 + n2
26
+ : op === '-' ? n1 - n2
27
+ : op === '*' ? n1 * n2
28
+ : n2 !== 0 ? n1 / n2
29
+ : n1;
30
+ return `: ${result}`;
31
+ },
32
+ );
33
+
34
+ // 2. Strip JS-style single-line comments
35
+ s = s.replace(/\/\/[^\n]*/g, '');
36
+
37
+ // 3. Quote bare (unquoted) object keys
38
+ s = s.replace(/([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g, '$1"$2":');
39
+
40
+ // 4. Replace single-quoted strings with double-quoted ones
41
+ s = s.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"');
42
+
43
+ // 5. Remove trailing commas before ] or }
44
+ s = s.replace(/,\s*([}\]])/g, '$1');
45
+
46
+ // 6. Remove completely empty key-value pairs left by prior cleanup
47
+ s = s.replace(/([{,])\s*""\s*(?=[,}])/g, '$1');
48
+
49
+ // 7. Balance braces/brackets: walk char-by-char, track depth, append closers
50
+ // Do this BEFORE the lastValidIndex trim so we don't cut valid JSON.
51
+ {
52
+ let inString = false;
53
+ let escape = false;
54
+ let braces = 0;
55
+ let brackets = 0;
56
+
57
+ for (const ch of s) {
58
+ if (escape) { escape = false; continue; }
59
+ if (ch === '\\' && inString) { escape = true; continue; }
60
+ if (ch === '"') { inString = !inString; continue; }
61
+ if (inString) continue;
62
+ if (ch === '{') braces++;
63
+ else if (ch === '}') braces--;
64
+ else if (ch === '[') brackets++;
65
+ else if (ch === ']') brackets--;
66
+ }
67
+
68
+ // Close any unclosed string first
69
+ if (inString) s += '"';
70
+ // Then close structures in the correct order
71
+ while (brackets > 0) { s += ']'; brackets--; }
72
+ while (braces > 0) { s += '}'; braces--; }
73
+ }
74
+
75
+ // 8. Trim to the last valid closing char only if there is trailing garbage
76
+ // (e.g. a partial second object after the first one ends)
77
+ const lastClose = Math.max(s.lastIndexOf('}'), s.lastIndexOf(']'));
78
+ if (lastClose !== -1 && lastClose < s.length - 1) {
79
+ s = s.substring(0, lastClose + 1);
80
+ }
81
+
82
+ return s;
83
+ }
84
+
85
+ // ─── Tiny helpers ─────────────────────────────────────────────────────────────
86
+
87
+ function resolveImage(data: Record<string, unknown>): string | undefined {
88
+ for (const key of ['image', 'img', 'thumbnail']) {
89
+ const v = data[key];
90
+ if (typeof v === 'string' && v) return v;
91
+ }
92
+ if (Array.isArray(data.images) && typeof data.images[0] === 'string') {
93
+ return data.images[0];
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ // ─── Chart code block renderer ────────────────────────────────────────────────
99
+
100
+ interface ChartBlockProps {
101
+ rawContent: string;
102
+ primaryColor: string;
103
+ accentColor: string;
104
+ }
105
+
106
+ function ChartBlock({ rawContent, primaryColor, accentColor }: ChartBlockProps) {
107
+ const result = React.useMemo<{ config: ChartConfig } | { error: string }>(() => {
108
+ if (!rawContent || rawContent === 'undefined') return { error: 'Empty chart config.' };
109
+ try {
110
+ const sanitized = sanitizeJson(rawContent);
111
+ const config = JSON.parse(sanitized) as ChartConfig;
112
+ return { config };
113
+ } catch (err) {
114
+ const sanitized = sanitizeJson(rawContent);
115
+ console.error('[ChartBlock] Parsing failed.\nError:', err, '\nSanitized:\n', sanitized);
116
+ return { error: String(err) };
117
+ }
118
+ }, [rawContent]);
119
+
120
+ if ('error' in result) {
121
+ return (
122
+ <div className="p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm border border-red-100 dark:border-red-900/30">
123
+ <p className="font-medium mb-1">Failed to render chart</p>
124
+ <p className="opacity-70 text-xs">The generated configuration is invalid. Check the console for details.</p>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ return (
130
+ <div className="my-4 p-4 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
131
+ <DynamicChart config={result.config} primaryColor={primaryColor} accentColor={accentColor} />
132
+ </div>
133
+ );
134
+ }
135
+
136
+ // ─── Component ────────────────────────────────────────────────────────────────
137
+
12
138
  export function MessageBubble({
13
139
  message,
14
140
  sources,
@@ -20,71 +146,51 @@ export function MessageBubble({
20
146
  const isUser = message.role === 'user';
21
147
  const [showSources, setShowSources] = React.useState(false);
22
148
 
23
- // Helper to extract image from metadata or object
24
- const resolveImage = (data: Record<string, unknown>): string | undefined => {
25
- if (!data) return undefined;
26
-
27
- // Check single string fields
28
- const singleFields = ['image', 'img', 'thumbnail'];
29
- for (const field of singleFields) {
30
- const val = data[field];
31
- if (typeof val === 'string' && val) return val;
32
- }
33
-
34
- // Check images array
35
- if (Array.isArray(data.images) && data.images.length > 0) {
36
- if (typeof data.images[0] === 'string') return data.images[0];
37
- }
38
-
39
- return undefined;
40
- };
41
-
42
- // Extract products from sources
43
- const productsFromSources = React.useMemo(() => {
149
+ // ── Products from sources ──────────────────────────────────────────────────
150
+ const productsFromSources = React.useMemo<Product[]>(() => {
44
151
  if (isUser || !sources) return [];
45
152
  return sources
46
153
  .filter(s => {
47
- const m = s.metadata || {};
154
+ const m = s.metadata ?? {};
48
155
  return m.price || m.image || m.img || m.thumbnail || m.images || m.brand || m.type === 'product';
49
156
  })
50
157
  .map(s => {
51
- const m = (s.metadata || {}) as Record<string, unknown>;
158
+ const m = (s.metadata ?? {}) as Record<string, unknown>;
52
159
  return {
53
160
  id: s.id,
54
- name: (m.name || m.title || s.content.split('\n')[0] || 'Unknown Product') as string,
161
+ name: (m.name ?? m.title ?? s.content.split('\n')[0] ?? 'Unknown Product') as string,
55
162
  brand: m.brand as string,
56
163
  price: m.price as string | number,
57
164
  image: resolveImage(m),
58
165
  link: m.link as string,
59
- description: s.content
166
+ description: s.content,
60
167
  };
61
168
  });
62
169
  }, [sources, isUser]);
63
170
 
64
- // Extract products from content (structured JSON)
171
+ // ── Products + cleaned content from structured JSON blocks in the message ──
65
172
  const { productsFromContent, cleanContent } = React.useMemo(() => {
66
- if (isUser) return { productsFromContent: [], cleanContent: message.content };
173
+ if (isUser) return { productsFromContent: [] as Product[], cleanContent: message.content };
67
174
 
68
175
  const jsonRegex = /```json\s*([\s\S]*?)\s*```/g;
69
176
  const products: Product[] = [];
70
177
  let content = message.content;
71
178
 
72
- // Only process if it looks like it might contain product data
73
179
  if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
74
- const matches = Array.from(content.matchAll(jsonRegex));
75
- for (const m of matches) {
180
+ for (const match of content.matchAll(jsonRegex)) {
76
181
  try {
77
- const data = JSON.parse(m[1]);
182
+ const data = JSON.parse(match[1]);
78
183
  if (data.type === 'products' && Array.isArray(data.items)) {
79
- const formattedItems = data.items.map((item: Record<string, unknown>) => ({
80
- ...item,
81
- image: (item.image as string) || resolveImage(item)
82
- })) as Product[];
83
- products.push(...formattedItems);
84
- content = content.replace(m[0], '');
184
+ products.push(
185
+ ...data.items.map((item: Record<string, unknown>) => ({
186
+ ...item,
187
+ image: (item.image as string) ?? resolveImage(item),
188
+ })) as Product[],
189
+ );
190
+ content = content.replace(match[0], '');
85
191
  }
86
192
  } catch {
87
- // Skip invalid JSON
193
+ // Malformed product block — skip silently
88
194
  }
89
195
  }
90
196
  }
@@ -92,60 +198,110 @@ export function MessageBubble({
92
198
  return { productsFromContent: products, cleanContent: content.trim() };
93
199
  }, [message.content, isUser]);
94
200
 
95
- const allProducts = React.useMemo(() => {
96
- // If the LLM explicitly provided products in a structured format, only show those.
201
+ // ── Merge & deduplicate products ───────────────────────────────────────────
202
+ const allProducts = React.useMemo<Product[]>(() => {
97
203
  if (productsFromContent.length > 0) return productsFromContent;
98
204
 
99
- // Fallback: Show unique products from sources, but ONLY if they are mentioned in the message text.
100
- // This aligns the carousel with what the assistant is actually "displaying" in its response.
101
- const uniqueProducts: Product[] = [];
102
- const seenIds = new Set();
205
+ const seen = new Set<string>();
103
206
  const contentLower = message.content.toLowerCase();
104
207
 
105
- for (const p of productsFromSources) {
106
- const identifier = p.id || p.name;
107
- if (seenIds.has(identifier)) continue;
108
-
109
- // Check if the product name or brand is mentioned in the assistant's text
110
- const nameMatch = contentLower.includes(p.name.toLowerCase());
111
- const brandMatch = p.brand ? contentLower.includes(p.brand.toLowerCase()) : false;
112
-
113
- if (nameMatch || brandMatch) {
114
- seenIds.add(identifier);
115
- uniqueProducts.push(p);
208
+ return productsFromSources.filter(p => {
209
+ const id = String(p.id ?? p.name);
210
+ if (seen.has(id)) return false;
211
+ const mentioned =
212
+ contentLower.includes(p.name.toLowerCase()) ||
213
+ (p.brand ? contentLower.includes(p.brand.toLowerCase()) : false);
214
+ if (mentioned) {
215
+ seen.add(id);
216
+ return true;
116
217
  }
117
- }
118
-
119
- return uniqueProducts;
218
+ return false;
219
+ });
120
220
  }, [productsFromSources, productsFromContent, message.content]);
121
221
 
122
- const hasProducts = allProducts.length > 0;
222
+ // ── Markdown component overrides ───────────────────────────────────────────
223
+ const markdownComponents = React.useMemo(
224
+ () => ({
225
+ table: ({ ...props }: React.HTMLAttributes<HTMLTableElement>) => (
226
+ <div className="overflow-x-auto my-4 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
227
+ <table className="w-full text-left border-collapse min-w-[400px]" {...props} />
228
+ </div>
229
+ ),
230
+ thead: ({ ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
231
+ <thead
232
+ className="bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10"
233
+ {...props}
234
+ />
235
+ ),
236
+ th: ({ children, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
237
+ <th
238
+ className="p-3 font-semibold text-slate-700 dark:text-white/90 first:rounded-tl-xl last:rounded-tr-xl"
239
+ {...props}
240
+ >
241
+ {normaliseChild(children)}
242
+ </th>
243
+ ),
244
+ td: ({ children, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
245
+ <td
246
+ className="p-3 border-b border-slate-100 dark:border-white/5 last:border-0 text-slate-600 dark:text-white/70"
247
+ {...props}
248
+ >
249
+ {normaliseChild(children)}
250
+ </td>
251
+ ),
252
+ code({
253
+ inline,
254
+ className,
255
+ children,
256
+ ...props
257
+ }: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
258
+ const lang = /language-(\w+)/.exec(className ?? '')?.[1];
123
259
 
260
+ if (!inline && lang === 'chart') {
261
+ return (
262
+ <ChartBlock
263
+ rawContent={String(children ?? '').trim()}
264
+ primaryColor={primaryColor}
265
+ accentColor={accentColor}
266
+ />
267
+ );
268
+ }
269
+
270
+ return (
271
+ <code className={className} {...props}>
272
+ {typeof children === 'string' || typeof children === 'number'
273
+ ? children
274
+ : JSON.stringify(children)}
275
+ </code>
276
+ );
277
+ },
278
+ }),
279
+ [primaryColor, accentColor],
280
+ );
281
+
282
+ // ── Render ─────────────────────────────────────────────────────────────────
124
283
  return (
125
284
  <div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'} items-start`}>
126
285
  {/* Avatar */}
127
286
  <div
128
- className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center shadow-lg ${isUser ? 'text-white' : 'bg-slate-100 dark:bg-white/10 text-slate-700 dark:text-white/80'
287
+ className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center shadow-lg ${isUser
288
+ ? 'text-white'
289
+ : 'bg-slate-100 dark:bg-white/10 text-slate-700 dark:text-white/80'
129
290
  }`}
130
291
  style={isUser ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` } : {}}
131
292
  >
132
- {isUser
133
- ? <User className="w-4 h-4 text-white" />
134
- : <Bot className="w-4 h-4" />
135
- }
293
+ {isUser ? <User className="w-4 h-4 text-white" /> : <Bot className="w-4 h-4" />}
136
294
  </div>
137
295
 
138
296
  <div className={`flex flex-col gap-1 max-w-[90%] ${isUser ? 'items-end' : 'items-start'}`}>
139
297
  {/* Bubble */}
140
298
  <div
141
299
  className={`relative px-4 py-3 rounded-2xl text-sm leading-relaxed shadow-sm dark:shadow-lg ${isUser
142
- ? 'text-white rounded-tr-sm'
143
- : 'bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-800 dark:text-white/90 rounded-tl-sm'
300
+ ? 'text-white rounded-tr-sm'
301
+ : 'bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-800 dark:text-white/90 rounded-tl-sm'
144
302
  }`}
145
303
  style={
146
- isUser
147
- ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` }
148
- : {}
304
+ isUser ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` } : {}
149
305
  }
150
306
  >
151
307
  {isStreaming && !message.content ? (
@@ -156,89 +312,7 @@ export function MessageBubble({
156
312
  </span>
157
313
  ) : (
158
314
  <div className={`prose prose-sm max-w-none ${isUser ? 'prose-invert' : 'dark:prose-invert'}`}>
159
- <ReactMarkdown
160
- remarkPlugins={[remarkGfm]}
161
- components={{
162
- table: ({...props}) => (
163
- <div className="overflow-x-auto my-4 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
164
- <table className="w-full text-left border-collapse min-w-[400px]" {...props} />
165
- </div>
166
- ),
167
- thead: ({...props}) => (
168
- <thead className="bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10" {...props} />
169
- ),
170
- th: ({children, ...props}) => (
171
- <th className="p-3 font-semibold text-slate-700 dark:text-white/90 first:rounded-tl-xl last:rounded-tr-xl" {...props}>
172
- {typeof children === 'object' && children !== null && !Array.isArray(children) && !(children as any).$$typeof ? JSON.stringify(children) : children}
173
- </th>
174
- ),
175
- td: ({children, ...props}) => (
176
- <td className="p-3 border-b border-slate-100 dark:border-white/5 last:border-0 text-slate-600 dark:text-white/70" {...props}>
177
- {typeof children === 'object' && children !== null && !Array.isArray(children) && !(children as any).$$typeof ? JSON.stringify(children) : children}
178
- </td>
179
- ),
180
- code({ inline, className, children, ...props }: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
181
- const match = /language-(\w+)/.exec(className || '');
182
- const isChart = match && match[1] === 'chart';
183
-
184
- if (!inline && isChart) {
185
- const rawContent = String(children || '').trim();
186
-
187
- if (!rawContent || rawContent === 'undefined') {
188
- return null;
189
- }
190
-
191
- // Optimized sanitization helper to handle LLM quirks
192
- const sanitizeJson = (json: string) => {
193
- return json
194
- // 1. Resolve arithmetic: "value": 495 / 1000 => "value": 0.495
195
- .replace(/:\s*(\d+(\.\d+)?)\s*([\+\-\*\/])\s*(\d+(\.\d+)?)/g, (_, n1, __, op, n2) => {
196
- const v1 = parseFloat(n1); const v2 = parseFloat(n2);
197
- const ops: Record<string, number> = { '+': v1 + v2, '-': v1 - v2, '*': v1 * v2, '/': v1 / v2 };
198
- return `: ${ops[op] ?? v1}`;
199
- })
200
- // 2. Quote unquoted keys and fix single quotes
201
- .replace(/([{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1 "$2":')
202
- .replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"')
203
- // 3. Syntax cleanup: trailing/missing commas and comments
204
- .replace(/,\s*([\]}])/g, '$1')
205
- .replace(/}\s*{/g, '},{')
206
- .replace(/]\s*\[/g, '],[')
207
- .replace(/\/\/.*$/gm, '')
208
- // 4. Remove dangling empty strings
209
- .replace(/([{,])\s*""\s*([,}])/g, '$1$2');
210
- };
211
-
212
- try {
213
- const sanitized = sanitizeJson(rawContent);
214
- const config = JSON.parse(sanitized) as ChartConfig;
215
- return (
216
- <div className="my-4 p-4 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
217
- <DynamicChart
218
- config={config}
219
- primaryColor={primaryColor}
220
- accentColor={accentColor}
221
- />
222
- </div>
223
- );
224
- } catch (err) {
225
- console.error('[MessageBubble] Chart parsing failed:', err, '\nSanitized content:', sanitizeJson(rawContent));
226
- return (
227
- <div className="p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm border border-red-100 dark:border-red-900/30">
228
- <p className="font-medium mb-1">Failed to render chart</p>
229
- <p className="opacity-70 text-xs">The generated configuration is invalid. Check console for details.</p>
230
- </div>
231
- );
232
- }
233
- }
234
- return (
235
- <code className={className} {...props}>
236
- {typeof children === 'string' || typeof children === 'number' ? children : JSON.stringify(children)}
237
- </code>
238
- );
239
- }
240
- }}
241
- >
315
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
242
316
  {cleanContent || message.content}
243
317
  </ReactMarkdown>
244
318
 
@@ -249,8 +323,8 @@ export function MessageBubble({
249
323
  )}
250
324
  </div>
251
325
 
252
- {/* Product Carousel (Outside the bubble) */}
253
- {!isUser && hasProducts && (
326
+ {/* Product Carousel */}
327
+ {!isUser && allProducts.length > 0 && (
254
328
  <div className="w-full mt-1">
255
329
  <ProductCarousel
256
330
  products={allProducts}
@@ -260,17 +334,14 @@ export function MessageBubble({
260
334
  </div>
261
335
  )}
262
336
 
263
- {/* Sources toggle */}
337
+ {/* Sources */}
264
338
  {!isUser && sources && sources.length > 0 && (
265
339
  <div className="w-full">
266
340
  <button
267
- onClick={() => setShowSources((s) => !s)}
341
+ onClick={() => setShowSources(s => !s)}
268
342
  className="text-[11px] text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1 mt-1"
269
343
  >
270
- {showSources
271
- ? <ChevronDown className="w-3 h-3" />
272
- : <ChevronRight className="w-3 h-3" />
273
- }
344
+ {showSources ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
274
345
  {sources.length} source{sources.length !== 1 ? 's' : ''} used
275
346
  </button>
276
347
 
@@ -287,3 +358,18 @@ export function MessageBubble({
287
358
  </div>
288
359
  );
289
360
  }
361
+
362
+ // ─── Util ─────────────────────────────────────────────────────────────────────
363
+
364
+ /** Safely render table cell children that might be plain objects. */
365
+ function normaliseChild(children: React.ReactNode): React.ReactNode {
366
+ if (
367
+ typeof children === 'object' &&
368
+ children !== null &&
369
+ !Array.isArray(children) &&
370
+ !React.isValidElement(children)
371
+ ) {
372
+ return JSON.stringify(children);
373
+ }
374
+ return children;
375
+ }
@@ -57,7 +57,8 @@ export class LangChainAgent {
57
57
  1. Use ONLY valid JSON (double quotes, no trailing commas).
58
58
  2. NO math expressions (e.g., use 100, NOT 50+50).
59
59
  3. dataKeys MUST exactly match the property names in the data objects.
60
- 4. DO NOT output naked JSON; it MUST be wrapped in \`\`\`chart ... \`\`\`.
60
+ 4. data objects MUST be FLAT (no nested objects or arrays).
61
+ 5. DO NOT output naked JSON; it MUST be wrapped in \`\`\`chart ... \`\`\`.
61
62
  \`\`\`chart
62
63
  {
63
64
  "type": "bar" | "line" | "pie",
@@ -107,7 +107,8 @@ export class Pipeline {
107
107
  1. Use ONLY valid JSON (double quotes, no trailing commas).
108
108
  2. NO math expressions (e.g., use 100, NOT 50+50).
109
109
  3. dataKeys MUST exactly match the property names in the data objects.
110
- 4. DO NOT output naked JSON; it MUST be wrapped in \`\`\`chart ... \`\`\`.
110
+ 4. data objects MUST be FLAT (no nested objects or arrays).
111
+ 5. DO NOT output naked JSON; it MUST be wrapped in \`\`\`chart ... \`\`\`.
111
112
  \`\`\`chart
112
113
  {
113
114
  "type": "bar" | "line" | "pie",