@retrivora-ai/rag-engine 1.8.0 → 1.8.1
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/DocumentChunker-Dh9TvmGG.d.mts +45 -0
- package/dist/DocumentChunker-Dh9TvmGG.d.ts +45 -0
- package/dist/{index-DPsQodME.d.mts → ILLMProvider-BfRgI1Xh.d.mts} +58 -1
- package/dist/{index-DPsQodME.d.ts → ILLMProvider-BfRgI1Xh.d.ts} +58 -1
- package/dist/MultiTablePostgresProvider-YY7LPNJK.mjs +8 -0
- package/dist/{chunk-PV3MFHWU.mjs → chunk-BFYLQYQU.mjs} +808 -439
- package/dist/chunk-R3RGUMHE.mjs +218 -0
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +995 -603
- package/dist/handlers/index.mjs +1 -1
- package/dist/{index-Bb2yEopi.d.mts → index-1Z4GuYBi.d.ts} +7 -1
- package/dist/{index-CkbTzj9J.d.ts → index-BV0z5mb6.d.mts} +7 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +491 -316
- package/dist/index.mjs +411 -217
- package/dist/server.d.mts +35 -5
- package/dist/server.d.ts +35 -5
- package/dist/server.js +1146 -777
- package/dist/server.mjs +163 -176
- package/package.json +4 -2
- package/src/app/page.tsx +1 -2
- package/src/components/MessageBubble.tsx +211 -69
- package/src/components/ProductCard.tsx +2 -2
- package/src/components/VisualizationRenderer.tsx +347 -247
- package/src/config/RagConfig.ts +5 -0
- package/src/config/serverConfig.ts +3 -1
- package/src/core/Pipeline.ts +65 -206
- package/src/core/ProviderRegistry.ts +2 -2
- package/src/core/VectorPlugin.ts +9 -0
- package/src/handlers/index.ts +23 -7
- package/src/hooks/useRagChat.ts +2 -2
- package/src/llm/LLMFactory.ts +54 -2
- package/src/llm/providers/AnthropicProvider.ts +12 -8
- package/src/llm/providers/GeminiProvider.ts +188 -143
- package/src/llm/providers/OllamaProvider.ts +7 -3
- package/src/llm/providers/OpenAIProvider.ts +12 -8
- package/src/types/chat.ts +6 -0
- package/src/types/index.ts +80 -0
- package/src/utils/SchemaMapper.ts +129 -0
- package/src/utils/UITransformer.ts +491 -181
- package/dist/DocumentChunker-C1GEEosY.d.ts +0 -93
- package/dist/DocumentChunker-CFEiRopR.d.mts +0 -93
- package/dist/PostgreSQLProvider-BMOETDZA.mjs +0 -8
- package/dist/chunk-FLOSGE6A.mjs +0 -202
|
@@ -2,15 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { Bot, User, ChevronDown, ChevronRight } from 'lucide-react';
|
|
5
|
-
import ReactMarkdown from 'react-markdown';
|
|
6
|
-
import
|
|
7
|
-
import { MessageBubbleProps, Product } from '../types';
|
|
5
|
+
import ReactMarkdown, { Components } from 'react-markdown';
|
|
6
|
+
import { MessageBubbleProps, Product, UITransformationResponse } from '../types';
|
|
8
7
|
import { ChatViewportSize } from '../types/props';
|
|
9
8
|
import { SourceCard } from './SourceCard';
|
|
10
9
|
import { ProductCarousel } from './ProductCarousel';
|
|
11
10
|
import { DynamicChart } from './DynamicChart';
|
|
12
11
|
import { VisualizationRenderer } from './VisualizationRenderer';
|
|
13
|
-
import { UITransformationResponse } from '../utils/UITransformer';
|
|
14
12
|
|
|
15
13
|
// ─── JSON sanitization ────────────────────────────────────────────────────────
|
|
16
14
|
// Order matters: each step is a targeted fix, no step undoes a previous one.
|
|
@@ -126,50 +124,52 @@ function stripStructuredUiLabels(raw: string): string {
|
|
|
126
124
|
}
|
|
127
125
|
|
|
128
126
|
function extractBareJsonObject(raw: string): string | null {
|
|
129
|
-
|
|
127
|
+
// Find the first occurrence of '{'
|
|
128
|
+
let start = raw.indexOf('{');
|
|
130
129
|
if (start === -1) return null;
|
|
131
130
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
// We want to find the LARGEST valid JSON object in the string.
|
|
132
|
+
// Sometimes there might be a small JSON object followed by a larger one.
|
|
133
|
+
// We'll iterate through all { and find the one that spans the most.
|
|
134
|
+
let bestJson: string | null = null;
|
|
135
|
+
let maxLen = 0;
|
|
135
136
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
escape = false;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (ch === '\\' && inString) {
|
|
145
|
-
escape = true;
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (ch === '"') {
|
|
150
|
-
inString = !inString;
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
137
|
+
while (start !== -1) {
|
|
138
|
+
let depth = 0;
|
|
139
|
+
let inString = false;
|
|
140
|
+
let escape = false;
|
|
153
141
|
|
|
154
|
-
|
|
142
|
+
for (let i = start; i < raw.length; i += 1) {
|
|
143
|
+
const ch = raw[i];
|
|
144
|
+
if (escape) { escape = false; continue; }
|
|
145
|
+
if (ch === '\\' && inString) { escape = true; continue; }
|
|
146
|
+
if (ch === '"') { inString = !inString; continue; }
|
|
147
|
+
if (inString) continue;
|
|
155
148
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
149
|
+
if (ch === '{') depth += 1;
|
|
150
|
+
else if (ch === '}') {
|
|
151
|
+
depth -= 1;
|
|
152
|
+
if (depth === 0) {
|
|
153
|
+
const candidate = raw.slice(start, i + 1);
|
|
154
|
+
if (candidate.length > maxLen) {
|
|
155
|
+
maxLen = candidate.length;
|
|
156
|
+
bestJson = candidate;
|
|
157
|
+
}
|
|
158
|
+
break; // Found the end of this object
|
|
159
|
+
}
|
|
161
160
|
}
|
|
162
161
|
}
|
|
162
|
+
start = raw.indexOf('{', start + 1);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
return
|
|
165
|
+
return bestJson;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
function extractStructuredPayload(raw: string): { payload: string | null; text: string } {
|
|
169
169
|
let working = raw;
|
|
170
170
|
const fenceRegex = /```(?:[a-z]+)?\s*([\s\S]*?)```/gi;
|
|
171
171
|
|
|
172
|
-
for (const match of raw.matchAll(fenceRegex)) {
|
|
172
|
+
for (const match of Array.from(raw.matchAll(fenceRegex))) {
|
|
173
173
|
const body = match[1]?.trim() ?? '';
|
|
174
174
|
if (!looksLikeStructuredPayload(body)) continue;
|
|
175
175
|
|
|
@@ -199,28 +199,44 @@ function isLikelyUiOnlyMessage(text: string): boolean {
|
|
|
199
199
|
const trimmed = text.trim();
|
|
200
200
|
return (
|
|
201
201
|
trimmed.length === 0 ||
|
|
202
|
-
|
|
202
|
+
trimmed.length < 30 ||
|
|
203
|
+
/^(chart data|table data|visualization|visual representation|product catalog|product recommendations|catalog summary|availability|inventory status|details)\s*:?\s*$/i.test(trimmed)
|
|
203
204
|
);
|
|
204
205
|
}
|
|
205
206
|
|
|
206
207
|
// ─── Tiny helpers ─────────────────────────────────────────────────────────────
|
|
207
208
|
|
|
208
209
|
function resolveImage(data: Record<string, unknown>): string | undefined {
|
|
209
|
-
const isPlaceholder = (val: unknown) =>
|
|
210
|
+
const isPlaceholder = (val: unknown) =>
|
|
210
211
|
typeof val === 'string' && (val === '...' || val === '' || val.toLowerCase() === 'null' || val.toLowerCase() === 'undefined');
|
|
211
212
|
|
|
212
|
-
for (const key of ['image', 'img', 'thumbnail']) {
|
|
213
|
+
for (const key of ['image', 'img', 'thumbnail', 'images', 'product_image', 'Image', 'Thumbnail']) {
|
|
213
214
|
const v = data[key];
|
|
214
215
|
if (typeof v === 'string' && v && !isPlaceholder(v)) return v;
|
|
215
|
-
|
|
216
|
-
if (Array.isArray(data.images) && typeof data.images[0] === 'string' && !isPlaceholder(data.images[0])) {
|
|
217
|
-
return data.images[0];
|
|
216
|
+
if (Array.isArray(v) && typeof v[0] === 'string' && v[0] && !isPlaceholder(v[0])) return v[0];
|
|
218
217
|
}
|
|
219
218
|
return undefined;
|
|
220
219
|
}
|
|
221
220
|
|
|
221
|
+
/** Robustly extract a value from metadata by checking synonyms and case-insensitivity. */
|
|
222
|
+
function getMetadataValue(meta: Record<string, unknown>, keys: string[]): unknown {
|
|
223
|
+
const searchKeys = keys.map(k => k.toLowerCase());
|
|
224
|
+
const metaKeys = Object.keys(meta);
|
|
225
|
+
|
|
226
|
+
// Try direct case-insensitive match
|
|
227
|
+
const foundKey = metaKeys.find(k => searchKeys.includes(k.toLowerCase()));
|
|
228
|
+
if (foundKey) return meta[foundKey];
|
|
229
|
+
|
|
230
|
+
// Try partial match
|
|
231
|
+
const partialKey = metaKeys.find(k => {
|
|
232
|
+
const kLow = k.toLowerCase();
|
|
233
|
+
return searchKeys.some(sk => kLow.includes(sk) || sk.includes(kLow));
|
|
234
|
+
});
|
|
235
|
+
return partialKey ? meta[partialKey] : undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
222
238
|
/** Safely render table cell children that might be plain objects. */
|
|
223
|
-
function normaliseChild(children:
|
|
239
|
+
function normaliseChild(children: unknown): React.ReactNode {
|
|
224
240
|
if (
|
|
225
241
|
typeof children === 'object' &&
|
|
226
242
|
children !== null &&
|
|
@@ -229,7 +245,7 @@ function normaliseChild(children: React.ReactNode): React.ReactNode {
|
|
|
229
245
|
) {
|
|
230
246
|
return JSON.stringify(children);
|
|
231
247
|
}
|
|
232
|
-
return children;
|
|
248
|
+
return children as React.ReactNode;
|
|
233
249
|
}
|
|
234
250
|
|
|
235
251
|
function normaliseFieldName(value: string): string {
|
|
@@ -431,6 +447,15 @@ function normalizeTabularRows(
|
|
|
431
447
|
): Record<string, unknown>[] {
|
|
432
448
|
if (!Array.isArray(rows)) return [];
|
|
433
449
|
|
|
450
|
+
// Common synonyms for mapping natural language headers to technical keys
|
|
451
|
+
const SYNONYMS: Record<string, string[]> = {
|
|
452
|
+
name: ['product', 'item', 'title', 'label', 'heading', 'product name', 'item name'],
|
|
453
|
+
price: ['cost', 'amount', 'msrp', 'price', 'rate', 'value', 'price_usd'],
|
|
454
|
+
brand: ['manufacturer', 'vendor', 'make', 'company', 'brand_name', 'supplier'],
|
|
455
|
+
image: ['imageUrl', 'thumbnail', 'img', 'url', 'photo', 'picture', 'media'],
|
|
456
|
+
stock: ['inventory', 'quantity', 'count', 'availability', 'stock_level', 'in stock', 'status'],
|
|
457
|
+
};
|
|
458
|
+
|
|
434
459
|
return rows
|
|
435
460
|
.map((row) => {
|
|
436
461
|
if (Array.isArray(row)) {
|
|
@@ -445,7 +470,46 @@ function normalizeTabularRows(
|
|
|
445
470
|
}
|
|
446
471
|
|
|
447
472
|
if (row && typeof row === 'object') {
|
|
448
|
-
|
|
473
|
+
const result: Record<string, unknown> = { ...row };
|
|
474
|
+
|
|
475
|
+
// Self-Healing: If columns are provided, ensure the row has keys matching those columns
|
|
476
|
+
if (Array.isArray(columns)) {
|
|
477
|
+
columns.forEach(col => {
|
|
478
|
+
if (result[col] !== undefined) return; // Already matches
|
|
479
|
+
|
|
480
|
+
// Try to find a match among row keys
|
|
481
|
+
const rowKeys = Object.keys(row);
|
|
482
|
+
const colLower = col.toLowerCase().replace(/_/g, ' ');
|
|
483
|
+
|
|
484
|
+
// 1. Direct case-insensitive / space-to-underscore match
|
|
485
|
+
const foundKey = rowKeys.find(rk => {
|
|
486
|
+
const rkLower = rk.toLowerCase().replace(/_/g, ' ');
|
|
487
|
+
return rkLower === colLower || rkLower.includes(colLower) || colLower.includes(rkLower);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (foundKey) {
|
|
491
|
+
result[col] = row[foundKey];
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// 2. Synonym match
|
|
496
|
+
for (const [target, aliases] of Object.entries(SYNONYMS)) {
|
|
497
|
+
const isMatch = target === colLower || aliases.some(a => colLower.includes(a) || a.includes(colLower));
|
|
498
|
+
if (isMatch) {
|
|
499
|
+
const mappedKey = rowKeys.find(rk => {
|
|
500
|
+
const rkL = rk.toLowerCase();
|
|
501
|
+
return rkL === target || aliases.some(a => rkL.includes(a) || a.includes(rkL));
|
|
502
|
+
});
|
|
503
|
+
if (mappedKey) {
|
|
504
|
+
result[col] = row[mappedKey];
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return result;
|
|
449
513
|
}
|
|
450
514
|
|
|
451
515
|
return { value: row };
|
|
@@ -486,7 +550,7 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAd
|
|
|
486
550
|
.trim();
|
|
487
551
|
const parsed = JSON.parse(sanitizeJson(extractLikelyJsonBlock(clean)));
|
|
488
552
|
const config = { ...parsed } as Partial<RawUIConfig> & Record<string, unknown>;
|
|
489
|
-
|
|
553
|
+
|
|
490
554
|
// V7 Field Mapping
|
|
491
555
|
if (config.chart) {
|
|
492
556
|
if (!config.chartType) config.chartType = config.chart.type;
|
|
@@ -524,7 +588,7 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAd
|
|
|
524
588
|
// 2. Auto-Detection: Fill in missing or dynamic view
|
|
525
589
|
if (!config.view || ['pie', 'bar', 'line', 'pie_chart', 'bar_chart', 'line_chart'].includes(String(config.view))) {
|
|
526
590
|
const viewStr = String(config.view || '').toLowerCase();
|
|
527
|
-
|
|
591
|
+
|
|
528
592
|
if (viewStr.includes('carousel') || config.type === 'products' || Array.isArray(config.items) || hasProductLikeData) {
|
|
529
593
|
config.view = 'carousel';
|
|
530
594
|
} else if (
|
|
@@ -661,16 +725,23 @@ export function MessageBubble({
|
|
|
661
725
|
const isCompact = viewportSize === 'compact';
|
|
662
726
|
const isMedium = viewportSize === 'medium';
|
|
663
727
|
const [showSources, setShowSources] = React.useState(false);
|
|
664
|
-
const hasStructuredProductBlock = React.useMemo(
|
|
665
|
-
() => /```(?:ui|json|chart)\s*[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products|table)"?/i.test(message.content) ||
|
|
666
|
-
(/\{[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products)"?[\s\S]*\}/i.test(message.content) && looksLikeStructuredPayload(message.content)),
|
|
667
|
-
[message.content],
|
|
668
|
-
);
|
|
669
728
|
const structuredContent = React.useMemo(
|
|
670
729
|
() => isUser ? { payload: null, text: message.content } : extractStructuredPayload(message.content),
|
|
671
730
|
[isUser, message.content],
|
|
672
731
|
);
|
|
673
|
-
const
|
|
732
|
+
const hasRichUI = React.useMemo(() => {
|
|
733
|
+
if (message.uiTransformation) {
|
|
734
|
+
const type = (message.uiTransformation as UITransformationResponse).type;
|
|
735
|
+
// Table is not considered "Rich enough" to block a product carousel
|
|
736
|
+
if (type && !['text', 'table'].includes(type)) return true;
|
|
737
|
+
}
|
|
738
|
+
if (structuredContent.payload) {
|
|
739
|
+
return /"?(?:view|type|chartType)"?\s*:\s*"?(?:chart|carousel|pie|bar|line|product_carousel)"?/i.test(structuredContent.payload);
|
|
740
|
+
}
|
|
741
|
+
return false;
|
|
742
|
+
}, [message.uiTransformation, structuredContent.payload]);
|
|
743
|
+
|
|
744
|
+
const shouldRenderStructuredOnly = !isUser && hasRichUI && isLikelyUiOnlyMessage(structuredContent.text);
|
|
674
745
|
|
|
675
746
|
// ── Products from sources ──────────────────────────────────────────────────
|
|
676
747
|
const productsFromSources = React.useMemo<Product[]>(() => {
|
|
@@ -678,17 +749,26 @@ export function MessageBubble({
|
|
|
678
749
|
return sources
|
|
679
750
|
.filter(s => {
|
|
680
751
|
const m = s.metadata ?? {};
|
|
681
|
-
|
|
752
|
+
const keys = Object.keys(m).map(k => k.toLowerCase());
|
|
753
|
+
const hasProductKey = keys.some(k =>
|
|
754
|
+
['price', 'image', 'img', 'thumbnail', 'images', 'brand', 'product', 'sku', 'category', 'model', 'cost'].includes(k)
|
|
755
|
+
);
|
|
756
|
+
const hasPricePattern = /\$\s*\d+/.test(s.content);
|
|
757
|
+
return hasProductKey || hasPricePattern || m.type === 'product';
|
|
682
758
|
})
|
|
683
759
|
.map(s => {
|
|
684
760
|
const m = (s.metadata ?? {}) as Record<string, unknown>;
|
|
761
|
+
const name = getMetadataValue(m, ['name', 'product', 'title', 'label', 'item']) as string;
|
|
762
|
+
const brand = getMetadataValue(m, ['brand', 'manufacturer', 'vendor', 'make']) as string;
|
|
763
|
+
const price = getMetadataValue(m, ['price', 'cost', 'amount', 'msrp', 'rate']) as string | number;
|
|
764
|
+
|
|
685
765
|
return {
|
|
686
766
|
id: s.id,
|
|
687
|
-
name:
|
|
688
|
-
brand:
|
|
689
|
-
price:
|
|
767
|
+
name: name ?? s.content.split('\n')[0] ?? 'Unknown Product',
|
|
768
|
+
brand: brand,
|
|
769
|
+
price: price,
|
|
690
770
|
image: resolveImage(m),
|
|
691
|
-
link: m
|
|
771
|
+
link: getMetadataValue(m, ['link', 'url', 'product_url']) as string,
|
|
692
772
|
description: s.content,
|
|
693
773
|
};
|
|
694
774
|
});
|
|
@@ -704,7 +784,7 @@ export function MessageBubble({
|
|
|
704
784
|
const products: Product[] = [];
|
|
705
785
|
let content = structuredContent.text;
|
|
706
786
|
|
|
707
|
-
const payloadCandidates = [structuredContent.payload, ...content.matchAll(jsonRegex).map((match) => match[1])].filter(Boolean) as string[];
|
|
787
|
+
const payloadCandidates = [structuredContent.payload, ...Array.from(content.matchAll(jsonRegex)).map((match) => match[1])].filter(Boolean) as string[];
|
|
708
788
|
|
|
709
789
|
for (const candidate of payloadCandidates) {
|
|
710
790
|
try {
|
|
@@ -732,7 +812,7 @@ export function MessageBubble({
|
|
|
732
812
|
}
|
|
733
813
|
|
|
734
814
|
if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
|
|
735
|
-
for (const match of content.matchAll(jsonRegex)) {
|
|
815
|
+
for (const match of Array.from(content.matchAll(jsonRegex))) {
|
|
736
816
|
try {
|
|
737
817
|
const data = JSON.parse(sanitizeJson(match[1]));
|
|
738
818
|
if (data.type === 'products' && Array.isArray(data.items)) {
|
|
@@ -750,8 +830,52 @@ export function MessageBubble({
|
|
|
750
830
|
}
|
|
751
831
|
}
|
|
752
832
|
|
|
833
|
+
// 4. Fallback: Scan for plain text bulleted products (e.g. • Name - $Price)
|
|
834
|
+
// Support various bullets (optional) and dash types (-, –, —)
|
|
835
|
+
const bulletRegex = /(?:[•*-]\s*)?([^•\n\-\$*–—\(]+?)(?:\s*\(?Price\s*[:\-–—]?\s*)?(?:\s*[:\-–—]\s*|\s+)\$?([\d,.]+)(?:\s*USD)?/gi;
|
|
836
|
+
const matches = Array.from(content.matchAll(bulletRegex));
|
|
837
|
+
|
|
838
|
+
if (matches.length >= 2) {
|
|
839
|
+
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
840
|
+
|
|
841
|
+
for (const match of matches) {
|
|
842
|
+
let name = match[1]?.trim() || '';
|
|
843
|
+
// Clean up common leftovers
|
|
844
|
+
name = name.replace(/\s*\(?Price\s*$/i, '').replace(/[:\-–—]\s*$/g, '').trim();
|
|
845
|
+
|
|
846
|
+
let price = match[2]?.trim() || '';
|
|
847
|
+
price = price.replace(/[,\s]+$/, '').trim(); // Remove trailing commas or spaces
|
|
848
|
+
|
|
849
|
+
if (name && price) {
|
|
850
|
+
const newProduct: Product = {
|
|
851
|
+
id: `text-prod-${name}-${price}`,
|
|
852
|
+
name,
|
|
853
|
+
price: `$${price}`,
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
// Rehydration: Fuzzy match in sources to get image/brand/link
|
|
857
|
+
const normName = normalize(name);
|
|
858
|
+
const sourceMatch = productsFromSources.find(s => {
|
|
859
|
+
const sn = normalize(s.name);
|
|
860
|
+
return sn.includes(normName) || normName.includes(sn);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
if (sourceMatch) {
|
|
864
|
+
newProduct.image = sourceMatch.image;
|
|
865
|
+
newProduct.brand = sourceMatch.brand;
|
|
866
|
+
newProduct.link = sourceMatch.link;
|
|
867
|
+
newProduct.description = sourceMatch.description;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
products.push(newProduct);
|
|
871
|
+
content = content.replace(match[0], `\n**${name}** — $${price}`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
content = content.replace(/\n{3,}/g, '\n\n').trim();
|
|
875
|
+
}
|
|
876
|
+
|
|
753
877
|
return { productsFromContent: products, cleanContent: content.trim() };
|
|
754
|
-
}, [message.content, isUser, structuredContent]);
|
|
878
|
+
}, [message.content, isUser, structuredContent, productsFromSources]);
|
|
755
879
|
|
|
756
880
|
// ── Merge & deduplicate products ───────────────────────────────────────────
|
|
757
881
|
const allProducts = React.useMemo<Product[]>(() => {
|
|
@@ -771,7 +895,7 @@ export function MessageBubble({
|
|
|
771
895
|
});
|
|
772
896
|
}, [productsFromSources, productsFromContent, message.content]);
|
|
773
897
|
|
|
774
|
-
const processedMarkdown = React.useMemo(() => {
|
|
898
|
+
const processedMarkdown = React.useMemo<string>(() => {
|
|
775
899
|
let raw = structuredContent.payload ? structuredContent.text : (cleanContent || message.content);
|
|
776
900
|
const hasStructuredUiBlock = Boolean(structuredContent.payload) || /```(?:ui|json|chart)\s*[\s\S]*?"(?:view|chartType|type|data|items|rows|products|results)"\s*:/i.test(raw);
|
|
777
901
|
|
|
@@ -813,7 +937,7 @@ export function MessageBubble({
|
|
|
813
937
|
}, [cleanContent, message.content, isStreaming, structuredContent]);
|
|
814
938
|
|
|
815
939
|
// ── Markdown component overrides ───────────────────────────────────────────
|
|
816
|
-
const markdownComponents = React.useMemo(
|
|
940
|
+
const markdownComponents: Components = React.useMemo(
|
|
817
941
|
() => ({
|
|
818
942
|
// Wrap in not-prose so Tailwind Typography resets don't clobber our styles.
|
|
819
943
|
// prose applies display:block and padding:0 to table/th/td — not-prose opts out.
|
|
@@ -992,18 +1116,12 @@ export function MessageBubble({
|
|
|
992
1116
|
)}
|
|
993
1117
|
|
|
994
1118
|
{!shouldRenderStructuredOnly && (
|
|
995
|
-
<ReactMarkdown
|
|
1119
|
+
<ReactMarkdown components={markdownComponents}>
|
|
996
1120
|
{processedMarkdown}
|
|
997
1121
|
</ReactMarkdown>
|
|
998
1122
|
)}
|
|
999
1123
|
|
|
1000
1124
|
{/* Automated UI Fallback (if LLM failed to output structured block but UITransformer found data) */}
|
|
1001
|
-
{!isUser && !structuredContent.payload && !!message.uiTransformation && (
|
|
1002
|
-
<div className="mt-4 border-t border-slate-100 dark:border-white/5 pt-4">
|
|
1003
|
-
<VisualizationRenderer data={message.uiTransformation as UITransformationResponse} />
|
|
1004
|
-
</div>
|
|
1005
|
-
)}
|
|
1006
|
-
|
|
1007
1125
|
{isStreaming && message.content && (
|
|
1008
1126
|
<span className="inline-block w-1.5 h-3.5 ml-1 bg-emerald-500 animate-pulse align-middle" />
|
|
1009
1127
|
)}
|
|
@@ -1011,8 +1129,32 @@ export function MessageBubble({
|
|
|
1011
1129
|
)}
|
|
1012
1130
|
</div>
|
|
1013
1131
|
|
|
1014
|
-
{/*
|
|
1015
|
-
{
|
|
1132
|
+
{/* Auto-generated visualization fallback (only if no inline UI payload and not a redundant text/table block) */}
|
|
1133
|
+
{(() => {
|
|
1134
|
+
if (isUser || structuredContent.payload || !message.uiTransformation) return null;
|
|
1135
|
+
const ui = message.uiTransformation as UITransformationResponse;
|
|
1136
|
+
const textContent = (ui.data as { content?: string })?.content ?? '';
|
|
1137
|
+
const shouldShow =
|
|
1138
|
+
(ui.type === 'table' && allProducts.length === 0) ||
|
|
1139
|
+
!['text', 'table'].includes(ui.type) ||
|
|
1140
|
+
(ui.type === 'text' && !hasRichUI && allProducts.length === 0 && textContent &&
|
|
1141
|
+
!processedMarkdown.toLowerCase().includes(textContent.trim().toLowerCase()));
|
|
1142
|
+
if (!shouldShow) return null;
|
|
1143
|
+
return (
|
|
1144
|
+
<div className="w-full mt-3">
|
|
1145
|
+
<VisualizationRenderer
|
|
1146
|
+
data={ui}
|
|
1147
|
+
primaryColor={primaryColor}
|
|
1148
|
+
onAddToCart={onAddToCart}
|
|
1149
|
+
className="rounded-3xl border border-slate-200/60 dark:border-white/10 bg-white/90 dark:bg-slate-900/80 p-4"
|
|
1150
|
+
/>
|
|
1151
|
+
</div>
|
|
1152
|
+
);
|
|
1153
|
+
})()}
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
{/* Product Carousel (only if no other rich UI is present) */}
|
|
1157
|
+
{!isUser && !hasRichUI && allProducts.length > 0 && (
|
|
1016
1158
|
<div className="w-full mt-1">
|
|
1017
1159
|
<ProductCarousel
|
|
1018
1160
|
products={allProducts}
|
|
@@ -24,7 +24,7 @@ export function ProductCard({ product, primaryColor = '#6366f1', onAddToCart }:
|
|
|
24
24
|
<ShoppingCart className="w-12 h-12" />
|
|
25
25
|
</div>
|
|
26
26
|
)}
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
{/* Brand Badge */}
|
|
29
29
|
{product.brand && (
|
|
30
30
|
<div className="absolute top-2 left-2 px-2 py-1 bg-white/90 dark:bg-black/60 backdrop-blur-md rounded-md text-[10px] font-bold uppercase tracking-wider text-slate-700 dark:text-white/90 shadow-sm">
|
|
@@ -45,7 +45,7 @@ export function ProductCard({ product, primaryColor = '#6366f1', onAddToCart }:
|
|
|
45
45
|
</span>
|
|
46
46
|
)}
|
|
47
47
|
</div>
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
{product.description && (
|
|
50
50
|
<p className="text-[11px] text-slate-500 dark:text-white/50 line-clamp-2 leading-relaxed">
|
|
51
51
|
{product.description}
|