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

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

Expand All @@ -11,7 +12,7 @@ export default createTool<displayChart.Input, displayChart.Output>({
outputSchema: displayChart.OutputSchema,

execute: async (input, context) => {
if (input.chart_type === 'table') {
if (displayChart.isTableInput(input)) {
const queryResult = await getQueryResult(context, input.query_id);
if (!queryResult) {
return {
Expand All @@ -25,6 +26,23 @@ export default createTool<displayChart.Input, displayChart.Output>({
}

const { chart_type: chartType, x_axis_key: xAxisKey, series } = input;
if (!displayChart.isBuiltinChartType(chartType)) {
if (!context.supportsCustomCharts) {
return {
_version: '1',
success: false,
error: 'Custom charts are only available in interactive web chats.',
};
}
if (!hasChartPlugin(context.projectFolder, chartType)) {
return {
_version: '1',
success: false,
error: `Custom chart "${chartType}" is not available in this project.`,
};
}
return { _version: '1', success: true };
}

// Validate xAxisKey is provided for cartesian and polar charts
if (displayChart.chartTypeRequiresXAxisKey(chartType) && !xAxisKey) {
Expand Down Expand Up @@ -56,7 +74,7 @@ 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);
context.generatedArtifacts.charts.push({ ...input, chart_type: chartType });
return { _version: '1', success: true };
},

Expand Down
44 changes: 44 additions & 0 deletions apps/backend/src/components/ai/system-prompt.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ChartPluginManifestEntry } from '@nao/shared';

import { Block, Bold, Br, Link, List, ListItem, Location, Span, Title } from '../../lib/markdown';
import type { Skill } from '../../services/skill';
import { tokenCounter } from '../../services/token-counter';
Expand All @@ -18,6 +20,7 @@ type SystemPromptProps = {
userRules?: string;
connections?: Connection[];
skills?: Skill[];
customCharts?: ChartPluginManifestEntry[];
/** Names of MCP servers the agent is allowed to call (tools discovered as on-disk specs). */
mcpServers?: string[];
timezone?: string;
Expand All @@ -31,6 +34,7 @@ export function SystemPrompt({
userRules,
connections = [],
skills = [],
customCharts = [],
mcpServers = [],
timezone,
testMode,
Expand Down Expand Up @@ -230,6 +234,8 @@ export function SystemPrompt({
</Block>
)}

{customCharts.length > 0 && <CustomChartsBlock charts={customCharts} />}

{mcpServers.length > 0 && <McpServersBlock servers={mcpServers} />}

{visibleMemories.length > 0 && <MemoryBlock memories={visibleMemories} />}
Expand All @@ -238,6 +244,44 @@ export function SystemPrompt({
);
}

const MAX_PROMPT_CUSTOM_CHARTS = 50;
const MAX_CHART_DESCRIPTION_LENGTH = 200;

function CustomChartsBlock({ charts }: { charts: ChartPluginManifestEntry[] }) {
const visibleCharts = charts.slice(0, MAX_PROMPT_CUSTOM_CHARTS);
const hiddenCount = charts.length - visibleCharts.length;
return (
<Block>
<Title level={2}>Custom charts</Title>
<Span>
The project provides the custom chart types below. Use them through display_chart only when their
description fits the request. They render in interactive web chats only, so do not use them in stories
or exports.
</Span>
<List>
{visibleCharts.map((chart) => (
<ListItem key={chart.type}>
<Bold>{chart.type}</Bold>
{chart.description ? `: ${truncateChartDescription(chart.description)}` : ''}
</ListItem>
))}
</List>
{hiddenCount > 0 && (
<Span>
And {hiddenCount} more custom chart type{hiddenCount === 1 ? '' : 's'} in agent/charts — list that
folder to discover the rest.
</Span>
)}
</Block>
);
}

function truncateChartDescription(description: string): string {
return description.length <= MAX_CHART_DESCRIPTION_LENGTH
? description
: `${description.slice(0, MAX_CHART_DESCRIPTION_LENGTH - 1).trimEnd()}…`;
}

function McpServersBlock({ servers }: { servers: string[] }) {
return (
<Block>
Expand Down
10 changes: 7 additions & 3 deletions apps/backend/src/components/generate-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { bucketPieData, buildChart, defaultColorFor, labelize } from '@nao/shared';
import type { DateFormatSettings } from '@nao/shared/date';
import type { displayChart } from '@nao/shared/tools';
import { displayChart } from '@nao/shared/tools';
import React from 'react';
import { renderToString } from 'react-dom/server';

Expand Down Expand Up @@ -44,6 +44,10 @@ export function generateChartImage(input: RenderChartInput): Buffer {

export function renderChartToSvg(input: RenderChartInput): string {
const { config, data, dateFormat } = input;
if (!displayChart.isBuiltinChartType(config.chart_type)) {
throw new Error(`Custom chart "${config.chart_type}" cannot be rendered on the server.`);
}
const chartType = 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 @@ -58,7 +62,7 @@ export function renderChartToSvg(input: RenderChartInput): string {
const labelFormatter = (value: string) => labelize(value, dateFormat);
const maxLabelWidth = estimateMaxLabelWidth(data, xAxisKey, dateFormat);

const isPie = config.chart_type === 'pie' || config.chart_type === 'donut';
const isPie = chartType === 'pie' || chartType === 'donut';

const chartData = isPie ? bucketPieData(data, xAxisKey, config.series[0]?.data_key ?? '') : data;

Expand All @@ -79,7 +83,7 @@ export function renderChartToSvg(input: RenderChartInput): string {

const chart = buildChart({
data: chartData,
chartType: config.chart_type,
chartType,
xAxisKey,
xAxisType: config.x_axis_type === 'number' ? 'number' : 'category',
series: config.series,
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/handlers/automation.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ async function finishAutomationRun(automation: AutomationWithSchedule, run: DBAu
: undefined,
{
excludeFollowUps: true,
supportsCustomCharts: false,
tools: ({ chat: agentChat, agentSettings, webTools }) =>
getTools(
agentSettings,
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/queries/chart-image.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function selectLatestDisplayChartTableFormats(
const formatsByQueryId: Record<string, displayChart.ColumnConditionalFormats> = {};
for (const row of rows) {
const parsed = displayChart.InputSchema.safeParse(row.toolInput);
if (!parsed.success || parsed.data.chart_type !== 'table') {
if (!parsed.success || !displayChart.isTableInput(parsed.data)) {
continue;
}
const { query_id: queryId, conditional_formats: conditionalFormats } = parsed.data;
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/routes/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const testRoutes = async (app: App) => {
chatId: '',
userId,
projectId: projectId,
supportsCustomCharts: false,
agentSettings: null,
envVars: {},
azureAccessToken: null,
Expand Down
16 changes: 14 additions & 2 deletions apps/backend/src/services/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
import { logger } from '../utils/logger';
import { addPromptCache } from '../utils/prompt-cache';
import { truncateMiddle } from '../utils/utils';
import { listChartPlugins } from './chart-plugin';
import { compactionService } from './compaction';
import { hasFeature, LICENSE_FEATURES } from './license.service';
import { mcpService } from './mcp';
Expand Down Expand Up @@ -142,6 +143,7 @@ export async function buildToolContext(opts: {
chatId: string;
agentSettings?: AgentSettings | null;
adminMode?: boolean;
supportsCustomCharts?: boolean;
}): Promise<ToolContext> {
const base = await _buildContextBase(opts);
return { ...base, chatId: opts.chatId, adminMode: opts.adminMode ?? false };
Expand All @@ -152,14 +154,15 @@ export async function buildMcpToolContext(opts: {
userId: string;
agentSettings?: AgentSettings | null;
}): Promise<McpToolContext> {
const base = await _buildContextBase(opts);
const base = await _buildContextBase({ ...opts, supportsCustomCharts: false });
return { ...base, chatId: null };
}

async function _buildContextBase(opts: {
projectId: string;
userId: string;
agentSettings?: AgentSettings | null;
supportsCustomCharts?: boolean;
}): Promise<Omit<ToolContext, 'chatId'>> {
const project = await projectQueries.retrieveProjectById(opts.projectId);
if (!project.path) {
Expand All @@ -175,6 +178,7 @@ async function _buildContextBase(opts: {
projectFolder: project.path,
userId: opts.userId,
projectId: opts.projectId,
supportsCustomCharts: opts.supportsCustomCharts !== false,
agentSettings,
envVars,
azureAccessToken,
Expand Down Expand Up @@ -231,6 +235,8 @@ export class AgentService {
* of the user's warehouse (see `ToolContext.adminMode`).
*/
adminMode?: boolean;
/** Enables project-defined charts that render only in the web client. */
supportsCustomCharts?: boolean;
} = {},
): Promise<AgentManager> {
this._disposeAgent(chat.id);
Expand All @@ -244,6 +250,7 @@ export class AgentService {
chat.userId,
agentSettings,
options.adminMode,
options.supportsCustomCharts,
);
const webTools = await this._resolveWebTools(chat.projectId, resolvedLlmSelectedModel.provider, agentSettings);
const resolveTools = options.tools ?? defaultAgentTools;
Expand Down Expand Up @@ -301,8 +308,9 @@ export class AgentService {
userId: string,
agentSettings: AgentSettings | null,
adminMode?: boolean,
supportsCustomCharts?: boolean,
): Promise<ToolContext> {
return buildToolContext({ projectId, userId, chatId, agentSettings, adminMode });
return buildToolContext({ projectId, userId, chatId, agentSettings, adminMode, supportsCustomCharts });
}

private _disposeAgent(chatId: string): void {
Expand Down Expand Up @@ -579,13 +587,17 @@ class AgentManager {
const userRules = getUserRules(this._toolContext.projectFolder);
const connections = getConnections(this._toolContext.projectFolder);
const skills = skillService.getSkills(this.chat.projectId);
const customCharts = this._toolContext.supportsCustomCharts
? listChartPlugins(this._toolContext.projectFolder)
: [];
const mcpServers = await mcpService.getEnabledServers(this.chat.projectId);
const basePrompt = renderToMarkdown(
SystemPrompt({
memories,
userRules,
connections,
skills,
customCharts,
mcpServers,
timezone,
testMode: this.chat.testMode,
Expand Down
Loading
Loading