@retrivora-ai/rag-engine 1.8.0 → 1.8.2
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-BOJFz3Na.d.mts} +104 -1
- package/dist/{index-DPsQodME.d.ts → ILLMProvider-BOJFz3Na.d.ts} +104 -1
- package/dist/MultiTablePostgresProvider-ZLGSKTJR.mjs +8 -0
- package/dist/chunk-ICKRMZQK.mjs +76 -0
- package/dist/{chunk-PV3MFHWU.mjs → chunk-LZVVLSDN.mjs} +977 -516
- package/dist/chunk-OZFBG4BA.mjs +291 -0
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +1269 -656
- package/dist/handlers/index.mjs +4 -1
- package/dist/{index-Bb2yEopi.d.mts → index-BwpcaziY.d.ts} +10 -2
- package/dist/{index-CkbTzj9J.d.ts → index-D3V9Et2M.d.mts} +10 -2
- package/dist/index.d.mts +24 -4
- package/dist/index.d.ts +24 -4
- package/dist/index.js +1354 -826
- package/dist/index.mjs +1284 -795
- package/dist/server.d.mts +47 -27
- package/dist/server.d.ts +47 -27
- package/dist/server.js +1417 -829
- package/dist/server.mjs +164 -176
- package/package.json +6 -2
- package/src/app/api/upload/route.ts +4 -0
- package/src/app/constants.tsx +2 -2
- package/src/app/page.tsx +12 -322
- package/src/components/AmbientBackground.tsx +29 -0
- package/src/components/ArchitectureCard.tsx +17 -0
- package/src/components/ArchitectureCardsSection.tsx +15 -0
- package/src/components/ChatWindow.tsx +32 -0
- package/src/components/CodeViewer.tsx +51 -0
- package/src/components/ConfigProvider.tsx +1 -0
- package/src/components/DocViewer.tsx +37 -0
- package/src/components/DocumentUpload.tsx +44 -1
- package/src/components/Documentation.tsx +58 -0
- package/src/components/DynamicChart.tsx +27 -2
- package/src/components/Hero.tsx +59 -0
- package/src/components/HourglassLoader.tsx +87 -0
- package/src/components/Lifecycle.tsx +37 -0
- package/src/components/MarkdownComponents.tsx +140 -0
- package/src/components/MessageBubble.tsx +124 -904
- package/src/components/Navbar.tsx +55 -0
- package/src/components/ObservabilityPanel.tsx +374 -0
- package/src/components/ProductCard.tsx +5 -3
- package/src/components/UIDispatcher.tsx +344 -0
- package/src/components/VisualizationRenderer.tsx +372 -250
- package/src/config/RagConfig.ts +5 -0
- package/src/config/serverConfig.ts +3 -1
- package/src/core/Pipeline.ts +240 -271
- package/src/core/ProviderRegistry.ts +2 -2
- package/src/core/VectorPlugin.ts +9 -0
- package/src/handlers/index.ts +91 -15
- package/src/hooks/useRagChat.ts +21 -11
- package/src/index.ts +9 -1
- 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/providers/vectordb/MultiTablePostgresProvider.ts +150 -64
- package/src/types/chat.ts +8 -0
- package/src/types/index.ts +132 -0
- package/src/types/props.ts +9 -1
- package/src/utils/ProductExtractor.ts +347 -0
- package/src/utils/SchemaMapper.ts +129 -0
- package/src/utils/UITransformer.ts +470 -209
- package/src/utils/synonyms.ts +78 -0
- 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
|
@@ -1,650 +1,26 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
|
-
import { Bot, User, ChevronDown, ChevronRight } from 'lucide-react';
|
|
4
|
+
import { Bot, User, ChevronDown, ChevronRight, Activity } from 'lucide-react';
|
|
5
5
|
import ReactMarkdown from 'react-markdown';
|
|
6
|
-
import
|
|
7
|
-
import { MessageBubbleProps, Product } from '../types';
|
|
8
|
-
import { ChatViewportSize } from '../types/props';
|
|
6
|
+
import { MessageBubbleProps, Product, UITransformationResponse } from '../types';
|
|
9
7
|
import { SourceCard } from './SourceCard';
|
|
10
8
|
import { ProductCarousel } from './ProductCarousel';
|
|
11
|
-
import { DynamicChart } from './DynamicChart';
|
|
12
9
|
import { VisualizationRenderer } from './VisualizationRenderer';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const result =
|
|
28
|
-
op === '+' ? n1 + n2
|
|
29
|
-
: op === '-' ? n1 - n2
|
|
30
|
-
: op === '*' ? n1 * n2
|
|
31
|
-
: n2 !== 0 ? n1 / n2
|
|
32
|
-
: n1;
|
|
33
|
-
return `: ${result}`;
|
|
34
|
-
},
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
// 2. Strip JS-style comments while preserving strings (e.g. URLs)
|
|
38
|
-
s = s.replace(/("(?:\\.|[^\\"])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, g1) => g1 || '');
|
|
39
|
-
|
|
40
|
-
// 3. Quote bare (unquoted) object keys
|
|
41
|
-
s = s.replace(/([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g, '$1"$2":');
|
|
42
|
-
|
|
43
|
-
// 4. Replace single-quoted strings with double-quoted ones
|
|
44
|
-
s = s.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"');
|
|
45
|
-
|
|
46
|
-
// 5. Remove trailing commas before ] or }
|
|
47
|
-
s = s.replace(/,\s*([}\]])/g, '$1');
|
|
48
|
-
|
|
49
|
-
// 6. Remove completely empty key-value pairs left by prior cleanup
|
|
50
|
-
s = s.replace(/([{,])\s*""\s*(?=[,}])/g, '$1');
|
|
51
|
-
|
|
52
|
-
// 7. Stack-based structure balancer.
|
|
53
|
-
// Walk every character, maintain a stack of openers so we close each
|
|
54
|
-
// unclosed structure with its exact matching closer — in the right order.
|
|
55
|
-
// A flat counter approach appends all ]'s then all }'s, which corrupts
|
|
56
|
-
// nested structures like {..., [..., {...}]}.
|
|
57
|
-
{
|
|
58
|
-
const stack: ('{' | '[')[] = [];
|
|
59
|
-
let inString = false;
|
|
60
|
-
let escape = false;
|
|
61
|
-
|
|
62
|
-
for (const ch of s) {
|
|
63
|
-
if (escape) { escape = false; continue; }
|
|
64
|
-
if (ch === '\\' && inString) { escape = true; continue; }
|
|
65
|
-
if (ch === '"') { inString = !inString; continue; }
|
|
66
|
-
if (inString) continue;
|
|
67
|
-
|
|
68
|
-
if (ch === '{' || ch === '[') {
|
|
69
|
-
stack.push(ch);
|
|
70
|
-
} else if (ch === '}') {
|
|
71
|
-
if (stack[stack.length - 1] === '{') stack.pop();
|
|
72
|
-
} else if (ch === ']') {
|
|
73
|
-
if (stack[stack.length - 1] === '[') stack.pop();
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Close any unterminated string literal first
|
|
78
|
-
if (inString) s += '"';
|
|
79
|
-
|
|
80
|
-
// Close every unclosed opener in reverse (innermost first)
|
|
81
|
-
for (let i = stack.length - 1; i >= 0; i--) {
|
|
82
|
-
s += stack[i] === '{' ? '}' : ']';
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// 8. Trim trailing garbage that appears after the outermost closing char.
|
|
87
|
-
// Only fires when something non-whitespace follows the last } or ].
|
|
88
|
-
const lastClose = Math.max(s.lastIndexOf('}'), s.lastIndexOf(']'));
|
|
89
|
-
if (lastClose !== -1 && lastClose < s.length - 1 && /\S/.test(s.slice(lastClose + 1))) {
|
|
90
|
-
s = s.substring(0, lastClose + 1);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return s;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function extractLikelyJsonBlock(raw: string): string {
|
|
97
|
-
const trimmed = raw.trim();
|
|
98
|
-
const objectStart = trimmed.indexOf('{');
|
|
99
|
-
const arrayStart = trimmed.indexOf('[');
|
|
100
|
-
|
|
101
|
-
const starts = [objectStart, arrayStart].filter((index) => index >= 0);
|
|
102
|
-
if (starts.length === 0) return trimmed;
|
|
103
|
-
|
|
104
|
-
return trimmed.slice(Math.min(...starts));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function looksLikeStructuredPayload(raw: string): boolean {
|
|
108
|
-
return /"?(view|chartType|type|data|items|rows|products|results)"?\s*:/.test(raw);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function stripMarkdownTables(raw: string): string {
|
|
112
|
-
return raw.replace(
|
|
113
|
-
/(?:^|\n)\|[^\n]+\|\n\|(?:\s*:?-+:?\s*\|)+\n(?:\|[^\n]*\|\n?)*/g,
|
|
114
|
-
'\n\n',
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function stripStructuredUiLabels(raw: string): string {
|
|
119
|
-
return raw.replace(
|
|
120
|
-
/(?:^|\n)\*\*(?:Chart Data|Table Data|Visualization|Visual Representation)\*\:\s*(?=\n|$)/gi,
|
|
121
|
-
'\n',
|
|
122
|
-
).replace(
|
|
123
|
-
/(?:^|\n)(?:Chart Data|Table Data|Visualization|Visual Representation)\s*\:\s*(?=\n|$)/gi,
|
|
124
|
-
'\n',
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function extractBareJsonObject(raw: string): string | null {
|
|
129
|
-
const start = raw.indexOf('{');
|
|
130
|
-
if (start === -1) return null;
|
|
131
|
-
|
|
132
|
-
let depth = 0;
|
|
133
|
-
let inString = false;
|
|
134
|
-
let escape = false;
|
|
135
|
-
|
|
136
|
-
for (let i = start; i < raw.length; i += 1) {
|
|
137
|
-
const ch = raw[i];
|
|
138
|
-
|
|
139
|
-
if (escape) {
|
|
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
|
-
}
|
|
153
|
-
|
|
154
|
-
if (inString) continue;
|
|
155
|
-
|
|
156
|
-
if (ch === '{') depth += 1;
|
|
157
|
-
if (ch === '}') {
|
|
158
|
-
depth -= 1;
|
|
159
|
-
if (depth === 0) {
|
|
160
|
-
return raw.slice(start, i + 1);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function extractStructuredPayload(raw: string): { payload: string | null; text: string } {
|
|
169
|
-
let working = raw;
|
|
170
|
-
const fenceRegex = /```(?:[a-z]+)?\s*([\s\S]*?)```/gi;
|
|
171
|
-
|
|
172
|
-
for (const match of raw.matchAll(fenceRegex)) {
|
|
173
|
-
const body = match[1]?.trim() ?? '';
|
|
174
|
-
if (!looksLikeStructuredPayload(body)) continue;
|
|
175
|
-
|
|
176
|
-
working = working.replace(match[0], '');
|
|
177
|
-
return {
|
|
178
|
-
payload: body,
|
|
179
|
-
text: stripStructuredUiLabels(working).replace(/\n{3,}/g, '\n\n').trim(),
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const bareJson = extractBareJsonObject(raw);
|
|
184
|
-
if (bareJson && looksLikeStructuredPayload(bareJson)) {
|
|
185
|
-
working = working.replace(bareJson, '');
|
|
186
|
-
return {
|
|
187
|
-
payload: bareJson,
|
|
188
|
-
text: stripStructuredUiLabels(working).replace(/\n{3,}/g, '\n\n').trim(),
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return {
|
|
193
|
-
payload: null,
|
|
194
|
-
text: stripStructuredUiLabels(raw).replace(/\n{3,}/g, '\n\n').trim(),
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function isLikelyUiOnlyMessage(text: string): boolean {
|
|
199
|
-
const trimmed = text.trim();
|
|
200
|
-
return (
|
|
201
|
-
trimmed.length === 0 ||
|
|
202
|
-
/^(chart data|table data|visualization|visual representation|product catalog|product recommendations|catalog summary)\s*:?\s*$/i.test(trimmed)
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// ─── Tiny helpers ─────────────────────────────────────────────────────────────
|
|
207
|
-
|
|
208
|
-
function resolveImage(data: Record<string, unknown>): string | undefined {
|
|
209
|
-
const isPlaceholder = (val: unknown) =>
|
|
210
|
-
typeof val === 'string' && (val === '...' || val === '' || val.toLowerCase() === 'null' || val.toLowerCase() === 'undefined');
|
|
211
|
-
|
|
212
|
-
for (const key of ['image', 'img', 'thumbnail']) {
|
|
213
|
-
const v = data[key];
|
|
214
|
-
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];
|
|
218
|
-
}
|
|
219
|
-
return undefined;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Safely render table cell children that might be plain objects. */
|
|
223
|
-
function normaliseChild(children: React.ReactNode): React.ReactNode {
|
|
224
|
-
if (
|
|
225
|
-
typeof children === 'object' &&
|
|
226
|
-
children !== null &&
|
|
227
|
-
!Array.isArray(children) &&
|
|
228
|
-
!React.isValidElement(children)
|
|
229
|
-
) {
|
|
230
|
-
return JSON.stringify(children);
|
|
231
|
-
}
|
|
232
|
-
return children;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function normaliseFieldName(value: string): string {
|
|
236
|
-
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function formatColumnLabel(value: string): string {
|
|
240
|
-
return value
|
|
241
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
242
|
-
.replace(/[_-]+/g, ' ')
|
|
243
|
-
.replace(/\s+/g, ' ')
|
|
244
|
-
.trim()
|
|
245
|
-
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// ─── Inline data table renderer ───────────────────────────────────────────────
|
|
249
|
-
// Handles chart configs where type === "table". The LLM may emit any of:
|
|
250
|
-
// columns, dataKeys (+optional xAxisKey), or no key hints (auto-detect from row).
|
|
251
|
-
|
|
252
|
-
interface TableConfig {
|
|
253
|
-
type: 'table';
|
|
254
|
-
title?: string;
|
|
255
|
-
description?: string;
|
|
256
|
-
columns?: string[]; // preferred: explicit ordered column labels
|
|
257
|
-
dataKeys?: string[]; // fallback column keys (may omit xAxisKey)
|
|
258
|
-
xAxisKey?: string; // row-label key to prepend when using dataKeys
|
|
259
|
-
data: Record<string, unknown>[];
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function DataTable({ config, viewportSize = 'large' }: { config: TableConfig; viewportSize?: ChatViewportSize }) {
|
|
263
|
-
const isCompact = viewportSize === 'compact';
|
|
264
|
-
const isMedium = viewportSize === 'medium';
|
|
265
|
-
const columns = React.useMemo<Array<{ label: string; accessor: string }>>(() => {
|
|
266
|
-
const rowKeys = Array.from(
|
|
267
|
-
new Set(
|
|
268
|
-
config.data.flatMap((row) => Object.keys(row)),
|
|
269
|
-
),
|
|
270
|
-
);
|
|
271
|
-
|
|
272
|
-
const findAccessor = (label: string) => {
|
|
273
|
-
const exact = rowKeys.find((key) => key === label);
|
|
274
|
-
if (exact) return exact;
|
|
275
|
-
|
|
276
|
-
const normalisedLabel = normaliseFieldName(label);
|
|
277
|
-
const normalisedMatch = rowKeys.find((key) => normaliseFieldName(key) === normalisedLabel);
|
|
278
|
-
if (normalisedMatch) return normalisedMatch;
|
|
279
|
-
|
|
280
|
-
return label;
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
if (Array.isArray(config.columns) && config.columns.length) {
|
|
284
|
-
return config.columns.map((label) => ({
|
|
285
|
-
label: formatColumnLabel(label),
|
|
286
|
-
accessor: findAccessor(label),
|
|
287
|
-
}));
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
if (Array.isArray(config.dataKeys) && config.dataKeys.length) {
|
|
291
|
-
const keys = config.xAxisKey && !config.dataKeys.includes(config.xAxisKey)
|
|
292
|
-
? [config.xAxisKey, ...config.dataKeys]
|
|
293
|
-
: config.dataKeys;
|
|
294
|
-
|
|
295
|
-
return keys.map((key) => ({
|
|
296
|
-
label: formatColumnLabel(key),
|
|
297
|
-
accessor: findAccessor(key),
|
|
298
|
-
}));
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return rowKeys.map((key) => ({ label: formatColumnLabel(key), accessor: key }));
|
|
302
|
-
}, [config]);
|
|
303
|
-
|
|
304
|
-
if (!config.data.length || !columns.length) {
|
|
305
|
-
return <p className="text-xs text-slate-400 italic my-2">No data to display.</p>;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const formatCell = (val: unknown): string => {
|
|
309
|
-
if (val === null || val === undefined) return '—';
|
|
310
|
-
if (typeof val === 'boolean') return val ? '✓' : '✗';
|
|
311
|
-
return String(val);
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
return (
|
|
315
|
-
<div className="my-4 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden">
|
|
316
|
-
{config.title && (
|
|
317
|
-
<div className="px-4 py-2.5 bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10">
|
|
318
|
-
<p className="text-xs font-semibold text-slate-600 dark:text-white/70 uppercase tracking-wide">
|
|
319
|
-
{config.title}
|
|
320
|
-
</p>
|
|
321
|
-
</div>
|
|
322
|
-
)}
|
|
323
|
-
|
|
324
|
-
<div className="overflow-x-auto">
|
|
325
|
-
<table className={`w-full text-left border-collapse ${isCompact ? 'min-w-[240px] text-xs' : isMedium ? 'min-w-[280px] text-[13px]' : 'min-w-[320px] text-sm'}`}>
|
|
326
|
-
<thead className="bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10">
|
|
327
|
-
<tr>
|
|
328
|
-
{columns.map((column) => (
|
|
329
|
-
<th
|
|
330
|
-
key={column.label}
|
|
331
|
-
className={`${isCompact ? 'px-2.5 py-2' : 'px-4 py-3'} font-semibold text-slate-700 dark:text-white/90 whitespace-nowrap`}
|
|
332
|
-
>
|
|
333
|
-
{column.label}
|
|
334
|
-
</th>
|
|
335
|
-
))}
|
|
336
|
-
</tr>
|
|
337
|
-
</thead>
|
|
338
|
-
<tbody>
|
|
339
|
-
{config.data.map((row, ri) => (
|
|
340
|
-
<tr
|
|
341
|
-
key={ri}
|
|
342
|
-
className="border-b border-slate-100 dark:border-white/5 last:border-0 hover:bg-slate-50/60 dark:hover:bg-white/5 transition-colors"
|
|
343
|
-
>
|
|
344
|
-
{columns.map((column) => (
|
|
345
|
-
<td
|
|
346
|
-
key={column.label}
|
|
347
|
-
className={`${isCompact ? 'px-2.5 py-2' : 'px-4 py-3'} text-slate-600 dark:text-white/70 ${column.accessor.toLowerCase().includes('description') ? 'whitespace-normal break-words' : 'whitespace-nowrap'}`}
|
|
348
|
-
>
|
|
349
|
-
{formatCell(row[column.accessor])}
|
|
350
|
-
</td>
|
|
351
|
-
))}
|
|
352
|
-
</tr>
|
|
353
|
-
))}
|
|
354
|
-
</tbody>
|
|
355
|
-
</table>
|
|
356
|
-
</div>
|
|
357
|
-
|
|
358
|
-
<div className="px-4 py-2 bg-slate-50 dark:bg-white/5 border-t border-slate-100 dark:border-white/5">
|
|
359
|
-
<p className="text-[11px] text-slate-400 dark:text-white/30">
|
|
360
|
-
{config.data.length} row{config.data.length !== 1 ? 's' : ''}
|
|
361
|
-
</p>
|
|
362
|
-
</div>
|
|
363
|
-
</div>
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// ─── Universal UI Dispatcher ────────────────────────────────────────────────
|
|
368
|
-
// Handles charts, product carousels, and data tables via a single protocol.
|
|
369
|
-
|
|
370
|
-
interface UIConfig {
|
|
371
|
-
view: 'chart' | 'carousel' | 'table';
|
|
372
|
-
chartType?: 'pie' | 'bar' | 'line';
|
|
373
|
-
xAxisKey?: string;
|
|
374
|
-
dataKeys?: string[];
|
|
375
|
-
columns?: string[];
|
|
376
|
-
colors?: string[];
|
|
377
|
-
data: Record<string, unknown>[];
|
|
378
|
-
title?: string;
|
|
379
|
-
description?: string;
|
|
380
|
-
insights?: string[];
|
|
381
|
-
type?: string;
|
|
382
|
-
items?: Record<string, unknown>[];
|
|
383
|
-
rows?: Record<string, unknown>[];
|
|
384
|
-
products?: Record<string, unknown>[];
|
|
385
|
-
results?: Record<string, unknown>[];
|
|
386
|
-
|
|
387
|
-
// V7 Protocol Fields
|
|
388
|
-
carousel?: {
|
|
389
|
-
items: Record<string, unknown>[];
|
|
390
|
-
};
|
|
391
|
-
chart?: {
|
|
392
|
-
type: 'pie' | 'bar' | 'line';
|
|
393
|
-
xKey: string;
|
|
394
|
-
yKeys: string[];
|
|
395
|
-
data: Record<string, unknown>[];
|
|
396
|
-
};
|
|
397
|
-
table?: {
|
|
398
|
-
columns: string[];
|
|
399
|
-
rows?: Record<string, unknown>[];
|
|
400
|
-
data?: Record<string, unknown>[];
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
interface RawUIConfig extends Omit<UIConfig, 'data' | 'items' | 'rows' | 'products' | 'results' | 'carousel' | 'chart' | 'table'> {
|
|
405
|
-
data: Array<Record<string, unknown> | unknown[]>;
|
|
406
|
-
items?: Array<Record<string, unknown> | unknown[]>;
|
|
407
|
-
rows?: Array<Record<string, unknown> | unknown[]>;
|
|
408
|
-
products?: Array<Record<string, unknown> | unknown[]>;
|
|
409
|
-
results?: Array<Record<string, unknown> | unknown[]>;
|
|
410
|
-
|
|
411
|
-
// V7 Protocol Fields (Raw)
|
|
412
|
-
carousel?: {
|
|
413
|
-
items: Array<Record<string, unknown> | unknown[]>;
|
|
414
|
-
};
|
|
415
|
-
chart?: {
|
|
416
|
-
type: 'pie' | 'bar' | 'line';
|
|
417
|
-
xKey: string;
|
|
418
|
-
yKeys: string[];
|
|
419
|
-
data: Array<Record<string, unknown> | unknown[]>;
|
|
420
|
-
};
|
|
421
|
-
table?: {
|
|
422
|
-
columns: string[];
|
|
423
|
-
rows?: Array<Record<string, unknown> | unknown[]>;
|
|
424
|
-
data?: Array<Record<string, unknown> | unknown[]>;
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function normalizeTabularRows(
|
|
429
|
-
rows: Array<Record<string, unknown> | unknown[]> | undefined,
|
|
430
|
-
columns?: string[],
|
|
431
|
-
): Record<string, unknown>[] {
|
|
432
|
-
if (!Array.isArray(rows)) return [];
|
|
433
|
-
|
|
434
|
-
return rows
|
|
435
|
-
.map((row) => {
|
|
436
|
-
if (Array.isArray(row)) {
|
|
437
|
-
const keys = Array.isArray(columns) && columns.length > 0
|
|
438
|
-
? columns
|
|
439
|
-
: row.map((_, index) => `column_${index + 1}`);
|
|
440
|
-
|
|
441
|
-
return keys.reduce<Record<string, unknown>>((acc, key, index) => {
|
|
442
|
-
acc[key] = row[index];
|
|
443
|
-
return acc;
|
|
444
|
-
}, {});
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (row && typeof row === 'object') {
|
|
448
|
-
return row as Record<string, unknown>;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
return { value: row };
|
|
452
|
-
})
|
|
453
|
-
.filter((row) => Object.keys(row).length > 0);
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function resolveStructuredRows(config: Partial<RawUIConfig> & Record<string, unknown>) {
|
|
457
|
-
// V7 Nested Structures
|
|
458
|
-
if (config.carousel?.items && Array.isArray(config.carousel.items)) return config.carousel.items;
|
|
459
|
-
if (config.chart?.data && Array.isArray(config.chart.data)) return config.chart.data;
|
|
460
|
-
if (config.table?.rows && Array.isArray(config.table.rows)) return config.table.rows;
|
|
461
|
-
if (config.table?.data && Array.isArray(config.table.data)) return config.table.data;
|
|
462
|
-
|
|
463
|
-
// V6 Flat Structures
|
|
464
|
-
if (Array.isArray(config.data) && config.data.length > 0) return config.data;
|
|
465
|
-
if (Array.isArray(config.items) && config.items.length > 0) return config.items;
|
|
466
|
-
if (Array.isArray(config.products) && config.products.length > 0) return config.products;
|
|
467
|
-
if (Array.isArray(config.rows) && config.rows.length > 0) return config.rows;
|
|
468
|
-
if (Array.isArray(config.results) && config.results.length > 0) return config.results;
|
|
469
|
-
return [];
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function UIDispatcher({ rawContent, primaryColor, accentColor, isStreaming, onAddToCart, viewportSize = 'large' }: {
|
|
473
|
-
rawContent: string;
|
|
474
|
-
primaryColor?: string;
|
|
475
|
-
accentColor?: string;
|
|
476
|
-
isStreaming?: boolean;
|
|
477
|
-
onAddToCart?: (product: Product) => void;
|
|
478
|
-
viewportSize?: ChatViewportSize;
|
|
479
|
-
}) {
|
|
480
|
-
const result = React.useMemo<{ config: UIConfig } | { error: string } | { loading: true }>(() => {
|
|
481
|
-
if (isStreaming) return { loading: true };
|
|
482
|
-
try {
|
|
483
|
-
const clean = rawContent
|
|
484
|
-
.replace(/^```[a-z]*\s*/i, '')
|
|
485
|
-
.replace(/```\s*$/, '')
|
|
486
|
-
.trim();
|
|
487
|
-
const parsed = JSON.parse(sanitizeJson(extractLikelyJsonBlock(clean)));
|
|
488
|
-
const config = { ...parsed } as Partial<RawUIConfig> & Record<string, unknown>;
|
|
489
|
-
|
|
490
|
-
// V7 Field Mapping
|
|
491
|
-
if (config.chart) {
|
|
492
|
-
if (!config.chartType) config.chartType = config.chart.type;
|
|
493
|
-
if (!config.xAxisKey) config.xAxisKey = config.chart.xKey;
|
|
494
|
-
if (!config.dataKeys) config.dataKeys = config.chart.yKeys;
|
|
495
|
-
}
|
|
496
|
-
if (config.table) {
|
|
497
|
-
if (!config.columns) config.columns = config.table.columns;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
const resolvedRows = resolveStructuredRows(config);
|
|
501
|
-
|
|
502
|
-
// ─── Intent Healing & Auto-Detection ───────────────────────────────────
|
|
503
|
-
|
|
504
|
-
const hasProductLikeData = resolvedRows.length > 0 &&
|
|
505
|
-
typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
|
|
506
|
-
!Array.isArray(resolvedRows[0]) &&
|
|
507
|
-
(['price', 'brand', 'image', 'link', 'thumbnail'].some(key => key in resolvedRows[0]) ||
|
|
508
|
-
Object.keys(resolvedRows[0]).length >= 4);
|
|
509
|
-
|
|
510
|
-
const hasAggregationLikeData = resolvedRows.length > 0 &&
|
|
511
|
-
typeof resolvedRows[0] === 'object' && resolvedRows[0] !== null &&
|
|
512
|
-
!Array.isArray(resolvedRows[0]) &&
|
|
513
|
-
(['value', 'count', 'total', 'percentage'].some(key => key in resolvedRows[0]) ||
|
|
514
|
-
(Object.keys(resolvedRows[0]).length <= 3 && Object.values(resolvedRows[0]).some(v => typeof v === 'number')));
|
|
515
|
-
|
|
516
|
-
// 1. Healing: Override explicit view if it contradicts the data shape
|
|
517
|
-
if (config.view === 'chart' && hasProductLikeData && !hasAggregationLikeData) {
|
|
518
|
-
config.view = 'carousel';
|
|
519
|
-
} else if (config.view === 'carousel' && hasAggregationLikeData && !hasProductLikeData) {
|
|
520
|
-
config.view = 'chart';
|
|
521
|
-
config.chartType = (config.chartType as 'pie' | 'bar' | 'line') || 'bar';
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// 2. Auto-Detection: Fill in missing or dynamic view
|
|
525
|
-
if (!config.view || ['pie', 'bar', 'line', 'pie_chart', 'bar_chart', 'line_chart'].includes(String(config.view))) {
|
|
526
|
-
const viewStr = String(config.view || '').toLowerCase();
|
|
527
|
-
|
|
528
|
-
if (viewStr.includes('carousel') || config.type === 'products' || Array.isArray(config.items) || hasProductLikeData) {
|
|
529
|
-
config.view = 'carousel';
|
|
530
|
-
} else if (
|
|
531
|
-
viewStr.includes('chart') ||
|
|
532
|
-
viewStr.includes('pie') || viewStr.includes('bar') || viewStr.includes('line') ||
|
|
533
|
-
hasAggregationLikeData ||
|
|
534
|
-
(typeof config.type === 'string' && ['pie', 'bar', 'line'].includes(config.type)) ||
|
|
535
|
-
(typeof config.chartType === 'string' && ['pie', 'bar', 'line'].includes(config.chartType))
|
|
536
|
-
) {
|
|
537
|
-
config.view = 'chart';
|
|
538
|
-
config.chartType = (config.chartType as 'pie' | 'bar' | 'line') ||
|
|
539
|
-
(viewStr.replace('_chart', '') as 'pie' | 'bar' | 'line') ||
|
|
540
|
-
(config.type as 'pie' | 'bar' | 'line') || 'bar';
|
|
541
|
-
} else if (viewStr.includes('table')) {
|
|
542
|
-
config.view = 'table';
|
|
543
|
-
} else {
|
|
544
|
-
config.view = 'table';
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
const normalizedConfig: UIConfig = {
|
|
549
|
-
...(config as Omit<UIConfig, 'data'>),
|
|
550
|
-
data: normalizeTabularRows(resolvedRows, config.columns),
|
|
551
|
-
};
|
|
552
|
-
|
|
553
|
-
return { config: normalizedConfig };
|
|
554
|
-
} catch (err) {
|
|
555
|
-
return { error: String(err) };
|
|
556
|
-
}
|
|
557
|
-
}, [rawContent, isStreaming]);
|
|
558
|
-
|
|
559
|
-
if ('loading' in result) {
|
|
560
|
-
return (
|
|
561
|
-
<div className="my-4 p-8 bg-slate-50 dark:bg-white/5 rounded-xl border border-dashed border-slate-300 dark:border-white/10 flex flex-col items-center justify-center gap-3 animate-pulse">
|
|
562
|
-
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
|
563
|
-
<p className="text-xs text-slate-500 font-medium italic">Preparing view...</p>
|
|
564
|
-
</div>
|
|
565
|
-
);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
if ('error' in result) {
|
|
569
|
-
return <pre className="p-4 my-2 bg-slate-900 text-slate-100 rounded-lg text-[10px] overflow-auto max-h-40"><code>{rawContent}</code></pre>;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
const { config } = result;
|
|
573
|
-
const isCompact = viewportSize === 'compact';
|
|
574
|
-
const hasInsights = Array.isArray(config.insights) && config.insights.length > 0;
|
|
575
|
-
const hasDescription = typeof config.description === 'string' && config.description.trim().length > 0;
|
|
576
|
-
|
|
577
|
-
switch (config.view) {
|
|
578
|
-
case 'chart':
|
|
579
|
-
return (
|
|
580
|
-
<div className={`${isCompact ? 'my-4 p-3' : 'my-6 p-4'} bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden`}>
|
|
581
|
-
{config.title && <h4 className={`${isCompact ? 'text-[11px] mb-3' : 'text-xs mb-4'} font-semibold text-slate-500 px-2`}>{config.title}</h4>}
|
|
582
|
-
{hasDescription && (
|
|
583
|
-
<p className={`px-2 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
|
|
584
|
-
)}
|
|
585
|
-
<DynamicChart
|
|
586
|
-
config={{
|
|
587
|
-
type: (config.chartType || 'bar') as 'bar' | 'line' | 'pie',
|
|
588
|
-
data: config.data as unknown as Record<string, string | number>[],
|
|
589
|
-
xAxisKey: config.xAxisKey,
|
|
590
|
-
dataKeys: config.dataKeys,
|
|
591
|
-
colors: config.colors,
|
|
592
|
-
}}
|
|
593
|
-
primaryColor={primaryColor}
|
|
594
|
-
accentColor={accentColor}
|
|
595
|
-
viewportSize={viewportSize}
|
|
596
|
-
/>
|
|
597
|
-
{hasInsights && (
|
|
598
|
-
<div className="mt-4 flex flex-wrap gap-2 px-2">
|
|
599
|
-
{config.insights?.map((insight, i) => (
|
|
600
|
-
<span
|
|
601
|
-
key={`insight-${i}`}
|
|
602
|
-
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`}
|
|
603
|
-
>
|
|
604
|
-
{String(insight)}
|
|
605
|
-
</span>
|
|
606
|
-
))}
|
|
607
|
-
</div>
|
|
608
|
-
)}
|
|
609
|
-
</div>
|
|
610
|
-
);
|
|
611
|
-
case 'carousel':
|
|
612
|
-
return (
|
|
613
|
-
<div className="my-4">
|
|
614
|
-
{config.title && <h4 className={`${isCompact ? 'mb-1.5 text-[11px]' : 'mb-2 text-xs'} font-semibold text-slate-500`}>{config.title}</h4>}
|
|
615
|
-
{hasDescription && (
|
|
616
|
-
<p className={`mb-3 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
|
|
617
|
-
)}
|
|
618
|
-
<ProductCarousel products={(config.data as unknown as Product[]).map(item => ({
|
|
619
|
-
...item,
|
|
620
|
-
image: item.image ?? (typeof resolveImage === 'function' ? resolveImage(item as unknown as Record<string, unknown>) : undefined)
|
|
621
|
-
}))} primaryColor={primaryColor} onAddToCart={onAddToCart} />
|
|
622
|
-
</div>
|
|
623
|
-
);
|
|
624
|
-
case 'table':
|
|
625
|
-
return (
|
|
626
|
-
<div className={`${isCompact ? 'my-3 p-3 max-h-[320px]' : 'my-4 p-4 max-h-[400px]'} bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm overflow-auto`}>
|
|
627
|
-
{config.title && <h4 className={`${isCompact ? 'text-[11px]' : 'text-xs'} font-semibold text-slate-500 mb-2`}>{config.title}</h4>}
|
|
628
|
-
{hasDescription && (
|
|
629
|
-
<p className={`mb-3 ${isCompact ? 'text-xs' : 'text-sm'} text-slate-500 dark:text-white/60`}>{config.description}</p>
|
|
630
|
-
)}
|
|
631
|
-
<DataTable config={{
|
|
632
|
-
type: 'table',
|
|
633
|
-
title: config.title,
|
|
634
|
-
description: config.description,
|
|
635
|
-
columns: config.columns,
|
|
636
|
-
dataKeys: config.dataKeys,
|
|
637
|
-
xAxisKey: config.xAxisKey,
|
|
638
|
-
data: config.data,
|
|
639
|
-
}} viewportSize={viewportSize} />
|
|
640
|
-
</div>
|
|
641
|
-
);
|
|
642
|
-
default:
|
|
643
|
-
return null;
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
|
|
10
|
+
import { ObservabilityPanel } from './ObservabilityPanel';
|
|
11
|
+
import HourglassLoader from './HourglassLoader';
|
|
12
|
+
import {
|
|
13
|
+
extractStructuredPayload,
|
|
14
|
+
isLikelyUiOnlyMessage,
|
|
15
|
+
extractProductsFromSources,
|
|
16
|
+
extractProductsFromContent,
|
|
17
|
+
deduplicateProducts,
|
|
18
|
+
looksLikeStructuredPayload,
|
|
19
|
+
stripStructuredUiLabels,
|
|
20
|
+
stripMarkdownTables
|
|
21
|
+
} from '../utils/ProductExtractor';
|
|
22
|
+
import { createMarkdownComponents } from './MarkdownComponents';
|
|
23
|
+
import { UIDispatcher } from './UIDispatcher';
|
|
648
24
|
|
|
649
25
|
// ─── Main component ───────────────────────────────────────────────────────────
|
|
650
26
|
|
|
@@ -660,118 +36,57 @@ export function MessageBubble({
|
|
|
660
36
|
const isUser = message.role === 'user';
|
|
661
37
|
const isCompact = viewportSize === 'compact';
|
|
662
38
|
const isMedium = viewportSize === 'medium';
|
|
39
|
+
|
|
663
40
|
const [showSources, setShowSources] = React.useState(false);
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
(/\{[\s\S]*?"?(?:view|type|data|products)"?\s*:\s*"?(?:carousel|products)"?[\s\S]*\}/i.test(message.content) && looksLikeStructuredPayload(message.content)),
|
|
667
|
-
[message.content],
|
|
668
|
-
);
|
|
41
|
+
const [showTrace, setShowTrace] = React.useState(false);
|
|
42
|
+
|
|
669
43
|
const structuredContent = React.useMemo(
|
|
670
44
|
() => isUser ? { payload: null, text: message.content } : extractStructuredPayload(message.content),
|
|
671
45
|
[isUser, message.content],
|
|
672
46
|
);
|
|
673
|
-
const shouldRenderStructuredOnly = !isUser && Boolean(structuredContent.payload) && isLikelyUiOnlyMessage(structuredContent.text);
|
|
674
47
|
|
|
675
|
-
|
|
48
|
+
const hasRichUI = React.useMemo(() => {
|
|
49
|
+
if (message.uiTransformation) {
|
|
50
|
+
const type = (message.uiTransformation as UITransformationResponse).type;
|
|
51
|
+
if (type && !['text', 'table'].includes(type)) return true;
|
|
52
|
+
}
|
|
53
|
+
if (structuredContent.payload) {
|
|
54
|
+
return /"?(?:view|type|chartType)"?\s*:\s*"?(?:chart|carousel|pie|bar|line|product_carousel)"?/i.test(structuredContent.payload);
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}, [message.uiTransformation, structuredContent.payload]);
|
|
58
|
+
|
|
59
|
+
const shouldRenderStructuredOnly = !isUser && hasRichUI && isLikelyUiOnlyMessage(structuredContent.text);
|
|
60
|
+
|
|
61
|
+
// ── Extraction ─────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
676
63
|
const productsFromSources = React.useMemo<Product[]>(() => {
|
|
677
|
-
|
|
678
|
-
return sources
|
|
679
|
-
.filter(s => {
|
|
680
|
-
const m = s.metadata ?? {};
|
|
681
|
-
return m.price || m.image || m.img || m.thumbnail || m.images || m.brand || m.type === 'product';
|
|
682
|
-
})
|
|
683
|
-
.map(s => {
|
|
684
|
-
const m = (s.metadata ?? {}) as Record<string, unknown>;
|
|
685
|
-
return {
|
|
686
|
-
id: s.id,
|
|
687
|
-
name: (m.name ?? m.title ?? s.content.split('\n')[0] ?? 'Unknown Product') as string,
|
|
688
|
-
brand: m.brand as string,
|
|
689
|
-
price: m.price as string | number,
|
|
690
|
-
image: resolveImage(m),
|
|
691
|
-
link: m.link as string,
|
|
692
|
-
description: s.content,
|
|
693
|
-
};
|
|
694
|
-
});
|
|
64
|
+
return extractProductsFromSources(sources, isUser);
|
|
695
65
|
}, [sources, isUser]);
|
|
696
66
|
|
|
697
|
-
// ── Products + cleaned content from structured JSON blocks in the message ──
|
|
698
67
|
const { productsFromContent, cleanContent } = React.useMemo(() => {
|
|
699
68
|
if (isUser) {
|
|
700
69
|
return { productsFromContent: [] as Product[], cleanContent: message.content };
|
|
701
70
|
}
|
|
702
71
|
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const payloadCandidates = [structuredContent.payload, ...content.matchAll(jsonRegex).map((match) => match[1])].filter(Boolean) as string[];
|
|
72
|
+
const payloadCandidates = [
|
|
73
|
+
structuredContent.payload,
|
|
74
|
+
...Array.from(structuredContent.text.matchAll(/```(?:json|ui)?\s*([\s\S]*?)\s*```/g)).map(m => m[1])
|
|
75
|
+
].filter(Boolean) as string[];
|
|
708
76
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
: null;
|
|
717
|
-
|
|
718
|
-
if (
|
|
719
|
-
(data.type === 'products' || data.view === 'carousel' || data.view === 'table' || Array.isArray(data.products)) &&
|
|
720
|
-
itemSet
|
|
721
|
-
) {
|
|
722
|
-
products.push(
|
|
723
|
-
...itemSet.map((item: Record<string, unknown>) => ({
|
|
724
|
-
...item,
|
|
725
|
-
image: (item.image as string) ?? resolveImage(item),
|
|
726
|
-
})) as Product[],
|
|
727
|
-
);
|
|
728
|
-
}
|
|
729
|
-
} catch {
|
|
730
|
-
// Ignore malformed product payloads
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
if (content.includes('"type": "products"') || content.includes('"type":"products"')) {
|
|
735
|
-
for (const match of content.matchAll(jsonRegex)) {
|
|
736
|
-
try {
|
|
737
|
-
const data = JSON.parse(sanitizeJson(match[1]));
|
|
738
|
-
if (data.type === 'products' && Array.isArray(data.items)) {
|
|
739
|
-
products.push(
|
|
740
|
-
...data.items.map((item: Record<string, unknown>) => ({
|
|
741
|
-
...item,
|
|
742
|
-
image: (item.image as string) ?? resolveImage(item),
|
|
743
|
-
})) as Product[],
|
|
744
|
-
);
|
|
745
|
-
content = content.replace(match[0], '');
|
|
746
|
-
}
|
|
747
|
-
} catch {
|
|
748
|
-
// Malformed product block — skip silently
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
return { productsFromContent: products, cleanContent: content.trim() };
|
|
754
|
-
}, [message.content, isUser, structuredContent]);
|
|
77
|
+
return extractProductsFromContent(
|
|
78
|
+
message.content,
|
|
79
|
+
structuredContent.text,
|
|
80
|
+
payloadCandidates,
|
|
81
|
+
productsFromSources
|
|
82
|
+
);
|
|
83
|
+
}, [message.content, isUser, structuredContent, productsFromSources]);
|
|
755
84
|
|
|
756
|
-
// ── Merge & deduplicate products ───────────────────────────────────────────
|
|
757
85
|
const allProducts = React.useMemo<Product[]>(() => {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const seen = new Set<string>();
|
|
761
|
-
const contentLower = message.content.toLowerCase();
|
|
762
|
-
|
|
763
|
-
return productsFromSources.filter(p => {
|
|
764
|
-
const id = String(p.id ?? p.name);
|
|
765
|
-
if (seen.has(id)) return false;
|
|
766
|
-
const mentioned =
|
|
767
|
-
contentLower.includes(p.name.toLowerCase()) ||
|
|
768
|
-
(p.brand ? contentLower.includes(p.brand.toLowerCase()) : false);
|
|
769
|
-
if (mentioned) { seen.add(id); return true; }
|
|
770
|
-
return false;
|
|
771
|
-
});
|
|
86
|
+
return deduplicateProducts(productsFromSources, productsFromContent, message.content);
|
|
772
87
|
}, [productsFromSources, productsFromContent, message.content]);
|
|
773
88
|
|
|
774
|
-
const processedMarkdown = React.useMemo(() => {
|
|
89
|
+
const processedMarkdown = React.useMemo<string>(() => {
|
|
775
90
|
let raw = structuredContent.payload ? structuredContent.text : (cleanContent || message.content);
|
|
776
91
|
const hasStructuredUiBlock = Boolean(structuredContent.payload) || /```(?:ui|json|chart)\s*[\s\S]*?"(?:view|chartType|type|data|items|rows|products|results)"\s*:/i.test(raw);
|
|
777
92
|
|
|
@@ -779,12 +94,9 @@ export function MessageBubble({
|
|
|
779
94
|
raw = raw.replace(/([^\n])\n\|/g, '$1\n\n|') // Ensure blank line before table
|
|
780
95
|
.replace(/(\|[^\n]*\|)\n\n+(?=\|)/g, '$1\n'); // Remove blank lines between rows
|
|
781
96
|
|
|
782
|
-
// 2. HEALING:
|
|
783
|
-
// This handles models (like Llama 3.2) that often omit the ```chart markers.
|
|
97
|
+
// 2. HEALING: Wrap naked JSON blocks
|
|
784
98
|
if (!isStreaming) {
|
|
785
|
-
// Look for objects that look like chart or product data
|
|
786
99
|
raw = raw.replace(/(?:^|\n)\s*\{[\s\S]*\}\s*(?:\n|$)/g, (match) => {
|
|
787
|
-
// Only wrap if not already in a code block
|
|
788
100
|
if (match.includes('```')) return match;
|
|
789
101
|
if (!looksLikeStructuredPayload(match)) return match;
|
|
790
102
|
const type = match.includes('"view"') || match.includes('"chartType"') || match.includes('"type": "pie"') || match.includes('"type":"pie"') || match.includes('"type": "bar"') || match.includes('"type":"bar"') || match.includes('"type": "line"') || match.includes('"type":"line"')
|
|
@@ -794,156 +106,19 @@ export function MessageBubble({
|
|
|
794
106
|
});
|
|
795
107
|
}
|
|
796
108
|
|
|
797
|
-
// 2b.
|
|
798
|
-
// markdown tables that often follow as a fallback explanation.
|
|
109
|
+
// 2b. Suppress duplicated markdown tables if a UI block is present
|
|
799
110
|
if (hasStructuredUiBlock) {
|
|
800
111
|
raw = stripStructuredUiLabels(stripMarkdownTables(raw))
|
|
801
112
|
.replace(/\n{3,}/g, '\n\n')
|
|
802
113
|
.trim();
|
|
803
114
|
}
|
|
804
115
|
|
|
805
|
-
// 3. HIJACKING: If streaming, hide table-like content to prevent raw markdown flashing.
|
|
806
|
-
if (isStreaming) {
|
|
807
|
-
raw = raw.replace(/((?:^|\n)\|.*)+/g, (match) => {
|
|
808
|
-
return `\n\n\`\`\`table-loading\n${match.trim()}\n\`\`\`\n\n`;
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
|
|
812
116
|
return raw;
|
|
813
117
|
}, [cleanContent, message.content, isStreaming, structuredContent]);
|
|
814
118
|
|
|
815
119
|
// ── Markdown component overrides ───────────────────────────────────────────
|
|
816
120
|
const markdownComponents = React.useMemo(
|
|
817
|
-
() => ({
|
|
818
|
-
// Wrap in not-prose so Tailwind Typography resets don't clobber our styles.
|
|
819
|
-
// prose applies display:block and padding:0 to table/th/td — not-prose opts out.
|
|
820
|
-
table: ({ ...props }: React.HTMLAttributes<HTMLTableElement>) => {
|
|
821
|
-
if (isStreaming) {
|
|
822
|
-
return (
|
|
823
|
-
<div className="my-5 p-6 bg-slate-50 dark:bg-white/5 rounded-xl border border-dashed border-slate-300 dark:border-white/10 flex items-center justify-center gap-3 select-none">
|
|
824
|
-
<div className="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
|
825
|
-
<p className="text-xs text-slate-500 font-medium italic animate-pulse">
|
|
826
|
-
Generating data table...
|
|
827
|
-
</p>
|
|
828
|
-
</div>
|
|
829
|
-
);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
return (
|
|
833
|
-
<div className="not-prose overflow-hidden my-5 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm bg-white dark:bg-slate-900/50">
|
|
834
|
-
<div className="overflow-x-auto">
|
|
835
|
-
<table
|
|
836
|
-
className="!table w-full text-left border-collapse min-w-[400px] text-sm"
|
|
837
|
-
{...props}
|
|
838
|
-
/>
|
|
839
|
-
</div>
|
|
840
|
-
</div>
|
|
841
|
-
);
|
|
842
|
-
},
|
|
843
|
-
thead: ({ ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
|
844
|
-
<thead
|
|
845
|
-
className="!table-header-group bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10"
|
|
846
|
-
{...props}
|
|
847
|
-
/>
|
|
848
|
-
),
|
|
849
|
-
tbody: ({ ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
|
850
|
-
<tbody className="!table-row-group divide-y divide-slate-100 dark:divide-white/5" {...props} />
|
|
851
|
-
),
|
|
852
|
-
tr: ({ ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
|
|
853
|
-
<tr
|
|
854
|
-
className="!table-row hover:bg-slate-50/70 dark:hover:bg-white/5 transition-colors"
|
|
855
|
-
{...props}
|
|
856
|
-
/>
|
|
857
|
-
),
|
|
858
|
-
th: ({ children, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
|
|
859
|
-
<th
|
|
860
|
-
className="!table-cell px-4 py-3 font-bold text-slate-700 dark:text-white/90 whitespace-nowrap text-[11px] uppercase tracking-wider text-left"
|
|
861
|
-
{...props}
|
|
862
|
-
>
|
|
863
|
-
{normaliseChild(children)}
|
|
864
|
-
</th>
|
|
865
|
-
),
|
|
866
|
-
td: ({ children, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
|
|
867
|
-
<td
|
|
868
|
-
className="!table-cell px-4 py-3 text-slate-600 dark:text-white/70 whitespace-nowrap text-sm"
|
|
869
|
-
{...props}
|
|
870
|
-
>
|
|
871
|
-
{normaliseChild(children)}
|
|
872
|
-
</td>
|
|
873
|
-
),
|
|
874
|
-
code({
|
|
875
|
-
inline,
|
|
876
|
-
className,
|
|
877
|
-
children,
|
|
878
|
-
...props
|
|
879
|
-
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
|
|
880
|
-
const lang = /language-(\w+)/.exec(className ?? '')?.[1];
|
|
881
|
-
|
|
882
|
-
if (!inline && (lang === 'ui' || lang === 'chart')) {
|
|
883
|
-
return (
|
|
884
|
-
<UIDispatcher
|
|
885
|
-
rawContent={String(children ?? '').trim()}
|
|
886
|
-
primaryColor={primaryColor}
|
|
887
|
-
accentColor={accentColor}
|
|
888
|
-
isStreaming={isStreaming}
|
|
889
|
-
onAddToCart={onAddToCart}
|
|
890
|
-
viewportSize={viewportSize}
|
|
891
|
-
/>
|
|
892
|
-
);
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
if (!inline && lang === 'json') {
|
|
896
|
-
const content = String(children ?? '').trim();
|
|
897
|
-
if (looksLikeStructuredPayload(content)) {
|
|
898
|
-
return (
|
|
899
|
-
<UIDispatcher
|
|
900
|
-
rawContent={content}
|
|
901
|
-
primaryColor={primaryColor}
|
|
902
|
-
accentColor={accentColor}
|
|
903
|
-
isStreaming={isStreaming}
|
|
904
|
-
onAddToCart={onAddToCart}
|
|
905
|
-
viewportSize={viewportSize}
|
|
906
|
-
/>
|
|
907
|
-
);
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
if (!inline) {
|
|
912
|
-
const content = String(children ?? '').trim();
|
|
913
|
-
if (looksLikeStructuredPayload(content)) {
|
|
914
|
-
return (
|
|
915
|
-
<UIDispatcher
|
|
916
|
-
rawContent={content}
|
|
917
|
-
primaryColor={primaryColor}
|
|
918
|
-
accentColor={accentColor}
|
|
919
|
-
isStreaming={isStreaming}
|
|
920
|
-
onAddToCart={onAddToCart}
|
|
921
|
-
viewportSize={viewportSize}
|
|
922
|
-
/>
|
|
923
|
-
);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
if (!inline && lang === 'table-loading') {
|
|
928
|
-
return (
|
|
929
|
-
<div className="my-5 p-6 bg-slate-50 dark:bg-white/5 rounded-xl border border-dashed border-slate-300 dark:border-white/10 flex items-center justify-center gap-3 select-none">
|
|
930
|
-
<div className="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
|
931
|
-
<p className="text-xs text-slate-500 font-medium italic animate-pulse">
|
|
932
|
-
Generating data table...
|
|
933
|
-
</p>
|
|
934
|
-
</div>
|
|
935
|
-
);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
return (
|
|
939
|
-
<code className={className} {...props}>
|
|
940
|
-
{typeof children === 'string' || typeof children === 'number'
|
|
941
|
-
? children
|
|
942
|
-
: JSON.stringify(children)}
|
|
943
|
-
</code>
|
|
944
|
-
);
|
|
945
|
-
},
|
|
946
|
-
}),
|
|
121
|
+
() => createMarkdownComponents({ primaryColor, accentColor, isStreaming, onAddToCart, viewportSize }),
|
|
947
122
|
[primaryColor, accentColor, isStreaming, onAddToCart, viewportSize],
|
|
948
123
|
);
|
|
949
124
|
|
|
@@ -961,7 +136,7 @@ export function MessageBubble({
|
|
|
961
136
|
{isUser ? <User className={`${isCompact ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-white`} /> : <Bot className={`${isCompact ? 'w-3.5 h-3.5' : 'w-4 h-4'}`} />}
|
|
962
137
|
</div>
|
|
963
138
|
|
|
964
|
-
<div className={`flex flex-col gap-1 ${isCompact ? 'max-w-[94%]' : isMedium ? 'max-w-[92%]' : 'max-w-[90%]'} ${isUser ? 'items-end' : 'items-start'}`}>
|
|
139
|
+
<div className={`flex flex-col gap-1 min-w-0 ${isCompact ? 'max-w-[94%]' : isMedium ? 'max-w-[92%]' : 'max-w-[90%]'} ${isUser ? 'items-end' : 'items-start'}`}>
|
|
965
140
|
{/* Bubble */}
|
|
966
141
|
<div
|
|
967
142
|
className={`relative ${isCompact ? 'px-3 py-2.5 text-[13px]' : 'px-4 py-3 text-sm'} rounded-2xl leading-relaxed shadow-sm dark:shadow-lg ${isUser
|
|
@@ -992,27 +167,52 @@ export function MessageBubble({
|
|
|
992
167
|
)}
|
|
993
168
|
|
|
994
169
|
{!shouldRenderStructuredOnly && (
|
|
995
|
-
<ReactMarkdown
|
|
170
|
+
<ReactMarkdown components={markdownComponents}>
|
|
996
171
|
{processedMarkdown}
|
|
997
172
|
</ReactMarkdown>
|
|
998
173
|
)}
|
|
999
174
|
|
|
1000
|
-
{/*
|
|
1001
|
-
{
|
|
1002
|
-
<
|
|
1003
|
-
<
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
175
|
+
{/* Fix: Always show streaming cursor when isStreaming is true */}
|
|
176
|
+
{isStreaming && (
|
|
177
|
+
<span className="inline-flex items-center gap-1.5 ml-2 text-emerald-500 animate-pulse align-middle text-xs font-medium">
|
|
178
|
+
<HourglassLoader
|
|
179
|
+
size={18}
|
|
180
|
+
primaryColor={primaryColor}
|
|
181
|
+
accentColor={accentColor}
|
|
182
|
+
glow={false} />
|
|
183
|
+
Thinking...
|
|
184
|
+
</span>
|
|
1009
185
|
)}
|
|
1010
186
|
</div>
|
|
1011
187
|
)}
|
|
1012
188
|
</div>
|
|
1013
189
|
|
|
190
|
+
{/* Auto-generated visualization fallback */}
|
|
191
|
+
{(() => {
|
|
192
|
+
if (isUser || structuredContent.payload || !message.uiTransformation) return null;
|
|
193
|
+
const ui = message.uiTransformation as UITransformationResponse;
|
|
194
|
+
const textContent = (ui.data as { content?: string })?.content ?? '';
|
|
195
|
+
const shouldShow =
|
|
196
|
+
(ui.type === 'table' && allProducts.length === 0) ||
|
|
197
|
+
!['text', 'table'].includes(ui.type) ||
|
|
198
|
+
(ui.type === 'text' && !hasRichUI && allProducts.length === 0 && textContent &&
|
|
199
|
+
!processedMarkdown.toLowerCase().includes(textContent.trim().toLowerCase()));
|
|
200
|
+
if (!shouldShow) return null;
|
|
201
|
+
return (
|
|
202
|
+
<div className="w-full mt-3">
|
|
203
|
+
<VisualizationRenderer
|
|
204
|
+
data={ui}
|
|
205
|
+
primaryColor={primaryColor}
|
|
206
|
+
onAddToCart={onAddToCart}
|
|
207
|
+
className="rounded-3xl border border-slate-200/60 dark:border-white/10 bg-white/90 dark:bg-slate-900/80 p-4"
|
|
208
|
+
isStreaming={isStreaming}
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
})()}
|
|
213
|
+
|
|
1014
214
|
{/* Product Carousel */}
|
|
1015
|
-
{!isUser && !
|
|
215
|
+
{!isUser && !hasRichUI && allProducts.length > 0 && (
|
|
1016
216
|
<div className="w-full mt-1">
|
|
1017
217
|
<ProductCarousel
|
|
1018
218
|
products={allProducts}
|
|
@@ -1022,26 +222,46 @@ export function MessageBubble({
|
|
|
1022
222
|
</div>
|
|
1023
223
|
)}
|
|
1024
224
|
|
|
1025
|
-
{/* Sources */}
|
|
1026
|
-
{!isUser &&
|
|
1027
|
-
<div className="w-full">
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
225
|
+
{/* Footer actions: Sources and Trace */}
|
|
226
|
+
{!isUser && (
|
|
227
|
+
<div className="flex items-center gap-4 mt-1 w-full flex-wrap">
|
|
228
|
+
{sources && sources.length > 0 && (
|
|
229
|
+
<button
|
|
230
|
+
onClick={() => setShowSources(s => !s)}
|
|
231
|
+
className="text-[11px] text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1"
|
|
232
|
+
>
|
|
233
|
+
{showSources ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
|
234
|
+
{sources.length} source{sources.length !== 1 ? 's' : ''} used
|
|
235
|
+
</button>
|
|
236
|
+
)}
|
|
1035
237
|
|
|
1036
|
-
{
|
|
1037
|
-
<
|
|
1038
|
-
{
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
238
|
+
{message.trace && (
|
|
239
|
+
<button
|
|
240
|
+
onClick={() => setShowTrace(t => !t)}
|
|
241
|
+
className="text-[11px] text-emerald-500 hover:text-emerald-400 transition-colors flex items-center gap-1"
|
|
242
|
+
>
|
|
243
|
+
<Activity className="w-3 h-3" />
|
|
244
|
+
{showTrace ? 'Hide Trace' : 'Trace'}
|
|
245
|
+
</button>
|
|
1042
246
|
)}
|
|
1043
247
|
</div>
|
|
1044
248
|
)}
|
|
249
|
+
|
|
250
|
+
{/* Expandable areas */}
|
|
251
|
+
<div className="w-full flex flex-col gap-2 min-w-0">
|
|
252
|
+
{showSources && sources && sources.length > 0 && (
|
|
253
|
+
<div className="mt-1 flex flex-col gap-2">
|
|
254
|
+
{sources.map((src, i) => (
|
|
255
|
+
<SourceCard key={src.id} source={src} index={i} />
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
{showTrace && message.trace && (
|
|
261
|
+
<ObservabilityPanel trace={message.trace} primaryColor={primaryColor} />
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
|
|
1045
265
|
</div>
|
|
1046
266
|
</div>
|
|
1047
267
|
);
|