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
34 changes: 27 additions & 7 deletions apps/backend/src/agents/tools/display-chart.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { isBuiltinChartType } from '@nao/shared';
import { displayChart } from '@nao/shared/tools';

import { DisplayChartOutput, renderToModelOutput } from '../../components/tool-outputs';
import { chartPluginService } from '../../services/chart-plugin.service';
import { createTool } from '../../utils/tools';

export default createTool<displayChart.Input, displayChart.Output>({
Expand All @@ -11,6 +13,31 @@ export default createTool<displayChart.Input, displayChart.Output>({
execute: async (input, context) => {
const { chart_type: chartType, x_axis_key: xAxisKey, series } = input;

// Validate series is not empty (applies to every chart type)
if (series.length === 0) {
return { _version: '1', success: false, error: 'At least one series is required.' };
}

// Custom chart plugins (agent/charts/) define their own data expectations,
// so skip the built-in shape validation and only check the plugin exists.
if (!isBuiltinChartType(chartType)) {
if (!chartPluginService.hasPlugin(context.projectId, chartType)) {
const available = chartPluginService
.getPlugins(context.projectId)
.map((p) => p.type)
.join(', ');
return {
_version: '1',
success: false,
error:
`Unknown chart type "${chartType}". Use a built-in type or a custom chart plugin defined in agent/charts/.` +
(available ? ` Available custom charts: ${available}.` : ''),
};
}
context.generatedArtifacts.charts.push(input);
return { _version: '1', success: true };
}

// Validate xAxisKey is provided for cartesian and polar charts
if (['bar', 'line', 'area', 'stacked_area', 'scatter', 'radar'].includes(chartType) && !xAxisKey) {
return { _version: '1', success: false, error: `xAxisKey is required for ${chartType} charts.` };
Expand All @@ -21,11 +48,6 @@ export default createTool<displayChart.Input, displayChart.Output>({
return { _version: '1', success: false, error: 'Pie charts require exactly one series.' };
}

// Validate series is not empty
if (series.length === 0) {
return { _version: '1', success: false, error: 'At least one series is required.' };
}

// Stacked charts require at least two series
if ((chartType === 'stacked_bar' || chartType === 'stacked_area') && series.length < 2) {
return {
Expand All @@ -35,8 +57,6 @@ export default createTool<displayChart.Input, displayChart.Output>({
};
}

// TODO: check that the chart is displayable and that the data is valid

context.generatedArtifacts.charts.push(input);
return { _version: '1', success: true };
},
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { authRoutes } from './routes/auth';
import { authErrorRedirectRoutes } from './routes/auth-error-redirect';
import { brandingRoutes } from './routes/branding';
import { chartRoutes } from './routes/chart';
import { chartPluginRoutes } from './routes/chart-plugins';
import { deployRoutes } from './routes/deploy';
import { embedStoryDownloadRoutes } from './routes/embed-story-download';
import { githubRoutes } from './routes/github';
Expand Down Expand Up @@ -161,6 +162,10 @@ app.register(chartRoutes, {
prefix: '/c',
});

app.register(chartPluginRoutes, {
prefix: '/api/charts',
});

app.register(imageRoutes, {
prefix: '/i',
});
Expand Down
27 changes: 26 additions & 1 deletion apps/backend/src/components/ai/system-prompt.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Block, Bold, Br, Link, List, ListItem, Location, Span, Title } from '../../lib/markdown';
import { Block, Bold, Br, Code, Link, List, ListItem, Location, Span, Title } from '../../lib/markdown';
import type { Skill } from '../../services/skill';
import { tokenCounter } from '../../services/token-counter';
import type { UserMemory } from '../../types/memory';
Expand All @@ -12,11 +12,18 @@ type Connection = {
database: string;
};

type CustomChart = {
type: string;
name: string;
description: string;
};

type SystemPromptProps = {
memories?: UserMemory[];
userRules?: string;
connections?: Connection[];
skills?: Skill[];
customCharts?: CustomChart[];
timezone?: string;
testMode?: boolean;
};
Expand All @@ -28,6 +35,7 @@ export function SystemPrompt({
userRules,
connections = [],
skills = [],
customCharts = [],
timezone,
testMode,
}: SystemPromptProps) {
Expand Down Expand Up @@ -101,6 +109,23 @@ 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>
{customCharts.length > 0 && (
<ListItem>
This project defines <Bold>custom chart plugins</Bold>. To use one, pass its name as the
display_chart <Bold>chart_type</Bold> (along with query_id, x_axis_key and series, just like a
built-in chart). Only use a custom chart when the user asks for it or it clearly fits better
than a built-in type. Available custom charts:
<List>
{customCharts.map((chart) => (
<ListItem key={chart.type}>
<Code>{chart.type}</Code>
{chart.name && chart.name !== chart.type ? ` (${chart.name})` : ''}
{chart.description ? `: ${chart.description}` : ''}
</ListItem>
))}
</List>
</ListItem>
)}
{hasClickHouse && (
<ListItem>
When available, use indexes.md to see how the table is ordered and indexed (ORDER BY, PRIMARY
Expand Down
35 changes: 33 additions & 2 deletions apps/backend/src/components/generate-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { buildChart, defaultColorFor, labelize } from '@nao/shared';
import { buildChart, defaultColorFor, isBuiltinChartType, labelize } from '@nao/shared';
import type { DateFormatSettings } from '@nao/shared/date';
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';
import { renderCustomChartImage } from '../utils/render-custom-chart';

export interface RenderChartInput {
/** Project that owns the chart; required to resolve custom chart plugins. */
projectId?: string;
config: Pick<displayChart.Input, 'chart_type' | 'x_axis_key' | 'x_axis_type' | 'series' | 'title'>;
data: Record<string, unknown>[];
width?: number;
Expand All @@ -21,8 +24,36 @@ export function generateChartImage(input: RenderChartInput): Buffer {
return svgToPng(svg);
}

/**
* Renders any chart type to a PNG. Built-in charts use the fast synchronous SVG
* path; custom chart plugins are rendered in a headless browser (which requires
* Chromium) and are therefore async.
*/
export async function renderChartImage(input: RenderChartInput): Promise<Buffer> {
if (isBuiltinChartType(input.config.chart_type)) {
return generateChartImage(input);
}
if (!input.projectId) {
throw new Error(`Cannot render custom chart "${input.config.chart_type}" without a project context.`);
}
return renderCustomChartImage({
projectId: input.projectId,
config: input.config,
data: input.data,
width: input.width,
height: input.height,
});
}

export function renderChartToSvg(input: RenderChartInput): string {
const { config, data, dateFormat } = input;

// Custom chart plugins render in the browser (vanilla JS / Recharts) and have
// no server-side SVG path, so PNG export is unavailable for them.
if (!isBuiltinChartType(config.chart_type)) {
throw new Error(`Server-side image generation is not supported for custom chart type "${config.chart_type}".`);
}

const width = input.width ?? 800;
const height = input.height ?? 500;
const margin = input.margin ?? { top: 10, right: 20, bottom: 5, left: 0 };
Expand All @@ -38,7 +69,7 @@ export function renderChartToSvg(input: RenderChartInput): string {

const chart = buildChart({
data,
chartType: config.chart_type,
chartType: config.chart_type as displayChart.ChartType,
xAxisKey: config.x_axis_key,
xAxisType: config.x_axis_type === 'number' ? 'number' : 'category',
series: config.series,
Expand Down
25 changes: 25 additions & 0 deletions apps/backend/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@ const envSchema = z.object({

NAO_DEFAULT_PROJECT_PATH: z.string().optional(),
NAO_MODE: z.enum(['self-hosted', 'cloud']).default('self-hosted'),
/**
* Hot reload of custom chart plugins (`agent/charts/`). Enabled by default
* for local single-project setups (i.e. `nao chat`); not needed for Docker
* deployments where the project folder is static. Set to "false" to disable.
*/
NAO_CHART_HOT_RELOAD: z.enum(['true', 'false']).optional(),
/**
* Base URL of the ESM CDN used to import React/ReactDOM/Recharts when
* rendering custom chart plugins to PNG in a headless browser. Point this at
* an internal mirror for offline/air-gapped deployments. Defaults to esm.sh.
*/
NAO_CHART_CDN_URL: z
.string()
.default('https://esm.sh')
.transform((val) => val.replace(/\/+$/, '')),
NAO_PROJECTS_DIR: z.string().default('./projects'),
NAO_CORE_VERSION: z.string().optional(),

Expand Down Expand Up @@ -151,6 +166,16 @@ export function __reloadEnvForTesting(): void {
export const isCloud = env.NAO_MODE === 'cloud';
export const isSelfHosted = env.NAO_MODE === 'self-hosted';

/**
* Whether to watch `agent/charts/` and push hot-reload events to the UI.
* Honors an explicit `NAO_CHART_HOT_RELOAD` flag; otherwise defaults on for
* local single-project (self-hosted with a default project path) setups.
*/
export const chartHotReloadEnabled =
env.NAO_CHART_HOT_RELOAD !== undefined
? env.NAO_CHART_HOT_RELOAD === 'true'
: isSelfHosted && Boolean(env.NAO_DEFAULT_PROJECT_PATH);

const normalizedBaseUrl = env.BETTER_AUTH_URL.replace(/\/+$/, '');
export const MCP_SERVER_URL = `${normalizedBaseUrl}/mcp`;

Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/handlers/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { noProjectMessage } from '../env';
import * as chatQueries from '../queries/chat.queries';
import * as imageQueries from '../queries/image.queries';
import { agentService } from '../services/agent';
import { chartPluginService } from '../services/chart-plugin.service';
import { mcpService } from '../services/mcp';
import { skillService } from '../services/skill';
import { AgentRequest, AgentRequestUserMessage, UIMessagePart } from '../types/chat';
Expand Down Expand Up @@ -58,6 +59,7 @@ export const handleAgentRoute = async (opts: HandleAgentMessageInput): Promise<H

await mcpService.initializeMcpState(projectId);
await skillService.initializeSkills(projectId);
await chartPluginService.initialize(projectId);

const agent = await agentService.create({ ...chat, userId, projectId }, model);

Expand Down
96 changes: 96 additions & 0 deletions apps/backend/src/routes/chart-plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { ChartPluginManifest } from '@nao/shared';
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
import type { FastifyRequest } from 'fastify';
import { z } from 'zod/v4';

import type { App } from '../app';
import { chartHotReloadEnabled } from '../env';
import { authMiddleware } from '../middleware/auth';
import { type ChartPluginReloadEvent, chartPluginService } from '../services/chart-plugin.service';
import { HandlerError } from '../utils/error';

const fileParamsSchema = z.object({
file: z.string().regex(/^[a-zA-Z0-9_-]+\.(js|mjs)$/, 'Invalid plugin file name'),
});

/**
* Serves custom chart plugins to the frontend (authenticated; the plugin set is
* scoped to the requester's project):
* - `GET /api/charts/plugins` — manifest of available plugins
* - `GET /api/charts/plugins/:file` — a plugin's ES module source
* - `GET /api/charts/events` — SSE stream of hot-reload events
*/
export const chartPluginRoutes = async (app: App) => {
app.addHook('preHandler', authMiddleware);

app.get('/plugins', async (request): Promise<ChartPluginManifest> => {
const projectId = await ensureInitialized(request);
const plugins = chartPluginService.getPlugins(projectId).map(({ type, name, description, url }) => ({
type,
name,
description,
url,
}));
return { plugins, version: chartPluginService.getVersion(projectId), hotReload: chartHotReloadEnabled };
});

app.get('/plugins/:file', { schema: { params: fileParamsSchema } }, async (request, reply) => {
const projectId = await ensureInitialized(request);
const type = request.params.file.replace(/\.[^.]+$/, '');
const source = chartPluginService.getPluginSource(projectId, type);
if (source === null) {
throw new HandlerError('NOT_FOUND', `Chart plugin "${type}" not found`);
}
return reply
.header('Content-Type', 'text/javascript; charset=utf-8')
.header('Cache-Control', 'no-store')
.send(source);
});

app.get('/events', async (request, reply) => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (!chartHotReloadEnabled) {
return reply.status(204).send();
}

const projectId = await ensureInitialized(request);

reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
reply.raw.write(`event: ready\ndata: ${chartPluginService.getVersion(projectId)}\n\n`);

const onReload = ({ projectId: changedProjectId, version }: ChartPluginReloadEvent) => {
if (changedProjectId !== projectId) {
return;
}
reply.raw.write(`event: reload\ndata: ${version}\n\n`);
};
chartPluginService.on('reload', onReload);

const heartbeat = setInterval(() => {
reply.raw.write(': ping\n\n');
}, 25_000);

request.raw.on('close', () => {
clearInterval(heartbeat);
chartPluginService.off('reload', onReload);
});

return reply.hijack();
});
};

/**
* Lazily initializes the plugin service against the authenticated request's
* project so plugin source is only served for projects the caller can access.
* Returns the resolved project id to scope every subsequent read.
*/
async function ensureInitialized(request: FastifyRequest): Promise<string> {
if (!request.project) {
throw new HandlerError('NOT_FOUND', 'No project configured for this user');
}
const projectId = request.project.id;
await chartPluginService.initialize(projectId);
return projectId;
}
Comment thread
cursor[bot] marked this conversation as resolved.
1 change: 1 addition & 0 deletions apps/backend/src/routes/embed-story-download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const embedStoryDownloadRoutes = async (app: App) => {
story.code,
story.queryData,
story.dateFormat,
story.projectId,
);

const safeName = filename.replace(/[^\x20-\x7E]+/g, '_');
Expand Down
16 changes: 15 additions & 1 deletion apps/backend/src/services/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
import { logger } from '../utils/logger';
import { addPromptCache } from '../utils/prompt-cache';
import { truncateMiddle } from '../utils/utils';
import { chartPluginService } from './chart-plugin.service';
import { compactionService } from './compaction';
import { hasFeature, LICENSE_FEATURES } from './license.service';
import { memoryService } from './memory';
Expand Down Expand Up @@ -514,8 +515,21 @@ class AgentManager {
const userRules = getUserRules(this._toolContext.projectFolder);
const connections = getConnections(this._toolContext.projectFolder);
const skills = skillService.getSkills();
const customCharts = chartPluginService.getPlugins(this.chat.projectId).map(({ type, name, description }) => ({
type,
name,
description,
}));
const basePrompt = renderToMarkdown(
SystemPrompt({ memories, userRules, connections, skills, timezone, testMode: this.chat.testMode }),
SystemPrompt({
memories,
userRules,
connections,
skills,
customCharts,
timezone,
testMode: this.chat.testMode,
}),
);
const renderedPrompt = provider
? renderToMarkdown(MessagingProviderSystemPrompt({ basePrompt, provider, chatUrl }))
Expand Down
Loading
Loading