Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/backend/src/agents/tools/display-chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default createTool<displayChart.Input, displayChart.Output>({
const { chart_type: chartType, x_axis_key: xAxisKey, series } = input;

// Validate xAxisKey is provided for cartesian and polar charts
if (['bar', 'line', 'area', 'stacked_area', 'scatter', 'radar'].includes(chartType) && !xAxisKey) {
if (['bar', 'line', 'area', 'stacked_area', 'scatter', 'radar', 'combo'].includes(chartType) && !xAxisKey) {
return { _version: '1', success: false, error: `xAxisKey is required for ${chartType} charts.` };
}

Expand Down
8 changes: 8 additions & 0 deletions apps/backend/src/components/ai/system-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ export function SystemPrompt({
desired (similar to "line"). Use "stacked_area" to show how multiple series compose a total over
time (e.g. revenue by payment method, users by plan) — requires 2+ series and pivoted data.
</ListItem>
<ListItem>
For display_chart chart_type: use "combo" to mix chart types and/or plot metrics on two Y-axes (e.g.
revenue as bars on the left axis vs. conversion rate as a line on the right axis). Set each series'
"series_type" ("bar", "line", or "area") and "y_axis" ("left" or "right"). A right axis is drawn
automatically when any series uses "y_axis": "right". Use the top-level "y_axes" object to label
each axis (e.g. y_axes.left.label, y_axes.right.label) and to fix a scale via "domain" ("auto" or{' '}
{'{ min, max }'}). Prefer "combo" with a right axis when comparing metrics on very different scales.
</ListItem>
{hasClickHouse && (
<ListItem>
When available, use indexes.md to see how the table is ordered and indexed (ORDER BY, PRIMARY
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/components/generate-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { renderToString } from 'react-dom/server';
import { createSvg, type LegendEntry, svgToPng } from '../utils/generate-chart';

export interface RenderChartInput {
config: Pick<displayChart.Input, 'chart_type' | 'x_axis_key' | 'x_axis_type' | 'series' | 'title'>;
config: Pick<displayChart.Input, 'chart_type' | 'x_axis_key' | 'x_axis_type' | 'series' | 'title'> &
Partial<Pick<displayChart.Input, 'y_axes'>>;
data: Record<string, unknown>[];
width?: number;
height?: number;
Expand Down Expand Up @@ -42,6 +43,7 @@ export function renderChartToSvg(input: RenderChartInput): string {
xAxisKey: config.x_axis_key,
xAxisType: config.x_axis_type === 'number' ? 'number' : 'category',
series: config.series,
yAxes: config.y_axes,
colorFor,
labelFormatter,
showGrid: true,
Expand Down
13 changes: 10 additions & 3 deletions apps/backend/src/mcp/tools/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ export async function buildChartEmbedFromArtifact(
ctx: McpContext,
opts: { chatId: string | null; callLogId: string },
): Promise<{ payload: ChartToolPayload; sandboxChartHtml: string | null } | { keyError: ChartKeyError } | null> {
const { query_id, chart_type, x_axis_key, x_axis_type, series, title } = artifact;
const block = buildStoryChartBlock({ query_id, chart_type, x_axis_key, x_axis_type, series, title });
const { query_id, chart_type, x_axis_key, x_axis_type, series, y_axes, title } = artifact;
const block = buildStoryChartBlock({ query_id, chart_type, x_axis_key, x_axis_type, series, y_axes, title });

const queryData = await resolveChartQueryData({
queryId: query_id,
Expand Down Expand Up @@ -159,7 +159,14 @@ export async function buildChartEmbedFromArtifact(
chartEmbedId: id,
queryId: query_id,
projectId: ctx.projectId,
chartConfig: { chartType: chart_type, xAxisKey: x_axis_key, xAxisType: x_axis_type, series, title },
chartConfig: {
chartType: chart_type,
xAxisKey: x_axis_key,
xAxisType: x_axis_type,
series,
yAxes: y_axes,
title,
},
sourceChatId: effectiveChatId,
});
if (inserted) {
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/utils/story-html.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ function toChartConfig(chart: ParsedChartBlock) {
x_axis_key: chart.xAxisKey,
x_axis_type: chart.xAxisType as displayChart.XAxisType | null,
series: chart.series,
y_axes: chart.yAxes,
title: chart.title,
};
}
Expand Down Expand Up @@ -502,7 +503,7 @@ const TOOLTIP_SCRIPT_TEMPLATE = `
+'<span class="nao-tooltip-value">'+formatVal(val)+'</span>'
+'</div>';
});
if(numericValues.length>1){
if(numericValues.length>1&&cfg.chartType!=='combo'){
var total=numericValues.reduce(function(a,b){return a+b},0);
html+='<div class="nao-tooltip-total">'
+'<span class="nao-tooltip-name">Total</span>'
Expand Down
83 changes: 83 additions & 0 deletions apps/backend/tests/generate-chart-combo.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';

import type { RenderChartInput } from '../src/components/generate-chart';
import { renderChartToSvg } from '../src/components/generate-chart';

const data = [
{ month: '2024-01-01', revenue: 1000, orders: 12 },
{ month: '2024-02-01', revenue: 1500, orders: 18 },
{ month: '2024-03-01', revenue: 1200, orders: 9 },
];

function comboConfig(overrides: Partial<RenderChartInput['config']> = {}): RenderChartInput['config'] {
return {
chart_type: 'combo',
x_axis_key: 'month',
x_axis_type: 'date',
title: 'Revenue vs orders',
series: [
{ data_key: 'revenue', series_type: 'bar', y_axis: 'left' },
{ data_key: 'orders', series_type: 'line', y_axis: 'right' },
],
...overrides,
};
}

describe('renderChartToSvg (combo)', () => {
it('renders mixed bar + line series', () => {
const svg = renderChartToSvg({ config: comboConfig(), data });

expect(svg).toContain('recharts-bar');
expect(svg).toContain('recharts-line');
});

it('renders a second Y-axis when a series uses the right axis', () => {
const svg = renderChartToSvg({ config: comboConfig(), data });
const yAxes = svg.match(/recharts-yAxis/g) ?? [];
expect(yAxes.length).toBeGreaterThanOrEqual(2);
});

it('renders a single Y-axis when all series use the left axis', () => {
const svg = renderChartToSvg({
config: comboConfig({
series: [
{ data_key: 'revenue', series_type: 'bar', y_axis: 'left' },
{ data_key: 'orders', series_type: 'line', y_axis: 'left' },
],
}),
data,
});
const yAxes = svg.match(/recharts-yAxis/g) ?? [];
expect(yAxes.length).toBe(1);
});

it('renders axis labels from y_axes config', () => {
const svg = renderChartToSvg({
config: comboConfig({
y_axes: {
left: { label: 'Revenue USD' },
right: { label: 'Order count' },
},
}),
data,
});

expect(svg).toContain('Revenue USD');
expect(svg).toContain('Order count');
});

it('renders an area series in a combo chart', () => {
const svg = renderChartToSvg({
config: comboConfig({
series: [
{ data_key: 'revenue', series_type: 'bar', y_axis: 'left' },
{ data_key: 'orders', series_type: 'area', y_axis: 'right' },
],
}),
data,
});

expect(svg).toContain('recharts-bar');
expect(svg).toContain('recharts-area');
});
});
3 changes: 3 additions & 0 deletions apps/frontend/src/components/mcp-app/chart-app-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export const ChartAppView = memo(function ChartAppView({ config, data, naoUrl }:
data_key: s.data_key,
color: s.color ?? `var(--chart-${(i % 5) + 1})`,
label: s.label,
series_type: s.series_type,
y_axis: s.y_axis,
})),
[config.series],
);
Expand Down Expand Up @@ -54,6 +56,7 @@ export const ChartAppView = memo(function ChartAppView({ config, data, naoUrl }:
xAxisKey={config.xAxisKey}
xAxisType={xAxisType}
series={series}
yAxes={config.yAxes}
title={config.title}
/>
</div>
Expand Down
15 changes: 6 additions & 9 deletions apps/frontend/src/components/side-panel/story-chart-embed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { memo, useMemo, useState } from 'react';
import { Pencil } from 'lucide-react';
import type { UIMessage } from '@nao/backend/chat';
import type { displayChart } from '@nao/shared/tools';
import type { ParsedChartBlock } from '@nao/shared/story-segments';
import { Button } from '@/components/ui/button';
import { useOptionalAgentContext } from '@/contexts/agent.provider';
import { useStoryEmbedData } from '@/contexts/story-embed-data';
Expand All @@ -10,15 +11,7 @@ import { ChartDisplay } from '@/components/tool-calls/display-chart';
import { ChartConfigEditDialog } from '@/components/tool-calls/display-chart-edit-dialog';
import { sortByDateKey } from '@/lib/charts.utils';

interface ChartBlock {
queryId: string;
chartType: string;
xAxisKey: string;
xAxisType: string | null;
series: Array<{ data_key: string; color: string; label?: string }>;
title: string;
rawTag?: string;
}
type ChartBlock = ParsedChartBlock;

export const StoryChartEmbed = memo(function StoryChartEmbed({ chart }: { chart: ChartBlock }) {
const agent = useOptionalAgentContext();
Expand Down Expand Up @@ -78,6 +71,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ chart }: { chart:
xAxisKey={chart.xAxisKey}
xAxisType={xAxisType}
series={chart.series}
yAxes={chart.yAxes}
title={chart.title}
/>
</StoryChartEmbedShell>
Expand Down Expand Up @@ -109,7 +103,10 @@ export function StoryChartEmbedShell({ chart, availableColumns, children }: Stor
data_key: s.data_key,
color: s.color || undefined,
label: s.label,
series_type: s.series_type,
y_axis: s.y_axis,
})),
y_axes: chart.yAxes,
title: chart.title,
}),
[chart],
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/components/story-embeds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({
xAxisKey={chart.xAxisKey}
xAxisType={chart.xAxisType === 'number' ? 'number' : 'category'}
series={chart.series}
yAxes={chart.yAxes}
title={chart.title}
/>
</StoryChartEmbedShell>
Expand Down
Loading
Loading