feat: add inline chart support for notes
Some checks failed
CI / Lint, Test & Build (push) Failing after 15s
CI / Deploy production (on server) (push) Has been skipped

- 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) <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-22 17:29:10 +00:00
parent 5728452b4a
commit beca2c52c3
7 changed files with 414 additions and 1 deletions

View File

@@ -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 }) => (
<a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" />
),
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 <NoteChartFromCode code={String(children).replace(/\n$/, '')} />
}
// Other code blocks
if (!inline && match) {
return (
<code className={className} {...props}>
{children}
</code>
)
}
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm" {...props}>
{children}
</code>
)
},
}}
>
{content}

View File

@@ -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 (
<ResponsiveContainer width="100%" height={height}>
<BarChart data={data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey={xKey} tick={tickStyle} axisLine={false} tickLine={false} />
<YAxis tick={tickStyle} axisLine={false} tickLine={false} width={40} />
<Tooltip contentStyle={ttStyle} cursor={{ fill: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)' }} />
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
<Bar dataKey={yKeys[0]} fill={chartColors[0]} radius={[4, 4, 0, 0]} maxBarSize={56} />
</BarChart>
</ResponsiveContainer>
)
case 'horizontal-bar':
return (
<ResponsiveContainer width="100%" height={height}>
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 24, left: 60, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis type="number" tick={tickStyle} axisLine={false} tickLine={false} />
<YAxis dataKey={xKey} type="category" tick={tickStyle} axisLine={false} tickLine={false} width={55} />
<Tooltip contentStyle={ttStyle} cursor={{ fill: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)' }} />
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
<Bar dataKey={yKeys[0]} fill={chartColors[0]} radius={[0, 4, 4, 0]} maxBarSize={56} />
</BarChart>
</ResponsiveContainer>
)
case 'line':
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey={xKey} tick={tickStyle} axisLine={false} tickLine={false} />
<YAxis tick={tickStyle} axisLine={false} tickLine={false} width={40} />
<Tooltip contentStyle={ttStyle} />
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
<Line type="monotone" dataKey={yKeys[0]} stroke={chartColors[0]} strokeWidth={2.5} dot={{ r: 4, strokeWidth: 0 }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
)
case 'area':
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey={xKey} tick={tickStyle} axisLine={false} tickLine={false} />
<YAxis tick={tickStyle} axisLine={false} tickLine={false} width={40} />
<Tooltip contentStyle={ttStyle} />
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
<Area type="monotone" dataKey={yKeys[0]} stroke={chartColors[0]} fill={`${chartColors[0]}28`} strokeWidth={2.5} />
</AreaChart>
</ResponsiveContainer>
)
case 'pie':
return (
<ResponsiveContainer width="100%" height={height}>
<PieChart>
<Pie data={data} dataKey={yKeys[0]} nameKey={xKey} cx="50%" cy="50%" outerRadius="70%" innerRadius="35%" paddingAngle={2}>
{data.map((_, i) => <Cell key={i} fill={chartColors[i % chartColors.length]} stroke="transparent" />)}
</Pie>
<Tooltip contentStyle={ttStyle} />
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
</PieChart>
</ResponsiveContainer>
)
case 'radar':
return (
<ResponsiveContainer width="100%" height={height}>
<RadarChart data={data} cx="50%" cy="50%">
<PolarGrid stroke={dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'} />
<PolarAngleAxis dataKey={xKey} tick={{ fill: dark ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)', fontSize: 11 }} />
<Radar name={title || 'Value'} dataKey={yKeys[0]} stroke={chartColors[0]} fill={chartColors[0]} fillOpacity={0.15} />
<Tooltip contentStyle={ttStyle} />
</RadarChart>
</ResponsiveContainer>
)
case 'funnel':
return (
<ResponsiveContainer width="100%" height={height}>
<FunnelChart>
<Tooltip contentStyle={ttStyle} />
<Funnel dataKey={yKeys[0]} data={data.map((d, i) => ({ ...d, fill: chartColors[i % chartColors.length] }))} isAnimationActive>
<LabelList position="center" fill={dark ? '#fff' : '#000'} fontSize={12} fontWeight={700} dataKey={xKey} />
</Funnel>
</FunnelChart>
</ResponsiveContainer>
)
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 (
<div style={{ width: '100%', height: `${height}px`, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<svg viewBox="0 0 200 120" style={{ width: '60%', maxHeight: '70%' }}>
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke={dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'} strokeWidth="16" strokeLinecap="round" />
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke={color} strokeWidth="16" strokeLinecap="round"
strokeDasharray={`${pct * 251.2} 251.2`} />
<text x="100" y="90" textAnchor="middle" fill={dark ? '#fff' : '#000'} fontSize="28" fontWeight="800">{value}</text>
<text x="100" y="112" textAnchor="middle" fill={dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'} fontSize="11">{title || 'Value'}</text>
</svg>
</div>
)
}
default:
return <div className="text-center text-muted-foreground py-8">Type de graphique non supporté: {type}</div>
}
}
return (
<div className="my-6 rounded-xl border border-border bg-card p-4">
{title && <h4 className="text-sm font-semibold text-foreground mb-4">{title}</h4>}
{renderChart()}
</div>
)
}
// 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 <div className="text-center text-muted-foreground py-4">Format de graphique invalide</div>
return <NoteChart {...props} />
}

View File

@@ -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}` }
}
},
}),
})

View File

@@ -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'

View File

@@ -52,7 +52,7 @@ class ToolRegistry {
* When webOnly is true, only web tools are included (no note access).
*/
buildToolsForChat(ctx: ToolContext & { webOnly?: boolean }): Record<string, any> {
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) {

View File

@@ -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",

View File

@@ -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",