diff --git a/apps/backend/src/agents/tools/display-chart.ts b/apps/backend/src/agents/tools/display-chart.ts index b46b2f821..28665699d 100644 --- a/apps/backend/src/agents/tools/display-chart.ts +++ b/apps/backend/src/agents/tools/display-chart.ts @@ -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({ @@ -11,6 +13,31 @@ export default createTool({ 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.` }; @@ -21,11 +48,6 @@ export default createTool({ 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 { @@ -35,8 +57,6 @@ export default createTool({ }; } - // TODO: check that the chart is displayable and that the data is valid - context.generatedArtifacts.charts.push(input); return { _version: '1', success: true }; }, diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index eb0fefb43..494fa3859 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -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'; @@ -161,6 +162,10 @@ app.register(chartRoutes, { prefix: '/c', }); +app.register(chartPluginRoutes, { + prefix: '/api/charts', +}); + app.register(imageRoutes, { prefix: '/i', }); diff --git a/apps/backend/src/components/ai/system-prompt.tsx b/apps/backend/src/components/ai/system-prompt.tsx index 390ec8ee7..efa54a11c 100644 --- a/apps/backend/src/components/ai/system-prompt.tsx +++ b/apps/backend/src/components/ai/system-prompt.tsx @@ -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'; @@ -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; }; @@ -28,6 +35,7 @@ export function SystemPrompt({ userRules, connections = [], skills = [], + customCharts = [], timezone, testMode, }: SystemPromptProps) { @@ -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. + {customCharts.length > 0 && ( + + This project defines custom chart plugins. To use one, pass its name as the + display_chart chart_type (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: + + {customCharts.map((chart) => ( + + {chart.type} + {chart.name && chart.name !== chart.type ? ` (${chart.name})` : ''} + {chart.description ? `: ${chart.description}` : ''} + + ))} + + + )} {hasClickHouse && ( When available, use indexes.md to see how the table is ordered and indexed (ORDER BY, PRIMARY diff --git a/apps/backend/src/components/generate-chart.tsx b/apps/backend/src/components/generate-chart.tsx index 77419681a..48656cb35 100644 --- a/apps/backend/src/components/generate-chart.tsx +++ b/apps/backend/src/components/generate-chart.tsx @@ -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; data: Record[]; width?: number; @@ -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 { + 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 }; @@ -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, diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 7dd22047d..2a5a09e3e 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -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(), @@ -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`; diff --git a/apps/backend/src/handlers/agent.ts b/apps/backend/src/handlers/agent.ts index 173785241..5f4c0d11b 100644 --- a/apps/backend/src/handlers/agent.ts +++ b/apps/backend/src/handlers/agent.ts @@ -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'; @@ -58,6 +59,7 @@ export const handleAgentRoute = async (opts: HandleAgentMessageInput): Promise { + app.addHook('preHandler', authMiddleware); + + app.get('/plugins', async (request): Promise => { + 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) => { + 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 { + 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; +} diff --git a/apps/backend/src/routes/embed-story-download.ts b/apps/backend/src/routes/embed-story-download.ts index 75a856f2e..30bece7ca 100644 --- a/apps/backend/src/routes/embed-story-download.ts +++ b/apps/backend/src/routes/embed-story-download.ts @@ -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, '_'); diff --git a/apps/backend/src/services/agent.ts b/apps/backend/src/services/agent.ts index e6c2358ff..09f70f3d0 100644 --- a/apps/backend/src/services/agent.ts +++ b/apps/backend/src/services/agent.ts @@ -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'; @@ -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 })) diff --git a/apps/backend/src/services/automation-tools.ts b/apps/backend/src/services/automation-tools.ts index 322290df2..3d2a4e0e9 100644 --- a/apps/backend/src/services/automation-tools.ts +++ b/apps/backend/src/services/automation-tools.ts @@ -2,13 +2,14 @@ import type { DateFormatSettings } from '@nao/shared/date'; import type { displayChart } from '@nao/shared/tools'; import { z } from 'zod/v4'; -import { generateChartImage } from '../components/generate-chart'; +import { renderChartImage } from '../components/generate-chart'; import * as automationQueries from '../queries/automation.queries'; import * as projectQueries from '../queries/project.queries'; import * as storyQueries from '../queries/story.queries'; import type { AutomationIntegrationConfig } from '../types/automation'; import type { EmailAttachment } from '../types/email'; import type { ToolContext } from '../types/tools'; +import { logger } from '../utils/logger'; import { buildDownloadResponse, type QueryDataMap } from '../utils/story-download'; import { createTool } from '../utils/tools'; import { emailService } from './email'; @@ -151,7 +152,7 @@ function createEmailTools(projectId: string, integrations: AutomationIntegration if (!emailService.isEnabled()) { throw new Error('SMTP email is not configured.'); } - const attachments = await buildGeneratedArtifactAttachments(projectId, context); + const { attachments, failedCharts } = await buildGeneratedArtifactAttachments(projectId, context); const content = appendInlineChartImages(html ?? `
${escapeHtml(text ?? '')}
`, attachments); const emailAttachments = attachments.map(toEmailAttachment); const resolvedSubject = config.subject ?? subject ?? 'nao automation report'; @@ -165,7 +166,12 @@ function createEmailTools(projectId: string, integrations: AutomationIntegration }), ), ); - return { ok: true, recipients: resolvedRecipients, attachments: attachments.map((a) => a.filename) }; + return { + ok: true, + recipients: resolvedRecipients, + attachments: attachments.map((a) => a.filename), + ...(failedCharts.length > 0 && { failedCharts }), + }; }, }), }; @@ -202,14 +208,21 @@ function createSlackTools( chatId, threadId: thread_id, }); - const attachments = (await buildGeneratedArtifactAttachments(projectId, context)).filter( - (attachment) => !uploadedArtifacts.has(attachment.filename), + const { attachments: allAttachments, failedCharts } = await buildGeneratedArtifactAttachments( + projectId, + context, ); + const attachments = allAttachments.filter((attachment) => !uploadedArtifacts.has(attachment.filename)); await slackService.uploadFiles(projectId, result.threadId, attachments.map(toSlackFileUpload)); for (const attachment of attachments) { uploadedArtifacts.add(attachment.filename); } - return { ok: true, ...result, attachments: attachments.map((attachment) => attachment.filename) }; + return { + ok: true, + ...result, + attachments: attachments.map((attachment) => attachment.filename), + ...(failedCharts.length > 0 && { failedCharts }), + }; }, }), }; @@ -251,23 +264,27 @@ function toSlackFileUpload(attachment: GeneratedArtifactAttachment): SlackFileUp }; } -async function buildGeneratedArtifactAttachments( - projectId: string, - context: ToolContext, -): Promise { +interface GeneratedArtifacts { + attachments: GeneratedArtifactAttachment[]; + /** Titles of charts that could not be rendered, surfaced back to the agent. */ + failedCharts: string[]; +} + +async function buildGeneratedArtifactAttachments(projectId: string, context: ToolContext): Promise { const displaySettings = await projectQueries.getDisplaySettings(projectId); const dateFormat = displaySettings.dateFormat ?? null; - const chartAttachments = await buildChartImageAttachments(context, dateFormat); + const { attachments: chartAttachments, failedCharts } = await buildChartImageAttachments(context, dateFormat); const storyAttachments = await buildStoryPdfAttachments(context, dateFormat); - return [...chartAttachments, ...storyAttachments]; + return { attachments: [...chartAttachments, ...storyAttachments], failedCharts }; } async function buildChartImageAttachments( context: ToolContext, dateFormat: DateFormatSettings | null, -): Promise { +): Promise<{ attachments: GeneratedArtifactAttachment[]; failedCharts: string[] }> { const charts = uniqueCharts(context.generatedArtifacts.charts); const attachments: GeneratedArtifactAttachment[] = []; + const failedCharts: string[] = []; for (const [index, chart] of charts.entries()) { const queryResult = await getQueryResult(context, chart.query_id); @@ -276,17 +293,30 @@ async function buildChartImageAttachments( } const title = chart.title ?? `Chart ${index + 1}`; - attachments.push({ - kind: 'chart', - title, - filename: sanitizeFilename(title, `chart-${index + 1}`, 'png'), - content: generateChartImage({ config: chart, data: queryResult.data, dateFormat }), - contentType: 'image/png', - cid: `automation-chart-${index}-${crypto.randomUUID()}@nao`, - }); + try { + const content = await renderChartImage({ + projectId: context.projectId, + config: chart, + data: queryResult.data, + dateFormat, + }); + attachments.push({ + kind: 'chart', + title, + filename: sanitizeFilename(title, `chart-${index + 1}`, 'png'), + content, + contentType: 'image/png', + cid: `automation-chart-${index}-${crypto.randomUUID()}@nao`, + }); + } catch (error) { + logger.error(`Failed to render chart "${title}" for automation attachment: ${String(error)}`, { + source: 'system', + }); + failedCharts.push(title); + } } - return attachments; + return { attachments, failedCharts }; } async function buildStoryPdfAttachments( @@ -303,7 +333,14 @@ async function buildStoryPdfAttachments( } const queryData = await getStoryQueryData(context, latest.code); - const pdf = await buildDownloadResponse('pdf', latest.title, latest.code, queryData, dateFormat); + const pdf = await buildDownloadResponse( + 'pdf', + latest.title, + latest.code, + queryData, + dateFormat, + context.projectId, + ); return { kind: 'story', title: latest.title, diff --git a/apps/backend/src/services/chart-plugin.service.ts b/apps/backend/src/services/chart-plugin.service.ts new file mode 100644 index 000000000..21537923c --- /dev/null +++ b/apps/backend/src/services/chart-plugin.service.ts @@ -0,0 +1,290 @@ +import { EventEmitter } from 'node:events'; +import { existsSync, readdirSync, readFileSync, realpathSync, statSync, watch } from 'node:fs'; +import { join, sep } from 'node:path'; + +import type { ChartPluginManifestEntry } from '@nao/shared'; +import { debounce } from '@nao/shared'; + +import { chartHotReloadEnabled } from '../env'; +import * as projectQueries from '../queries/project.queries'; +import { logger } from '../utils/logger'; + +/** Folder (relative to the project root) where custom chart plugins live. */ +export const CHART_PLUGINS_DIR = join('agent', 'charts'); + +/** File extensions a chart plugin can use — browser-importable ES modules only. */ +const PLUGIN_EXTENSIONS = ['.js', '.mjs']; + +/** URL prefix the frontend imports plugin modules from. */ +const PLUGIN_URL_PREFIX = '/api/charts/plugins'; + +export interface ChartPlugin extends ChartPluginManifestEntry { + /** Absolute path of the plugin file on disk. */ + filePath: string; + fileName: string; +} + +/** Payload emitted on the `reload` event when a project's plugin files change. */ +export interface ChartPluginReloadEvent { + projectId: string; + version: number; +} + +/** Per-project plugin discovery state. Keeping state keyed by project avoids a + * process-global singleton that concurrent requests for different projects + * could switch between init and read (cross-tenant leak). */ +interface ProjectPluginState { + projectPath: string; + pluginsFolderPath: string; + /** Canonical (symlink-resolved) plugins folder, used for containment checks. */ + realFolderPath: string | null; + plugins: ChartPlugin[]; + version: number; + watcher: ReturnType | null; + debouncedReload: () => void; +} + +/** + * Discovers and serves custom chart plugins ("vibe coded charts") from a + * project's `agent/charts/` folder. State is kept per project so plugin + * metadata/source can never leak across projects, and plugin files are + * containment-checked (symlink-resolved) before being read. + */ +class ChartPluginService extends EventEmitter { + private _byProject = new Map(); + private _initPromises = new Map>(); + + constructor() { + super(); + // One `reload` listener is added per open SSE client; lift the default cap. + this.setMaxListeners(0); + } + + /** + * Discovers plugins for `projectId`. Idempotent per project and retries + * after a transient failure rather than permanently disabling the service. + */ + public async initialize(projectId: string | undefined): Promise { + if (!projectId) { + return; + } + const existing = this._initPromises.get(projectId); + if (existing) { + return existing; + } + const promise = this._initialize(projectId).catch((error) => { + // Allow a later call to retry after a transient failure. + this._initPromises.delete(projectId); + throw error; + }); + this._initPromises.set(projectId, promise); + return promise; + } + + private async _initialize(projectId: string): Promise { + const project = await projectQueries.retrieveProjectById(projectId); + const state = this._byProject.get(projectId) ?? this._createState(projectId); + this._byProject.set(projectId, state); + + state.projectPath = project.path || ''; + state.pluginsFolderPath = state.projectPath ? join(state.projectPath, CHART_PLUGINS_DIR) : ''; + this._loadPlugins(state); + + if (chartHotReloadEnabled && state.pluginsFolderPath) { + this._setupFileWatcher(state); + } + } + + private _createState(projectId: string): ProjectPluginState { + const state: ProjectPluginState = { + projectPath: '', + pluginsFolderPath: '', + realFolderPath: null, + plugins: [], + version: 0, + watcher: null, + debouncedReload: () => {}, + }; + state.debouncedReload = debounce(() => { + this._loadPlugins(state); + state.version += 1; + this.emit('reload', { projectId, version: state.version } satisfies ChartPluginReloadEvent); + }, 500); + return state; + } + + public getPlugins(projectId: string): ChartPlugin[] { + return this._byProject.get(projectId)?.plugins ?? []; + } + + public getVersion(projectId: string): number { + return this._byProject.get(projectId)?.version ?? 0; + } + + public hasPlugin(projectId: string, type: string): boolean { + return this.getPlugins(projectId).some((plugin) => plugin.type === type); + } + + /** Returns the raw module source for a plugin type, or null if unknown. */ + public getPluginSource(projectId: string, type: string): string | null { + const state = this._byProject.get(projectId); + if (!state) { + return null; + } + const plugin = state.plugins.find((p) => p.type === type); + if (!plugin) { + return null; + } + return this._readContainedFile(state, plugin.filePath, type); + } + + private _loadPlugins(state: ProjectPluginState): void { + try { + if (!state.pluginsFolderPath || !existsSync(state.pluginsFolderPath)) { + state.realFolderPath = null; + state.plugins = []; + return; + } + + if (!statSync(state.pluginsFolderPath).isDirectory()) { + logger.error(`Chart plugins path is not a directory: ${state.pluginsFolderPath}`, { source: 'agent' }); + state.realFolderPath = null; + state.plugins = []; + return; + } + + // Reject a plugins folder that (via symlink) escapes the project root, + // otherwise files outside the project could be discovered and served. + const realProjectPath = realpathSync(state.projectPath); + const realFolderPath = realpathSync(state.pluginsFolderPath); + if (!isContained(realProjectPath, realFolderPath)) { + logger.error( + `Chart plugins folder resolves outside the project root; ignoring: ${state.pluginsFolderPath}`, + { + source: 'agent', + }, + ); + state.realFolderPath = null; + state.plugins = []; + return; + } + + state.realFolderPath = realFolderPath; + const files = readdirSync(state.pluginsFolderPath).filter((file) => + PLUGIN_EXTENSIONS.some((ext) => file.endsWith(ext)), + ); + state.plugins = files + .map((file) => this._readPlugin(state, file)) + .filter((plugin): plugin is ChartPlugin => plugin !== null) + .sort((a, b) => a.type.localeCompare(b.type)); + } catch (error) { + logger.error(`Failed to load chart plugins: ${String(error)}`, { source: 'agent' }); + state.realFolderPath = null; + state.plugins = []; + } + } + + private _readPlugin(state: ProjectPluginState, fileName: string): ChartPlugin | null { + const filePath = join(state.pluginsFolderPath, fileName); + const type = fileName.replace(/\.[^.]+$/, ''); + + // Skip plugin files that resolve (via symlink) outside the plugins folder. + const source = this._readContainedFile(state, filePath, type); + if (source === null) { + return null; + } + + let name = humanize(type); + let description = ''; + const meta = extractMeta(source); + name = meta.name || name; + description = meta.description || ''; + + return { + type, + name, + description, + url: `${PLUGIN_URL_PREFIX}/${type}.js`, + filePath, + fileName, + }; + } + + /** + * Reads a file only if its canonical path is contained within the project's + * plugins folder, defeating symlink traversal that could otherwise disclose + * arbitrary host files through the plugin-source endpoint. + */ + private _readContainedFile(state: ProjectPluginState, filePath: string, type: string): string | null { + try { + if (!state.realFolderPath) { + return null; + } + const realFile = realpathSync(filePath); + if (!isContained(state.realFolderPath, realFile)) { + logger.error(`Chart plugin "${type}" resolves outside the plugins folder; refusing to read.`, { + source: 'agent', + }); + return null; + } + return readFileSync(realFile, 'utf8'); + } catch (error) { + logger.error(`Failed to read chart plugin "${type}": ${String(error)}`, { source: 'agent' }); + return null; + } + } + + private _setupFileWatcher(state: ProjectPluginState): void { + state.watcher?.close(); + state.watcher = null; + if (!state.pluginsFolderPath || !existsSync(state.pluginsFolderPath)) { + return; + } + try { + state.watcher = watch(state.pluginsFolderPath, { recursive: true }, (eventType) => { + if (eventType === 'change' || eventType === 'rename') { + state.debouncedReload(); + } + }); + } catch (error) { + logger.error(`Chart plugins file watcher setup failed: ${String(error)}`, { source: 'agent' }); + } + } +} + +/** True when `file` is the directory itself or sits inside it. */ +function isContained(dir: string, file: string): boolean { + return file === dir || file.startsWith(dir + sep); +} + +/** Turns a plugin file name into a readable default name ("revenue_bubble" -> "Revenue Bubble"). */ +function humanize(value: string): string { + return value + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()) + .trim(); +} + +/** + * Extracts `name` and `description` from a plugin's `export const meta = {...}` + * without executing the module. Tolerates single/double/back-tick quotes. + */ +function extractMeta(source: string): { name?: string; description?: string } { + const metaMatch = source.match(/export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/); + if (!metaMatch) { + return {}; + } + const body = metaMatch[1]; + return { + name: extractStringField(body, 'name'), + description: extractStringField(body, 'description'), + }; +} + +function extractStringField(body: string, field: string): string | undefined { + // Matches `field: '...'`, `field: "..."` or `field: ` + backtick string. + const match = body.match(new RegExp(field + '\\s*:\\s*([\'"`])((?:\\\\.|(?!\\1).)*)\\1')); + return match ? match[2].replace(/\\(['"`])/g, '$1') : undefined; +} + +export const chartPluginService = new ChartPluginService(); diff --git a/apps/backend/src/services/slack.ts b/apps/backend/src/services/slack.ts index f239d3f22..52fd4bb4d 100644 --- a/apps/backend/src/services/slack.ts +++ b/apps/backend/src/services/slack.ts @@ -8,7 +8,7 @@ import { type ChatPostMessageArguments, WebClient } from '@slack/web-api'; import { InferUIMessageChunk, readUIMessageStream } from 'ai'; import { Card, Chat, deriveChannelId, Message, SentMessage, Thread, ThreadImpl } from 'chat'; -import { generateChartImage } from '../components/generate-chart'; +import { renderChartImage } from '../components/generate-chart'; import * as chartImageQueries from '../queries/chart-image'; import * as chatQueries from '../queries/chat.queries'; import * as feedbackQueries from '../queries/feedback.queries'; @@ -734,7 +734,8 @@ class ProjectSlackBot { } try { const displaySettings = await projectQueries.getDisplaySettings(this.projectId); - const png = generateChartImage({ + const png = await renderChartImage({ + projectId: this.projectId, config: part.input, data: sqlOutput.rows, dateFormat: displaySettings.dateFormat, @@ -757,6 +758,12 @@ class ProjectSlackBot { source: 'system', context: { chatId: ctx.chatId, toolCallId: part.toolCallId }, }); + // Avoid re-attempting (and re-failing) the same chart on later stream chunks. + state.renderedChartIds.add(part.toolCallId); + const chartName = part.input.title ? `"${part.input.title}"` : 'a chart'; + ctx.textBlockIndex = -1; + ctx.blocks.push(createTextBlock(`_⚠️ Could not render ${chartName} as an image._`)); + await ctx.convMessage?.edit(Card({ children: ctx.blocks })); } } diff --git a/apps/backend/src/services/teams.ts b/apps/backend/src/services/teams.ts index dda93a5e9..dd9d417b8 100644 --- a/apps/backend/src/services/teams.ts +++ b/apps/backend/src/services/teams.ts @@ -8,7 +8,7 @@ import type { LlmSelectedModel } from '@nao/shared/types'; import { InferUIMessageChunk, readUIMessageStream } from 'ai'; import { Card, Chat, Message, SentMessage, Thread } from 'chat'; -import { generateChartImage } from '../components/generate-chart'; +import { renderChartImage } from '../components/generate-chart'; import * as chartImageQueries from '../queries/chart-image'; import * as chatQueries from '../queries/chat.queries'; import * as feedbackQueries from '../queries/feedback.queries'; @@ -363,7 +363,8 @@ class TeamsService { } try { const displaySettings = await projectQueries.getDisplaySettings(this._projectId); - const png = generateChartImage({ + const png = await renderChartImage({ + projectId: this._projectId, config: part.input, data: sqlOutput.rows, dateFormat: displaySettings.dateFormat, diff --git a/apps/backend/src/services/telegram.ts b/apps/backend/src/services/telegram.ts index dc9a2244a..e8baef473 100644 --- a/apps/backend/src/services/telegram.ts +++ b/apps/backend/src/services/telegram.ts @@ -5,7 +5,7 @@ import type { LlmSelectedModel } from '@nao/shared/types'; import { InferUIMessageChunk, readUIMessageStream } from 'ai'; import { Card, CardElement, Chat, Message, SentMessage, Thread } from 'chat'; -import { generateChartImage } from '../components/generate-chart'; +import { renderChartImage } from '../components/generate-chart'; import * as chatQueries from '../queries/chat.queries'; import * as feedbackQueries from '../queries/feedback.queries'; import * as projectQueries from '../queries/project.queries'; @@ -376,7 +376,8 @@ class TelegramService { } try { const displaySettings = await projectQueries.getDisplaySettings(this._projectId); - const png = generateChartImage({ + const png = await renderChartImage({ + projectId: this._projectId, config: part.input, data: sqlOutput.rows, dateFormat: displaySettings.dateFormat, diff --git a/apps/backend/src/services/whatsapp.ts b/apps/backend/src/services/whatsapp.ts index d55291f4c..5f7cb1ef5 100644 --- a/apps/backend/src/services/whatsapp.ts +++ b/apps/backend/src/services/whatsapp.ts @@ -6,7 +6,7 @@ import type { LlmSelectedModel } from '@nao/shared/types'; import { InferUIMessageChunk, readUIMessageStream } from 'ai'; import { Attachment, Chat, Message, Thread } from 'chat'; -import { generateChartImage } from '../components/generate-chart'; +import { renderChartImage } from '../components/generate-chart'; import { env } from '../env'; import * as chartImageQueries from '../queries/chart-image'; import * as chatQueries from '../queries/chat.queries'; @@ -524,7 +524,8 @@ class WhatsappService { } try { const displaySettings = await projectQueries.getDisplaySettings(this._projectId); - const png = generateChartImage({ + const png = await renderChartImage({ + projectId: this._projectId, config: part.input, data: sqlOutput.rows, dateFormat: displaySettings.dateFormat, diff --git a/apps/backend/src/trpc/embed.routes.ts b/apps/backend/src/trpc/embed.routes.ts index 7c5bae8dd..2ae0fb0c4 100644 --- a/apps/backend/src/trpc/embed.routes.ts +++ b/apps/backend/src/trpc/embed.routes.ts @@ -89,6 +89,13 @@ export const embedRoutes = router({ chatId: story.chatId, metadata: { type: 'download', format: input.format, title: story.title }, }); - return buildDownloadResponse(input.format, story.title, story.code, story.queryData, story.dateFormat); + return buildDownloadResponse( + input.format, + story.title, + story.code, + story.queryData, + story.dateFormat, + story.projectId, + ); }), }); diff --git a/apps/backend/src/trpc/shared-story.routes.ts b/apps/backend/src/trpc/shared-story.routes.ts index ca2439d20..2b827a4cb 100644 --- a/apps/backend/src/trpc/shared-story.routes.ts +++ b/apps/backend/src/trpc/shared-story.routes.ts @@ -328,6 +328,7 @@ export const sharedStoryRoutes = { version.code, queryData, displaySettings?.dateFormat, + shared.projectId, ); }), }; diff --git a/apps/backend/src/trpc/story.routes.ts b/apps/backend/src/trpc/story.routes.ts index 77b60aa6e..63bf05286 100644 --- a/apps/backend/src/trpc/story.routes.ts +++ b/apps/backend/src/trpc/story.routes.ts @@ -379,6 +379,7 @@ export const storyRoutes = { story.code, cache?.queryData ?? null, displaySettings?.dateFormat, + story.projectId, ); }), @@ -433,6 +434,7 @@ export const storyRoutes = { version.code, queryData, displaySettings?.dateFormat, + projectId, ); }), }; diff --git a/apps/backend/src/utils/headless-browser.ts b/apps/backend/src/utils/headless-browser.ts new file mode 100644 index 000000000..0f4d7b3ec --- /dev/null +++ b/apps/backend/src/utils/headless-browser.ts @@ -0,0 +1,79 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import type { Browser } from 'puppeteer-core'; + +/** + * Shared headless Chromium instance (via puppeteer-core) used for server-side + * rendering tasks such as story PDF export and custom chart image generation. + * + * The browser is launched lazily, reused across calls, and closed on process + * exit. puppeteer-core is imported dynamically so the rest of the backend keeps + * working when Chrome/Chromium is not installed. + */ + +let browserPromise: Promise | null = null; + +async function loadPuppeteer() { + try { + return await import('puppeteer-core'); + } catch { + throw new Error( + 'puppeteer-core is not available. Headless rendering requires puppeteer-core and a Chrome/Chromium installation.', + ); + } +} + +export async function getBrowser(): Promise { + if (browserPromise) { + const browser = await browserPromise; + if (browser.connected) { + return browser; + } + await browser.close().catch(() => {}); + } + const puppeteer = await loadPuppeteer(); + browserPromise = puppeteer.default.launch({ + headless: true, + executablePath: findChromePath(), + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'], + }); + return browserPromise; +} + +function findChromePath(): string { + const candidates = [ + process.env.CHROME_PATH, + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/google-chrome', + ]; + + for (const candidate of candidates) { + if (candidate && existsSync(candidate)) { + return candidate; + } + } + + try { + return execSync('which chromium || which chromium-browser || which google-chrome', { + encoding: 'utf-8', + }).trim(); + } catch { + throw new Error('Chrome/Chromium not found. Install chromium or set the CHROME_PATH environment variable.'); + } +} + +export async function closeBrowser(): Promise { + if (!browserPromise) { + return; + } + const browser = await browserPromise.catch(() => null); + browserPromise = null; + await browser?.close().catch(() => {}); +} + +// Only async-close on signals: the synchronous `exit` event cannot await async +// work, and puppeteer already kills its launched browser on process exit. +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => void closeBrowser()); +} diff --git a/apps/backend/src/utils/private-network.ts b/apps/backend/src/utils/private-network.ts new file mode 100644 index 000000000..cf361e7c0 --- /dev/null +++ b/apps/backend/src/utils/private-network.ts @@ -0,0 +1,204 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; + +/** + * SSRF guard helpers used when executing untrusted project content (custom + * chart plugins) inside a server-side headless browser. They block requests + * that target loopback/private/link-local/reserved addresses — in particular + * the cloud metadata endpoint (169.254.169.254) and internal services. + */ + +/** True when `ip` is a loopback, private, link-local, or otherwise reserved address. */ +export function isPrivateOrReservedIp(ip: string): boolean { + const version = isIP(ip); + if (version === 4) { + return isPrivateIpv4(ip); + } + if (version === 6) { + return isPrivateIpv6(ip); + } + // Not a literal IP — treat as unknown/unsafe. + return true; +} + +/** + * Resolves `hostname` to a single public IP literal to connect to, or returns + * `null` when the host is empty, a `localhost` alias, an unsafe literal IP, or + * resolves (via DNS) to any private/reserved address. + * + * Callers MUST connect to the returned IP rather than re-resolving `hostname` + * themselves: pinning the validated address is what closes the DNS-rebinding + * (TOCTOU) gap where a name resolves to a public IP during the check and to a + * private IP at connection time. Fails closed on lookup errors. + */ +export async function resolvePublicAddress(hostname: string): Promise { + if (!hostname) { + return null; + } + const normalized = stripBrackets(hostname).toLowerCase(); + if (normalized === 'localhost' || normalized.endsWith('.localhost')) { + return null; + } + if (isIP(normalized)) { + return isPrivateOrReservedIp(normalized) ? null : normalized; + } + try { + const records = await lookup(normalized, { all: true }); + if (records.length === 0) { + return null; + } + if (records.some((record) => isPrivateOrReservedIp(record.address))) { + return null; + } + return records[0].address; + } catch { + return null; + } +} + +/** + * Resolves `hostname` and returns true if it is empty, an unsafe literal IP, or + * resolves (via DNS) to any private/reserved address. Fails closed on lookup + * errors so an attacker cannot bypass the guard by forcing resolution to fail. + */ +export async function resolvesToPrivateHost(hostname: string): Promise { + return (await resolvePublicAddress(hostname)) === null; +} + +function stripBrackets(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; +} + +function isPrivateIpv4(ip: string): boolean { + const octets = ip.split('.').map(Number); + if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) { + return true; + } + const [a, b] = octets; + if (a === 0 || a === 10 || a === 127) { + return true; // "this" network, private, loopback + } + if (a === 100 && b >= 64 && b <= 127) { + return true; // 100.64.0.0/10 carrier-grade NAT + } + if (a === 169 && b === 254) { + return true; // 169.254.0.0/16 link-local (cloud metadata) + } + if (a === 172 && b >= 16 && b <= 31) { + return true; // 172.16.0.0/12 + } + if (a === 192 && b === 168) { + return true; // 192.168.0.0/16 + } + if (a === 198 && (b === 18 || b === 19)) { + return true; // 198.18.0.0/15 benchmarking + } + if (a >= 224) { + return true; // multicast + reserved + } + return false; +} + +function isPrivateIpv6(ip: string): boolean { + const groups = expandIpv6(ip.toLowerCase()); + if (!groups) { + return true; // unparseable — treat as unsafe + } + + const upper96Zero = groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0; + // IPv4-mapped (::ffff:a.b.c.d and its hex/uncompressed forms) — the routed + // destination is the embedded IPv4, so validate that instead. + if (upper96Zero && groups[5] === 0xffff) { + return isPrivateIpv4(embeddedIpv4(groups)); + } + // IPv4-compatible (::a.b.c.d, deprecated), loopback (::1) and unspecified (::): + // all map to or behave like low IPv4 space — block conservatively. + if (upper96Zero && groups[5] === 0) { + return true; + } + // NAT64 well-known prefix 64:ff9b::/96 also embeds an IPv4 destination. + if ( + groups[0] === 0x64 && + groups[1] === 0xff9b && + groups[2] === 0 && + groups[3] === 0 && + groups[4] === 0 && + groups[5] === 0 + ) { + return isPrivateIpv4(embeddedIpv4(groups)); + } + + const first = groups[0]; + if ((first & 0xfe00) === 0xfc00) { + return true; // fc00::/7 unique local + } + if ((first & 0xffc0) === 0xfe80) { + return true; // fe80::/10 link-local + } + if ((first & 0xff00) === 0xff00) { + return true; // ff00::/8 multicast + } + return false; +} + +/** Renders the last 32 bits of an expanded IPv6 address as a dotted IPv4 string. */ +function embeddedIpv4(groups: number[]): string { + const a = (groups[6] >> 8) & 0xff; + const b = groups[6] & 0xff; + const c = (groups[7] >> 8) & 0xff; + const d = groups[7] & 0xff; + return `${a}.${b}.${c}.${d}`; +} + +/** + * Expands any valid IPv6 textual form (compressed `::`, uncompressed, or with a + * trailing dotted-quad IPv4) into its eight 16-bit groups. Returns `null` when + * the input is not a well-formed IPv6 address. + */ +function expandIpv6(address: string): number[] | null { + let work = address.split('%')[0]; // drop any zone identifier + + // Fold a trailing dotted-quad IPv4 (e.g. ::ffff:127.0.0.1) into two hextets. + const dotted = work.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (dotted) { + const octets = dotted[1].split('.').map(Number); + if (octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) { + return null; + } + const high = ((octets[0] << 8) | octets[1]).toString(16); + const low = ((octets[2] << 8) | octets[3]).toString(16); + work = work.slice(0, work.length - dotted[1].length) + `${high}:${low}`; + } + + const halves = work.split('::'); + if (halves.length > 2) { + return null; + } + const parseSide = (side: string): number[] | null => { + if (side === '') { + return []; + } + const parts = side.split(':'); + const values = parts.map((part) => (/^[0-9a-f]{1,4}$/.test(part) ? parseInt(part, 16) : NaN)); + return values.some((value) => Number.isNaN(value)) ? null : values; + }; + + const head = parseSide(halves[0]); + const tail = halves.length === 2 ? parseSide(halves[1]) : []; + if (!head || !tail) { + return null; + } + + let groups: number[]; + if (halves.length === 2) { + const missing = 8 - head.length - tail.length; + if (missing < 1) { + return null; // `::` must stand in for at least one zero group + } + groups = [...head, ...new Array(missing).fill(0), ...tail]; + } else { + groups = head; + } + + return groups.length === 8 ? groups : null; +} diff --git a/apps/backend/src/utils/render-custom-chart.ts b/apps/backend/src/utils/render-custom-chart.ts new file mode 100644 index 000000000..f56cd86a5 --- /dev/null +++ b/apps/backend/src/utils/render-custom-chart.ts @@ -0,0 +1,177 @@ +import { DEFAULT_COLORS } from '@nao/shared'; +import type { displayChart } from '@nao/shared/tools'; +import type { HTTPRequest, Page } from 'puppeteer-core'; + +import { env } from '../env'; +import { chartPluginService } from '../services/chart-plugin.service'; +import { getBrowser } from './headless-browser'; +import { logger } from './logger'; +import { getSsrfProxyUrl } from './ssrf-proxy'; + +export interface RenderCustomChartInput { + /** Project that owns the chart; scopes which plugin set is used. */ + projectId: string; + config: Pick; + data: Record[]; + width?: number; + height?: number; +} + +const DEFAULT_WIDTH = 800; +const DEFAULT_HEIGHT = 500; +const RENDER_TIMEOUT_MS = 15000; + +/** + * Renders a custom chart plugin ("vibe coded chart") to a PNG for headless + * contexts (automations, Slack, Telegram, ...). + * + * Custom plugins are browser-only ES modules with no server-side SVG path, so + * we execute them in a headless Chromium page — mirroring the frontend's + * `CustomChart` render context — and screenshot the result. + */ +export async function renderCustomChartImage(input: RenderCustomChartInput): Promise { + await chartPluginService.initialize(input.projectId); + const source = chartPluginService.getPluginSource(input.projectId, input.config.chart_type); + if (!source) { + throw new Error(`Custom chart plugin "${input.config.chart_type}" was not found.`); + } + + const width = input.width ?? DEFAULT_WIDTH; + const height = input.height ?? DEFAULT_HEIGHT; + const html = buildChartHtml(source, input, width, height); + + const browser = await getBrowser(); + const proxyServer = await getSsrfProxyUrl(); + // Route all plugin egress through the SSRF proxy in an isolated context. + // `<-loopback>` cancels Chromium's implicit loopback bypass so even + // localhost/127.0.0.1 targets are forced through (and blocked by) the proxy. + const context = await browser.createBrowserContext({ proxyServer, proxyBypassList: ['<-loopback>'] }); + const page = await context.newPage(); + try { + await restrictNetworkEgress(page); + await page.setViewport({ width, height, deviceScaleFactor: 2 }); + await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: RENDER_TIMEOUT_MS }); + // The page sets `data-rendered` only after the plugin (and its CDN imports) finish. + const element = await page.waitForSelector('#chart[data-rendered="true"]', { timeout: RENDER_TIMEOUT_MS }); + if (!element) { + throw new Error('Custom chart did not finish rendering.'); + } + const error = await page.$eval('#chart', (el) => el.getAttribute('data-error')); + if (error) { + throw new Error(`Custom chart render failed: ${error}`); + } + const screenshot = await element.screenshot({ type: 'png' }); + return Buffer.from(screenshot); + } finally { + await page.close().catch(() => {}); + await context.close().catch(() => {}); + } +} + +/** + * Restricts what the (untrusted) plugin code can reach from the headless + * browser. Only `data:`/`blob:`/`about:` resources and `http(s)` requests are + * allowed through; every other scheme (e.g. `file:`, `ftp:`) is aborted. + * + * Egress to loopback/private/link-local/reserved addresses — e.g. the cloud + * metadata endpoint or internal services — is blocked by the SSRF proxy this + * page's context is bound to, which resolves and pins each host to a validated + * public IP. Doing the IP check at the proxy (rather than here, then calling + * `request.continue()`) is what removes the DNS-rebinding gap: Chromium never + * resolves hosts itself, so a name cannot flip to an internal IP after the + * check. + */ +async function restrictNetworkEgress(page: Page): Promise { + await page.setRequestInterception(true); + page.on('request', (request: HTTPRequest) => { + void guardRequest(request); + }); +} + +async function guardRequest(request: HTTPRequest): Promise { + try { + const scheme = new URL(request.url()).protocol; + if ( + scheme === 'data:' || + scheme === 'blob:' || + scheme === 'about:' || + scheme === 'http:' || + scheme === 'https:' + ) { + await request.continue(); + return; + } + await abort(request, request.url()); + } catch (error) { + // Fail closed: if we can't evaluate the request, block it. + await abort(request, request.url(), error); + } +} + +async function abort(request: HTTPRequest, url: string, error?: unknown): Promise { + logger.warn(`Blocked custom chart plugin network request to ${url}` + (error ? `: ${String(error)}` : ''), { + source: 'system', + }); + await request.abort('blockedbyclient').catch(() => {}); +} + +function buildChartHtml(source: string, input: RenderCustomChartInput, width: number, height: number): string { + const CDN = env.NAO_CHART_CDN_URL; + const context = { + data: input.data, + config: { + chartType: input.config.chart_type, + xAxisKey: input.config.x_axis_key, + xAxisType: input.config.x_axis_type ?? null, + series: input.config.series, + title: input.config.title, + }, + theme: 'light', + colors: DEFAULT_COLORS, + }; + + return ` + + + + + + +
+ + +`; +} + +/** Serializes a value into a script-safe JS literal (escapes `<` and line separators). */ +function js(value: unknown): string { + return JSON.stringify(value) + .replace(/ | null = null; +let proxyServer: http.Server | null = null; + +export async function getSsrfProxyUrl(): Promise { + if (!proxyPromise) { + proxyPromise = startProxy(); + } + return proxyPromise; +} + +export async function closeSsrfProxy(): Promise { + const server = proxyServer; + proxyServer = null; + proxyPromise = null; + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +async function startProxy(): Promise { + const server = http.createServer(handlePlainHttp); + server.on('connect', handleConnect); + server.on('clientError', (_error, socket) => socket.destroy()); + + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + + proxyServer = server; + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to determine SSRF proxy address.'); + } + return `http://127.0.0.1:${address.port}`; +} + +/** Tunnels HTTPS (CONNECT) requests to the pinned IP; TLS stays end-to-end. */ +function handleConnect(req: http.IncomingMessage, clientSocket: net.Socket, head: Buffer): void { + const { host, port } = parseAuthority(req.url ?? '', 443); + void (async () => { + const ip = host ? await resolvePublicAddress(host) : null; + if (!ip) { + blocked(host); + clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); + return; + } + const upstream = net.connect(port, ip, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length > 0) { + upstream.write(head); + } + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + upstream.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => upstream.destroy()); + })(); +} + +/** Forwards plain HTTP requests to the pinned IP. */ +function handlePlainHttp(req: http.IncomingMessage, res: http.ServerResponse): void { + let target: URL; + try { + target = new URL(req.url ?? ''); + } catch { + res.writeHead(400).end(); + return; + } + void (async () => { + const ip = await resolvePublicAddress(target.hostname); + if (!ip) { + blocked(target.hostname); + res.writeHead(403).end(); + return; + } + const upstream = http.request( + { + host: ip, + port: target.port || 80, + method: req.method, + path: `${target.pathname}${target.search}`, + headers: { ...req.headers, host: target.host }, + }, + (upstreamRes) => { + res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers); + upstreamRes.pipe(res); + }, + ); + upstream.on('error', () => res.writeHead(502).end()); + req.pipe(upstream); + })(); +} + +function parseAuthority(authority: string, defaultPort: number): { host: string; port: number } { + const lastColon = authority.lastIndexOf(':'); + if (authority.startsWith('[')) { + const close = authority.indexOf(']'); + const host = authority.slice(1, close < 0 ? undefined : close); + const port = close >= 0 && authority[close + 1] === ':' ? Number(authority.slice(close + 2)) : defaultPort; + return { host, port: Number.isInteger(port) ? port : defaultPort }; + } + if (lastColon === -1) { + return { host: authority, port: defaultPort }; + } + const port = Number(authority.slice(lastColon + 1)); + return { host: authority.slice(0, lastColon), port: Number.isInteger(port) ? port : defaultPort }; +} + +function blocked(host: string): void { + logger.warn(`Blocked custom chart plugin network request to private/unresolved host "${host}"`, { + source: 'system', + }); +} diff --git a/apps/backend/src/utils/story-download.ts b/apps/backend/src/utils/story-download.ts index 82a6be529..6a4c15444 100644 --- a/apps/backend/src/utils/story-download.ts +++ b/apps/backend/src/utils/story-download.ts @@ -1,7 +1,7 @@ import type { DateFormatSettings } from '@nao/shared/date'; import type { DownloadFormat } from '@nao/shared/types'; -import { generateStoryHtml } from './story-html'; +import { generateStoryHtml, prerenderCustomChartImages } from './story-html'; import { generateStoryPdf } from './story-pdf'; export type QueryDataMap = Record; @@ -22,9 +22,10 @@ export async function buildStoryDownloadFile( code: string, queryData: QueryDataMap | null, dateFormat?: DateFormatSettings | null, + projectId?: string | null, ): Promise<{ buffer: Buffer; filename: string; mimeType: string }> { const story = { title, code }; - const buffer = await generateStoryBuffer(format, story, queryData, dateFormat); + const buffer = await generateStoryBuffer(format, story, queryData, dateFormat, projectId ?? null); return { buffer, filename: formatDownloadFilename(title, format), @@ -38,8 +39,16 @@ export async function buildDownloadResponse( code: string, queryData: QueryDataMap | null, dateFormat?: DateFormatSettings | null, + projectId?: string | null, ): Promise<{ data: string; filename: string; mimeType: string }> { - const { buffer, filename, mimeType } = await buildStoryDownloadFile(format, title, code, queryData, dateFormat); + const { buffer, filename, mimeType } = await buildStoryDownloadFile( + format, + title, + code, + queryData, + dateFormat, + projectId, + ); return { data: buffer.toString('base64'), filename, @@ -52,12 +61,14 @@ async function generateStoryBuffer( story: StoryInput, queryData: QueryDataMap | null, dateFormat: DateFormatSettings | null | undefined, + projectId: string | null, ): Promise { + const customChartImages = await prerenderCustomChartImages(story, queryData, projectId); switch (format) { case 'pdf': - return generateStoryPdf(story, queryData, dateFormat); + return generateStoryPdf(story, queryData, dateFormat, customChartImages); case 'html': - return Buffer.from(generateStoryHtml(story, queryData, dateFormat)); + return Buffer.from(generateStoryHtml(story, queryData, dateFormat, customChartImages)); } } diff --git a/apps/backend/src/utils/story-html.tsx b/apps/backend/src/utils/story-html.tsx index 002876095..deb0e402c 100644 --- a/apps/backend/src/utils/story-html.tsx +++ b/apps/backend/src/utils/story-html.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_COLORS, defaultColorFor, formatCompactNumber, labelize } from '@nao/shared'; +import { DEFAULT_COLORS, defaultColorFor, formatCompactNumber, isBuiltinChartType, labelize } from '@nao/shared'; import { type DateFormatSettings, DEFAULT_DATE_FORMAT_SETTINGS, @@ -14,6 +14,9 @@ import React, { createContext, useContext } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { renderChartToSvg } from '../components/generate-chart'; +import { chartPluginService } from '../services/chart-plugin.service'; +import { logger } from './logger'; +import { renderCustomChartImage } from './render-custom-chart'; import type { QueryDataMap, StoryInput } from './story-download'; const MAX_TABLE_ROWS = 10; @@ -25,26 +28,114 @@ const CHART_HEIGHT = Math.round((CHART_WIDTH * 9) / 16); const DateFormatContext = createContext({ ...DEFAULT_DATE_FORMAT_SETTINGS }); +/** Maps a custom chart block to its pre-rendered PNG data URL (see {@link prerenderCustomChartImages}). */ +const CustomChartImageContext = createContext | null>(null); + export function generateStoryHtml( story: StoryInput, queryData: QueryDataMap | null, dateFormat?: DateFormatSettings | null, + customChartImages?: Map | null, ): string { const resolvedDateFormat = dateFormat ?? { ...DEFAULT_DATE_FORMAT_SETTINGS }; const segments = splitCodeIntoSegments(story.code); const markup = renderToStaticMarkup( - - {segments.map((seg, i) => ( - - ))} - - + + + {segments.map((seg, i) => ( + + ))} + + + , ); return `\n${markup}`; } +/** + * Renders every custom chart plugin in a story to a PNG (in a headless browser) + * ahead of the synchronous HTML render, since plugins are browser-only and have + * no server-side SVG path. The resulting map is passed to {@link generateStoryHtml}. + */ +export async function prerenderCustomChartImages( + story: StoryInput, + queryData: QueryDataMap | null, + projectId: string | null, +): Promise> { + const customCharts = collectChartSegments(splitCodeIntoSegments(story.code)).filter( + (chart) => !isBuiltinChartType(chart.chartType), + ); + const images = new Map(); + if (customCharts.length === 0 || !projectId) { + return images; + } + + await ensurePluginsInitialized(projectId); + + for (const chart of customCharts) { + const key = chartKey(chart); + if (images.has(key)) { + continue; + } + const rows = queryData?.[chart.queryId]?.data as Record[] | undefined; + if (!rows?.length) { + continue; + } + try { + const png = await renderCustomChartImage({ + projectId, + config: toChartConfig(chart), + data: rows, + width: CHART_WIDTH, + height: CHART_HEIGHT, + }); + images.set(key, `data:image/png;base64,${png.toString('base64')}`); + } catch (error) { + logger.error( + `Failed to render custom chart "${chart.title || chart.chartType}" for story export: ${String(error)}`, + { source: 'system' }, + ); + } + } + + return images; +} + +async function ensurePluginsInitialized(projectId: string): Promise { + try { + await chartPluginService.initialize(projectId); + } catch (error) { + // Don't fail the whole export here — each chart render is guarded and + // falls back individually if its plugin is unavailable. + logger.warn(`Chart plugins: initialization for story export failed: ${String(error)}`, { source: 'system' }); + } +} + +function collectChartSegments(segments: Segment[]): ParsedChartBlock[] { + const charts: ParsedChartBlock[] = []; + for (const segment of segments) { + if (segment.type === 'chart') { + charts.push(segment.chart); + } else if (segment.type === 'grid') { + charts.push(...collectChartSegments(segment.children)); + } + } + return charts; +} + +function chartKey(chart: ParsedChartBlock): string { + return JSON.stringify({ + queryId: chart.queryId, + chartType: chart.chartType, + xAxisKey: chart.xAxisKey, + xAxisType: chart.xAxisType, + series: chart.series, + title: chart.title, + }); +} + function StoryDocument({ title, children }: { title: string; children: React.ReactNode }) { const dateFormat = useContext(DateFormatContext); const pattern = resolveDateFormatPattern(dateFormat); @@ -131,6 +222,7 @@ function GridBlock({ function ChartBlock({ chart, queryData }: { chart: ParsedChartBlock; queryData: QueryDataMap | null }) { const dateFormat = useContext(DateFormatContext); + const customChartImages = useContext(CustomChartImageContext); const rows = queryData?.[chart.queryId]?.data as Record[] | undefined; if (!rows?.length) { return ; @@ -140,6 +232,24 @@ function ChartBlock({ chart, queryData }: { chart: ParsedChartBlock; queryData: return ; } + // Custom chart plugins are rendered to a PNG ahead of time (see `prerenderCustomChartImages`). + if (!isBuiltinChartType(chart.chartType)) { + const image = customChartImages?.get(chartKey(chart)); + if (!image) { + return ; + } + return ( +
+ {chart.title && ( +
+ {chart.title} +
+ )} + {chart.title +
+ ); + } + try { const svg = renderChartToSvg({ config: toChartConfig(chart), diff --git a/apps/backend/src/utils/story-pdf.ts b/apps/backend/src/utils/story-pdf.ts index cfed5893c..06a526a3f 100644 --- a/apps/backend/src/utils/story-pdf.ts +++ b/apps/backend/src/utils/story-pdf.ts @@ -1,29 +1,16 @@ import type { DateFormatSettings } from '@nao/shared/date'; -import { execSync } from 'child_process'; -import { existsSync } from 'fs'; -import type { Browser } from 'puppeteer-core'; +import { getBrowser } from './headless-browser'; import type { QueryDataMap, StoryInput } from './story-download'; import { generateStoryHtml } from './story-html'; -let browserPromise: Promise | null = null; - -async function loadPuppeteer() { - try { - return await import('puppeteer-core'); - } catch { - throw new Error( - 'puppeteer-core is not available. PDF export requires puppeteer-core and a Chrome/Chromium installation.', - ); - } -} - export async function generateStoryPdf( story: StoryInput, queryData: QueryDataMap | null, dateFormat?: DateFormatSettings | null, + customChartImages?: Map | null, ): Promise { - const html = generateStoryHtml(story, queryData, dateFormat); + const html = generateStoryHtml(story, queryData, dateFormat, customChartImages); const browser = await getBrowser(); const page = await browser.newPage(); @@ -39,56 +26,3 @@ export async function generateStoryPdf( await page.close(); } } - -async function getBrowser(): Promise { - if (browserPromise) { - const browser = await browserPromise; - if (browser.connected) { - return browser; - } - await browser.close().catch(() => {}); - } - const puppeteer = await loadPuppeteer(); - browserPromise = puppeteer.default.launch({ - headless: true, - executablePath: findChromePath(), - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'], - }); - return browserPromise; -} - -function findChromePath(): string { - const candidates = [ - process.env.CHROME_PATH, - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/usr/bin/google-chrome', - ]; - - for (const candidate of candidates) { - if (candidate && existsSync(candidate)) { - return candidate; - } - } - - try { - return execSync('which chromium || which chromium-browser || which google-chrome', { - encoding: 'utf-8', - }).trim(); - } catch { - throw new Error('Chrome/Chromium not found. Install chromium or set the CHROME_PATH environment variable.'); - } -} - -async function closeBrowser() { - if (!browserPromise) { - return; - } - const browser = await browserPromise.catch(() => null); - browserPromise = null; - await browser?.close().catch(() => {}); -} - -for (const signal of ['SIGINT', 'SIGTERM', 'exit'] as const) { - process.on(signal, () => void closeBrowser()); -} diff --git a/apps/backend/tests/private-network.test.ts b/apps/backend/tests/private-network.test.ts new file mode 100644 index 000000000..5e0e97c2b --- /dev/null +++ b/apps/backend/tests/private-network.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { isPrivateOrReservedIp } from '../src/utils/private-network'; + +describe('isPrivateOrReservedIp', () => { + it('flags IPv4 loopback, private, link-local and reserved ranges', () => { + for (const ip of [ + '127.0.0.1', + '10.0.0.1', + '172.16.5.4', + '192.168.1.1', + '169.254.169.254', // cloud metadata endpoint + '100.64.0.1', // carrier-grade NAT + '0.0.0.0', + '198.18.0.1', + '224.0.0.1', + ]) { + expect(isPrivateOrReservedIp(ip), ip).toBe(true); + } + }); + + it('allows public IPv4 addresses', () => { + for (const ip of ['8.8.8.8', '1.1.1.1', '93.184.216.34']) { + expect(isPrivateOrReservedIp(ip), ip).toBe(false); + } + }); + + it('flags IPv6 loopback, unspecified, ULA, link-local and multicast', () => { + for (const ip of ['::1', '::', 'fc00::1', 'fd12:3456::1', 'fe80::1', 'ff02::1']) { + expect(isPrivateOrReservedIp(ip), ip).toBe(true); + } + }); + + it('allows public IPv6 addresses', () => { + for (const ip of ['2606:4700:4700::1111', '2001:4860:4860::8888']) { + expect(isPrivateOrReservedIp(ip), ip).toBe(false); + } + }); + + it('flags IPv4-mapped IPv6 in every textual form (rebinding bypass guard)', () => { + for (const ip of [ + '::ffff:127.0.0.1', // dotted-quad mapped + '::ffff:7f00:1', // hex mapped -> 127.0.0.1 + '0:0:0:0:0:ffff:127.0.0.1', // uncompressed dotted mapped + '::FFFF:169.254.169.254', // upper-case metadata endpoint + '::ffff:a9fe:a9fe', // hex metadata endpoint + '::ffff:10.0.0.1', // mapped private + '::ffff:c0a8:0101', // hex 192.168.1.1 + ]) { + expect(isPrivateOrReservedIp(ip), ip).toBe(true); + } + }); + + it('allows IPv4-mapped IPv6 that points at a public address', () => { + expect(isPrivateOrReservedIp('::ffff:8.8.8.8')).toBe(false); + expect(isPrivateOrReservedIp('::ffff:0808:0808')).toBe(false); + }); + + it('flags IPv4-compatible and NAT64-embedded private addresses', () => { + expect(isPrivateOrReservedIp('::127.0.0.1')).toBe(true); + expect(isPrivateOrReservedIp('64:ff9b::127.0.0.1')).toBe(true); + expect(isPrivateOrReservedIp('64:ff9b::7f00:1')).toBe(true); + }); + + it('treats non-IP literals as unsafe', () => { + expect(isPrivateOrReservedIp('not-an-ip')).toBe(true); + expect(isPrivateOrReservedIp('')).toBe(true); + }); +}); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 699439779..d73f8134d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -51,6 +51,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "fuse.js": "^7.3.0", + "html-to-image": "^1.11.13", "jszip": "^3.10.1", "lucide-react": "^0.562.0", "posthog-js": "^1.336.4", diff --git a/apps/frontend/src/components/side-panel/story-chart-embed.tsx b/apps/frontend/src/components/side-panel/story-chart-embed.tsx index d0d162118..1b5dfdc79 100644 --- a/apps/frontend/src/components/side-panel/story-chart-embed.tsx +++ b/apps/frontend/src/components/side-panel/story-chart-embed.tsx @@ -1,5 +1,6 @@ import { memo, useMemo, useState } from 'react'; import { Pencil } from 'lucide-react'; +import { isBuiltinChartType } from '@nao/shared'; import type { UIMessage } from '@nao/backend/chat'; import type { displayChart } from '@nao/shared/tools'; import { Button } from '@/components/ui/button'; @@ -98,6 +99,10 @@ export function StoryChartEmbedShell({ chart, availableColumns, children }: Stor const edit = useStoryChartEdit(); const [isEditOpen, setIsEditOpen] = useState(false); const canEdit = Boolean(edit && chart.rawTag); + // Built-in charts draw their title inside the chart (via `buildChart`), but + // custom chart plugins don't — and the story embed has no header — so we + // render the title here to keep parity with built-in charts. + const showCustomTitle = !isBuiltinChartType(chart.chartType) && Boolean(chart.title); const config = useMemo( () => ({ @@ -116,7 +121,9 @@ export function StoryChartEmbedShell({ chart, availableColumns, children }: Stor ); return ( -
+
{canEdit && ( )} - {children} + {showCustomTitle && ( +
+ {chart.title} +
+ )} + {showCustomTitle ?
{children}
: children} {canEdit && edit && chart.rawTag && ( []; + config: ChartPluginConfig; +} + +function resolveTheme(theme: 'light' | 'dark' | 'system'): 'light' | 'dark' { + if (theme === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return theme; +} + +/** + * Renders a custom chart plugin ("vibe coded chart"). + * + * Dynamically imports the plugin module for `type`, then hands it a container + * element plus the data, config and the injected libraries (React, ReactDOM, + * Recharts). Re-renders when the data/config/theme change or when the plugin + * file is hot reloaded. + */ +export function CustomChart({ type, data, config }: CustomChartProps) { + const containerRef = useRef(null); + const version = useChartPluginVersion(); + const { theme } = useTheme(); + const resolvedTheme = resolveTheme(theme); + const [error, setError] = useState(null); + const configKey = JSON.stringify(config); + + // Keep the latest config without making it a reactive effect dependency — + // `configKey` already captures content changes, so this avoids re-importing + // the plugin on every render when callers pass a fresh config object. + const configRef = useRef(config); + configRef.current = config; + + useEffect(() => { + const element = containerRef.current; + if (!element) { + return; + } + + let cancelled = false; + let cleanup: ChartPluginCleanup; + setError(null); + + const context: ChartPluginRenderContext = { + data, + config: configRef.current, + libs: { React, ReactDOM, Recharts }, + theme: resolvedTheme, + colors: DEFAULT_COLORS, + }; + + loadChartPlugin(type, version) + .then(async (module) => { + if (cancelled || !containerRef.current) { + return; + } + cleanup = await module.render(containerRef.current, context); + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + } + }); + + return () => { + cancelled = true; + if (typeof cleanup === 'function') { + try { + cleanup(); + } catch (err) { + console.error(`Error cleaning up chart plugin "${type}":`, err); + } + } + element.replaceChildren(); + }; + }, [type, version, data, configKey, resolvedTheme]); + + if (error) { + return ( +
+ Could not render custom chart "{type}": {error} +
+ ); + } + + return
; +} diff --git a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx index acab6cf14..974fb35d5 100644 --- a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx @@ -8,9 +8,10 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import type { UIMessage, UIToolPart } from '@nao/backend/chat'; import { useAgentContext } from '@/contexts/agent.provider'; +import { useChartPluginManifest } from '@/lib/chart-plugins'; import { trpc } from '@/main'; -const CHART_TYPE_OPTIONS: { value: displayChart.ChartType; label: string }[] = [ +const CHART_TYPE_OPTIONS: { value: string; label: string }[] = [ { value: 'bar', label: 'Bar' }, { value: 'stacked_bar', label: 'Stacked bar' }, { value: 'line', label: 'Line' }, @@ -51,6 +52,7 @@ export function ChartConfigEditDialog({ }: ChartConfigEditDialogProps) { const [draft, setDraft] = useState(config); const [error, setError] = useState(null); + const { data: pluginManifest } = useChartPluginManifest(); useEffect(() => { if (open) { @@ -59,6 +61,19 @@ export function ChartConfigEditDialog({ } }, [open, config]); + const chartTypeOptions = useMemo(() => { + const custom = (pluginManifest?.plugins ?? []).map((plugin) => ({ + value: plugin.type, + label: plugin.name || plugin.type, + })); + const options = [...CHART_TYPE_OPTIONS, ...custom]; + // Keep the current type selectable even if its plugin is no longer listed. + if (draft.chart_type && !options.some((option) => option.value === draft.chart_type)) { + options.push({ value: draft.chart_type, label: draft.chart_type }); + } + return options; + }, [pluginManifest, draft.chart_type]); + const xAxisOptions = useMemo(() => { if (availableColumns.length === 0) { return [config.x_axis_key]; @@ -141,15 +156,13 @@ export function ChartConfigEditDialog({ Chart type