diff --git a/frontend/src/components/chat/MessageContentWithArtifacts.tsx b/frontend/src/components/chat/MessageContentWithArtifacts.tsx index 8e148369..6a53c48b 100644 --- a/frontend/src/components/chat/MessageContentWithArtifacts.tsx +++ b/frontend/src/components/chat/MessageContentWithArtifacts.tsx @@ -1,17 +1,23 @@ /** - * Helper component that parses and renders message content with artifacts, web previews, and JSX previews. - * Extracts , , and tags from content and renders them as components. + * Helper component that parses and renders message content with artifacts, web previews, + * JSX previews, and structured UI components. + * Extracts , , , and tags from content + * and renders them as components. + * Parse order: UI components → JSX previews → web previews → artifacts. */ import { MessageResponse } from '@/components/ai-elements/message'; import { ArtifactRenderer } from './ArtifactRenderer'; import { WebPreviewRenderer } from './WebPreviewRenderer'; import { JSXPreviewRenderer } from './JSXPreviewRenderer'; +import { UIComponentRenderer } from './ui-components/UIComponentRenderer'; import { hasArtifacts } from '@/utils/artifactParser'; import { hasWebPreviews } from '@/utils/webPreviewParser'; import { hasJSXPreviews } from '@/utils/jsxPreviewParser'; import { parseMessagePreviewContent } from '@/utils/messageContentParser'; import { decodeHtmlEntities } from '@/utils/decodeHtmlEntities'; +import { parseUIComponents, hasUIComponents } from './ui-components/uiComponentParser'; +import type { ParsedUIComponent } from '@/types/artifact.types'; interface MessageContentWithArtifactsProps { content: string; @@ -25,8 +31,9 @@ export function MessageContentWithArtifacts({ content, messageId }: MessageConte const contentHasArtifacts = hasArtifacts(decodedContent); const contentHasWebPreviews = hasWebPreviews(decodedContent); const contentHasJSXPreviews = hasJSXPreviews(decodedContent); + const contentHasUIComponents = hasUIComponents(decodedContent); - if (!contentHasArtifacts && !contentHasWebPreviews && !contentHasJSXPreviews) { + if (!contentHasArtifacts && !contentHasWebPreviews && !contentHasJSXPreviews && !contentHasUIComponents) { return (
{content} @@ -34,7 +41,17 @@ export function MessageContentWithArtifacts({ content, messageId }: MessageConte ); } - const parsed = parseMessagePreviewContent(content); + // Extract UI components first so the shared preview pipeline never sees + // tags (parse order: UI → JSX → web-preview → artifact). + let uiComponents: ParsedUIComponent[] = []; + let remainingContent = content; + if (contentHasUIComponents) { + const parsedUI = parseUIComponents(decodedContent); + uiComponents = parsedUI.components; + remainingContent = parsedUI.text; + } + + const parsed = parseMessagePreviewContent(remainingContent); const { textContent, jsxPreviews, webPreviews, artifacts } = parsed; return ( @@ -45,6 +62,10 @@ export function MessageContentWithArtifacts({ content, messageId }: MessageConte
)} + {uiComponents.map((comp, idx) => ( + + ))} + {jsxPreviews.map((preview, idx) => ( + + Component error + + <ui-component type="{component.type}"> + {' '}{component.error ?? 'Missing data'} + + + ); + } + + const Renderer = getUIComponentRenderer(component.type); + + if (!Renderer) { + const known = getRegisteredComponentTypes().join(', '); + return ( + + + Unknown component type: {component.type} + +

Registered types: {known}

+
+ + Show raw data + +
+							{JSON.stringify(component.data, null, 2)}
+						
+
+
+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/chat/ui-components/registry.ts b/frontend/src/components/chat/ui-components/registry.ts new file mode 100644 index 00000000..d3d92c45 --- /dev/null +++ b/frontend/src/components/chat/ui-components/registry.ts @@ -0,0 +1,45 @@ +/** + * UI Component Registry + * + * Maps type strings to their React renderer + * components. Add new renderers here to make them available in chat. + */ + +import type { ComponentType } from 'react'; +import type { ParsedUIComponent } from '@/types/artifact.types'; + +export interface UIComponentRendererProps { + component: ParsedUIComponent; +} + +// Static registry — all renderers are imported eagerly. Recharts is already +// in the main bundle via jsx-preview, so lazy loading gains nothing here. +import { StatsCardRenderer } from './renderers/StatsCardRenderer'; +import { KPIGridRenderer } from './renderers/KPIGridRenderer'; +import { BarChartRenderer } from './renderers/BarChartRenderer'; +import { LineChartRenderer } from './renderers/LineChartRenderer'; +import { PieChartRenderer } from './renderers/PieChartRenderer'; +import { AreaChartRenderer } from './renderers/AreaChartRenderer'; +import { DataTableRenderer } from './renderers/DataTableRenderer'; +import { ProgressCardRenderer } from './renderers/ProgressCardRenderer'; +import { InfoCardRenderer } from './renderers/InfoCardRenderer'; + +const registry: Record> = { + 'stats-card': StatsCardRenderer, + 'kpi-grid': KPIGridRenderer, + 'bar-chart': BarChartRenderer, + 'line-chart': LineChartRenderer, + 'pie-chart': PieChartRenderer, + 'area-chart': AreaChartRenderer, + 'data-table': DataTableRenderer, + 'progress-card': ProgressCardRenderer, + 'info-card': InfoCardRenderer, +}; + +export function getUIComponentRenderer(type: string): ComponentType | null { + return registry[type] ?? null; +} + +export function getRegisteredComponentTypes(): string[] { + return Object.keys(registry); +} diff --git a/frontend/src/components/chat/ui-components/renderers/AreaChartRenderer.tsx b/frontend/src/components/chat/ui-components/renderers/AreaChartRenderer.tsx new file mode 100644 index 00000000..3baab7b6 --- /dev/null +++ b/frontend/src/components/chat/ui-components/renderers/AreaChartRenderer.tsx @@ -0,0 +1,72 @@ +import { + AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, +} from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import type { UIComponentRendererProps } from '../registry'; + +const COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#00C49F', '#FF8042', '#0088FE']; + +interface AreaChartData { + title?: string; + description?: string; + items: Record[]; + areas?: { dataKey: string; label?: string; color?: string }[]; + xKey?: string; + stacked?: boolean; +} + +export function AreaChartRenderer({ component }: UIComponentRendererProps) { + const d = component.data as AreaChartData | null; + if (!d?.items?.length) return null; + + const xKey = d.xKey ?? 'name'; + const areas = d.areas ?? inferAreas(d.items, xKey); + + return ( + + {(d.title || d.description) && ( + + {d.title && {d.title}} + {d.description && {d.description}} + + )} + + + + + + + + {areas.length > 1 && } + {areas.map((area, i) => { + const color = area.color ?? COLORS[i % COLORS.length]; + return ( + + ); + })} + + + + + ); +} + +function inferAreas( + items: Record[], + xKey: string, +): { dataKey: string; label?: string; color?: string }[] { + if (!items.length) return []; + return Object.keys(items[0]) + .filter((k) => k !== xKey && typeof items[0][k] === 'number') + .map((k) => ({ dataKey: k })); +} diff --git a/frontend/src/components/chat/ui-components/renderers/BarChartRenderer.tsx b/frontend/src/components/chat/ui-components/renderers/BarChartRenderer.tsx new file mode 100644 index 00000000..e8912258 --- /dev/null +++ b/frontend/src/components/chat/ui-components/renderers/BarChartRenderer.tsx @@ -0,0 +1,78 @@ +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, +} from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import type { UIComponentRendererProps } from '../registry'; + +const COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#00C49F', '#FF8042', '#0088FE']; + +interface BarChartData { + title?: string; + description?: string; + items: Record[]; + bars?: { dataKey: string; label?: string; color?: string }[]; + dataKey?: string; + categoryKey?: string; + stacked?: boolean; + horizontal?: boolean; +} + +export function BarChartRenderer({ component }: UIComponentRendererProps) { + const d = component.data as BarChartData | null; + if (!d?.items?.length) return null; + + const catKey = d.categoryKey ?? 'name'; + const bars = d.bars ?? (d.dataKey ? [{ dataKey: d.dataKey }] : inferBars(d.items, catKey)); + + return ( + + {(d.title || d.description) && ( + + {d.title && {d.title}} + {d.description && {d.description}} + + )} + + + + + {d.horizontal ? ( + <> + + + + ) : ( + <> + + + + )} + + {bars.length > 1 && } + {bars.map((bar, i) => ( + + ))} + + + + + ); +} + +function inferBars(items: Record[], categoryKey: string) { + if (!items.length) return []; + return Object.keys(items[0]) + .filter((k) => k !== categoryKey && typeof items[0][k] === 'number') + .map((k) => ({ dataKey: k })); +} diff --git a/frontend/src/components/chat/ui-components/renderers/DataTableRenderer.tsx b/frontend/src/components/chat/ui-components/renderers/DataTableRenderer.tsx new file mode 100644 index 00000000..30f63ba5 --- /dev/null +++ b/frontend/src/components/chat/ui-components/renderers/DataTableRenderer.tsx @@ -0,0 +1,111 @@ +import { + Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption, +} from '@/components/ui/table'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { UIComponentRendererProps } from '../registry'; + +interface DataTableData { + title?: string; + description?: string; + columns: (string | { label: string; align?: 'left' | 'center' | 'right'; format?: 'currency' | 'number' | 'percent' | 'badge' })[]; + rows: (string | number | null)[][]; + caption?: string; + striped?: boolean; +} + +function resolveColumn(col: DataTableData['columns'][number]) { + if (typeof col === 'string') return { label: col, align: 'left' as const, format: undefined }; + return { label: col.label, align: col.align ?? 'left', format: col.format }; +} + +function formatCell(value: string | number | null, format?: string): React.ReactNode { + if (value === null || value === undefined) return '—'; + + if (format === 'badge') { + const str = String(value); + const variant = badgeVariant(str); + return {str}; + } + + if (typeof value === 'number') { + switch (format) { + case 'currency': + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value); + case 'percent': + return `${value.toFixed(1)}%`; + case 'number': + return new Intl.NumberFormat().format(value); + default: + return new Intl.NumberFormat().format(value); + } + } + + return String(value); +} + +function badgeVariant(text: string): 'default' | 'secondary' | 'destructive' | 'outline' { + const lower = text.toLowerCase(); + if (['paid', 'completed', 'active', 'approved', 'submitted', 'success'].some((w) => lower.includes(w))) + return 'default'; + if (['overdue', 'failed', 'cancelled', 'rejected', 'error'].some((w) => lower.includes(w))) + return 'destructive'; + if (['pending', 'draft', 'unpaid', 'open'].some((w) => lower.includes(w))) + return 'secondary'; + return 'outline'; +} + +export function DataTableRenderer({ component }: UIComponentRendererProps) { + const d = component.data as DataTableData | null; + if (!d?.columns?.length || !d?.rows?.length) return null; + + const cols = d.columns.map(resolveColumn); + + return ( + + {(d.title || d.description) && ( + + {d.title && {d.title}} + {d.description && {d.description}} + + )} + +
+ + {d.caption && {d.caption}} + + + {cols.map((col, i) => ( + + {col.label} + + ))} + + + + {d.rows.map((row, ri) => ( + + {row.map((cell, ci) => { + const col = cols[ci]; + return ( + + {formatCell(cell, col?.format)} + + ); + })} + + ))} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/chat/ui-components/renderers/InfoCardRenderer.tsx b/frontend/src/components/chat/ui-components/renderers/InfoCardRenderer.tsx new file mode 100644 index 00000000..25ff7f50 --- /dev/null +++ b/frontend/src/components/chat/ui-components/renderers/InfoCardRenderer.tsx @@ -0,0 +1,82 @@ +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import type { UIComponentRendererProps } from '../registry'; + +interface InfoItem { + label: string; + value: string | number; + badge?: boolean; +} + +interface InfoSection { + heading?: string; + items: InfoItem[]; +} + +interface InfoCardData { + title: string; + description?: string; + status?: string; + items?: InfoItem[]; + sections?: InfoSection[]; +} + +function badgeVariant(text: string): 'default' | 'secondary' | 'destructive' | 'outline' { + const lower = text.toLowerCase(); + if (['paid', 'completed', 'active', 'approved', 'submitted', 'success'].some((w) => lower.includes(w))) + return 'default'; + if (['overdue', 'failed', 'cancelled', 'rejected', 'error'].some((w) => lower.includes(w))) + return 'destructive'; + if (['pending', 'draft', 'unpaid', 'open'].some((w) => lower.includes(w))) + return 'secondary'; + return 'outline'; +} + +function renderItems(items: InfoItem[]) { + return ( +
+ {items.map((item, i) => ( +
+ {item.label} + {item.badge ? ( + {String(item.value)} + ) : ( + {String(item.value)} + )} +
+ ))} +
+ ); +} + +export function InfoCardRenderer({ component }: UIComponentRendererProps) { + const d = component.data as InfoCardData | null; + if (!d?.title) return null; + + return ( + + +
+ {d.title} + {d.status && {d.status}} +
+ {d.description && {d.description}} +
+ + {d.items && renderItems(d.items)} + {d.sections?.map((section, si) => ( +
+ {si > 0 && } + {section.heading && ( +

+ {section.heading} +

+ )} + {renderItems(section.items)} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/chat/ui-components/renderers/KPIGridRenderer.tsx b/frontend/src/components/chat/ui-components/renderers/KPIGridRenderer.tsx new file mode 100644 index 00000000..0556941b --- /dev/null +++ b/frontend/src/components/chat/ui-components/renderers/KPIGridRenderer.tsx @@ -0,0 +1,85 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { UIComponentRendererProps } from '../registry'; + +interface KPIItem { + label: string; + value: number | string; + change?: number; + format?: 'number' | 'currency' | 'percent' | 'compact'; + prefix?: string; + suffix?: string; +} + +interface KPIGridData { + title?: string; + items: KPIItem[]; + columns?: number; +} + +function fmtValue(v: number | string, format?: string, prefix?: string, suffix?: string): string { + if (typeof v === 'string') return `${prefix ?? ''}${v}${suffix ?? ''}`; + const p = prefix ?? ''; + const s = suffix ?? ''; + switch (format) { + case 'currency': + return `${p}${new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(v)}${s}`; + case 'percent': + return `${p}${v.toFixed(1)}%${s}`; + case 'compact': + return `${p}${new Intl.NumberFormat('en-US', { notation: 'compact' }).format(v)}${s}`; + default: + return `${p}${new Intl.NumberFormat().format(v)}${s}`; + } +} + +export function KPIGridRenderer({ component }: UIComponentRendererProps) { + const d = component.data as KPIGridData | null; + if (!d?.items?.length) return null; + + const cols = d.columns ?? Math.min(d.items.length, 4); + const gridClass = cols <= 2 ? 'grid-cols-2' : cols === 3 ? 'grid-cols-3' : 'grid-cols-4'; + + return ( + + {d.title && ( + + {d.title} + + )} + +
+ {d.items.map((item, i) => { + const change = item.change ?? 0; + const isPos = change > 0; + const isZero = change === 0; + return ( +
+

{item.label}

+

+ {fmtValue(item.value, item.format, item.prefix, item.suffix)} +

+ {item.change !== undefined && ( +
+ {isZero + ? + : isPos + ? + : } + {isPos ? '+' : ''}{change.toFixed(1)}% +
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/chat/ui-components/renderers/LineChartRenderer.tsx b/frontend/src/components/chat/ui-components/renderers/LineChartRenderer.tsx new file mode 100644 index 00000000..39b0c789 --- /dev/null +++ b/frontend/src/components/chat/ui-components/renderers/LineChartRenderer.tsx @@ -0,0 +1,70 @@ +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, +} from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import type { UIComponentRendererProps } from '../registry'; + +const COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#00C49F', '#FF8042', '#0088FE']; + +interface LineChartData { + title?: string; + description?: string; + items: Record[]; + lines?: { dataKey: string; label?: string; color?: string; dashed?: boolean }[]; + xKey?: string; + curved?: boolean; +} + +export function LineChartRenderer({ component }: UIComponentRendererProps) { + const d = component.data as LineChartData | null; + if (!d?.items?.length) return null; + + const xKey = d.xKey ?? 'name'; + const lines = d.lines ?? inferLines(d.items, xKey); + const curve = d.curved !== false ? 'monotone' : 'linear'; + + return ( + + {(d.title || d.description) && ( + + {d.title && {d.title}} + {d.description && {d.description}} + + )} + + + + + + + + {lines.length > 1 && } + {lines.map((line, i) => ( + + ))} + + + + + ); +} + +function inferLines( + items: Record[], + xKey: string, +): { dataKey: string; label?: string; color?: string; dashed?: boolean }[] { + if (!items.length) return []; + return Object.keys(items[0]) + .filter((k) => k !== xKey && typeof items[0][k] === 'number') + .map((k) => ({ dataKey: k })); +} diff --git a/frontend/src/components/chat/ui-components/renderers/PieChartRenderer.tsx b/frontend/src/components/chat/ui-components/renderers/PieChartRenderer.tsx new file mode 100644 index 00000000..98ec7e32 --- /dev/null +++ b/frontend/src/components/chat/ui-components/renderers/PieChartRenderer.tsx @@ -0,0 +1,65 @@ +import { + PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, +} from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import type { UIComponentRendererProps } from '../registry'; + +const COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#00C49F', '#FF8042', '#0088FE', '#FFBB28']; + +interface PieChartData { + title?: string; + description?: string; + items: Record[]; + nameKey?: string; + valueKey?: string; + donut?: boolean; + colors?: string[]; +} + +export function PieChartRenderer({ component }: UIComponentRendererProps) { + const d = component.data as PieChartData | null; + if (!d?.items?.length) return null; + + const nameKey = d.nameKey ?? 'name'; + const valueKey = d.valueKey ?? 'value'; + const colors = d.colors ?? COLORS; + const innerRadius = d.donut ? 60 : 0; + + return ( + + {(d.title || d.description) && ( + + {d.title && {d.title}} + {d.description && {d.description}} + + )} + + + + `${name} ${(percent * 100).toFixed(0)}%`} + labelLine={{ strokeWidth: 1 }} + > + {d.items.map((_, i) => ( + + ))} + + new Intl.NumberFormat().format(value)} + contentStyle={{ fontSize: 12, borderRadius: 8 }} + /> + + + + + + ); +} diff --git a/frontend/src/components/chat/ui-components/renderers/ProgressCardRenderer.tsx b/frontend/src/components/chat/ui-components/renderers/ProgressCardRenderer.tsx new file mode 100644 index 00000000..b2de1e3e --- /dev/null +++ b/frontend/src/components/chat/ui-components/renderers/ProgressCardRenderer.tsx @@ -0,0 +1,84 @@ +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; +import type { UIComponentRendererProps } from '../registry'; + +interface ProgressItem { + label: string; + value: number; + max?: number; + color?: string; +} + +interface ProgressCardData { + title?: string; + description?: string; + items?: ProgressItem[]; + value?: number; + max?: number; + label?: string; + showPercent?: boolean; +} + +function pct(value: number, max: number): number { + return Math.min(100, Math.max(0, (value / max) * 100)); +} + +export function ProgressCardRenderer({ component }: UIComponentRendererProps) { + const d = component.data as ProgressCardData | null; + if (!d) return null; + + // Single progress bar mode + if (d.value !== undefined && !d.items) { + const max = d.max ?? 100; + const percent = pct(d.value, max); + return ( + + {(d.title || d.description) && ( + + {d.title && {d.title}} + {d.description && {d.description}} + + )} + +
+ {d.label ?? 'Progress'} + + {d.showPercent !== false ? `${percent.toFixed(0)}%` : `${new Intl.NumberFormat().format(d.value)} / ${new Intl.NumberFormat().format(max)}`} + +
+ +
+
+ ); + } + + // Multi-progress mode + if (!d.items?.length) return null; + + return ( + + {(d.title || d.description) && ( + + {d.title && {d.title}} + {d.description && {d.description}} + + )} + + {d.items.map((item, i) => { + const max = item.max ?? 100; + const percent = pct(item.value, max); + return ( +
+
+ {item.label} + {percent.toFixed(0)}% +
+ +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/chat/ui-components/renderers/StatsCardRenderer.tsx b/frontend/src/components/chat/ui-components/renderers/StatsCardRenderer.tsx new file mode 100644 index 00000000..86e2a6da --- /dev/null +++ b/frontend/src/components/chat/ui-components/renderers/StatsCardRenderer.tsx @@ -0,0 +1,72 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { UIComponentRendererProps } from '../registry'; + +interface StatsCardData { + title: string; + value: number | string; + change?: number; + changeLabel?: string; + format?: 'number' | 'currency' | 'percent' | 'compact'; + prefix?: string; + suffix?: string; + description?: string; +} + +function formatValue(value: number | string, format?: string, prefix?: string, suffix?: string): string { + if (typeof value === 'string') return `${prefix ?? ''}${value}${suffix ?? ''}`; + const p = prefix ?? ''; + const s = suffix ?? ''; + switch (format) { + case 'currency': + return `${p}${new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value)}${s}`; + case 'percent': + return `${p}${value.toFixed(1)}%${s}`; + case 'compact': + return `${p}${new Intl.NumberFormat('en-US', { notation: 'compact' }).format(value)}${s}`; + default: + return `${p}${new Intl.NumberFormat().format(value)}${s}`; + } +} + +export function StatsCardRenderer({ component }: UIComponentRendererProps) { + const d = component.data as StatsCardData | null; + if (!d?.title) return null; + + const change = d.change ?? 0; + const isPositive = change > 0; + const isNeutral = change === 0; + + return ( + + + {d.title} + + +
+ {formatValue(d.value, d.format, d.prefix, d.suffix)} +
+ {d.change !== undefined && ( +
+ {isNeutral + ? + : isPositive + ? + : } + {isPositive ? '+' : ''}{change.toFixed(1)}% + {d.changeLabel && {d.changeLabel}} +
+ )} + {d.description && ( +

{d.description}

+ )} +
+
+ ); +} diff --git a/frontend/src/components/chat/ui-components/uiComponentParser.ts b/frontend/src/components/chat/ui-components/uiComponentParser.ts new file mode 100644 index 00000000..26ed711b --- /dev/null +++ b/frontend/src/components/chat/ui-components/uiComponentParser.ts @@ -0,0 +1,79 @@ +/** + * Parser for AI-generated structured UI component tags in message content. + * + * Detects and extracts tags that carry a type and JSON data payload. + * Each tag is self-closing and looks like: + * + * + * The data attribute uses single-quoted JSON so double quotes inside the JSON + * don't break the attribute boundary. HTML-encoded entities (" &) are + * decoded before JSON.parse. + */ + +import type { + ParsedUIComponent, + UIComponentParseResult, +} from '@/types/artifact.types'; + +// Matches self-closing tags. +// Capture group 1 = type value, group 2 = data value (single- or double-quoted). +const UI_COMPONENT_REGEX = + //gi; + +// Fallback for data wrapped in double quotes (less common because JSON uses double quotes internally) +const UI_COMPONENT_REGEX_DQ = + //gi; + +function decodeDataValue(raw: string): string { + return raw + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/'/g, "'"); +} + +function extractComponents( + content: string, + regex: RegExp, +): { text: string; components: ParsedUIComponent[] } { + const components: ParsedUIComponent[] = []; + regex.lastIndex = 0; + + const text = content.replace(regex, (_match, type: string, rawData: string) => { + try { + const decoded = decodeDataValue(rawData); + const data = JSON.parse(decoded) as Record; + components.push({ type, data }); + } catch { + components.push({ type, data: null, error: 'Invalid JSON in data attribute' }); + } + return ''; + }); + + return { text, components }; +} + +/** + * Parse tags from message content. + */ +export function parseUIComponents(content: string): UIComponentParseResult { + // Try single-quoted data first (preferred), then double-quoted fallback + let result = extractComponents(content, UI_COMPONENT_REGEX); + if (result.components.length === 0) { + result = extractComponents(content, UI_COMPONENT_REGEX_DQ); + } + + const cleanedText = result.text.replace(/\n{3,}/g, '\n\n').trim(); + return { text: cleanedText, components: result.components }; +} + +/** + * Quick check for presence of tags. + */ +export function hasUIComponents(content: string): boolean { + if (!content) return false; + UI_COMPONENT_REGEX.lastIndex = 0; + UI_COMPONENT_REGEX_DQ.lastIndex = 0; + return UI_COMPONENT_REGEX.test(content) || UI_COMPONENT_REGEX_DQ.test(content); +} diff --git a/frontend/src/types/artifact.types.ts b/frontend/src/types/artifact.types.ts index 5cc93e69..8d05a461 100644 --- a/frontend/src/types/artifact.types.ts +++ b/frontend/src/types/artifact.types.ts @@ -47,3 +47,19 @@ export interface JSXPreviewParseResult { text: string; previews: ParsedJSXPreview[]; } + +/** + * Structured UI component rendered inline in chat. + * The AI emits tags + * and the frontend maps them to purpose-built renderers. + */ +export interface ParsedUIComponent { + type: string; + data: Record | null; + error?: string; +} + +export interface UIComponentParseResult { + text: string; + components: ParsedUIComponent[]; +}