Skip to content

Commit 97281e9

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(plugin-chatbot): render AI data-query charts inline (data-chart) (#1712)
Companion to the framework `visualize_data` tool. The data-query assistant can now answer with a CHART: the tool emits a `data-chart` custom stream part and the chat lifts it onto the message and renders it with the SDUI `<chart>` component, right in the assistant bubble. - mapMessages.ts: `extractCharts()` lifts every `data-chart` part into `ChatMessage.charts` (defensive narrowing; keeps multiple charts in order). Mirrors the existing `data-build-progress` → `buildProgress` path. - ChatbotEnhanced.tsx: `ChatChart` type + `charts` field; renders each via `<SchemaRenderer schema={{ type:'chart', … }}/>` (decoupled — no hard dep on plugin-charts). Gives the chart a definite `width: min(520px, 80vw)` so recharts' ResponsiveContainer measures a stable, non-zero width inside the `w-fit` bubble (otherwise the circular width dependency leaves bars unpainted). - mapMessages.test.ts: +3 tests (single chart lift, multi-chart order + series-less drop, absent → undefined). Verified end-to-end in the console against a live LLM: AI → visualize_data → data-chart → a 5-bar chart renders in the chat bubble. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b99d9bd commit 97281e9

3 files changed

Lines changed: 164 additions & 1 deletion

File tree

packages/plugin-chatbot/src/ChatbotEnhanced.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
*/
2020
import * as React from 'react';
2121
import { cn } from '@object-ui/components';
22+
import { SchemaRenderer } from '@object-ui/react';
2223
import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, Eye, GitCompareArrows, Rocket, Clock3, CheckCircle2, XCircle, Loader2, ShieldCheck, TriangleAlert } from 'lucide-react';
2324
import type { ChatStatus } from 'ai';
2425
import {
@@ -103,6 +104,40 @@ export interface ChatMessage {
103104
* instead of staring at a thinking spinner.
104105
*/
105106
buildProgress?: ChatBuildProgress;
107+
/**
108+
* Charts to render inline in the assistant bubble, lifted from the stream's
109+
* `data-chart` parts (emitted by the `visualize_data` tool via
110+
* `ctx.onProgress`). Each renders through the platform's SDUI `<chart>`
111+
* component, so an AI data answer can show a bar/line/pie chart rather than
112+
* only a wall of text.
113+
*/
114+
charts?: ChatChart[];
115+
}
116+
117+
/**
118+
* A chart lifted from a `data-chart` stream part. Mirrors the `schema` prop of
119+
* the SDUI `<chart>` renderer (plugin-charts `ChartRenderer`), so it can be
120+
* fed straight into `<SchemaRenderer schema={{ type: 'chart', ...chart }} />`.
121+
*/
122+
export interface ChatChart {
123+
chartType?:
124+
| 'bar'
125+
| 'column'
126+
| 'horizontal-bar'
127+
| 'line'
128+
| 'area'
129+
| 'pie'
130+
| 'donut'
131+
| 'radar'
132+
| 'scatter';
133+
/** Optional chart title shown above the chart. */
134+
title?: string;
135+
/** Aggregated rows — one object per category. */
136+
data: Array<Record<string, unknown>>;
137+
/** Field name used for the category axis (x-axis / pie slices). */
138+
xAxisKey?: string;
139+
/** One entry per plotted measure. `dataKey` is the row column to read. */
140+
series: Array<{ dataKey: string; label?: string }>;
106141
}
107142

108143
/** A reconciled snapshot of an in-flight app build (apply_blueprint). */
@@ -1426,6 +1461,40 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
14261461
className="ml-0.5 inline-block w-[2px] h-4 align-middle bg-current animate-pulse"
14271462
/>
14281463
) : null}
1464+
{!isUser && (message.charts?.length ?? 0) > 0 ? (
1465+
<div className="mt-2 flex flex-col gap-3" data-testid="chat-charts">
1466+
{message.charts!.map((chart, i) => (
1467+
<div
1468+
key={i}
1469+
className="rounded-lg border bg-background p-3"
1470+
// The chat bubble is `w-fit` (shrinks to content) while the
1471+
// SDUI chart's ResponsiveContainer is `width:100%` — a
1472+
// circular dependency. We give the chart a DEFINITE width
1473+
// sized to the viewport (NOT the shrink-to-fit parent, which
1474+
// would re-introduce the cycle) so recharts always measures a
1475+
// stable, non-zero width and renders. Height comes from the
1476+
// ChartContainer itself (h-[350px] / min-h 280).
1477+
style={{ width: 'min(520px, 80vw)' }}
1478+
data-testid="chat-chart"
1479+
>
1480+
{chart.title ? (
1481+
<div className="mb-2 text-sm font-medium text-foreground">
1482+
{chart.title}
1483+
</div>
1484+
) : null}
1485+
<SchemaRenderer
1486+
schema={{
1487+
type: 'chart',
1488+
chartType: chart.chartType ?? 'bar',
1489+
data: chart.data,
1490+
xAxisKey: chart.xAxisKey,
1491+
series: chart.series,
1492+
} as never}
1493+
/>
1494+
</div>
1495+
))}
1496+
</div>
1497+
) : null}
14291498
{!isUser && sources.length > 0 ? (
14301499
<Sources>
14311500
<SourcesTrigger count={sources.length} />

packages/plugin-chatbot/src/__tests__/mapMessages.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,62 @@ describe('uiMessageToChatMessage', () => {
182182
const out = uiMessageToChatMessage({ id: 'm7', role: 'assistant', parts: [{ type: 'text', text: 'hi' }] });
183183
expect(out.buildProgress).toBeUndefined();
184184
});
185+
186+
it('lifts data-chart parts into charts[] (visualize_data)', () => {
187+
const out = uiMessageToChatMessage({
188+
id: 'm8',
189+
role: 'assistant',
190+
parts: [
191+
{ type: 'text', text: 'Here is the breakdown:' },
192+
{
193+
type: 'data-chart',
194+
id: 'chart-0',
195+
data: {
196+
type: 'chart',
197+
chartType: 'bar',
198+
title: 'Deals by status',
199+
xAxisKey: 'status',
200+
series: [{ dataKey: 'count', label: 'Count' }],
201+
data: [
202+
{ status: 'won', count: 5 },
203+
{ status: 'lost', count: 2 },
204+
],
205+
},
206+
},
207+
],
208+
});
209+
expect(out.content).toBe('Here is the breakdown:');
210+
expect(out.charts).toHaveLength(1);
211+
expect(out.charts?.[0]).toEqual({
212+
chartType: 'bar',
213+
title: 'Deals by status',
214+
xAxisKey: 'status',
215+
series: [{ dataKey: 'count', label: 'Count' }],
216+
data: [
217+
{ status: 'won', count: 5 },
218+
{ status: 'lost', count: 2 },
219+
],
220+
});
221+
});
222+
223+
it('keeps multiple data-chart parts in arrival order and drops series-less payloads', () => {
224+
const out = uiMessageToChatMessage({
225+
id: 'm9',
226+
role: 'assistant',
227+
parts: [
228+
{ type: 'data-chart', id: 'c0', data: { type: 'chart', chartType: 'pie', series: [{ dataKey: 'count' }], data: [] } },
229+
{ type: 'data-chart', id: 'c1', data: { type: 'chart', series: [], data: [] } }, // unrenderable → dropped
230+
{ type: 'data-chart', id: 'c2', data: { type: 'chart', chartType: 'line', series: [{ dataKey: 'total' }], data: [] } },
231+
],
232+
});
233+
expect(out.charts).toHaveLength(2);
234+
expect(out.charts?.map((c) => c.chartType)).toEqual(['pie', 'line']);
235+
});
236+
237+
it('leaves charts undefined when there is no data-chart part', () => {
238+
const out = uiMessageToChatMessage({ id: 'm10', role: 'assistant', parts: [{ type: 'text', text: 'hi' }] });
239+
expect(out.charts).toBeUndefined();
240+
});
185241
});
186242

