Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions frontend/src/components/chat/MessageContentWithArtifacts.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
/**
* Helper component that parses and renders message content with artifacts, web previews, and JSX previews.
* Extracts <artifact>, <web-preview>, and <jsx-preview> 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 <ui-component>, <artifact>, <web-preview>, and <jsx-preview> 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;
Expand All @@ -25,16 +31,27 @@ 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 (
<div className="min-w-0 max-w-full overflow-x-auto">
<MessageResponse>{content}</MessageResponse>
</div>
);
}

const parsed = parseMessagePreviewContent(content);
// Extract UI components first so the shared preview pipeline never sees
// <ui-component> 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 (
Expand All @@ -45,6 +62,10 @@ export function MessageContentWithArtifacts({ content, messageId }: MessageConte
</div>
)}

{uiComponents.map((comp, idx) => (
<UIComponentRenderer key={`${messageId}-ui-${idx}`} component={comp} />
))}

{jsxPreviews.map((preview, idx) => (
<JSXPreviewRenderer
key={`${messageId}-jsx-${idx}`}
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/components/chat/ui-components/UIComponentRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Dispatcher that renders a ParsedUIComponent using the appropriate
* registered renderer. Falls back to a formatted JSON display for
* unknown types or data errors.
*/

import { AlertCircle } from 'lucide-react';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import type { ParsedUIComponent } from '@/types/artifact.types';
import { getUIComponentRenderer, getRegisteredComponentTypes } from './registry';

interface Props {
component: ParsedUIComponent;
}

export function UIComponentRenderer({ component }: Props) {
// Data-level error from parser
if (component.error || !component.data) {
return (
<Alert variant="destructive" className="my-3">
<AlertCircle className="size-4" />
<AlertTitle>Component error</AlertTitle>
<AlertDescription className="text-xs">
<span className="font-mono">&lt;ui-component type=&quot;{component.type}&quot;&gt;</span>
{' '}{component.error ?? 'Missing data'}
</AlertDescription>
</Alert>
);
}

const Renderer = getUIComponentRenderer(component.type);

if (!Renderer) {
const known = getRegisteredComponentTypes().join(', ');
return (
<Alert className="my-3">
<AlertCircle className="size-4" />
<AlertTitle>Unknown component type: <code className="font-mono">{component.type}</code></AlertTitle>
<AlertDescription className="text-xs space-y-2">
<p>Registered types: {known}</p>
<details>
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Show raw data
</summary>
<pre className="mt-1 overflow-x-auto rounded bg-muted p-2 text-[11px]">
{JSON.stringify(component.data, null, 2)}
</pre>
</details>
</AlertDescription>
</Alert>
);
}

return (
<div className="my-3">
<Renderer component={component} />
</div>
);
}
45 changes: 45 additions & 0 deletions frontend/src/components/chat/ui-components/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* UI Component Registry
*
* Maps <ui-component type="..."> 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<string, ComponentType<UIComponentRendererProps>> = {
'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<UIComponentRendererProps> | null {
return registry[type] ?? null;
}

export function getRegisteredComponentTypes(): string[] {
return Object.keys(registry);
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[];
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 (
<Card className="w-full">
{(d.title || d.description) && (
<CardHeader className="pb-2">
{d.title && <CardTitle className="text-base">{d.title}</CardTitle>}
{d.description && <CardDescription>{d.description}</CardDescription>}
</CardHeader>
)}
<CardContent className="pt-4">
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={d.items} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey={xKey} tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8 }} />
{areas.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
{areas.map((area, i) => {
const color = area.color ?? COLORS[i % COLORS.length];
return (
<Area
key={area.dataKey}
type="monotone"
dataKey={area.dataKey}
name={area.label ?? area.dataKey}
stroke={color}
fill={color}
fillOpacity={0.15}
strokeWidth={2}
stackId={d.stacked ? 'stack' : undefined}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

function inferAreas(
items: Record<string, unknown>[],
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 }));
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[];
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 (
<Card className="w-full">
{(d.title || d.description) && (
<CardHeader className="pb-2">
{d.title && <CardTitle className="text-base">{d.title}</CardTitle>}
{d.description && <CardDescription>{d.description}</CardDescription>}
</CardHeader>
)}
<CardContent className="pt-4">
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={d.items}
layout={d.horizontal ? 'vertical' : 'horizontal'}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
{d.horizontal ? (
<>
<YAxis dataKey={catKey} type="category" tick={{ fontSize: 12 }} width={80} />
<XAxis type="number" tick={{ fontSize: 12 }} />
</>
) : (
<>
<XAxis dataKey={catKey} tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
</>
)}
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8 }} />
{bars.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
{bars.map((bar, i) => (
<Bar
key={bar.dataKey}
dataKey={bar.dataKey}
name={bar.label ?? bar.dataKey}
fill={bar.color ?? COLORS[i % COLORS.length]}
radius={[4, 4, 0, 0]}
stackId={d.stacked ? 'stack' : undefined}
/>
))}
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

function inferBars(items: Record<string, unknown>[], categoryKey: string) {
if (!items.length) return [];
return Object.keys(items[0])
.filter((k) => k !== categoryKey && typeof items[0][k] === 'number')
.map((k) => ({ dataKey: k }));
}
Loading