@retrivora-ai/rag-engine 1.3.2 → 1.3.4

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,87 @@ 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
+ const stack = [];
339
+ let inString = false;
340
+ let escape = false;
341
+ for (const ch of s) {
342
+ if (escape) {
343
+ escape = false;
344
+ continue;
345
+ }
346
+ if (ch === "\\" && inString) {
347
+ escape = true;
348
+ continue;
349
+ }
350
+ if (ch === '"') {
351
+ inString = !inString;
352
+ continue;
353
+ }
354
+ if (inString) continue;
355
+ if (ch === "{" || ch === "[") {
356
+ stack.push(ch);
357
+ } else if (ch === "}") {
358
+ if (stack[stack.length - 1] === "{") stack.pop();
359
+ } else if (ch === "]") {
360
+ if (stack[stack.length - 1] === "[") stack.pop();
361
+ }
362
+ }
363
+ if (inString) s += '"';
364
+ for (let i = stack.length - 1; i >= 0; i--) {
365
+ s += stack[i] === "{" ? "}" : "]";
366
+ }
367
+ }
368
+ const lastClose = Math.max(s.lastIndexOf("}"), s.lastIndexOf("]"));
369
+ if (lastClose !== -1 && lastClose < s.length - 1 && /\S/.test(s.slice(lastClose + 1))) {
370
+ s = s.substring(0, lastClose + 1);
371
+ }
372
+ return s;
373
+ }
374
+ function resolveImage(data) {
375
+ for (const key of ["image", "img", "thumbnail"]) {
376
+ const v = data[key];
377
+ if (typeof v === "string" && v) return v;
378
+ }
379
+ if (Array.isArray(data.images) && typeof data.images[0] === "string") {
380
+ return data.images[0];
381
+ }
382
+ return void 0;
383
+ }
384
+ function ChartBlock({ rawContent, primaryColor, accentColor }) {
385
+ const result = import_react5.default.useMemo(() => {
386
+ if (!rawContent || rawContent === "undefined") return { error: "Empty chart config." };
387
+ try {
388
+ const sanitized = sanitizeJson(rawContent);
389
+ const config = JSON.parse(sanitized);
390
+ return { config };
391
+ } catch (err) {
392
+ const sanitized = sanitizeJson(rawContent);
393
+ console.error("[ChartBlock] Parsing failed.\nError:", err, "\nSanitized:\n", sanitized);
394
+ return { error: String(err) };
395
+ }
396
+ }, [rawContent]);
397
+ if ("error" in result) {
398
+ 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."));
399
+ }
400
+ 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 }));
401
+ }
321
402
  function MessageBubble({
322
403
  message,
323
404
  sources,
@@ -328,28 +409,18 @@ function MessageBubble({
328
409
  }) {
329
410
  const isUser = message.role === "user";
330
411
  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
412
  const productsFromSources = import_react5.default.useMemo(() => {
344
413
  if (isUser || !sources) return [];
345
414
  return sources.filter((s) => {
346
- const m = s.metadata || {};
415
+ var _a;
416
+ const m = (_a = s.metadata) != null ? _a : {};
347
417
  return m.price || m.image || m.img || m.thumbnail || m.images || m.brand || m.type === "product";
348
418
  }).map((s) => {
349
- const m = s.metadata || {};
419
+ var _a, _b, _c, _d;
420
+ const m = (_a = s.metadata) != null ? _a : {};
350
421
  return {
351
422
  id: s.id,
352
- name: m.name || m.title || s.content.split("\n")[0] || "Unknown Product",
423
+ name: (_d = (_c = (_b = m.name) != null ? _b : m.title) != null ? _c : s.content.split("\n")[0]) != null ? _d : "Unknown Product",
353
424
  brand: m.brand,
354
425
  price: m.price,
355
426
  image: resolveImage(m),
@@ -364,16 +435,19 @@ function MessageBubble({
364
435
  const products = [];
365
436
  let content = message.content;
366
437
  if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
367
- const matches = Array.from(content.matchAll(jsonRegex));
368
- for (const m of matches) {
438
+ for (const match of content.matchAll(jsonRegex)) {
369
439
  try {
370
- const data = JSON.parse(m[1]);
440
+ const data = JSON.parse(match[1]);
371
441
  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], "");
442
+ products.push(
443
+ ...data.items.map((item) => {
444
+ var _a;
445
+ return __spreadProps(__spreadValues({}, item), {
446
+ image: (_a = item.image) != null ? _a : resolveImage(item)
447
+ });
448
+ })
449
+ );
450
+ content = content.replace(match[0], "");
377
451
  }
378
452
  } catch (e) {
379
453
  }
@@ -383,22 +457,82 @@ function MessageBubble({
383
457
  }, [message.content, isUser]);
384
458
  const allProducts = import_react5.default.useMemo(() => {
385
459
  if (productsFromContent.length > 0) return productsFromContent;
386
- const uniqueProducts = [];
387
- const seenIds = /* @__PURE__ */ new Set();
460
+ const seen = /* @__PURE__ */ new Set();
388
461
  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);
462
+ return productsFromSources.filter((p) => {
463
+ var _a;
464
+ const id = String((_a = p.id) != null ? _a : p.name);
465
+ if (seen.has(id)) return false;
466
+ const mentioned = contentLower.includes(p.name.toLowerCase()) || (p.brand ? contentLower.includes(p.brand.toLowerCase()) : false);
467
+ if (mentioned) {
468
+ seen.add(id);
469
+ return true;
397
470
  }
398
- }
399
- return uniqueProducts;
471
+ return false;
472
+ });
400
473
  }, [productsFromSources, productsFromContent, message.content]);
401
- const hasProducts = allProducts.length > 0;
474
+ const markdownComponents = import_react5.default.useMemo(
475
+ () => ({
476
+ table: (_a) => {
477
+ var props = __objRest(_a, []);
478
+ 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)));
479
+ },
480
+ thead: (_b) => {
481
+ var props = __objRest(_b, []);
482
+ return /* @__PURE__ */ import_react5.default.createElement(
483
+ "thead",
484
+ __spreadValues({
485
+ className: "bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10"
486
+ }, props)
487
+ );
488
+ },
489
+ th: (_c) => {
490
+ var _d = _c, { children } = _d, props = __objRest(_d, ["children"]);
491
+ return /* @__PURE__ */ import_react5.default.createElement(
492
+ "th",
493
+ __spreadValues({
494
+ className: "p-3 font-semibold text-slate-700 dark:text-white/90 first:rounded-tl-xl last:rounded-tr-xl"
495
+ }, props),
496
+ normaliseChild(children)
497
+ );
498
+ },
499
+ td: (_e) => {
500
+ var _f = _e, { children } = _f, props = __objRest(_f, ["children"]);
501
+ return /* @__PURE__ */ import_react5.default.createElement(
502
+ "td",
503
+ __spreadValues({
504
+ className: "p-3 border-b border-slate-100 dark:border-white/5 last:border-0 text-slate-600 dark:text-white/70"
505
+ }, props),
506
+ normaliseChild(children)
507
+ );
508
+ },
509
+ code(_g) {
510
+ var _h = _g, {
511
+ inline,
512
+ className,
513
+ children
514
+ } = _h, props = __objRest(_h, [
515
+ "inline",
516
+ "className",
517
+ "children"
518
+ ]);
519
+ var _a;
520
+ const lang = (_a = /language-(\w+)/.exec(className != null ? className : "")) == null ? void 0 : _a[1];
521
+ if (!inline && lang === "chart") {
522
+ return /* @__PURE__ */ import_react5.default.createElement(
523
+ ChartBlock,
524
+ {
525
+ rawContent: String(children != null ? children : "").trim(),
526
+ primaryColor,
527
+ accentColor
528
+ }
529
+ );
530
+ }
531
+ return /* @__PURE__ */ import_react5.default.createElement("code", __spreadValues({ className }, props), typeof children === "string" || typeof children === "number" ? children : JSON.stringify(children));
532
+ }
533
+ }),
534
+ [primaryColor, accentColor]
535
+ );
402
536
  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
537
  "div",
404
538
  {
@@ -412,76 +546,8 @@ function MessageBubble({
412
546
  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
547
  style: isUser ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` } : {}
414
548
  },
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(
549
+ 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" }))
550
+ ), !isUser && allProducts.length > 0 && /* @__PURE__ */ import_react5.default.createElement("div", { className: "w-full mt-1" }, /* @__PURE__ */ import_react5.default.createElement(
485
551
  ProductCarousel,
486
552
  {
487
553
  products: allProducts,
@@ -501,6 +567,12 @@ function MessageBubble({
501
567
  " used"
502
568
  ), 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
569
  }
570
+ function normaliseChild(children) {
571
+ if (typeof children === "object" && children !== null && !Array.isArray(children) && !import_react5.default.isValidElement(children)) {
572
+ return JSON.stringify(children);
573
+ }
574
+ return children;
575
+ }
504
576
 
505
577
  // src/components/ConfigProvider.tsx
506
578
  var import_react6 = __toESM(require("react"));
package/dist/index.mjs CHANGED
@@ -281,6 +281,87 @@ 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
+ const stack = [];
302
+ let inString = false;
303
+ let escape = false;
304
+ for (const ch of s) {
305
+ if (escape) {
306
+ escape = false;
307
+ continue;
308
+ }
309
+ if (ch === "\\" && inString) {
310
+ escape = true;
311
+ continue;
312
+ }
313
+ if (ch === '"') {
314
+ inString = !inString;
315
+ continue;
316
+ }
317
+ if (inString) continue;
318
+ if (ch === "{" || ch === "[") {
319
+ stack.push(ch);
320
+ } else if (ch === "}") {
321
+ if (stack[stack.length - 1] === "{") stack.pop();
322
+ } else if (ch === "]") {
323
+ if (stack[stack.length - 1] === "[") stack.pop();
324
+ }
325
+ }
326
+ if (inString) s += '"';
327
+ for (let i = stack.length - 1; i >= 0; i--) {
328
+ s += stack[i] === "{" ? "}" : "]";
329
+ }
330
+ }
331
+ const lastClose = Math.max(s.lastIndexOf("}"), s.lastIndexOf("]"));
332
+ if (lastClose !== -1 && lastClose < s.length - 1 && /\S/.test(s.slice(lastClose + 1))) {
333
+ s = s.substring(0, lastClose + 1);
334
+ }
335
+ return s;
336
+ }
337
+ function resolveImage(data) {
338
+ for (const key of ["image", "img", "thumbnail"]) {
339
+ const v = data[key];
340
+ if (typeof v === "string" && v) return v;
341
+ }
342
+ if (Array.isArray(data.images) && typeof data.images[0] === "string") {
343
+ return data.images[0];
344
+ }
345
+ return void 0;
346
+ }
347
+ function ChartBlock({ rawContent, primaryColor, accentColor }) {
348
+ const result = React5.useMemo(() => {
349
+ if (!rawContent || rawContent === "undefined") return { error: "Empty chart config." };
350
+ try {
351
+ const sanitized = sanitizeJson(rawContent);
352
+ const config = JSON.parse(sanitized);
353
+ return { config };
354
+ } catch (err) {
355
+ const sanitized = sanitizeJson(rawContent);
356
+ console.error("[ChartBlock] Parsing failed.\nError:", err, "\nSanitized:\n", sanitized);
357
+ return { error: String(err) };
358
+ }
359
+ }, [rawContent]);
360
+ if ("error" in result) {
361
+ 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."));
362
+ }
363
+ 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 }));
364
+ }
284
365
  function MessageBubble({
285
366
  message,
286
367
  sources,
@@ -291,28 +372,18 @@ function MessageBubble({
291
372
  }) {
292
373
  const isUser = message.role === "user";
293
374
  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
375
  const productsFromSources = React5.useMemo(() => {
307
376
  if (isUser || !sources) return [];
308
377
  return sources.filter((s) => {
309
- const m = s.metadata || {};
378
+ var _a;
379
+ const m = (_a = s.metadata) != null ? _a : {};
310
380
  return m.price || m.image || m.img || m.thumbnail || m.images || m.brand || m.type === "product";
311
381
  }).map((s) => {
312
- const m = s.metadata || {};
382
+ var _a, _b, _c, _d;
383
+ const m = (_a = s.metadata) != null ? _a : {};
313
384
  return {
314
385
  id: s.id,
315
- name: m.name || m.title || s.content.split("\n")[0] || "Unknown Product",
386
+ name: (_d = (_c = (_b = m.name) != null ? _b : m.title) != null ? _c : s.content.split("\n")[0]) != null ? _d : "Unknown Product",
316
387
  brand: m.brand,
317
388
  price: m.price,
318
389
  image: resolveImage(m),
@@ -327,16 +398,19 @@ function MessageBubble({
327
398
  const products = [];
328
399
  let content = message.content;
329
400
  if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
330
- const matches = Array.from(content.matchAll(jsonRegex));
331
- for (const m of matches) {
401
+ for (const match of content.matchAll(jsonRegex)) {
332
402
  try {
333
- const data = JSON.parse(m[1]);
403
+ const data = JSON.parse(match[1]);
334
404
  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], "");
405
+ products.push(
406
+ ...data.items.map((item) => {
407
+ var _a;
408
+ return __spreadProps(__spreadValues({}, item), {
409
+ image: (_a = item.image) != null ? _a : resolveImage(item)
410
+ });
411
+ })
412
+ );
413
+ content = content.replace(match[0], "");
340
414
  }
341
415
  } catch (e) {
342
416
  }
@@ -346,22 +420,82 @@ function MessageBubble({
346
420
  }, [message.content, isUser]);
347
421
  const allProducts = React5.useMemo(() => {
348
422
  if (productsFromContent.length > 0) return productsFromContent;
349
- const uniqueProducts = [];
350
- const seenIds = /* @__PURE__ */ new Set();
423
+ const seen = /* @__PURE__ */ new Set();
351
424
  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);
425
+ return productsFromSources.filter((p) => {
426
+ var _a;
427
+ const id = String((_a = p.id) != null ? _a : p.name);
428
+ if (seen.has(id)) return false;
429
+ const mentioned = contentLower.includes(p.name.toLowerCase()) || (p.brand ? contentLower.includes(p.brand.toLowerCase()) : false);
430
+ if (mentioned) {
431
+ seen.add(id);
432
+ return true;
360
433
  }
361
- }
362
- return uniqueProducts;
434
+ return false;
435
+ });
363
436
  }, [productsFromSources, productsFromContent, message.content]);
364
- const hasProducts = allProducts.length > 0;
437
+ const markdownComponents = React5.useMemo(
438
+ () => ({
439
+ table: (_a) => {
440
+ var props = __objRest(_a, []);
441
+ 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)));
442
+ },
443
+ thead: (_b) => {
444
+ var props = __objRest(_b, []);
445
+ return /* @__PURE__ */ React5.createElement(
446
+ "thead",
447
+ __spreadValues({
448
+ className: "bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10"
449
+ }, props)
450
+ );
451
+ },
452
+ th: (_c) => {
453
+ var _d = _c, { children } = _d, props = __objRest(_d, ["children"]);
454
+ return /* @__PURE__ */ React5.createElement(
455
+ "th",
456
+ __spreadValues({
457
+ className: "p-3 font-semibold text-slate-700 dark:text-white/90 first:rounded-tl-xl last:rounded-tr-xl"
458
+ }, props),
459
+ normaliseChild(children)
460
+ );
461
+ },
462
+ td: (_e) => {
463
+ var _f = _e, { children } = _f, props = __objRest(_f, ["children"]);
464
+ return /* @__PURE__ */ React5.createElement(
465
+ "td",
466
+ __spreadValues({
467
+ className: "p-3 border-b border-slate-100 dark:border-white/5 last:border-0 text-slate-600 dark:text-white/70"
468
+ }, props),
469
+ normaliseChild(children)
470
+ );
471
+ },
472
+ code(_g) {
473
+ var _h = _g, {
474
+ inline,
475
+ className,
476
+ children
477
+ } = _h, props = __objRest(_h, [
478
+ "inline",
479
+ "className",
480
+ "children"
481
+ ]);
482
+ var _a;
483
+ const lang = (_a = /language-(\w+)/.exec(className != null ? className : "")) == null ? void 0 : _a[1];
484
+ if (!inline && lang === "chart") {
485
+ return /* @__PURE__ */ React5.createElement(
486
+ ChartBlock,
487
+ {
488
+ rawContent: String(children != null ? children : "").trim(),
489
+ primaryColor,
490
+ accentColor
491
+ }
492
+ );
493
+ }
494
+ return /* @__PURE__ */ React5.createElement("code", __spreadValues({ className }, props), typeof children === "string" || typeof children === "number" ? children : JSON.stringify(children));
495
+ }
496
+ }),
497
+ [primaryColor, accentColor]
498
+ );
365
499
  return /* @__PURE__ */ React5.createElement("div", { className: `flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"} items-start` }, /* @__PURE__ */ React5.createElement(
366
500
  "div",
367
501
  {
@@ -375,76 +509,8 @@ function MessageBubble({
375
509
  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
510
  style: isUser ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` } : {}
377
511
  },
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(
512
+ 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" }))
513
+ ), !isUser && allProducts.length > 0 && /* @__PURE__ */ React5.createElement("div", { className: "w-full mt-1" }, /* @__PURE__ */ React5.createElement(
448
514
  ProductCarousel,
449
515
  {
450
516
  products: allProducts,
@@ -464,6 +530,12 @@ function MessageBubble({
464
530
  " used"
465
531
  ), 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
532
  }
533
+ function normaliseChild(children) {
534
+ if (typeof children === "object" && children !== null && !Array.isArray(children) && !React5.isValidElement(children)) {
535
+ return JSON.stringify(children);
536
+ }
537
+ return children;
538
+ }
467
539
 
468
540
  // src/components/ConfigProvider.tsx
469
541
  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.4",
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,141 @@ 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. Stack-based structure balancer.
50
+ // Walk every character, maintain a stack of openers so we close each
51
+ // unclosed structure with its exact matching closer — in the right order.
52
+ // This is the critical fix: a flat counter approach appends all ]'s then
53
+ // all }'s, which corrupts nested structures like {..., [..., {...}]}.
54
+ {
55
+ const stack: ('{' | '[')[] = [];
56
+ let inString = false;
57
+ let escape = false;
58
+
59
+ for (const ch of s) {
60
+ if (escape) { escape = false; continue; }
61
+ if (ch === '\\' && inString) { escape = true; continue; }
62
+ if (ch === '"') { inString = !inString; continue; }
63
+ if (inString) continue;
64
+
65
+ if (ch === '{' || ch === '[') {
66
+ stack.push(ch);
67
+ } else if (ch === '}') {
68
+ if (stack[stack.length - 1] === '{') stack.pop();
69
+ // mismatched closer — leave stack alone (sanitize step 5 handles trailing commas)
70
+ } else if (ch === ']') {
71
+ if (stack[stack.length - 1] === '[') stack.pop();
72
+ }
73
+ }
74
+
75
+ // Close any unterminated string literal first
76
+ if (inString) s += '"';
77
+
78
+ // Close every unclosed opener in reverse (innermost first)
79
+ for (let i = stack.length - 1; i >= 0; i--) {
80
+ s += stack[i] === '{' ? '}' : ']';
81
+ }
82
+ }
83
+
84
+ // 8. Trim trailing garbage that appears after the outermost closing char.
85
+ // Only fires when something non-whitespace follows the last } or ].
86
+ const lastClose = Math.max(s.lastIndexOf('}'), s.lastIndexOf(']'));
87
+ if (lastClose !== -1 && lastClose < s.length - 1 && /\S/.test(s.slice(lastClose + 1))) {
88
+ s = s.substring(0, lastClose + 1);
89
+ }
90
+
91
+ return s;
92
+ }
93
+
94
+ // ─── Tiny helpers ─────────────────────────────────────────────────────────────
95
+
96
+ function resolveImage(data: Record<string, unknown>): string | undefined {
97
+ for (const key of ['image', 'img', 'thumbnail']) {
98
+ const v = data[key];
99
+ if (typeof v === 'string' && v) return v;
100
+ }
101
+ if (Array.isArray(data.images) && typeof data.images[0] === 'string') {
102
+ return data.images[0];
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ // ─── Chart code block renderer ────────────────────────────────────────────────
108
+
109
+ interface ChartBlockProps {
110
+ rawContent: string;
111
+ primaryColor: string;
112
+ accentColor: string;
113
+ }
114
+
115
+ function ChartBlock({ rawContent, primaryColor, accentColor }: ChartBlockProps) {
116
+ const result = React.useMemo<{ config: ChartConfig } | { error: string }>(() => {
117
+ if (!rawContent || rawContent === 'undefined') return { error: 'Empty chart config.' };
118
+ try {
119
+ const sanitized = sanitizeJson(rawContent);
120
+ const config = JSON.parse(sanitized) as ChartConfig;
121
+ return { config };
122
+ } catch (err) {
123
+ const sanitized = sanitizeJson(rawContent);
124
+ console.error('[ChartBlock] Parsing failed.\nError:', err, '\nSanitized:\n', sanitized);
125
+ return { error: String(err) };
126
+ }
127
+ }, [rawContent]);
128
+
129
+ if ('error' in result) {
130
+ return (
131
+ <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">
132
+ <p className="font-medium mb-1">Failed to render chart</p>
133
+ <p className="opacity-70 text-xs">The generated configuration is invalid. Check the console for details.</p>
134
+ </div>
135
+ );
136
+ }
137
+
138
+ return (
139
+ <div className="my-4 p-4 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
140
+ <DynamicChart config={result.config} primaryColor={primaryColor} accentColor={accentColor} />
141
+ </div>
142
+ );
143
+ }
144
+
145
+ // ─── Component ────────────────────────────────────────────────────────────────
146
+
12
147
  export function MessageBubble({
13
148
  message,
14
149
  sources,
@@ -20,71 +155,51 @@ export function MessageBubble({
20
155
  const isUser = message.role === 'user';
21
156
  const [showSources, setShowSources] = React.useState(false);
22
157
 
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(() => {
158
+ // ── Products from sources ──────────────────────────────────────────────────
159
+ const productsFromSources = React.useMemo<Product[]>(() => {
44
160
  if (isUser || !sources) return [];
45
161
  return sources
46
162
  .filter(s => {
47
- const m = s.metadata || {};
163
+ const m = s.metadata ?? {};
48
164
  return m.price || m.image || m.img || m.thumbnail || m.images || m.brand || m.type === 'product';
49
165
  })
50
166
  .map(s => {
51
- const m = (s.metadata || {}) as Record<string, unknown>;
167
+ const m = (s.metadata ?? {}) as Record<string, unknown>;
52
168
  return {
53
169
  id: s.id,
54
- name: (m.name || m.title || s.content.split('\n')[0] || 'Unknown Product') as string,
170
+ name: (m.name ?? m.title ?? s.content.split('\n')[0] ?? 'Unknown Product') as string,
55
171
  brand: m.brand as string,
56
172
  price: m.price as string | number,
57
173
  image: resolveImage(m),
58
174
  link: m.link as string,
59
- description: s.content
175
+ description: s.content,
60
176
  };
61
177
  });
62
178
  }, [sources, isUser]);
63
179
 
64
- // Extract products from content (structured JSON)
180
+ // ── Products + cleaned content from structured JSON blocks in the message ──
65
181
  const { productsFromContent, cleanContent } = React.useMemo(() => {
66
- if (isUser) return { productsFromContent: [], cleanContent: message.content };
182
+ if (isUser) return { productsFromContent: [] as Product[], cleanContent: message.content };
67
183
 
68
184
  const jsonRegex = /```json\s*([\s\S]*?)\s*```/g;
69
185
  const products: Product[] = [];
70
186
  let content = message.content;
71
187
 
72
- // Only process if it looks like it might contain product data
73
188
  if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
74
- const matches = Array.from(content.matchAll(jsonRegex));
75
- for (const m of matches) {
189
+ for (const match of content.matchAll(jsonRegex)) {
76
190
  try {
77
- const data = JSON.parse(m[1]);
191
+ const data = JSON.parse(match[1]);
78
192
  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], '');
193
+ products.push(
194
+ ...data.items.map((item: Record<string, unknown>) => ({
195
+ ...item,
196
+ image: (item.image as string) ?? resolveImage(item),
197
+ })) as Product[],
198
+ );
199
+ content = content.replace(match[0], '');
85
200
  }
86
201
  } catch {
87
- // Skip invalid JSON
202
+ // Malformed product block — skip silently
88
203
  }
89
204
  }
90
205
  }
@@ -92,60 +207,107 @@ export function MessageBubble({
92
207
  return { productsFromContent: products, cleanContent: content.trim() };
93
208
  }, [message.content, isUser]);
94
209
 
95
- const allProducts = React.useMemo(() => {
96
- // If the LLM explicitly provided products in a structured format, only show those.
210
+ // ── Merge & deduplicate products ───────────────────────────────────────────
211
+ const allProducts = React.useMemo<Product[]>(() => {
97
212
  if (productsFromContent.length > 0) return productsFromContent;
98
213
 
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();
214
+ const seen = new Set<string>();
103
215
  const contentLower = message.content.toLowerCase();
104
216
 
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;
217
+ return productsFromSources.filter(p => {
218
+ const id = String(p.id ?? p.name);
219
+ if (seen.has(id)) return false;
220
+ const mentioned =
221
+ contentLower.includes(p.name.toLowerCase()) ||
222
+ (p.brand ? contentLower.includes(p.brand.toLowerCase()) : false);
223
+ if (mentioned) { seen.add(id); return true; }
224
+ return false;
225
+ });
226
+ }, [productsFromSources, productsFromContent, message.content]);
112
227
 
113
- if (nameMatch || brandMatch) {
114
- seenIds.add(identifier);
115
- uniqueProducts.push(p);
116
- }
117
- }
228
+ // ── Markdown component overrides ───────────────────────────────────────────
229
+ const markdownComponents = React.useMemo(
230
+ () => ({
231
+ table: ({ ...props }: React.HTMLAttributes<HTMLTableElement>) => (
232
+ <div className="overflow-x-auto my-4 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
233
+ <table className="w-full text-left border-collapse min-w-[400px]" {...props} />
234
+ </div>
235
+ ),
236
+ thead: ({ ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
237
+ <thead
238
+ className="bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10"
239
+ {...props}
240
+ />
241
+ ),
242
+ th: ({ children, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
243
+ <th
244
+ className="p-3 font-semibold text-slate-700 dark:text-white/90 first:rounded-tl-xl last:rounded-tr-xl"
245
+ {...props}
246
+ >
247
+ {normaliseChild(children)}
248
+ </th>
249
+ ),
250
+ td: ({ children, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
251
+ <td
252
+ className="p-3 border-b border-slate-100 dark:border-white/5 last:border-0 text-slate-600 dark:text-white/70"
253
+ {...props}
254
+ >
255
+ {normaliseChild(children)}
256
+ </td>
257
+ ),
258
+ code({
259
+ inline,
260
+ className,
261
+ children,
262
+ ...props
263
+ }: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
264
+ const lang = /language-(\w+)/.exec(className ?? '')?.[1];
118
265
 
119
- return uniqueProducts;
120
- }, [productsFromSources, productsFromContent, message.content]);
266
+ if (!inline && lang === 'chart') {
267
+ return (
268
+ <ChartBlock
269
+ rawContent={String(children ?? '').trim()}
270
+ primaryColor={primaryColor}
271
+ accentColor={accentColor}
272
+ />
273
+ );
274
+ }
121
275
 
122
- const hasProducts = allProducts.length > 0;
276
+ return (
277
+ <code className={className} {...props}>
278
+ {typeof children === 'string' || typeof children === 'number'
279
+ ? children
280
+ : JSON.stringify(children)}
281
+ </code>
282
+ );
283
+ },
284
+ }),
285
+ [primaryColor, accentColor],
286
+ );
123
287
 
288
+ // ── Render ─────────────────────────────────────────────────────────────────
124
289
  return (
125
290
  <div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'} items-start`}>
126
291
  {/* Avatar */}
127
292
  <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'
293
+ className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center shadow-lg ${isUser
294
+ ? 'text-white'
295
+ : 'bg-slate-100 dark:bg-white/10 text-slate-700 dark:text-white/80'
129
296
  }`}
130
297
  style={isUser ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` } : {}}
131
298
  >
132
- {isUser
133
- ? <User className="w-4 h-4 text-white" />
134
- : <Bot className="w-4 h-4" />
135
- }
299
+ {isUser ? <User className="w-4 h-4 text-white" /> : <Bot className="w-4 h-4" />}
136
300
  </div>
137
301
 
138
302
  <div className={`flex flex-col gap-1 max-w-[90%] ${isUser ? 'items-end' : 'items-start'}`}>
139
303
  {/* Bubble */}
140
304
  <div
141
305
  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'
306
+ ? 'text-white rounded-tr-sm'
307
+ : '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
308
  }`}
145
309
  style={
146
- isUser
147
- ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` }
148
- : {}
310
+ isUser ? { background: `linear-gradient(135deg, ${primaryColor}, ${accentColor})` } : {}
149
311
  }
150
312
  >
151
313
  {isStreaming && !message.content ? (
@@ -156,101 +318,7 @@ export function MessageBubble({
156
318
  </span>
157
319
  ) : (
158
320
  <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
- >
321
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
254
322
  {cleanContent || message.content}
255
323
  </ReactMarkdown>
256
324
 
@@ -261,8 +329,8 @@ export function MessageBubble({
261
329
  )}
262
330
  </div>
263
331
 
264
- {/* Product Carousel (Outside the bubble) */}
265
- {!isUser && hasProducts && (
332
+ {/* Product Carousel */}
333
+ {!isUser && allProducts.length > 0 && (
266
334
  <div className="w-full mt-1">
267
335
  <ProductCarousel
268
336
  products={allProducts}
@@ -272,17 +340,14 @@ export function MessageBubble({
272
340
  </div>
273
341
  )}
274
342
 
275
- {/* Sources toggle */}
343
+ {/* Sources */}
276
344
  {!isUser && sources && sources.length > 0 && (
277
345
  <div className="w-full">
278
346
  <button
279
- onClick={() => setShowSources((s) => !s)}
347
+ onClick={() => setShowSources(s => !s)}
280
348
  className="text-[11px] text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1 mt-1"
281
349
  >
282
- {showSources
283
- ? <ChevronDown className="w-3 h-3" />
284
- : <ChevronRight className="w-3 h-3" />
285
- }
350
+ {showSources ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
286
351
  {sources.length} source{sources.length !== 1 ? 's' : ''} used
287
352
  </button>
288
353
 
@@ -299,3 +364,18 @@ export function MessageBubble({
299
364
  </div>
300
365
  );
301
366
  }
367
+
368
+ // ─── Util ─────────────────────────────────────────────────────────────────────
369
+
370
+ /** Safely render table cell children that might be plain objects. */
371
+ function normaliseChild(children: React.ReactNode): React.ReactNode {
372
+ if (
373
+ typeof children === 'object' &&
374
+ children !== null &&
375
+ !Array.isArray(children) &&
376
+ !React.isValidElement(children)
377
+ ) {
378
+ return JSON.stringify(children);
379
+ }
380
+ return children;
381
+ }