187243
// ADR-0033 Phase B — the chat lifts draft envelopes onto the invocation so a

packages/plugin-chatbot/src/mapMessages.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* apps that drive `useChat` themselves (e.g. Studio, which needs a custom
1515
* `prepareSendMessagesRequest` transport).
1616
*/
17-
import type { ChatMessage, ChatToolInvocation, ChatSource, ChatBuildProgress } from './ChatbotEnhanced';
17+
import type { ChatMessage, ChatToolInvocation, ChatSource, ChatBuildProgress, ChatChart } from './ChatbotEnhanced';
1818

1919
interface AnyPart {
2020
type?: string;
@@ -284,6 +284,43 @@ function extractBuildProgress(parts: AnyPart[]): ChatBuildProgress | undefined {
284284
};
285285
}
286286

287+
/**
288+
* Lift charts from the stream's `data-chart` parts (emitted by the
289+
* `visualize_data` tool via `ctx.onProgress`). Unlike `data-build-progress`
290+
* (one reconciled part), each chart is emitted with its OWN id, so a single
291+
* answer may carry several — we keep them all, in arrival order. Each part's
292+
* `data` already matches the SDUI `<chart>` schema; we defensively narrow it
293+
* so a malformed payload is dropped rather than rendered broken.
294+
*/
295+
function extractCharts(parts: AnyPart[]): ChatChart[] | undefined {
296+
const charts: ChatChart[] = [];
297+
for (const p of parts) {
298+
if (p.type !== 'data-chart') continue;
299+
const d = p.data;
300+
if (!d || typeof d !== 'object') continue;
301+
const c = d as Record<string, unknown>;
302+
const data = Array.isArray(c.data) ? (c.data as Array<Record<string, unknown>>) : [];
303+
const series = Array.isArray(c.series)
304+
? (c.series as Array<Record<string, unknown>>)
305+
.filter((s) => s && typeof s.dataKey === 'string')
306+
.map((s) => ({
307+
dataKey: s.dataKey as string,
308+
...(typeof s.label === 'string' ? { label: s.label } : {}),
309+
}))
310+
: [];
311+
// A chart with no series is unrenderable — skip it.
312+
if (series.length === 0) continue;
313+
charts.push({
314+
...(typeof c.chartType === 'string' ? { chartType: c.chartType as ChatChart['chartType'] } : {}),
315+
...(typeof c.title === 'string' ? { title: c.title } : {}),
316+
data,
317+
...(typeof c.xAxisKey === 'string' ? { xAxisKey: c.xAxisKey } : {}),
318+
series,
319+
});
320+
}
321+
return charts.length > 0 ? charts : undefined;
322+
}
323+
287324
/**
288325
* Map a single Vercel AI SDK v6 `UIMessage` to the `ChatMessage` shape that
289326
* `<ChatbotEnhanced>` renders.
@@ -309,6 +346,7 @@ export function uiMessageToChatMessage(
309346
toolInvocations: tools.length > 0 ? tools : legacyTools,
310347
sources: extractSources(parts),
311348
buildProgress: extractBuildProgress(parts),
349+
charts: extractCharts(parts),
312350
streaming: opts.streaming,
313351
};
314352
}

0 commit comments

Comments
 (0)