@retrivora-ai/rag-engine 1.3.2 → 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.
package/dist/index.js CHANGED
@@ -318,6 +318,90 @@ function DynamicChart({ config, primaryColor = "#6366f1", accentColor = "#8b5cf6
318
318
  }
319
319
 
320
320
  // src/components/MessageBubble.tsx
321
+ function sanitizeJson(raw) {
322
+ let s = raw.trim();
323
+ s = s.replace(
324
+ /:\s*(-?\d+(?:\.\d+)?)\s*([+\-*/])\s*(-?\d+(?:\.\d+)?)/g,
325
+ (_, a, op, b) => {
326
+ const n1 = parseFloat(a);
327
+ const n2 = parseFloat(b);
328
+ const result = op === "+" ? n1 + n2 : op === "-" ? n1 - n2 : op === "*" ? n1 * n2 : n2 !== 0 ? n1 / n2 : n1;
329
+ return `: ${result}`;
330
+ }
331
+ );
332
+ s = s.replace(/\/\/[^\n]*/g, "");
333
+ s = s.replace(/([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g, '$1"$2":');
334
+ s = s.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"');
335
+ s = s.replace(/,\s*([}\]])/g, "$1");
336
+ s = s.replace(/([{,])\s*""\s*(?=[,}])/g, "$1");
337
+ {
338
+ let inString = false;
339
+ let escape = false;
340
+ let braces = 0;
341
+ let brackets = 0;
342
+ for (const ch of s) {
343
+ if (escape) {
344
+ escape = false;
345
+ continue;
346
+ }
347
+ if (ch === "\\" && inString) {
348
+ escape = true;
349
+ continue;
350
+ }
351
+ if (ch === '"') {
352
+ inString = !inString;
353
+ continue;
354
+ }
355
+ if (inString) continue;
356
+ if (ch === "{") braces++;
357
+ else if (ch === "}") braces--;
358
+ else if (ch === "[") brackets++;
359
+ else if (ch === "]") brackets--;
360
+ }
361
+ if (inString) s += '"';
362
+ while (brackets > 0) {
363
+ s += "]";
364
+ brackets--;
365
+ }
366
+ while (braces > 0) {
367
+ s += "}";
368
+ braces--;
369
+ }
370
+ }
371
+ const lastClose = Math.max(s.lastIndexOf("}"), s.lastIndexOf("]"));
372
+ if (lastClose !== -1 && lastClose < s.length - 1) {
373
+ s = s.substring(0, lastClose + 1);
374
+ }
375
+ return s;
376
+ }
377
+ function resolveImage(data) {
378
+ for (const key of ["image", "img", "thumbnail"]) {
379
+ const v = data[key];
380
+ if (typeof v === "string" && v) return v;
381
+ }
382
+ if (Array.isArray(data.images) && typeof data.images[0] === "string") {
383
+ return data.images[0];
384
+ }
385
+ return void 0;
386
+ }
387
+ function ChartBlock({ rawContent, primaryColor, accentColor }) {
388
+ const result = import_react5.default.useMemo(() => {
389
+ if (!rawContent || rawContent === "undefined") return { error: "Empty chart config." };
390
+ try {
391
+ const sanitized = sanitizeJson(rawContent);
392
+ const config = JSON.parse(sanitized);
393
+ return { config };
394
+ } catch (err) {
395
+ const sanitized = sanitizeJson(rawContent);
396
+ console.error("[ChartBlock] Parsing failed.\nError:", err, "\nSanitized:\n", sanitized);
397
+ return { error: String(err) };
398
+ }
399
+ }, [rawContent]);
400
+ if ("error" in result) {
401
+ return /* @__PURE__ */ import_react5.default.createElement("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" }, /* @__PURE__ */ import_react5.default.createElement("p", { className: "font-medium mb-1" }, "Failed to render chart"), /* @__PURE__ */ import_react5.default.createElement("p", { className: "opacity-70 text-xs" }, "The generated configuration is invalid. Check the console for details."));
402
+ }
403
+ return /* @__PURE__ */ import_react5.default.createElement("div", { className: "my-4 p-4 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm" }, /* @__PURE__ */ import_react5.default.createElement(DynamicChart, { config: result.config, primaryColor, accentColor }));
404
+ }
321
405
  function MessageBubble({
322
406
  message,
323
407
  sources,
@@ -328,28 +412,18 @@ function MessageBubble({
328
412
  }) {
329
413
  const isUser = message.role === "user";
330
414
  const [showSources, setShowSources] = import_react5.default.useState(false);
331
- const resolveImage = (data) => {
332
- if (!data) return void 0;
333
- const singleFields = ["image", "img", "thumbnail"];
334
- for (const field of singleFields) {
335
- const val = data[field];
336
- if (typeof val === "string" && val) return val;
337
- }
338
- if (Array.isArray(data.images) && data.images.length > 0) {
339
- if (typeof data.images[0] === "string") return data.images[0];
340
- }
341
- return void 0;
342
- };
343
415
  const productsFromSources = import_react5.default.useMemo(() => {
344
416
  if (isUser || !sources) return [];
345
417
  return sources.filter((s) => {
346
- const m = s.metadata || {};
418
+ var _a;
419
+ const m = (_a = s.metadata) != null ? _a : {};
347
420
  return m.price || m.image || m.img || m.thumbnail || m.images || m.brand || m.type === "product";
348
421
  }).map((s) => {
349
- const m = s.metadata || {};
422
+ var _a, _b, _c, _d;
423
+ const m = (_a = s.metadata) != null ? _a : {};
350
424
  return {
351
425
  id: s.id,
352
- name: m.name || m.title || s.content.split("\n")[0] || "Unknown Product",
426
+ name: (_d = (_c = (_b = m.name) != null ? _b : m.title) != null ? _c : s.content.split("\n")[0]) != null ? _d : "Unknown Product",
353
427
  brand: m.brand,
354
428
  price: m.price,
355
429
  image: resolveImage(m),
@@ -364,16 +438,19 @@ function MessageBubble({
364
438
  const products = [];
365
439
  let content = message.content;
366
440
  if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
367
- const matches = Array.from(content.matchAll(jsonRegex));
368
- for (const m of matches) {
441
+ for (const match of content.matchAll(jsonRegex)) {
369
442
  try {
370
- const data = JSON.parse(m[1]);
443
+ const data = JSON.parse(match[1]);
371
444
  if (data.type === "products" && Array.isArray(data.items)) {
372
- const formattedItems = data.items.map((item) => __spreadProps(__spreadValues({}, item), {
373
- image: item.image || resolveImage(item)
374
- }));
375
- products.push(...formattedItems);
376
- content = content.replace(m[0], "");
445
+ products.push(
446
+ ...data.items.map((item) => {
447
+ var _a;
448
+ return __spreadProps(__spreadValues({}, item), {
449
+ image: (_a = item.image) != null ? _a : resolveImage(item)
450
+ });
451
+ })
452
+ );
453
+ content = content.replace(match[0], "");
377
454
  }
378
455
  } catch (e) {
379
456
  }
@@ -383,22 +460,82 @@ function MessageBubble({
383
460
  }, [message.content, isUser]);
384
461
  const allProducts = import_react5.default.useMemo(() => {
385
462
  if (productsFromContent.length > 0) return productsFromContent;
386
- const uniqueProducts = [];
387
- const seenIds = /* @__PURE__ */ new Set();
463
+ const seen = /* @__PURE__ */ new Set();
388
464
  const contentLower = message.content.toLowerCase();
389
- for (const p of productsFromSources) {
390
- const identifier = p.id || p.name;
391
- if (seenIds.has(identifier)) continue;
392
- const nameMatch = contentLower.includes(p.name.toLowerCase());
393
- const brandMatch = p.brand ? contentLower.includes(p.brand.toLowerCase()) : false;
394
- if (nameMatch || brandMatch) {
395
- seenIds.add(identifier);
396
- uniqueProducts.push(p);
465
+ return productsFromSources.filter((p) => {
466
+ var _a;
467
+ const id = String((_a = p.id) != null ? _a : p.name);
468
+ if (seen.has(id)) return false;
469
+ const mentioned = contentLower.includes(p.name.toLowerCase()) || (p.brand ? contentLower.includes(p.brand.toLowerCase()) : false);
470
+ if (mentioned) {
471
+ seen.add(id);
472
+ return true;
397
473
  }
398
- }
399
- return uniqueProducts;
474
+ return false;
475
+ });
400
476
  }, [productsFromSources, productsFromContent, message.content]);
401
- const hasProducts = allProducts.length > 0;
477
+ const markdownComponents = import_react5.default.useMemo(
478
+ () => ({
479
+ table: (_a) => {
480
+ var props = __objRest(_a, []);
481
+ return /* @__PURE__ */ import_react5.default.createElement("div", { className: "overflow-x-auto my-4 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm" }, /* @__PURE__ */ import_react5.default.createElement("table", __spreadValues({ className: "w-full text-left border-collapse min-w-[400px]" }, props)));
482
+ },
483
+ thead: (_b) => {
484
+ var props = __objRest(_b, []);
485
+ return /* @__PURE__ */ import_react5.default.createElement(
486
+ "thead",
487
+ __spreadValues({
488
+ className: "bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10"
489
+ }, props)
490
+ );
491
+ },
492
+ th: (_c) => {
493
+ var _d = _c, { children } = _d, props = __objRest(_d, ["children"]);
494
+ return /* @__PURE__ */ import_react5.default.createElement(
495
+ "th",
496
+ __spreadValues({
497
+ className: "p-3 font-semibold text-slate-700 dark:text-white/90 first:rounded-tl-xl last:rounded-tr-xl"
498
+ }, props),
499
+ normaliseChild(children)
500
+ );
501
+ },
502
+ td: (_e) => {
503
+ var _f = _e, { children } = _f, props = __objRest(_f, ["children"]);
504
+ return /* @__PURE__ */ import_react5.default.createElement(
505
+ "td",
506
+ __spreadValues({
507
+ className: "p-3 border-b border-slate-100 dark:border-white/5 last:border-0 text-slate-600 dark:text-white/70"
508
+ }, props),
509
+ normaliseChild(children)
510
+ );
511
+ },
512
+ code(_g) {
513
+ var _h = _g, {
514
+ inline,
515
+ className,
516
+ children
517
+ } = _h, props = __objRest(_h, [
518
+ "inline",
519
+ "className",
520
+ "children"
521
+ ]);
522
+ var _a;
523
+ const lang = (_a = /language-(\w+)/.exec(className != null ? className : "")) == null ? void 0 : _a[1];
524
+ if (!inline && lang === "chart") {
525
+ return /* @__PURE__ */ import_react5.default.createElement(
526
+ ChartBlock,
527
+ {
528
+ rawContent: String(children != null ? children : "").trim(),
529
+ primaryColor,
530
+ accentColor
531
+ }
532
+ );
533
+ }
534
+ return /* @__PURE__ */ import_react5.default.createElement("code", __spreadValues({ className }, props), typeof children === "string" || typeof children === "number" ? children : JSON.stringify(children));
535
+ }
536
+ }),
537
+ [primaryColor, accentColor]
538
+ );
402
539
  return /* @__PURE__ */ import_react5.default.createElement("div", { className: `flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"} items-start` }, /* @__PURE__ */ import_react5.default.createElement(
403
540
  "div",
404
541
  {
@@ -412,76 +549,8 @@ function MessageBubble({
412
549
  className: `relative px-4 py-3 rounded-2xl text-sm leading-relaxed shadow-sm dark:shadow-lg ${isUser ? "text-white rounded-tr-sm" : "bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-800 dark:text-white/90 rounded-tl-sm"}`,
413
550
  style: isUser ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` } : {}
414
551
  },
415
- isStreaming && !message.content ? /* @__PURE__ */ import_react5.default.createElement("span", { className: "flex items-center gap-1 py-1" }, /* @__PURE__ */ import_react5.default.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:0ms]" }), /* @__PURE__ */ import_react5.default.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:150ms]" }), /* @__PURE__ */ import_react5.default.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:300ms]" })) : /* @__PURE__ */ import_react5.default.createElement("div", { className: `prose prose-sm max-w-none ${isUser ? "prose-invert" : "dark:prose-invert"}` }, /* @__PURE__ */ import_react5.default.createElement(
416
- import_react_markdown.default,
417
- {
418
- remarkPlugins: [import_remark_gfm.default],
419
- components: {
420
- table: (_a) => {
421
- var props = __objRest(_a, []);
422
- return /* @__PURE__ */ import_react5.default.createElement("div", { className: "overflow-x-auto my-4 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm" }, /* @__PURE__ */ import_react5.default.createElement("table", __spreadValues({ className: "w-full text-left border-collapse min-w-[400px]" }, props)));
423
- },
424
- thead: (_b) => {
425
- var props = __objRest(_b, []);
426
- return /* @__PURE__ */ import_react5.default.createElement("thead", __spreadValues({ className: "bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10" }, props));
427
- },
428
- th: (_c) => {
429
- var _d = _c, { children } = _d, props = __objRest(_d, ["children"]);
430
- return /* @__PURE__ */ import_react5.default.createElement("th", __spreadValues({ className: "p-3 font-semibold text-slate-700 dark:text-white/90 first:rounded-tl-xl last:rounded-tr-xl" }, props), typeof children === "object" && children !== null && !Array.isArray(children) && !import_react5.default.isValidElement(children) ? JSON.stringify(children) : children);
431
- },
432
- td: (_e) => {
433
- var _f = _e, { children } = _f, props = __objRest(_f, ["children"]);
434
- return /* @__PURE__ */ import_react5.default.createElement("td", __spreadValues({ className: "p-3 border-b border-slate-100 dark:border-white/5 last:border-0 text-slate-600 dark:text-white/70" }, props), typeof children === "object" && children !== null && !Array.isArray(children) && !import_react5.default.isValidElement(children) ? JSON.stringify(children) : children);
435
- },
436
- code(_g) {
437
- var _h = _g, { inline, className, children } = _h, props = __objRest(_h, ["inline", "className", "children"]);
438
- const match = /language-(\w+)/.exec(className || "");
439
- const isChart = match && match[1] === "chart";
440
- if (!inline && isChart) {
441
- const rawContent = String(children || "").trim();
442
- if (!rawContent || rawContent === "undefined") {
443
- return null;
444
- }
445
- const sanitizeJson = (json) => {
446
- const firstBrace = json.indexOf("{");
447
- const firstBracket = json.indexOf("[");
448
- const start = firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket) ? firstBrace : firstBracket;
449
- const lastBrace = json.lastIndexOf("}");
450
- const lastBracket = json.lastIndexOf("]");
451
- const end = Math.max(lastBrace, lastBracket);
452
- if (start === -1 || end === -1 || end < start) return json;
453
- const trimmed = json.substring(start, end + 1);
454
- return trimmed.replace(/:\s*(\d+(\.\d+)?)\s*([\+\-\*\/])\s*(\d+(\.\d+)?)/g, (_, n1, __, op, n2) => {
455
- var _a;
456
- const v1 = parseFloat(n1);
457
- const v2 = parseFloat(n2);
458
- const ops = { "+": v1 + v2, "-": v1 - v2, "*": v1 * v2, "/": v1 / v2 };
459
- return `: ${(_a = ops[op]) != null ? _a : v1}`;
460
- }).replace(/([{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1 "$2":').replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"').replace(/,\s*([\]}])/g, "$1").replace(/}\s*{/g, "},{").replace(/]\s*\[/g, "],[").replace(/\/\/.*$/gm, "").replace(/([{,])\s*""\s*([,}])/g, "$1$2");
461
- };
462
- try {
463
- const sanitized = sanitizeJson(rawContent);
464
- const config = JSON.parse(sanitized);
465
- return /* @__PURE__ */ import_react5.default.createElement("div", { className: "my-4 p-4 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm" }, /* @__PURE__ */ import_react5.default.createElement(
466
- DynamicChart,
467
- {
468
- config,
469
- primaryColor,
470
- accentColor
471
- }
472
- ));
473
- } catch (err) {
474
- console.error("[MessageBubble] Chart parsing failed:", err, "\nSanitized content:", sanitizeJson(rawContent));
475
- return /* @__PURE__ */ import_react5.default.createElement("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" }, /* @__PURE__ */ import_react5.default.createElement("p", { className: "font-medium mb-1" }, "Failed to render chart"), /* @__PURE__ */ import_react5.default.createElement("p", { className: "opacity-70 text-xs" }, "The generated configuration is invalid. Check console for details."));
476
- }
477
- }
478
- return /* @__PURE__ */ import_react5.default.createElement("code", __spreadValues({ className }, props), typeof children === "string" || typeof children === "number" ? children : JSON.stringify(children));
479
- }
480
- }
481
- },
482
- cleanContent || message.content
483
- ), isStreaming && message.content && /* @__PURE__ */ import_react5.default.createElement("span", { className: "inline-block w-1.5 h-3.5 ml-1 bg-emerald-500 animate-pulse align-middle" }))
484
- ), !isUser && hasProducts && /* @__PURE__ */ import_react5.default.createElement("div", { className: "w-full mt-1" }, /* @__PURE__ */ import_react5.default.createElement(
552
+ isStreaming && !message.content ? /* @__PURE__ */ import_react5.default.createElement("span", { className: "flex items-center gap-1 py-1" }, /* @__PURE__ */ import_react5.default.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:0ms]" }), /* @__PURE__ */ import_react5.default.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:150ms]" }), /* @__PURE__ */ import_react5.default.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:300ms]" })) : /* @__PURE__ */ import_react5.default.createElement("div", { className: `prose prose-sm max-w-none ${isUser ? "prose-invert" : "dark:prose-invert"}` }, /* @__PURE__ */ import_react5.default.createElement(import_react_markdown.default, { remarkPlugins: [import_remark_gfm.default], components: markdownComponents }, cleanContent || message.content), isStreaming && message.content && /* @__PURE__ */ import_react5.default.createElement("span", { className: "inline-block w-1.5 h-3.5 ml-1 bg-emerald-500 animate-pulse align-middle" }))
553
+ ), !isUser && allProducts.length > 0 && /* @__PURE__ */ import_react5.default.createElement("div", { className: "w-full mt-1" }, /* @__PURE__ */ import_react5.default.createElement(
485
554
  ProductCarousel,
486
555
  {
487
556
  products: allProducts,
@@ -501,6 +570,12 @@ function MessageBubble({
501
570
  " used"
502
571
  ), showSources && /* @__PURE__ */ import_react5.default.createElement("div", { className: "mt-2 flex flex-col gap-2" }, sources.map((src, i) => /* @__PURE__ */ import_react5.default.createElement(SourceCard, { key: src.id, source: src, index: i }))))));
503
572
  }
573
+ function normaliseChild(children) {
574
+ if (typeof children === "object" && children !== null && !Array.isArray(children) && !import_react5.default.isValidElement(children)) {
575
+ return JSON.stringify(children);
576
+ }
577
+ return children;
578
+ }
504
579
 
505
580
  // src/components/ConfigProvider.tsx
506
581
  var import_react6 = __toESM(require("react"));
package/dist/index.mjs CHANGED
@@ -281,6 +281,90 @@ function DynamicChart({ config, primaryColor = "#6366f1", accentColor = "#8b5cf6
281
281
  }
282
282
 
283
283
  // src/components/MessageBubble.tsx
284
+ function sanitizeJson(raw) {
285
+ let s = raw.trim();
286
+ s = s.replace(
287
+ /:\s*(-?\d+(?:\.\d+)?)\s*([+\-*/])\s*(-?\d+(?:\.\d+)?)/g,
288
+ (_, a, op, b) => {
289
+ const n1 = parseFloat(a);
290
+ const n2 = parseFloat(b);
291
+ const result = op === "+" ? n1 + n2 : op === "-" ? n1 - n2 : op === "*" ? n1 * n2 : n2 !== 0 ? n1 / n2 : n1;
292
+ return `: ${result}`;
293
+ }
294
+ );
295
+ s = s.replace(/\/\/[^\n]*/g, "");
296
+ s = s.replace(/([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g, '$1"$2":');
297
+ s = s.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"');
298
+ s = s.replace(/,\s*([}\]])/g, "$1");
299
+ s = s.replace(/([{,])\s*""\s*(?=[,}])/g, "$1");
300
+ {
301
+ let inString = false;
302
+ let escape = false;
303
+ let braces = 0;
304
+ let brackets = 0;
305
+ for (const ch of s) {
306
+ if (escape) {
307
+ escape = false;
308
+ continue;
309
+ }
310
+ if (ch === "\\" && inString) {
311
+ escape = true;
312
+ continue;
313
+ }
314
+ if (ch === '"') {
315
+ inString = !inString;
316
+ continue;
317
+ }
318
+ if (inString) continue;
319
+ if (ch === "{") braces++;
320
+ else if (ch === "}") braces--;
321
+ else if (ch === "[") brackets++;
322
+ else if (ch === "]") brackets--;
323
+ }
324
+ if (inString) s += '"';
325
+ while (brackets > 0) {
326
+ s += "]";
327
+ brackets--;
328
+ }
329
+ while (braces > 0) {
330
+ s += "}";
331
+ braces--;
332
+ }
333
+ }
334
+ const lastClose = Math.max(s.lastIndexOf("}"), s.lastIndexOf("]"));
335
+ if (lastClose !== -1 && lastClose < s.length - 1) {
336
+ s = s.substring(0, lastClose + 1);
337
+ }
338
+ return s;
339
+ }
340
+ function resolveImage(data) {
341
+ for (const key of ["image", "img", "thumbnail"]) {
342
+ const v = data[key];
343
+ if (typeof v === "string" && v) return v;
344
+ }
345
+ if (Array.isArray(data.images) && typeof data.images[0] === "string") {
346
+ return data.images[0];
347
+ }
348
+ return void 0;
349
+ }
350
+ function ChartBlock({ rawContent, primaryColor, accentColor }) {
351
+ const result = React5.useMemo(() => {
352
+ if (!rawContent || rawContent === "undefined") return { error: "Empty chart config." };
353
+ try {
354
+ const sanitized = sanitizeJson(rawContent);
355
+ const config = JSON.parse(sanitized);
356
+ return { config };
357
+ } catch (err) {
358
+ const sanitized = sanitizeJson(rawContent);
359
+ console.error("[ChartBlock] Parsing failed.\nError:", err, "\nSanitized:\n", sanitized);
360
+ return { error: String(err) };
361
+ }
362
+ }, [rawContent]);
363
+ if ("error" in result) {
364
+ return /* @__PURE__ */ React5.createElement("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" }, /* @__PURE__ */ React5.createElement("p", { className: "font-medium mb-1" }, "Failed to render chart"), /* @__PURE__ */ React5.createElement("p", { className: "opacity-70 text-xs" }, "The generated configuration is invalid. Check the console for details."));
365
+ }
366
+ return /* @__PURE__ */ React5.createElement("div", { className: "my-4 p-4 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm" }, /* @__PURE__ */ React5.createElement(DynamicChart, { config: result.config, primaryColor, accentColor }));
367
+ }
284
368
  function MessageBubble({
285
369
  message,
286
370
  sources,
@@ -291,28 +375,18 @@ function MessageBubble({
291
375
  }) {
292
376
  const isUser = message.role === "user";
293
377
  const [showSources, setShowSources] = React5.useState(false);
294
- const resolveImage = (data) => {
295
- if (!data) return void 0;
296
- const singleFields = ["image", "img", "thumbnail"];
297
- for (const field of singleFields) {
298
- const val = data[field];
299
- if (typeof val === "string" && val) return val;
300
- }
301
- if (Array.isArray(data.images) && data.images.length > 0) {
302
- if (typeof data.images[0] === "string") return data.images[0];
303
- }
304
- return void 0;
305
- };
306
378
  const productsFromSources = React5.useMemo(() => {
307
379
  if (isUser || !sources) return [];
308
380
  return sources.filter((s) => {
309
- const m = s.metadata || {};
381
+ var _a;
382
+ const m = (_a = s.metadata) != null ? _a : {};
310
383
  return m.price || m.image || m.img || m.thumbnail || m.images || m.brand || m.type === "product";
311
384
  }).map((s) => {
312
- const m = s.metadata || {};
385
+ var _a, _b, _c, _d;
386
+ const m = (_a = s.metadata) != null ? _a : {};
313
387
  return {
314
388
  id: s.id,
315
- name: m.name || m.title || s.content.split("\n")[0] || "Unknown Product",
389
+ name: (_d = (_c = (_b = m.name) != null ? _b : m.title) != null ? _c : s.content.split("\n")[0]) != null ? _d : "Unknown Product",
316
390
  brand: m.brand,
317
391
  price: m.price,
318
392
  image: resolveImage(m),
@@ -327,16 +401,19 @@ function MessageBubble({
327
401
  const products = [];
328
402
  let content = message.content;
329
403
  if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
330
- const matches = Array.from(content.matchAll(jsonRegex));
331
- for (const m of matches) {
404
+ for (const match of content.matchAll(jsonRegex)) {
332
405
  try {
333
- const data = JSON.parse(m[1]);
406
+ const data = JSON.parse(match[1]);
334
407
  if (data.type === "products" && Array.isArray(data.items)) {
335
- const formattedItems = data.items.map((item) => __spreadProps(__spreadValues({}, item), {
336
- image: item.image || resolveImage(item)
337
- }));
338
- products.push(...formattedItems);
339
- content = content.replace(m[0], "");
408
+ products.push(
409
+ ...data.items.map((item) => {
410
+ var _a;
411
+ return __spreadProps(__spreadValues({}, item), {
412
+ image: (_a = item.image) != null ? _a : resolveImage(item)
413
+ });
414
+ })
415
+ );
416
+ content = content.replace(match[0], "");
340
417
  }
341
418
  } catch (e) {
342
419
  }
@@ -346,22 +423,82 @@ function MessageBubble({
346
423
  }, [message.content, isUser]);
347
424
  const allProducts = React5.useMemo(() => {
348
425
  if (productsFromContent.length > 0) return productsFromContent;
349
- const uniqueProducts = [];
350
- const seenIds = /* @__PURE__ */ new Set();
426
+ const seen = /* @__PURE__ */ new Set();
351
427
  const contentLower = message.content.toLowerCase();
352
- for (const p of productsFromSources) {
353
- const identifier = p.id || p.name;
354
- if (seenIds.has(identifier)) continue;
355
- const nameMatch = contentLower.includes(p.name.toLowerCase());
356
- const brandMatch = p.brand ? contentLower.includes(p.brand.toLowerCase()) : false;
357
- if (nameMatch || brandMatch) {
358
- seenIds.add(identifier);
359
- uniqueProducts.push(p);
428
+ return productsFromSources.filter((p) => {
429
+ var _a;
430
+ const id = String((_a = p.id) != null ? _a : p.name);
431
+ if (seen.has(id)) return false;
432
+ const mentioned = contentLower.includes(p.name.toLowerCase()) || (p.brand ? contentLower.includes(p.brand.toLowerCase()) : false);
433
+ if (mentioned) {
434
+ seen.add(id);
435
+ return true;
360
436
  }
361
- }
362
- return uniqueProducts;
437
+ return false;
438
+ });
363
439
  }, [productsFromSources, productsFromContent, message.content]);
364
- const hasProducts = allProducts.length > 0;
440
+ const markdownComponents = React5.useMemo(
441
+ () => ({
442
+ table: (_a) => {
443
+ var props = __objRest(_a, []);
444
+ return /* @__PURE__ */ React5.createElement("div", { className: "overflow-x-auto my-4 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm" }, /* @__PURE__ */ React5.createElement("table", __spreadValues({ className: "w-full text-left border-collapse min-w-[400px]" }, props)));
445
+ },
446
+ thead: (_b) => {
447
+ var props = __objRest(_b, []);
448
+ return /* @__PURE__ */ React5.createElement(
449
+ "thead",
450
+ __spreadValues({
451
+ className: "bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10"
452
+ }, props)
453
+ );
454
+ },
455
+ th: (_c) => {
456
+ var _d = _c, { children } = _d, props = __objRest(_d, ["children"]);
457
+ return /* @__PURE__ */ React5.createElement(
458
+ "th",
459
+ __spreadValues({
460
+ className: "p-3 font-semibold text-slate-700 dark:text-white/90 first:rounded-tl-xl last:rounded-tr-xl"
461
+ }, props),
462
+ normaliseChild(children)
463
+ );
464
+ },
465
+ td: (_e) => {
466
+ var _f = _e, { children } = _f, props = __objRest(_f, ["children"]);
467
+ return /* @__PURE__ */ React5.createElement(
468
+ "td",
469
+ __spreadValues({
470
+ className: "p-3 border-b border-slate-100 dark:border-white/5 last:border-0 text-slate-600 dark:text-white/70"
471
+ }, props),
472
+ normaliseChild(children)
473
+ );
474
+ },
475
+ code(_g) {
476
+ var _h = _g, {
477
+ inline,
478
+ className,
479
+ children
480
+ } = _h, props = __objRest(_h, [
481
+ "inline",
482
+ "className",
483
+ "children"
484
+ ]);
485
+ var _a;
486
+ const lang = (_a = /language-(\w+)/.exec(className != null ? className : "")) == null ? void 0 : _a[1];
487
+ if (!inline && lang === "chart") {
488
+ return /* @__PURE__ */ React5.createElement(
489
+ ChartBlock,
490
+ {
491
+ rawContent: String(children != null ? children : "").trim(),
492
+ primaryColor,
493
+ accentColor
494
+ }
495
+ );
496
+ }
497
+ return /* @__PURE__ */ React5.createElement("code", __spreadValues({ className }, props), typeof children === "string" || typeof children === "number" ? children : JSON.stringify(children));
498
+ }
499
+ }),
500
+ [primaryColor, accentColor]
501
+ );
365
502
  return /* @__PURE__ */ React5.createElement("div", { className: `flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"} items-start` }, /* @__PURE__ */ React5.createElement(
366
503
  "div",
367
504
  {
@@ -375,76 +512,8 @@ function MessageBubble({
375
512
  className: `relative px-4 py-3 rounded-2xl text-sm leading-relaxed shadow-sm dark:shadow-lg ${isUser ? "text-white rounded-tr-sm" : "bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-800 dark:text-white/90 rounded-tl-sm"}`,
376
513
  style: isUser ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` } : {}
377
514
  },
378
- isStreaming && !message.content ? /* @__PURE__ */ React5.createElement("span", { className: "flex items-center gap-1 py-1" }, /* @__PURE__ */ React5.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:0ms]" }), /* @__PURE__ */ React5.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:150ms]" }), /* @__PURE__ */ React5.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:300ms]" })) : /* @__PURE__ */ React5.createElement("div", { className: `prose prose-sm max-w-none ${isUser ? "prose-invert" : "dark:prose-invert"}` }, /* @__PURE__ */ React5.createElement(
379
- ReactMarkdown,
380
- {
381
- remarkPlugins: [remarkGfm],
382
- components: {
383
- table: (_a) => {
384
- var props = __objRest(_a, []);
385
- return /* @__PURE__ */ React5.createElement("div", { className: "overflow-x-auto my-4 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm" }, /* @__PURE__ */ React5.createElement("table", __spreadValues({ className: "w-full text-left border-collapse min-w-[400px]" }, props)));
386
- },
387
- thead: (_b) => {
388
- var props = __objRest(_b, []);
389
- return /* @__PURE__ */ React5.createElement("thead", __spreadValues({ className: "bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10" }, props));
390
- },
391
- th: (_c) => {
392
- var _d = _c, { children } = _d, props = __objRest(_d, ["children"]);
393
- return /* @__PURE__ */ React5.createElement("th", __spreadValues({ className: "p-3 font-semibold text-slate-700 dark:text-white/90 first:rounded-tl-xl last:rounded-tr-xl" }, props), typeof children === "object" && children !== null && !Array.isArray(children) && !React5.isValidElement(children) ? JSON.stringify(children) : children);
394
- },
395
- td: (_e) => {
396
- var _f = _e, { children } = _f, props = __objRest(_f, ["children"]);
397
- return /* @__PURE__ */ React5.createElement("td", __spreadValues({ className: "p-3 border-b border-slate-100 dark:border-white/5 last:border-0 text-slate-600 dark:text-white/70" }, props), typeof children === "object" && children !== null && !Array.isArray(children) && !React5.isValidElement(children) ? JSON.stringify(children) : children);
398
- },
399
- code(_g) {
400
- var _h = _g, { inline, className, children } = _h, props = __objRest(_h, ["inline", "className", "children"]);
401
- const match = /language-(\w+)/.exec(className || "");
402
- const isChart = match && match[1] === "chart";
403
- if (!inline && isChart) {
404
- const rawContent = String(children || "").trim();
405
- if (!rawContent || rawContent === "undefined") {
406
- return null;
407
- }
408
- const sanitizeJson = (json) => {
409
- const firstBrace = json.indexOf("{");
410
- const firstBracket = json.indexOf("[");
411
- const start = firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket) ? firstBrace : firstBracket;
412
- const lastBrace = json.lastIndexOf("}");
413
- const lastBracket = json.lastIndexOf("]");
414
- const end = Math.max(lastBrace, lastBracket);
415
- if (start === -1 || end === -1 || end < start) return json;
416
- const trimmed = json.substring(start, end + 1);
417
- return trimmed.replace(/:\s*(\d+(\.\d+)?)\s*([\+\-\*\/])\s*(\d+(\.\d+)?)/g, (_, n1, __, op, n2) => {
418
- var _a;
419
- const v1 = parseFloat(n1);
420
- const v2 = parseFloat(n2);
421
- const ops = { "+": v1 + v2, "-": v1 - v2, "*": v1 * v2, "/": v1 / v2 };
422
- return `: ${(_a = ops[op]) != null ? _a : v1}`;
423
- }).replace(/([{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1 "$2":').replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"').replace(/,\s*([\]}])/g, "$1").replace(/}\s*{/g, "},{").replace(/]\s*\[/g, "],[").replace(/\/\/.*$/gm, "").replace(/([{,])\s*""\s*([,}])/g, "$1$2");
424
- };
425
- try {
426
- const sanitized = sanitizeJson(rawContent);
427
- const config = JSON.parse(sanitized);
428
- return /* @__PURE__ */ React5.createElement("div", { className: "my-4 p-4 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm" }, /* @__PURE__ */ React5.createElement(
429
- DynamicChart,
430
- {
431
- config,
432
- primaryColor,
433
- accentColor
434
- }
435
- ));
436
- } catch (err) {
437
- console.error("[MessageBubble] Chart parsing failed:", err, "\nSanitized content:", sanitizeJson(rawContent));
438
- return /* @__PURE__ */ React5.createElement("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" }, /* @__PURE__ */ React5.createElement("p", { className: "font-medium mb-1" }, "Failed to render chart"), /* @__PURE__ */ React5.createElement("p", { className: "opacity-70 text-xs" }, "The generated configuration is invalid. Check console for details."));
439
- }
440
- }
441
- return /* @__PURE__ */ React5.createElement("code", __spreadValues({ className }, props), typeof children === "string" || typeof children === "number" ? children : JSON.stringify(children));
442
- }
443
- }
444
- },
445
- cleanContent || message.content
446
- ), isStreaming && message.content && /* @__PURE__ */ React5.createElement("span", { className: "inline-block w-1.5 h-3.5 ml-1 bg-emerald-500 animate-pulse align-middle" }))
447
- ), !isUser && hasProducts && /* @__PURE__ */ React5.createElement("div", { className: "w-full mt-1" }, /* @__PURE__ */ React5.createElement(
515
+ isStreaming && !message.content ? /* @__PURE__ */ React5.createElement("span", { className: "flex items-center gap-1 py-1" }, /* @__PURE__ */ React5.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:0ms]" }), /* @__PURE__ */ React5.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:150ms]" }), /* @__PURE__ */ React5.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-white/60 animate-bounce [animation-delay:300ms]" })) : /* @__PURE__ */ React5.createElement("div", { className: `prose prose-sm max-w-none ${isUser ? "prose-invert" : "dark:prose-invert"}` }, /* @__PURE__ */ React5.createElement(ReactMarkdown, { remarkPlugins: [remarkGfm], components: markdownComponents }, cleanContent || message.content), isStreaming && message.content && /* @__PURE__ */ React5.createElement("span", { className: "inline-block w-1.5 h-3.5 ml-1 bg-emerald-500 animate-pulse align-middle" }))
516
+ ), !isUser && allProducts.length > 0 && /* @__PURE__ */ React5.createElement("div", { className: "w-full mt-1" }, /* @__PURE__ */ React5.createElement(
448
517
  ProductCarousel,
449
518
  {
450
519
  products: allProducts,
@@ -464,6 +533,12 @@ function MessageBubble({
464
533
  " used"
465
534
  ), showSources && /* @__PURE__ */ React5.createElement("div", { className: "mt-2 flex flex-col gap-2" }, sources.map((src, i) => /* @__PURE__ */ React5.createElement(SourceCard, { key: src.id, source: src, index: i }))))));
466
535
  }
536
+ function normaliseChild(children) {
537
+ if (typeof children === "object" && children !== null && !Array.isArray(children) && !React5.isValidElement(children)) {
538
+ return JSON.stringify(children);
539
+ }
540
+ return children;
541
+ }
467
542
 
468
543
  // src/components/ConfigProvider.tsx
469
544
  import React6, { createContext, useContext } from "react";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retrivora-ai/rag-engine",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Retrivora AI is a plug-and-play AI engine for RAG chat experiences — generic vector DB + LLM provider, embeddable or standalone.",
5
5
  "author": "Abhinav Alkuchi",
6
6
  "license": "MIT",
@@ -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,101 +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) && !React.isValidElement(children) ? 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) && !React.isValidElement(children) ? 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
- // 0. Boundary detection: find the actual JSON object/array start/end
194
- // This removes stray backticks or text the LLM might have included
195
- const firstBrace = json.indexOf('{');
196
- const firstBracket = json.indexOf('[');
197
- const start = (firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket)) ? firstBrace : firstBracket;
198
- const lastBrace = json.lastIndexOf('}');
199
- const lastBracket = json.lastIndexOf(']');
200
- const end = Math.max(lastBrace, lastBracket);
201
-
202
- if (start === -1 || end === -1 || end < start) return json;
203
- const trimmed = json.substring(start, end + 1);
204
-
205
- return trimmed
206
- // 1. Resolve arithmetic: "value": 495 / 1000 => "value": 0.495
207
- .replace(/:\s*(\d+(\.\d+)?)\s*([\+\-\*\/])\s*(\d+(\.\d+)?)/g, (_, n1, __, op, n2) => {
208
- const v1 = parseFloat(n1); const v2 = parseFloat(n2);
209
- const ops: Record<string, number> = { '+': v1 + v2, '-': v1 - v2, '*': v1 * v2, '/': v1 / v2 };
210
- return `: ${ops[op] ?? v1}`;
211
- })
212
- // 2. Quote unquoted keys and fix single quotes
213
- .replace(/([{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1 "$2":')
214
- .replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"')
215
- // 3. Syntax cleanup: trailing/missing commas and comments
216
- .replace(/,\s*([\]}])/g, '$1')
217
- .replace(/}\s*{/g, '},{')
218
- .replace(/]\s*\[/g, '],[')
219
- .replace(/\/\/.*$/gm, '')
220
- // 4. Remove dangling empty strings
221
- .replace(/([{,])\s*""\s*([,}])/g, '$1$2');
222
- };
223
-
224
- try {
225
- const sanitized = sanitizeJson(rawContent);
226
- const config = JSON.parse(sanitized) as ChartConfig;
227
- return (
228
- <div className="my-4 p-4 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
229
- <DynamicChart
230
- config={config}
231
- primaryColor={primaryColor}
232
- accentColor={accentColor}
233
- />
234
- </div>
235
- );
236
- } catch (err) {
237
- console.error('[MessageBubble] Chart parsing failed:', err, '\nSanitized content:', sanitizeJson(rawContent));
238
- return (
239
- <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">
240
- <p className="font-medium mb-1">Failed to render chart</p>
241
- <p className="opacity-70 text-xs">The generated configuration is invalid. Check console for details.</p>
242
- </div>
243
- );
244
- }
245
- }
246
- return (
247
- <code className={className} {...props}>
248
- {typeof children === 'string' || typeof children === 'number' ? children : JSON.stringify(children)}
249
- </code>
250
- );
251
- }
252
- }}
253
- >
315
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
254
316
  {cleanContent || message.content}
255
317
  </ReactMarkdown>
256
318
 
@@ -261,8 +323,8 @@ export function MessageBubble({
261
323
  )}
262
324
  </div>
263
325
 
264
- {/* Product Carousel (Outside the bubble) */}
265
- {!isUser && hasProducts && (
326
+ {/* Product Carousel */}
327
+ {!isUser && allProducts.length > 0 && (
266
328
  <div className="w-full mt-1">
267
329
  <ProductCarousel
268
330
  products={allProducts}
@@ -272,17 +334,14 @@ export function MessageBubble({
272
334
  </div>
273
335
  )}
274
336
 
275
- {/* Sources toggle */}
337
+ {/* Sources */}
276
338
  {!isUser && sources && sources.length > 0 && (
277
339
  <div className="w-full">
278
340
  <button
279
- onClick={() => setShowSources((s) => !s)}
341
+ onClick={() => setShowSources(s => !s)}
280
342
  className="text-[11px] text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1 mt-1"
281
343
  >
282
- {showSources
283
- ? <ChevronDown className="w-3 h-3" />
284
- : <ChevronRight className="w-3 h-3" />
285
- }
344
+ {showSources ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
286
345
  {sources.length} source{sources.length !== 1 ? 's' : ''} used
287
346
  </button>
288
347
 
@@ -299,3 +358,18 @@ export function MessageBubble({
299
358
  </div>
300
359
  );
301
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
+ }