Skip to content
Merged
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
6 changes: 6 additions & 0 deletions apps/backend/src/components/ai/system-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ export function SystemPrompt({
For display_chart y_axis_min/y_axis_max: use them to fix the Y-axis scale when needed; by default,
line and scatter charts auto-scale to a readable range rather than always starting at zero.
</ListItem>
<ListItem>
Use "mixed" to show multiple metrics on different scales in one chart: give each series its own
"series_type" ("bar", "line" or "area", defaults to "bar") and "y_axis" ("left" or "right", defaults
to "left"). A second Y-axis is drawn whenever any series uses {'"y_axis": "right"'}. These
per-series settings only apply to "mixed" charts.
</ListItem>
<ListItem>
Use the <Bold>display_chart</Bold> tool with <Bold>{'chart_type: "table"'}</Bold> to present a table
from a previous execute_sql result with <Bold>conditional formatting</Bold> on specific columns.
Expand Down
8 changes: 8 additions & 0 deletions apps/backend/src/components/generate-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface RenderChartInput {
| 'series'
| 'y_axis_min'
| 'y_axis_max'
| 'y_axis_label'
| 'y_axis_right_min'
| 'y_axis_right_max'
| 'y_axis_right_label'
| 'title'
| 'show_data_labels'
>;
Expand Down Expand Up @@ -87,6 +91,10 @@ export function renderChartToSvg(input: RenderChartInput): string {
backgroundColor: '#ffffff',
yAxisMin: config.y_axis_min,
yAxisMax: config.y_axis_max,
yAxisLabel: config.y_axis_label,
yAxisRightMin: config.y_axis_right_min,
yAxisRightMax: config.y_axis_right_max,
yAxisRightLabel: config.y_axis_right_label,
});

const html = renderToString(React.cloneElement(chart, { width: chartWidth, height }));
Expand Down
32 changes: 29 additions & 3 deletions apps/backend/src/mcp/tools/asset-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,21 @@ function registerDisplayChart(server: McpServer, ctx: McpContext): void {
mapInput: ({ chat_id: _chatId, ...input }) => input,
resolveChatId: (input) => input.chat_id ?? null,
formatResult: async ({ input, output, callLogId }) => {
const { query_id, chart_type, x_axis_key, x_axis_type, series, y_axis_min, y_axis_max, title, chat_id } =
input;
const {
query_id,
chart_type,
x_axis_key,
x_axis_type,
series,
y_axis_min,
y_axis_max,
y_axis_label,
y_axis_right_min,
y_axis_right_max,
y_axis_right_label,
title,
chat_id,
} = input;
if (!output.success) {
return {
content: [{ type: 'text' as const, text: output.error ?? 'Chart config is invalid.' }],
Expand All @@ -116,7 +129,20 @@ function registerDisplayChart(server: McpServer, ctx: McpContext): void {

const validatedChatId = await resolveChartChatId(chat_id, ctx);
const result = await buildChartEmbedFromArtifact(
{ query_id, chart_type, x_axis_key, x_axis_type, series, y_axis_min, y_axis_max, title },
{
query_id,
chart_type,
x_axis_key,
x_axis_type,
series,
y_axis_min,
y_axis_max,
y_axis_label,
y_axis_right_min,
y_axis_right_max,
y_axis_right_label,
title,
},
ctx,
{ chatId: validatedChatId ?? null, callLogId },
);
Expand Down
23 changes: 22 additions & 1 deletion apps/backend/src/mcp/tools/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,20 @@ 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, y_axis_min, y_axis_max, title } = artifact;
const {
query_id,
chart_type,
x_axis_key,
x_axis_type,
series,
y_axis_min,
y_axis_max,
y_axis_label,
y_axis_right_min,
y_axis_right_max,
y_axis_right_label,
title,
} = artifact;
const block = buildStoryChartBlock({
query_id,
chart_type,
Expand All @@ -138,6 +151,10 @@ export async function buildChartEmbedFromArtifact(
series,
y_axis_min,
y_axis_max,
y_axis_label,
y_axis_right_min,
y_axis_right_max,
y_axis_right_label,
title,
});

Expand Down Expand Up @@ -175,6 +192,10 @@ export async function buildChartEmbedFromArtifact(
series,
yAxisMin: y_axis_min,
yAxisMax: y_axis_max,
yAxisLabel: y_axis_label,
yAxisRightMin: y_axis_right_min,
yAxisRightMax: y_axis_right_max,
yAxisRightLabel: y_axis_right_label,
title,
},
sourceChatId: effectiveChatId,
Expand Down
7 changes: 6 additions & 1 deletion apps/backend/src/utils/story-html.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,10 @@ function toChartConfig(chart: ParsedChartBlock) {
series: chart.series,
y_axis_min: chart.yAxisMin,
y_axis_max: chart.yAxisMax,
y_axis_label: chart.yAxisLabel,
y_axis_right_min: chart.yAxisRightMin,
y_axis_right_max: chart.yAxisRightMax,
y_axis_right_label: chart.yAxisRightLabel,
title: chart.title,
show_data_labels: chart.showDataLabels,
};
Expand Down Expand Up @@ -499,6 +503,7 @@ const TOOLTIP_SCRIPT_TEMPLATE = `
var html='<div class="nao-tooltip-label">'+labelize(label!=null?label:'')+'</div>';
html+='<div class="nao-tooltip-rows">';
var isPercent=cfg.chartType==='stacked_bar_100'||cfg.chartType==='stacked_area_100';
var isDualAxis=(cfg.series||[]).some(function(s){return s.y_axis==='right'});
var seriesTotal=0;
cfg.series.forEach(function(s){var sv=row[s.data_key];if(typeof sv==='number'&&!s.is_total)seriesTotal+=sv;});
function pctShare(v){if(typeof v!=='number'||!seriesTotal)return '0%';var sh=Math.round(v/seriesTotal*1000)/10;return (sh%1===0?sh:sh.toFixed(1))+'%';}
Expand All @@ -525,7 +530,7 @@ const TOOLTIP_SCRIPT_TEMPLATE = `
+'<span class="nao-tooltip-value">'+(isPercent?pctShare(val):formatVal(val))+'</span>'
+'</div>';
});
if(numericValues.length>1 && (isPercent || (!hasTotalSeries && !cfg.hideTotal))){
if(numericValues.length>1 && !isDualAxis && (isPercent || (!hasTotalSeries && !cfg.hideTotal))){
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
74 changes: 74 additions & 0 deletions apps/backend/tests/generate-chart-combo.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { displayChart } from '@nao/shared/tools';
import { describe, expect, it } from 'vitest';

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

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

function render(
config: Partial<Parameters<typeof renderChartToSvg>[0]['config']> & { series: displayChart.SeriesConfig[] },
) {
return renderChartToSvg({
config: {
chart_type: 'mixed',
x_axis_key: 'month',
x_axis_type: 'category',
title: 'Test',
...config,
},
data,
});
}

function countYAxes(svg: string): number {
return (svg.match(/recharts-yAxis/g) ?? []).length;
}

describe('renderChartToSvg (combo)', () => {
it('renders mixed bar and line series', () => {
const svg = render({
series: [
{ data_key: 'revenue', series_type: 'bar' },
{ data_key: 'orders', series_type: 'line' },
],
});
expect(svg).toContain('recharts-bar');
expect(svg).toContain('recharts-line');
});

it('draws a second Y-axis when a series uses the right axis', () => {
const svg = render({
series: [
{ data_key: 'revenue', series_type: 'bar', y_axis: 'left' },
{ data_key: 'orders', series_type: 'line', y_axis: 'right' },
],
});
expect(countYAxes(svg)).toBe(2);
});

it('keeps a single Y-axis when every series stays on the left', () => {
const svg = render({
series: [
{ data_key: 'revenue', series_type: 'bar', y_axis: 'left' },
{ data_key: 'orders', series_type: 'line', y_axis: 'left' },
],
});
expect(countYAxes(svg)).toBe(1);
});

it('renders independent axis labels', () => {
const svg = render({
series: [
{ data_key: 'revenue', series_type: 'bar', y_axis: 'left' },
{ data_key: 'orders', series_type: 'line', y_axis: 'right' },
],
y_axis_label: 'Revenue',
y_axis_right_label: 'Orders',
});
expect(svg).toContain('Revenue');
expect(svg).toContain('Orders');
});
});
4 changes: 4 additions & 0 deletions apps/frontend/src/components/automations-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,10 @@ function ChartSlide({ chart }: { chart: AutomationFeedChart }) {
title={chart.config.title}
yAxisMin={chart.config.y_axis_min}
yAxisMax={chart.config.y_axis_max}
yAxisLabel={chart.config.y_axis_label}
yAxisRightMin={chart.config.y_axis_right_min}
yAxisRightMax={chart.config.y_axis_right_max}
yAxisRightLabel={chart.config.y_axis_right_label}
/>
</div>
);
Expand Down
6 changes: 6 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 @@ -27,6 +27,8 @@ export const ChartAppView = memo(function ChartAppView({ config, data, naoUrl }:
color: s.color ?? `var(--chart-${(i % 5) + 1})`,
label: s.label,
is_total: s.is_total,
series_type: s.series_type,
y_axis: s.y_axis,
})),
[config.series],
);
Expand Down Expand Up @@ -58,6 +60,10 @@ export const ChartAppView = memo(function ChartAppView({ config, data, naoUrl }:
title={config.title}
yAxisMin={config.yAxisMin}
yAxisMax={config.yAxisMax}
yAxisLabel={config.yAxisLabel}
yAxisRightMin={config.yAxisRightMin}
yAxisRightMax={config.yAxisRightMax}
yAxisRightLabel={config.yAxisRightLabel}
/>
</div>
);
Expand Down
25 changes: 12 additions & 13 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 { Pencil } from 'lucide-react';
import { memo, useMemo, useState } from 'react';
import { StoryEmbedFallback } from './story-embed-fallback';
import type { UIMessage } from '@nao/backend/chat';
import type { ParsedChartBlock } from '@nao/shared/story-segments';
import type { displayChart } from '@nao/shared/tools';

import { ChartDisplay } from '@/components/tool-calls/display-chart';
Expand All @@ -14,19 +15,7 @@ import { sortByDateKey } from '@/lib/charts.utils';

const STORY_CHART_HEIGHT_CLASS = 'h-72';

interface ChartBlock {
queryId: string;
chartType: string;
xAxisKey: string;
xAxisType: string | null;
series: Array<{ data_key: string; color: string; label?: string; is_total?: boolean }>;
yAxisMin?: number;
yAxisMax?: number;
title: string;
showDataLabels?: boolean;
hideTotal?: boolean;
rawTag?: string;
}
type ChartBlock = ParsedChartBlock;

export const StoryChartEmbed = memo(function StoryChartEmbed({
chart,
Expand Down Expand Up @@ -91,6 +80,10 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({
title={chart.title}
yAxisMin={chart.yAxisMin}
yAxisMax={chart.yAxisMax}
yAxisLabel={chart.yAxisLabel}
yAxisRightMin={chart.yAxisRightMin}
yAxisRightMax={chart.yAxisRightMax}
yAxisRightLabel={chart.yAxisRightLabel}
showDataLabels={chart.showDataLabels}
normalSize
hideTotal={chart.hideTotal}
Expand Down Expand Up @@ -126,9 +119,15 @@ export function StoryChartEmbedShell({ chart, availableColumns, dragHandle, chil
color: s.color || undefined,
label: s.label,
is_total: s.is_total,
series_type: s.series_type,
y_axis: s.y_axis,
})),
y_axis_min: chart.yAxisMin,
y_axis_max: chart.yAxisMax,
y_axis_label: chart.yAxisLabel,
y_axis_right_min: chart.yAxisRightMin,
y_axis_right_max: chart.yAxisRightMax,
y_axis_right_label: chart.yAxisRightLabel,
title: chart.title,
show_data_labels: chart.showDataLabels,
hide_total: chart.hideTotal,
Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/components/story-embeds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({
title={chart.title}
yAxisMin={chart.yAxisMin}
yAxisMax={chart.yAxisMax}
yAxisLabel={chart.yAxisLabel}
yAxisRightMin={chart.yAxisRightMin}
yAxisRightMax={chart.yAxisRightMax}
yAxisRightLabel={chart.yAxisRightLabel}
showDataLabels={chart.showDataLabels}
normalSize
hideTotal={chart.hideTotal}
Expand Down
Loading
Loading