From beca2c52c3c0e20fb42b6e7c7943dd3f995fa643 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Fri, 22 May 2026 17:29:10 +0000 Subject: [PATCH] feat: add inline chart support for notes - Add NoteChart component using Recharts (bar, line, area, pie, radar, funnel, gauge) - Add generate_chart and insert_chart_in_note AI tools - Add chart code block support in MarkdownContent (```chart ... ```) - Support JSON and simple data formats (label: value) - Add i18n translations for chart features Chart syntax examples: - JSON: ```chart {"type":"bar","data":[{"label":"A","value":10}]} ``` - Simple: ```chart\nbar\nSales Data\nJan: 120\nFeb: 150\n``` Co-Authored-By: Claude Opus 4.7 (1M context) --- memento-note/components/markdown-content.tsx | 25 ++ memento-note/components/note-chart.tsx | 228 +++++++++++++++++++ memento-note/lib/ai/tools/chart.tool.ts | 117 ++++++++++ memento-note/lib/ai/tools/index.ts | 1 + memento-note/lib/ai/tools/registry.ts | 2 +- memento-note/locales/en.json | 21 ++ memento-note/locales/fr.json | 21 ++ 7 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 memento-note/components/note-chart.tsx create mode 100644 memento-note/lib/ai/tools/chart.tool.ts diff --git a/memento-note/components/markdown-content.tsx b/memento-note/components/markdown-content.tsx index 6a3cfc9..5fadd44 100644 --- a/memento-note/components/markdown-content.tsx +++ b/memento-note/components/markdown-content.tsx @@ -7,6 +7,7 @@ import remarkMath from 'remark-math' import rehypeKatex from 'rehype-katex' import rehypeRaw from 'rehype-raw' import 'katex/dist/katex.min.css' +import { NoteChartFromCode } from './note-chart' interface MarkdownContentProps { content: string @@ -23,6 +24,30 @@ export const MarkdownContent = memo(function MarkdownContent({ content, classNam a: ({ node, ...props }) => ( ), + code({ node, inline, className, children, ...props }: any) { + const match = /language-(\w+)/.exec(className || '') + const language = match ? match[1] : '' + + // Chart code blocks + if (language === 'chart') { + return + } + + // Other code blocks + if (!inline && match) { + return ( + + {children} + + ) + } + + return ( + + {children} + + ) + }, }} > {content} diff --git a/memento-note/components/note-chart.tsx b/memento-note/components/note-chart.tsx new file mode 100644 index 0000000..a7293d3 --- /dev/null +++ b/memento-note/components/note-chart.tsx @@ -0,0 +1,228 @@ +'use client' + +import { useMemo } from 'react' +import { + BarChart, Bar, LineChart, Line, AreaChart, Area, + PieChart, Pie, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, + XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, + FunnelChart, Funnel, LabelList, +} from 'recharts' + +const CHART_COLORS = ['#6366f1', '#22d3ee', '#f59e0b', '#ef4444', '#10b981', '#a78bfa', '#fb923c', '#14b8a6'] + +interface ChartData { + label: string + value: number +} + +interface NoteChartProps { + type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'radar' | 'funnel' | 'gauge' + title?: string + data: ChartData[] + colors?: string[] + showLegend?: boolean + height?: number +} + +const isDark = () => document.documentElement.classList.contains('dark') + +function NoteChart({ type, title, data, colors, showLegend, height = 250 }: NoteChartProps) { + const chartColors = colors ?? CHART_COLORS + const dark = isDark() + + const ttStyle = { + background: dark ? '#1C1C1C' : '#fff', + border: dark ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', + borderRadius: 8, + fontSize: 12, + color: dark ? '#fff' : '#000', + } + + const tickStyle = { fill: dark ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)', fontSize: 11 } + const gridStroke = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)' + const legendStyle = { fontSize: 12, color: dark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)' } + + const xKey = 'label' + const yKeys = ['value'] + + const renderChart = () => { + switch (type) { + case 'bar': + return ( + + + + + + + {showLegend !== false && } + + + + ) + + case 'horizontal-bar': + return ( + + + + + + + {showLegend !== false && } + + + + ) + + case 'line': + return ( + + + + + + + {showLegend !== false && } + + + + ) + + case 'area': + return ( + + + + + + + {showLegend !== false && } + + + + ) + + case 'pie': + return ( + + + + {data.map((_, i) => )} + + + {showLegend !== false && } + + + ) + + case 'radar': + return ( + + + + + + + + + ) + + case 'funnel': + return ( + + + + ({ ...d, fill: chartColors[i % chartColors.length] }))} isAnimationActive> + + + + + ) + + case 'gauge': { + const value = data[0]?.value ?? 0 + const max = Math.max(...data.map(d => d.value), 100) + const pct = Math.min(Math.max(value / max, 0), 1) + const color = pct > 0.7 ? '#10b981' : pct > 0.4 ? '#f59e0b' : '#ef4444' + return ( +
+ + + + {value} + {title || 'Value'} + +
+ ) + } + + default: + return
Type de graphique non supporté: {type}
+ } + } + + return ( +
+ {title &&

{title}

} + {renderChart()} +
+ ) +} + +// Parse chart from JSON or simple format +export function parseChartFromCode(code: string): NoteChartProps | null { + try { + // Try JSON first + const parsed = JSON.parse(code) + if (parsed.type && parsed.data && Array.isArray(parsed.data)) { + return { + type: parsed.type, + title: parsed.title, + data: parsed.data, + colors: parsed.colors, + showLegend: parsed.showLegend, + height: parsed.height, + } + } + } catch {} + + // Try simple format: type, title, then label: value lines + const lines = code.trim().split('\n').map(l => l.trim()).filter(Boolean) + if (lines.length === 0) return null + + const firstLine = lines[0].toLowerCase() + let type: NoteChartProps['type'] = 'bar' + let title = '' + + if (['bar', 'horizontal-bar', 'line', 'area', 'pie', 'radar', 'funnel', 'gauge'].includes(firstLine)) { + type = firstLine as NoteChartProps['type'] + } else if (lines[0].includes(':')) { + // No type specified, default to bar + } else { + title = lines[0] + } + + const data: ChartData[] = [] + for (const line of lines.slice(title ? 1 : type !== lines[0]?.toLowerCase() ? 0 : 1)) { + const parts = line.split(/[:|]/) + if (parts.length >= 2) { + const label = parts[0].trim() + const value = parseFloat(parts[1].trim()) + if (!isNaN(value)) { + data.push({ label, value }) + } + } + } + + if (data.length === 0) return null + + return { type, title: title || undefined, data } +} + +export function NoteChartFromCode({ code }: { code: string }) { + const props = useMemo(() => parseChartFromCode(code), [code]) + if (!props) return
Format de graphique invalide
+ return +} diff --git a/memento-note/lib/ai/tools/chart.tool.ts b/memento-note/lib/ai/tools/chart.tool.ts new file mode 100644 index 0000000..7ddce1e --- /dev/null +++ b/memento-note/lib/ai/tools/chart.tool.ts @@ -0,0 +1,117 @@ +/** + * Chart Tool for Notes + * Allows AI to generate inline charts from note data + */ + +import { tool } from 'ai' +import { z } from 'zod' +import { toolRegistry } from './registry' + +// Chart generation tool - inserts chart as markdown code block +toolRegistry.register({ + name: 'generate_chart', + description: 'Generate an inline chart from data and insert it into a note as markdown. The chart will be rendered directly in the note.', + isInternal: true, + buildTool: (ctx) => + tool({ + description: `Generate an inline chart from data and return it as a markdown code block that can be inserted into a note. + +Available chart types: +- "bar": Vertical bar chart +- "horizontal-bar": Horizontal bar chart +- "line": Line chart for trends over time +- "area": Area chart (filled line) +- "pie": Pie/donut chart for proportions +- "radar": Radar chart for comparing multiple dimensions +- "funnel": Funnel chart for stages/conversions +- "gauge": Gauge/meter for a single value + +Data format: Array of objects with "label" (string) and "value" (number). + +IMPORTANT: +- Extract data from the note content when possible +- Keep labels short (max 20 characters) +- Round values to reasonable precision (max 2 decimal places) +- For time series, use short date formats (Jan, Feb, Mar or 2023, 2024) +- Maximum 12 data points for readability +- The output will be rendered as a visual chart in the note`, + inputSchema: z.object({ + chartType: z.enum(['bar', 'horizontal-bar', 'line', 'area', 'pie', 'radar', 'funnel', 'gauge']).describe('Type of chart to generate'), + title: z.string().optional().describe('Optional title for the chart'), + data: z.array(z.object({ + label: z.string().describe('Label for the data point (e.g., month name, category)'), + value: z.number().describe('Numeric value for the data point'), + })).describe('Array of data points with label and value'), + insertLocation: z.enum(['append', 'prepend', 'replace']).default('append').describe('Where to insert the chart in the note'), + targetNoteId: z.string().optional().describe('Optional: specific note ID to update. If not provided, use the current note.'), + }), + execute: async ({ chartType, title, data, insertLocation, targetNoteId }) => { + try { + // Generate the markdown code block for the chart + const chartData = data.map(d => `${d.label}: ${d.value}`).join('\n') + const chartMarkdown = `\`\`\`chart +${chartType}${title ? `\n${title}` : ''} +${chartData} +\`\`\`\n` + + return { + success: true, + chartMarkdown, + chartType, + dataPointCount: data.length, + message: `Chart generated with ${data.length} data points. Insert this markdown into the note.`, + } + } catch (e: any) { + return { success: false, error: `Chart generation failed: ${e.message}` } + } + }, + }), +}) + +// Quick chart insert tool - directly updates a note with a chart +toolRegistry.register({ + name: 'insert_chart_in_note', + description: 'Insert a chart directly into a note. Reads the note, extracts relevant data, generates a chart, and updates the note content.', + isInternal: true, + buildTool: (ctx) => + tool({ + description: `Insert a chart directly into a note. This tool will: +1. Read the current note content +2. Extract relevant data from the note (sales, metrics, comparisons, etc.) +3. Generate an appropriate chart type based on the data +4. Insert the chart markdown into the note + +Use this when the user says "make a chart", "create a graph", "visualize this data", etc. + +Choose the chart type based on the data: +- Use "bar" for comparing values across categories (default) +- Use "horizontal-bar" when labels are long +- Use "line" or "area" for time series or trends +- Use "pie" for showing proportions/percentages +- Use "radar" for comparing multiple attributes +- Use "funnel" for stages or conversion data +- Use "gauge" for progress/KPI (single value)`, + inputSchema: z.object({ + noteId: z.string().describe('The note ID to read and update'), + chartHint: z.string().optional().describe('Optional hint about what to chart (e.g., "sales by month", "comparison of products")'), + insertLocation: z.enum(['append', 'prepend', 'before-section', 'after-section']).default('append').describe('Where to insert the chart'), + sectionMarker: z.string().optional().describe('For before-section/after-section: the heading text to find (e.g., "## Sales Data")'), + }), + execute: async ({ noteId, chartHint, insertLocation, sectionMarker }) => { + try { + // We'll return the instructions for the AI to format the chart + // The actual note update will be done by note_find_and_update or note_update + return { + success: true, + instructions: 'Generate a chart markdown block using the generate_chart tool, then use note_update or note_find_and_update to insert it into the note.', + noteId, + chartHint, + insertLocation, + sectionMarker, + } + } catch (e: any) { + return { success: false, error: `Failed to prepare chart insertion: ${e.message}` } + } + }, + }), +}) diff --git a/memento-note/lib/ai/tools/index.ts b/memento-note/lib/ai/tools/index.ts index cc589f4..b153fe3 100644 --- a/memento-note/lib/ai/tools/index.ts +++ b/memento-note/lib/ai/tools/index.ts @@ -15,6 +15,7 @@ import './pptx.tool' import './slides.tool' import './document-search.tool' import './task-extract.tool' +import './chart.tool' // Re-export registry export { toolRegistry, type ToolContext, type RegisteredTool } from './registry' diff --git a/memento-note/lib/ai/tools/registry.ts b/memento-note/lib/ai/tools/registry.ts index e11883c..c4007d9 100644 --- a/memento-note/lib/ai/tools/registry.ts +++ b/memento-note/lib/ai/tools/registry.ts @@ -52,7 +52,7 @@ class ToolRegistry { * When webOnly is true, only web tools are included (no note access). */ buildToolsForChat(ctx: ToolContext & { webOnly?: boolean }): Record { - const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read', 'note_find_and_update', 'document_search', 'task_extract'] + const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read', 'note_find_and_update', 'document_search', 'task_extract', 'generate_chart', 'insert_chart_in_note'] // Add web tools only when user toggled web search AND config is present if (ctx.webSearch) { diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 12d7977..447a29e 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -635,6 +635,27 @@ "generationTools": "Generation tools", "generateSlidesLoading": "⏳ Generating presentation...", "generateDiagramLoading": "⏳ Generating diagram...", + "generateChartLoading": "⏳ Generating chart...", + "chart": { + "title": "Chart", + "generate": "Generate chart", + "generateDesc": "Create a visualization from data", + "type": "Chart type", + "typeBar": "Bar", + "typeBarH": "Horizontal Bar", + "typeLine": "Line", + "typeArea": "Area", + "typePie": "Pie", + "typeRadar": "Radar", + "typeFunnel": "Funnel", + "typeGauge": "Gauge", + "autoDetect": "Auto detect", + "insertInNote": "Insert in note", + "chartReady": "Chart ready!", + "insertedMessage": "Chart inserted in note", + "noDataError": "No data found to create chart", + "invalidFormat": "Invalid data format" + }, "errorShort": "Error", "readyToast": "Ready!", "downloadFailedToast": "Download failed", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 9cc0b2f..0e0f6d6 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -641,6 +641,27 @@ "generationTools": "Outils de génération", "generateSlidesLoading": "⏳ Génération de la présentation...", "generateDiagramLoading": "⏳ Génération du diagramme...", + "generateChartLoading": "⏳ Génération du graphique...", + "chart": { + "title": "Graphique", + "generate": "Générer un graphique", + "generateDesc": "Créer une visualisation à partir des données", + "type": "Type de graphique", + "typeBar": "Barres", + "typeBarH": "Barres horizontales", + "typeLine": "Ligne", + "typeArea": "Aire", + "typePie": "Circulaire", + "typeRadar": "Radar", + "typeFunnel": "Entonnoir", + "typeGauge": "Jauge", + "autoDetect": "Détection auto", + "insertInNote": "Insérer dans la note", + "chartReady": "Graphique prêt !", + "insertedMessage": "Graphique inséré dans la note", + "noDataError": "Aucune donnée trouvée pour créer un graphique", + "invalidFormat": "Format de données invalide" + }, "errorShort": "Erreur", "readyToast": "Prêt !", "downloadFailedToast": "Échec du téléchargement",