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
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 @@ -94,6 +94,14 @@ export function SystemPrompt({ memories = [], userRules, connections = [], skill
(e.g. YYYY-MM-DD). Use "category" for quarter labels (quarter_ending), fiscal periods (FY25-Q1), or
any non-ISO-date strings.
</ListItem>
<ListItem>
For display_chart on pipeline or task run data: if the SQL result includes{' '}
<Italic>state_type</Italic> and/or <Italic>state_name</Italic> columns, pass{' '}
<Italic>filter_state_types</Italic> and/or <Italic>filter_state_names</Italic> (each a non-empty array
of strings) so only matching rows are plotted; when both are set, rows must satisfy both. Optional{' '}
<Italic>date_locale</Italic> (BCP 47 tag, e.g. en-GB) controls how ISO date axis values are
formatted.
</ListItem>
<ListItem>
For display_chart chart_type: use "scatter" for correlations between two numeric variables (set
x_axis_type to "number"). Use "radar" for comparing multiple metrics across a fixed set of
Expand Down
29 changes: 22 additions & 7 deletions apps/backend/src/components/generate-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { buildChart, defaultColorFor, labelize } from '@nao/shared';
import { buildChart, defaultColorFor, filterChartRowsByStateDimensions, labelize } from '@nao/shared';
import type { displayChart } from '@nao/shared/tools';
import React from 'react';
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'
| 'filter_state_types'
| 'filter_state_names'
| 'date_locale'
>;
data: Record<string, unknown>[];
width?: number;
height?: number;
Expand All @@ -20,7 +30,11 @@ export function generateChartImage(input: RenderChartInput): Buffer {
}

export function renderChartToSvg(input: RenderChartInput): string {
const { config, data } = input;
const { config } = input;
const data = filterChartRowsByStateDimensions(input.data, {
filterStateTypes: input.config.filter_state_types,
filterStateNames: input.config.filter_state_names,
});
const width = input.width ?? 800;
const height = input.height ?? 500;
const margin = input.margin ?? { top: 10, right: 20, bottom: 5, left: 0 };
Expand All @@ -31,7 +45,7 @@ export function renderChartToSvg(input: RenderChartInput): string {
return series?.color || defaultColorFor(key, index);
};

const maxLabelWidth = estimateMaxLabelWidth(data, config.x_axis_key);
const maxLabelWidth = estimateMaxLabelWidth(data, config.x_axis_key, config.date_locale);

const chart = buildChart({
data,
Expand All @@ -44,13 +58,14 @@ export function renderChartToSvg(input: RenderChartInput): string {
margin,
title: config.title,
maxXAxisTicks: Math.floor(width / maxLabelWidth),
dateLocale: config.date_locale,
});

const html = renderToString(React.cloneElement(chart, { width, height }));

const legend: LegendEntry[] = includeLegend
? config.series.map((s, i) => ({
label: s.label || labelize(s.data_key),
label: s.label || labelize(s.data_key, config.date_locale),
dataKey: s.data_key,
color: colorFor(s.data_key, i),
}))
Expand All @@ -63,9 +78,9 @@ const CHAR_WIDTH_PX = 7;
const TICK_PADDING_PX = 16;
const MIN_TICK_WIDTH_PX = 40;

function estimateMaxLabelWidth(data: Record<string, unknown>[], xAxisKey: string): number {
function estimateMaxLabelWidth(data: Record<string, unknown>[], xAxisKey: string, dateLocale?: string): number {
const maxCharCount = data.reduce((max, row) => {
const formatted = labelize(String(row[xAxisKey] ?? ''));
const formatted = labelize(String(row[xAxisKey] ?? ''), dateLocale);
return Math.max(max, formatted.length);
}, 0);
return Math.max(maxCharCount * CHAR_WIDTH_PX + TICK_PADDING_PX, MIN_TICK_WIDTH_PX);
Expand Down
38 changes: 26 additions & 12 deletions apps/backend/src/utils/story-html.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defaultColorFor, labelize } from '@nao/shared';
import { defaultColorFor, filterChartRowsByStateDimensions, labelize } from '@nao/shared';
import type { ParsedChartBlock, ParsedTableBlock, Segment } from '@nao/shared/story-segments';
import { splitCodeIntoSegments } from '@nao/shared/story-segments';
import { formatCellValue, isNumericColumn } from '@nao/shared/story-table-utils';
Expand Down Expand Up @@ -48,7 +48,7 @@ function StoryDocument({ title, children }: { title: string; children: React.Rea
}

function StoryFooter() {
const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
const date = new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
return (
<footer
style={{ marginTop: 48, paddingTop: 16, borderTop: '1px solid #e5e7eb', fontSize: 12, color: '#9ca3af' }}
Expand Down Expand Up @@ -115,24 +115,33 @@ function ChartBlock({ chart, queryData }: { chart: ParsedChartBlock; queryData:
return <Placeholder label={chart.title || 'Chart'} message='Data unavailable' />;
}

const filteredRows = filterChartRowsByStateDimensions(rows, {
filterStateTypes: chart.filterStateTypes,
filterStateNames: chart.filterStateNames,
});
if (!filteredRows.length) {
return <Placeholder label={chart.title || 'Chart'} message='Data unavailable after filters.' />;
}

if (chart.chartType === 'kpi_card') {
return <KpiCards chart={chart} rows={rows} />;
return <KpiCards chart={chart} rows={filteredRows} />;
}

try {
const svg = renderChartToSvg({
config: toChartConfig(chart),
data: rows,
data: filteredRows,
width: CHART_WIDTH,
height: CHART_HEIGHT,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
includeLegend: false,
});
const chartData = JSON.stringify({
data: rows,
data: filteredRows,
xAxisKey: chart.xAxisKey,
series: chart.series,
chartType: chart.chartType,
dateLocale: chart.dateLocale ?? null,
});
return (
<div style={{ margin: '16px 0' }}>
Expand All @@ -142,15 +151,15 @@ function ChartBlock({ chart, queryData }: { chart: ParsedChartBlock; queryData:
data-chart={chartData}
dangerouslySetInnerHTML={{ __html: svg }}
/>
{chart.chartType !== 'pie' && <ChartLegend series={chart.series} />}
{chart.chartType !== 'pie' && <ChartLegend series={chart.series} chart={chart} />}
</div>
);
} catch {
return <Placeholder label={chart.title || 'Chart'} message='Could not render chart' />;
}
}

function ChartLegend({ series }: { series: ParsedChartBlock['series'] }) {
function ChartLegend({ series, chart }: { series: ParsedChartBlock['series']; chart: ParsedChartBlock }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, paddingTop: 12 }}>
{series.map((s, i) => {
Expand All @@ -161,7 +170,7 @@ function ChartLegend({ series }: { series: ParsedChartBlock['series'] }) {
style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#6b7280', fontSize: 12 }}
>
<div style={{ width: 8, height: 8, borderRadius: 2, flexShrink: 0, background: color }} />
{s.label || labelize(s.data_key)}
{s.label || labelize(s.data_key, chart.dateLocale)}
</div>
);
})}
Expand Down Expand Up @@ -306,6 +315,9 @@ function toChartConfig(chart: ParsedChartBlock) {
x_axis_type: chart.xAxisType as displayChart.XAxisType | null,
series: chart.series,
title: chart.title,
filter_state_types: chart.filterStateTypes,
filter_state_names: chart.filterStateNames,
date_locale: chart.dateLocale,
};
}

Expand Down Expand Up @@ -345,9 +357,10 @@ const TOOLTIP_SCRIPT = `
(function(){
var PIE_COLORS=['#104e64','#f54900','#009689','#ffb900','#fe9a00'];
function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
function labelize(s){
function labelize(s,loc){
var str=String(s);
if(/^\\d{4}-\\d{2}-\\d{2}/.test(str)){var d=new Date(str);if(!isNaN(d.getTime()))return escHtml(d.toLocaleDateString('en-US',{timeZone:'UTC'}))}
var lc=loc||undefined;
if(/^\\d{4}-\\d{2}-\\d{2}/.test(str)){var d=new Date(str);if(!isNaN(d.getTime()))return escHtml(d.toLocaleDateString(lc,{timeZone:'UTC'}))}
return escHtml(str.replace(/_/g,' ').replace(/\\b\\w/g,function(c){return c.toUpperCase()}))
}
function formatVal(v){return escHtml(typeof v==='number'?v.toLocaleString():String(v!=null?v:''))}
Expand Down Expand Up @@ -416,7 +429,8 @@ const TOOLTIP_SCRIPT = `
function showTip(e,row){
var label=row[cfg.xAxisKey];
var isPie=!!pieColorMap;
var html='<div class="nao-tooltip-label">'+(isPie?labelize(cfg.series[0]&&(cfg.series[0].label||cfg.series[0].data_key)||''):labelize(label!=null?label:''))+'</div>';
var loc=cfg.dateLocale||undefined;
var html='<div class="nao-tooltip-label">'+(isPie?labelize(cfg.series[0]&&(cfg.series[0].label||cfg.series[0].data_key)||'',loc):labelize(label!=null?label:'',loc))+'</div>';
html+='<div class="nao-tooltip-rows">';
var numericValues=[];
cfg.series.forEach(function(s){
Expand All @@ -429,7 +443,7 @@ const TOOLTIP_SCRIPT = `
}
var val=row[s.data_key];
if(typeof val==='number')numericValues.push(val);
var rowName=isPie?labelize(label!=null?label:''):labelize(s.label||s.data_key);
var rowName=isPie?labelize(label!=null?label:'',loc):labelize(s.label||s.data_key,loc);
html+='<div class="nao-tooltip-row">'
+'<span class="nao-tooltip-swatch" style="background:'+escHtml(color)+'"></span>'
+'<span class="nao-tooltip-name">'+rowName+'</span>'
Expand Down
58 changes: 47 additions & 11 deletions apps/frontend/src/components/tool-calls/display-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { buildChart, labelize } from '@nao/shared';
import { buildChart, filterChartRowsByStateDimensions, labelize } from '@nao/shared';
import { Download, FilePlus } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useOptionalAgentContext } from '../../contexts/agent.provider';
Expand Down Expand Up @@ -93,10 +93,14 @@ export const DisplayChartToolCall = ({
if (!sourceData?.data || !config) {
return [];
}
const rows = filterChartRowsByStateDimensions(sourceData.data as Record<string, unknown>[], {
filterStateTypes: config.filter_state_types,
filterStateNames: config.filter_state_names,
});
if (config.x_axis_type !== 'date') {
return sourceData.data;
return rows;
}
const sorted = sortByDateKey(sourceData.data, config.x_axis_key);
const sorted = sortByDateKey(rows, config.x_axis_key);
return filterByDateRange(sorted, config.x_axis_key, dataRange);
}, [sourceData?.data, config, dataRange]);

Expand Down Expand Up @@ -143,6 +147,20 @@ export const DisplayChartToolCall = ({
);
}

if (filteredData.length === 0) {
const hasFilters =
(config.filter_state_types?.length ?? 0) > 0 || (config.filter_state_names?.length ?? 0) > 0;
return (
<div className='my-2 text-foreground/50 text-sm'>
{hasFilters
? 'Could not display the chart because no rows matched the requested state filters.'
: config.x_axis_type === 'date' && dataRange !== 'all'
? 'Could not display the chart because no rows matched the selected date range.'
: 'Could not display the chart because there is nothing to plot.'}
</div>
);
}

const handleAddToStory = async () => {
const latestStoryId = storyIds[storyIds.length - 1];
// Prefer the currently-visible story slug, but only if it's a real story
Expand All @@ -166,7 +184,18 @@ export const DisplayChartToolCall = ({
}

const seriesJson = JSON.stringify(config.series);
const chartBlock = `<chart query_id="${escapeDoubleQuotedAttr(config.query_id)}" chart_type="${escapeDoubleQuotedAttr(config.chart_type)}" x_axis_key="${escapeDoubleQuotedAttr(config.x_axis_key)}" x_axis_type="${escapeDoubleQuotedAttr(config.x_axis_type ?? '')}" series='${escapeSingleQuotedAttr(seriesJson)}' title="${escapeDoubleQuotedAttr(config.title ?? '')}" />`;
const extraAttrs: string[] = [];
if (config.filter_state_types?.length) {
extraAttrs.push(`filter_state_types='${escapeSingleQuotedAttr(JSON.stringify(config.filter_state_types))}'`);
}
if (config.filter_state_names?.length) {
extraAttrs.push(`filter_state_names='${escapeSingleQuotedAttr(JSON.stringify(config.filter_state_names))}'`);
}
if (config.date_locale?.trim()) {
extraAttrs.push(`date_locale="${escapeDoubleQuotedAttr(config.date_locale.trim())}"`);
}
const tail = extraAttrs.length ? ` ${extraAttrs.join(' ')}` : '';
const chartBlock = `<chart query_id="${escapeDoubleQuotedAttr(config.query_id)}" chart_type="${escapeDoubleQuotedAttr(config.chart_type)}" x_axis_key="${escapeDoubleQuotedAttr(config.x_axis_key)}" x_axis_type="${escapeDoubleQuotedAttr(config.x_axis_type ?? '')}" series='${escapeSingleQuotedAttr(seriesJson)}' title="${escapeDoubleQuotedAttr(config.title ?? '')}"${tail} />`;
const newCode = latest.code.trimEnd() + '\n\n' + chartBlock;

addToStoryMutation.mutate({
Expand Down Expand Up @@ -229,6 +258,7 @@ export const DisplayChartToolCall = ({
series={config.series}
xAxisType={config.x_axis_type === 'number' ? 'number' : 'category'}
title={config.title}
chartDateLocale={config.date_locale}
/>
</div>
);
Expand All @@ -243,6 +273,7 @@ export interface ChartDisplayProps {
series: displayChart.SeriesConfig[];
title?: string;
showGrid?: boolean;
chartDateLocale?: string;
}

export const ChartDisplay = memo(function ChartDisplay({
Expand All @@ -254,6 +285,7 @@ export const ChartDisplay = memo(function ChartDisplay({
series,
title,
showGrid = true,
chartDateLocale,
}: ChartDisplayProps) {
const { visibleSeries, hiddenSeriesKeys, handleToggleSeriesVisibility } = useSeriesVisibility(series);

Expand All @@ -263,27 +295,27 @@ export const ChartDisplay = memo(function ChartDisplay({
return [...values].reduce(
(acc, v, index) => {
acc[toKey(v)] = {
label: labelize(v),
label: labelize(v, chartDateLocale),
color: Colors[index % Colors.length],
};
return acc;
},
{
[xAxisKey]: {
label: labelize(xAxisKey),
label: labelize(xAxisKey, chartDateLocale),
},
} as ChartConfig,
);
}

return series.reduce((acc, s, idx) => {
acc[s.data_key] = {
label: s.label || labelize(s.data_key),
label: s.label || labelize(s.data_key, chartDateLocale),
color: s.color || Colors[idx % Colors.length],
};
return acc;
}, {} as ChartConfig);
}, [series, xAxisKey, data, chartType]);
}, [series, xAxisKey, data, chartType, chartDateLocale]);

const colorFor = useMemo(
() =>
Expand All @@ -296,12 +328,12 @@ export const ChartDisplay = memo(function ChartDisplay({
const legendPayload = useMemo(
() =>
series.map((s, idx) => ({
value: s.label || labelize(s.data_key),
value: s.label || labelize(s.data_key, chartDateLocale),
dataKey: s.data_key,
color: s.color || Colors[idx % Colors.length],
isHidden: hiddenSeriesKeys.has(s.data_key),
})),
[series, hiddenSeriesKeys],
[series, hiddenSeriesKeys, chartDateLocale],
);

const chartElement = useMemo(
Expand All @@ -314,6 +346,7 @@ export const ChartDisplay = memo(function ChartDisplay({
series: visibleSeries,
colorFor,
labelFormatter: xAxisLabelFormatter,
dateLocale: chartDateLocale,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Unvalidated date_locale is forwarded into shared date formatting, which can throw on invalid locale strings and break chart rendering.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/frontend/src/components/tool-calls/display-chart.tsx, line 349:

<comment>Unvalidated `date_locale` is forwarded into shared date formatting, which can throw on invalid locale strings and break chart rendering.</comment>

<file context>
@@ -314,6 +346,7 @@ export const ChartDisplay = memo(function ChartDisplay({
 				series: visibleSeries,
 				colorFor,
 				labelFormatter: xAxisLabelFormatter,
+				dateLocale: chartDateLocale,
 				showGrid,
 				margin: { top: 0, right: 0, bottom: 0, left: 0 },
</file context>

showGrid,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
children: [
Expand All @@ -322,7 +355,9 @@ export const ChartDisplay = memo(function ChartDisplay({
animationDuration={150}
animationEasing='linear'
allowEscapeViewBox={{ y: true, x: false }}
content={<ChartTooltipContent labelFormatter={(value) => labelize(value)} />}
content={
<ChartTooltipContent labelFormatter={(value) => labelize(value, chartDateLocale)} />
}
/>,
chartType !== 'pie' && (
<ChartLegend
Expand All @@ -346,6 +381,7 @@ export const ChartDisplay = memo(function ChartDisplay({
legendPayload,
handleToggleSeriesVisibility,
title,
chartDateLocale,
],
);

Expand Down
Loading
Loading