@retrivora-ai/rag-engine 1.7.9 → 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-wCRqMtdX.d.mts → ILLMProvider-BfRgI1Xh.d.mts} +59 -1
- package/dist/{index-wCRqMtdX.d.ts → ILLMProvider-BfRgI1Xh.d.ts} +59 -1
- package/dist/MultiTablePostgresProvider-YY7LPNJK.mjs +8 -0
- package/dist/{chunk-UHGYLTTY.mjs → chunk-BFYLQYQU.mjs} +846 -458
- 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 +1033 -622
- package/dist/handlers/index.mjs +1 -1
- package/dist/{index-BDqz2_Yu.d.ts → index-1Z4GuYBi.d.ts} +7 -1
- package/dist/{index-DhsG2o5q.d.mts → index-BV0z5mb6.d.mts} +7 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +507 -352
- package/dist/index.mjs +427 -253
- package/dist/server.d.mts +35 -5
- package/dist/server.d.ts +35 -5
- package/dist/server.js +1183 -795
- package/dist/server.mjs +163 -176
- package/package.json +4 -2
- package/src/app/page.tsx +1 -2
- package/src/components/DynamicChart.tsx +10 -35
- package/src/components/MessageBubble.tsx +223 -74
- 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 +70 -209
- 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 +81 -0
- package/src/utils/SchemaMapper.ts +129 -0
- package/src/utils/UITransformer.ts +524 -194
- package/dist/DocumentChunker-Bmscbh-X.d.ts +0 -93
- package/dist/DocumentChunker-DCuxrOdM.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;
|
|
@@ -521,18 +585,25 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAd
|
|
|
521
585
|
config.chartType = (config.chartType as 'pie' | 'bar' | 'line') || 'bar';
|
|
522
586
|
}
|
|
523
587
|
|
|
524
|
-
// 2. Auto-Detection: Fill in missing view
|
|
525
|
-
if (!config.view) {
|
|
526
|
-
|
|
588
|
+
// 2. Auto-Detection: Fill in missing or dynamic view
|
|
589
|
+
if (!config.view || ['pie', 'bar', 'line', 'pie_chart', 'bar_chart', 'line_chart'].includes(String(config.view))) {
|
|
590
|
+
const viewStr = String(config.view || '').toLowerCase();
|
|
591
|
+
|
|
592
|
+
if (viewStr.includes('carousel') || config.type === 'products' || Array.isArray(config.items) || hasProductLikeData) {
|
|
527
593
|
config.view = 'carousel';
|
|
528
594
|
} else if (
|
|
595
|
+
viewStr.includes('chart') ||
|
|
596
|
+
viewStr.includes('pie') || viewStr.includes('bar') || viewStr.includes('line') ||
|
|
529
597
|
hasAggregationLikeData ||
|
|
530
598
|
(typeof config.type === 'string' && ['pie', 'bar', 'line'].includes(config.type)) ||
|
|
531
599
|
(typeof config.chartType === 'string' && ['pie', 'bar', 'line'].includes(config.chartType))
|
|
532
600
|
) {
|
|
533
601
|
config.view = 'chart';
|
|
534
602
|
config.chartType = (config.chartType as 'pie' | 'bar' | 'line') ||
|
|
603
|
+
(viewStr.replace('_chart', '') as 'pie' | 'bar' | 'line') ||
|
|
535
604
|
(config.type as 'pie' | 'bar' | 'line') || 'bar';
|
|
605
|
+
} else if (viewStr.includes('table')) {
|
|
606
|
+
config.view = 'table';
|
|
536
607
|
} else {
|
|
537
608
|
config.view = 'table';
|
|
538
609
|
}
|
|
@@ -589,12 +660,12 @@ function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAd
|
|
|
589
660
|
/>
|
|
590
661
|
{hasInsights && (
|
|
591
662
|
<div className="mt-4 flex flex-wrap gap-2 px-2">
|
|
592
|
-
{config.insights?.map((insight) => (
|
|
663
|
+
{config.insights?.map((insight, i) => (
|
|
593
664
|
<span
|
|
594
|
-
key={insight}
|
|
665
|
+
key={`insight-${i}`}
|
|
595
666
|
className={`rounded-full border border-emerald-200 bg-emerald-50 ${isCompact ? 'px-2.5 py-1 text-[10px]' : 'px-3 py-1 text-[11px]'} font-medium text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300`}
|
|
596
667
|
>
|
|
597
|
-
{insight}
|
|
668
|
+
{String(insight)}
|
|
598
669
|
</span>
|
|
599
670
|
))}
|
|
600
671
|
</div>
|
|
@@ -654,16 +725,23 @@ export function MessageBubble({
|
|
|
654
725
|
const isCompact = viewportSize === 'compact';
|
|
655
726
|
const isMedium = viewportSize === 'medium';
|
|
656
727
|
const [showSources, setShowSources] = React.useState(false);
|
|
657
|
-
const hasStructuredProductBlock = React.useMemo(
|
|
658
|
-
() => /```(?:ui|json|chart)\s*[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products|table)"?/i.test(message.content) ||
|
|
659
|
-
(/\{[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products)"?[\s\S]*\}/i.test(message.content) && looksLikeStructuredPayload(message.content)),
|
|
660
|
-
[message.content],
|
|
661
|
-
);
|
|
662
728
|
const structuredContent = React.useMemo(
|
|
663
729
|
() => isUser ? { payload: null, text: message.content } : extractStructuredPayload(message.content),
|
|
664
730
|
[isUser, message.content],
|
|
665
731
|
);
|
|
666
|
-
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);
|
|
667
745
|
|
|
668
746
|
// ── Products from sources ──────────────────────────────────────────────────
|
|
669
747
|
const productsFromSources = React.useMemo<Product[]>(() => {
|
|
@@ -671,17 +749,26 @@ export function MessageBubble({
|
|
|
671
749
|
return sources
|
|
672
750
|
.filter(s => {
|
|
673
751
|
const m = s.metadata ?? {};
|
|
674
|
-
|
|
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';
|
|
675
758
|
})
|
|
676
759
|
.map(s => {
|
|
677
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
|
+
|
|
678
765
|
return {
|
|
679
766
|
id: s.id,
|
|
680
|
-
name:
|
|
681
|
-
brand:
|
|
682
|
-
price:
|
|
767
|
+
name: name ?? s.content.split('\n')[0] ?? 'Unknown Product',
|
|
768
|
+
brand: brand,
|
|
769
|
+
price: price,
|
|
683
770
|
image: resolveImage(m),
|
|
684
|
-
link: m
|
|
771
|
+
link: getMetadataValue(m, ['link', 'url', 'product_url']) as string,
|
|
685
772
|
description: s.content,
|
|
686
773
|
};
|
|
687
774
|
});
|
|
@@ -697,7 +784,7 @@ export function MessageBubble({
|
|
|
697
784
|
const products: Product[] = [];
|
|
698
785
|
let content = structuredContent.text;
|
|
699
786
|
|
|
700
|
-
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[];
|
|
701
788
|
|
|
702
789
|
for (const candidate of payloadCandidates) {
|
|
703
790
|
try {
|
|
@@ -725,7 +812,7 @@ export function MessageBubble({
|
|
|
725
812
|
}
|
|
726
813
|
|
|
727
814
|
if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
|
|
728
|
-
for (const match of content.matchAll(jsonRegex)) {
|
|
815
|
+
for (const match of Array.from(content.matchAll(jsonRegex))) {
|
|
729
816
|
try {
|
|
730
817
|
const data = JSON.parse(sanitizeJson(match[1]));
|
|
731
818
|
if (data.type === 'products' && Array.isArray(data.items)) {
|
|
@@ -743,8 +830,52 @@ export function MessageBubble({
|
|
|
743
830
|
}
|
|
744
831
|
}
|
|
745
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
|
+
|
|
746
877
|
return { productsFromContent: products, cleanContent: content.trim() };
|
|
747
|
-
}, [message.content, isUser, structuredContent]);
|
|
878
|
+
}, [message.content, isUser, structuredContent, productsFromSources]);
|
|
748
879
|
|
|
749
880
|
// ── Merge & deduplicate products ───────────────────────────────────────────
|
|
750
881
|
const allProducts = React.useMemo<Product[]>(() => {
|
|
@@ -764,7 +895,7 @@ export function MessageBubble({
|
|
|
764
895
|
});
|
|
765
896
|
}, [productsFromSources, productsFromContent, message.content]);
|
|
766
897
|
|
|
767
|
-
const processedMarkdown = React.useMemo(() => {
|
|
898
|
+
const processedMarkdown = React.useMemo<string>(() => {
|
|
768
899
|
let raw = structuredContent.payload ? structuredContent.text : (cleanContent || message.content);
|
|
769
900
|
const hasStructuredUiBlock = Boolean(structuredContent.payload) || /```(?:ui|json|chart)\s*[\s\S]*?"(?:view|chartType|type|data|items|rows|products|results)"\s*:/i.test(raw);
|
|
770
901
|
|
|
@@ -806,7 +937,7 @@ export function MessageBubble({
|
|
|
806
937
|
}, [cleanContent, message.content, isStreaming, structuredContent]);
|
|
807
938
|
|
|
808
939
|
// ── Markdown component overrides ───────────────────────────────────────────
|
|
809
|
-
const markdownComponents = React.useMemo(
|
|
940
|
+
const markdownComponents: Components = React.useMemo(
|
|
810
941
|
() => ({
|
|
811
942
|
// Wrap in not-prose so Tailwind Typography resets don't clobber our styles.
|
|
812
943
|
// prose applies display:block and padding:0 to table/th/td — not-prose opts out.
|
|
@@ -985,18 +1116,12 @@ export function MessageBubble({
|
|
|
985
1116
|
)}
|
|
986
1117
|
|
|
987
1118
|
{!shouldRenderStructuredOnly && (
|
|
988
|
-
<ReactMarkdown
|
|
1119
|
+
<ReactMarkdown components={markdownComponents}>
|
|
989
1120
|
{processedMarkdown}
|
|
990
1121
|
</ReactMarkdown>
|
|
991
1122
|
)}
|
|
992
1123
|
|
|
993
1124
|
{/* Automated UI Fallback (if LLM failed to output structured block but UITransformer found data) */}
|
|
994
|
-
{!isUser && !structuredContent.payload && !!message.uiTransformation && (
|
|
995
|
-
<div className="mt-4 border-t border-slate-100 dark:border-white/5 pt-4">
|
|
996
|
-
<VisualizationRenderer data={message.uiTransformation as UITransformationResponse} />
|
|
997
|
-
</div>
|
|
998
|
-
)}
|
|
999
|
-
|
|
1000
1125
|
{isStreaming && message.content && (
|
|
1001
1126
|
<span className="inline-block w-1.5 h-3.5 ml-1 bg-emerald-500 animate-pulse align-middle" />
|
|
1002
1127
|
)}
|
|
@@ -1004,8 +1129,32 @@ export function MessageBubble({
|
|
|
1004
1129
|
)}
|
|
1005
1130
|
</div>
|
|
1006
1131
|
|
|
1007
|
-
{/*
|
|
1008
|
-
{
|
|
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 && (
|
|
1009
1158
|
<div className="w-full mt-1">
|
|
1010
1159
|
<ProductCarousel
|
|
1011
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}
|