feat(ai): add AI chart suggestions in TipTap editor
Implement slash command "/suggest-charts" that analyzes note content and suggests 3 appropriate chart types with visual previews. Created: - chart-suggestion.tool.ts: AI tool for data extraction and chart recommendations - suggest-charts/route.ts: API endpoint for chart suggestions - chart-suggestion.service.ts: Frontend service layer - chart-suggestions-dialog.tsx: Modal with 3 chart proposals and thumbnails - tiptap-chart-extension.tsx: TipTap Node extension for rendering chart blocks Modified: - rich-text-editor.tsx: Added slash command, toolbar button, and dialog integration Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
174
memento-note/app/api/ai/suggest-charts/route.ts
Normal file
174
memento-note/app/api/ai/suggest-charts/route.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { generateText } from 'ai'
|
||||
import { resolveAiRouteWithTiming } from '@/lib/ai/router'
|
||||
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { toolRegistry } from '@/lib/ai/tools'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { trackFeatureUsage } from '@/lib/usage-tracker'
|
||||
|
||||
export const maxDuration = 30
|
||||
|
||||
interface SuggestChartsRequest {
|
||||
content: string
|
||||
selection?: string | null
|
||||
noteId?: string
|
||||
}
|
||||
|
||||
interface ChartSuggestion {
|
||||
type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'radar' | 'funnel' | 'gauge'
|
||||
title: string
|
||||
data: { label: string; value: number }[]
|
||||
description: string
|
||||
rationale?: string
|
||||
}
|
||||
|
||||
interface SuggestChartsResponse {
|
||||
suggestions: ChartSuggestion[]
|
||||
analyzedText: string
|
||||
detectedData: string
|
||||
hasData: boolean
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// 1. Auth check
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// 1.5 Quota check
|
||||
try {
|
||||
const sysConfigEarly = await getSystemConfig()
|
||||
const { usedByok: willUseByok } = await willUseByokForLane('suggest-charts', sysConfigEarly, userId)
|
||||
if (!willUseByok) {
|
||||
await checkEntitlementOrThrow(userId, 'ai')
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
return Response.json(err.toJSON(), { status: 402 })
|
||||
}
|
||||
console.error('[suggest-charts] Quota check error (fail-open):', err)
|
||||
}
|
||||
|
||||
// 2. Parse request body
|
||||
const body = await req.json() as SuggestChartsRequest
|
||||
const { content, selection, noteId } = body
|
||||
|
||||
if (!content || content.trim().length === 0) {
|
||||
return Response.json({
|
||||
error: 'Content is required',
|
||||
suggestions: [],
|
||||
analyzedText: '',
|
||||
detectedData: '',
|
||||
hasData: false,
|
||||
} satisfies SuggestChartsResponse, { status: 400 })
|
||||
}
|
||||
|
||||
const textToAnalyze = selection && selection.trim() ? selection.trim() : content.trim()
|
||||
|
||||
// 3. Build AI context
|
||||
const sysConfig = await getSystemConfig()
|
||||
const { provider, model, timingInfo } = await resolveAiRouteWithTiming('suggest-charts', sysConfig)
|
||||
|
||||
// Build context for AI
|
||||
const toolContext = {
|
||||
userId,
|
||||
noteId,
|
||||
config: sysConfig,
|
||||
}
|
||||
|
||||
// Use the suggest_charts tool
|
||||
const suggestChartsTool = toolRegistry.get('suggest_charts')
|
||||
const tools = suggestChartsTool ? { suggest_charts: suggestChartsTool.buildTool(toolContext) } : {}
|
||||
|
||||
try {
|
||||
// 4. Call AI to analyze and suggest
|
||||
const { text } = await generateText({
|
||||
model: provider(model),
|
||||
system: `You are a data visualization assistant. Analyze the provided text and suggest appropriate chart types.
|
||||
|
||||
CRITICAL: Extract ONLY numerical data present in the text. Do NOT invent values.
|
||||
- If fewer than 2 data points exist, return hasData=false with empty suggestions
|
||||
- Each suggestion must use the SAME extracted data (only chart type differs)
|
||||
- Return exactly 3 suggestions when data exists
|
||||
- Provide clear rationale for each chart type choice
|
||||
|
||||
Response format (JSON):
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"type": "bar|horizontal-bar|line|area|pie|radar|funnel|gauge",
|
||||
"title": "Chart title",
|
||||
"data": [{"label": "Category", "value": 123}],
|
||||
"description": "What this chart shows",
|
||||
"rationale": "Why this chart type suits the data"
|
||||
}
|
||||
],
|
||||
"analyzedText": "Text that was analyzed",
|
||||
"detectedData": "Description of what data was found",
|
||||
"hasData": true
|
||||
}`,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Analyze this text and suggest 3 appropriate chart types:\n\n${textToAnalyze}`,
|
||||
},
|
||||
],
|
||||
tools,
|
||||
temperature: 0.3,
|
||||
})
|
||||
|
||||
// 5. Parse AI response
|
||||
let parsed: SuggestChartsResponse
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No JSON found in response')
|
||||
}
|
||||
parsed = JSON.parse(jsonMatch[0])
|
||||
} catch (e) {
|
||||
console.error('[suggest-charts] Failed to parse AI response:', text)
|
||||
// Return empty suggestions on parse error
|
||||
return Response.json({
|
||||
suggestions: [],
|
||||
analyzedText: textToAnalyze.substring(0, 100),
|
||||
detectedData: 'Failed to parse AI response',
|
||||
hasData: false,
|
||||
} satisfies SuggestChartsResponse)
|
||||
}
|
||||
|
||||
// Validate response structure
|
||||
if (!parsed.suggestions || !Array.isArray(parsed.suggestions)) {
|
||||
parsed.suggestions = []
|
||||
}
|
||||
|
||||
// Ensure exactly 3 suggestions when data exists
|
||||
if (parsed.hasData && parsed.suggestions.length !== 3) {
|
||||
parsed.suggestions = parsed.suggestions.slice(0, 3)
|
||||
}
|
||||
|
||||
// Validate each suggestion has required fields
|
||||
parsed.suggestions = parsed.suggestions.filter(s =>
|
||||
s.type && s.title && s.data && Array.isArray(s.data) && s.data.length >= 2
|
||||
)
|
||||
|
||||
// Track usage
|
||||
await trackFeatureUsage(userId, 'ai', 'suggest-charts', 1)
|
||||
|
||||
return Response.json(parsed satisfies SuggestChartsResponse)
|
||||
|
||||
} catch (error) {
|
||||
console.error('[suggest-charts] Error:', error)
|
||||
return Response.json({
|
||||
error: 'Failed to generate chart suggestions',
|
||||
suggestions: [],
|
||||
analyzedText: textToAnalyze.substring(0, 100),
|
||||
detectedData: 'Error occurred during analysis',
|
||||
hasData: false,
|
||||
} satisfies SuggestChartsResponse, { status: 500 })
|
||||
}
|
||||
}
|
||||
244
memento-note/components/chart-suggestions-dialog.tsx
Normal file
244
memento-note/components/chart-suggestions-dialog.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { NoteChartFromCode } from './note-chart'
|
||||
import { suggestCharts, chartSuggestionToMarkdown, type ChartSuggestion, type SuggestChartsResponse } from '@/lib/ai/services/chart-suggestion.service'
|
||||
import { BarChart3, X, Search, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ChartSuggestionsDialogProps {
|
||||
isOpen: boolean
|
||||
content: string
|
||||
selection: string | null
|
||||
noteId?: string
|
||||
onClose: () => void
|
||||
onSelectChart: (markdown: string) => void
|
||||
}
|
||||
|
||||
export function ChartSuggestionsDialog({
|
||||
isOpen,
|
||||
content,
|
||||
selection,
|
||||
noteId,
|
||||
onClose,
|
||||
onSelectChart,
|
||||
}: ChartSuggestionsDialogProps) {
|
||||
const [response, setResponse] = useState<SuggestChartsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
let aborted = false
|
||||
|
||||
if (isOpen) {
|
||||
setResponse(null)
|
||||
setLoading(true)
|
||||
setSelectedIndex(null)
|
||||
|
||||
// Call the suggestion API
|
||||
suggestCharts({ content, selection, noteId })
|
||||
.then(data => {
|
||||
if (aborted) return
|
||||
setResponse(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => {
|
||||
if (aborted) return
|
||||
console.error('[ChartSuggestionsDialog] Error:', err)
|
||||
setResponse({
|
||||
suggestions: [],
|
||||
analyzedText: '',
|
||||
detectedData: '',
|
||||
hasData: false,
|
||||
error: err.message || 'Failed to load suggestions',
|
||||
})
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
}, [isOpen, content, selection, noteId])
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
if (e.key === 'ArrowRight' && response?.suggestions) {
|
||||
setSelectedIndex(i => i === null ? 0 : (i + 1) % response.suggestions.length)
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && response?.suggestions) {
|
||||
setSelectedIndex(i => i === null ? 0 : (i === 0 ? response.suggestions.length - 1 : i - 1))
|
||||
}
|
||||
if (e.key === 'Enter' && selectedIndex !== null && response?.suggestions?.[selectedIndex]) {
|
||||
onSelectChart(chartSuggestionToMarkdown(response.suggestions[selectedIndex]))
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onClose, selectedIndex, response, onSelectChart])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const textToAnalyze = selection || content
|
||||
const isAnalyzingSelection = !!selection
|
||||
const wordCount = textToAnalyze.split(/\s+/).length
|
||||
|
||||
const handleSelectChart = (suggestion: ChartSuggestion) => {
|
||||
onSelectChart(chartSuggestionToMarkdown(suggestion))
|
||||
onClose()
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-background rounded-xl shadow-lg w-full max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart3 className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Chart Suggestions</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Search className="w-3 h-3 animate-spin" />
|
||||
Analyzing {isAnalyzingSelection ? 'selection' : 'note'} ({wordCount} words)...
|
||||
</span>
|
||||
) : response?.hasData ? (
|
||||
`Found ${response?.detectedData || 'data'}`
|
||||
) : (
|
||||
'No data detected'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-muted rounded-lg transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
|
||||
<p className="text-muted-foreground">Analyzing your content for chart data...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : response?.error ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center max-w-md">
|
||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
|
||||
<h3 className="text-lg font-semibold mb-2">Error</h3>
|
||||
<p className="text-muted-foreground">{response.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !response?.hasData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center max-w-md">
|
||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold mb-2">No Data Detected</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Try adding numerical data to your note. Charts work best with:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground text-left inline-block">
|
||||
<li>• Sales figures, metrics, or measurements</li>
|
||||
<li>• Lists with values (e.g., "Jan: 5000, Feb: 7500")</li>
|
||||
<li>• Percentages or proportions</li>
|
||||
<li>• Time-series data</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a chart type to insert:
|
||||
</p>
|
||||
|
||||
{/* Chart suggestions grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{response.suggestions.map((suggestion, index) => {
|
||||
const isSelected = selectedIndex === index
|
||||
const markdown = chartSuggestionToMarkdown(suggestion)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSelectChart(suggestion)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={cn(
|
||||
'text-left p-4 rounded-xl border-2 transition-all hover:shadow-md',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
>
|
||||
{/* Mini thumbnail */}
|
||||
<div className="aspect-video bg-card rounded-lg mb-3 overflow-hidden flex items-center justify-center p-2">
|
||||
<div className="w-full scale-75 origin-center">
|
||||
<NoteChartFromCode code={markdown} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart type badge */}
|
||||
<div className="mb-2">
|
||||
<span className="text-xs font-medium px-2 py-1 bg-primary/10 text-primary rounded-full">
|
||||
{suggestion.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-sm mb-1">{suggestion.title}</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground mb-2">{suggestion.description}</p>
|
||||
|
||||
{/* Rationale */}
|
||||
{suggestion.rationale && (
|
||||
<p className="text-xs text-muted-foreground/70 italic">
|
||||
"{suggestion.rationale}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Data preview */}
|
||||
<div className="mt-2 pt-2 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{suggestion.data.length} data points
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<div className="flex items-center justify-center gap-4 pt-4 text-xs text-muted-foreground">
|
||||
<span>← → Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
<span>Esc Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import Superscript from '@tiptap/extension-superscript'
|
||||
import Subscript from '@tiptap/extension-subscript'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import { ChartExtension } from './tiptap-chart-extension'
|
||||
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
import {
|
||||
@@ -30,7 +32,7 @@ import {
|
||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen, Presentation
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -140,6 +142,11 @@ const slashCommands: SlashItem[] = [
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', icon: BarChart3, category: 'IA Note', isAi: true, command: (e) => {
|
||||
// Handler will be called by SlashCommandMenu
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||
@@ -218,6 +225,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
Superscript,
|
||||
Subscript,
|
||||
Typography,
|
||||
ChartExtension,
|
||||
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
||||
],
|
||||
content: content || '',
|
||||
@@ -255,15 +263,62 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
|
||||
const lastEmittedContent = useRef<string>(content || '')
|
||||
|
||||
// Chart suggestions dialog state
|
||||
const [chartSuggestionsOpen, setChartSuggestionsOpen] = useState(false)
|
||||
const [currentNoteContent, setCurrentNoteContent] = useState(content || '')
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content !== undefined && content !== lastEmittedContent.current) {
|
||||
editor.commands.setContent(content || '')
|
||||
lastEmittedContent.current = content || ''
|
||||
}
|
||||
// Update current note content for chart suggestions
|
||||
if (content !== undefined) {
|
||||
setCurrentNoteContent(content || '')
|
||||
}
|
||||
}, [content, editor])
|
||||
|
||||
useImperativeHandle(ref, () => ({ getEditor: () => editor }), [editor])
|
||||
|
||||
// Chart suggestion handlers
|
||||
const handleOpenChartSuggestions = useCallback(() => {
|
||||
if (!editor || !editor.isEditable) return
|
||||
|
||||
// Get current selection text if any
|
||||
const { from, to, empty } = editor.state.selection
|
||||
const selectionText = !empty ? editor.state.doc.textBetween(from, to, ' ') : null
|
||||
|
||||
setChartSuggestionsOpen(true)
|
||||
}, [editor])
|
||||
|
||||
const handleSelectChart = useCallback((markdown: string) => {
|
||||
if (!editor || !editor.isEditable) return
|
||||
|
||||
try {
|
||||
// Insert the chart markdown at current position
|
||||
const { from } = editor.state.selection
|
||||
|
||||
// Check if we're in the middle of text, if so create a new paragraph
|
||||
const $pos = editor.state.doc.resolve(from)
|
||||
const isInTextNode = $pos.parent.type.name === 'paragraph'
|
||||
|
||||
if (isInTextNode && $pos.parentOffset > 0) {
|
||||
// Create a new paragraph after current one
|
||||
const afterPos = $pos.after()
|
||||
// Ensure we don't exceed document bounds
|
||||
if (afterPos < editor.state.doc.content.size) {
|
||||
editor.chain().focus().selectParentNode().insertContentAt(afterPos, '<p></p>').run()
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the chart
|
||||
editor.chain().focus().insertContent(markdown).run()
|
||||
} catch (error) {
|
||||
console.error('[handleSelectChart] Failed to insert chart:', error)
|
||||
toast.error('Failed to insert chart. Please try again.')
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<div className={cn('notion-editor-wrapper', className)}>
|
||||
{editor && (
|
||||
@@ -287,13 +342,23 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} />}
|
||||
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{imageInsert.open && (
|
||||
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
|
||||
)}
|
||||
|
||||
{chartSuggestionsOpen && (
|
||||
<ChartSuggestionsDialog
|
||||
isOpen={chartSuggestionsOpen}
|
||||
content={currentNoteContent}
|
||||
selection={editor.state.selection.empty ? null : editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' ')}
|
||||
onClose={() => setChartSuggestionsOpen(false)}
|
||||
onSelectChart={handleSelectChart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -539,7 +604,7 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertImage: (editor: Editor) => void }) {
|
||||
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
|
||||
const { t } = useLanguage()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -580,6 +645,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[28], title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', categoryId: 'ai' },
|
||||
]
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
@@ -615,10 +681,12 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
toastAi(err)
|
||||
}
|
||||
finally { setAiLoading(false) }
|
||||
} else if (item.title === 'Suggest Charts') {
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else {
|
||||
deleteSlashText(); item.command(editor); closeMenu()
|
||||
}
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, t])
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t])
|
||||
|
||||
const presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
|
||||
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
|
||||
|
||||
171
memento-note/components/tiptap-chart-extension.tsx
Normal file
171
memento-note/components/tiptap-chart-extension.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import { Node } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from '@tiptap/react'
|
||||
import { NoteChartFromCode } from './note-chart'
|
||||
import { useState } from 'react'
|
||||
import { Code, BarChart3, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* ChartExtension - TipTap Node extension for rendering chart blocks
|
||||
*
|
||||
* Detects <pre><code class="language-chart"> blocks and renders them
|
||||
* as visual charts using the NoteChartFromCode component.
|
||||
*/
|
||||
export const ChartExtension = Node.create({
|
||||
name: 'chartBlock',
|
||||
|
||||
group: 'block',
|
||||
|
||||
code: true,
|
||||
|
||||
defining: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
code: {
|
||||
default: '',
|
||||
parseHTML: element => {
|
||||
if (element instanceof HTMLElement) {
|
||||
const codeEl = element.querySelector('code')
|
||||
return codeEl?.textContent || ''
|
||||
}
|
||||
return ''
|
||||
},
|
||||
renderHTML: () => ({})
|
||||
},
|
||||
language: {
|
||||
default: 'chart',
|
||||
parseHTML: element => {
|
||||
if (element instanceof HTMLElement) {
|
||||
const codeEl = element.querySelector('code')
|
||||
// Check for class="language-chart" or data-language="chart"
|
||||
if (codeEl?.classList.contains('language-chart')) return 'chart'
|
||||
return element.getAttribute('data-language') || 'chart'
|
||||
}
|
||||
return 'chart'
|
||||
},
|
||||
renderHTML: attributes => ({
|
||||
'data-language': attributes.language,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
getAttrs: node => {
|
||||
if (typeof node === 'string') return false
|
||||
const element = node as HTMLElement
|
||||
const codeEl = element.querySelector('code')
|
||||
|
||||
// Detect chart blocks by class="language-chart"
|
||||
if (codeEl && codeEl.classList.contains('language-chart')) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['pre', { ...HTMLAttributes, class: 'language-chart' }, ['code', { class: 'language-chart' }, 0]]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ChartBlockView, {
|
||||
contentEditable: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* ChartBlockView - React component for rendering chart blocks in TipTap
|
||||
*
|
||||
* Features:
|
||||
* - Visual chart rendering with NoteChartFromCode
|
||||
* - Toggle between visual and code view
|
||||
* - Edit mode for modifying chart data
|
||||
* - Error handling for invalid chart data
|
||||
*/
|
||||
function ChartBlockView(props: any) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [parseError, setParseError] = useState(false)
|
||||
|
||||
const code = props.node?.attrs?.code || ''
|
||||
|
||||
// Check if chart code is valid when not editing
|
||||
const isValidChart = !isEditing && code.trim().length > 0
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<NodeViewWrapper className="notion-chart-code-block my-4">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border-b border-border">
|
||||
<Code className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Chart Code</span>
|
||||
<button
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="ml-auto text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
<NodeViewContent as="pre" className="p-4 bg-muted/30 rounded-b-lg overflow-x-auto" />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="notion-chart-block my-4 group relative">
|
||||
<div className="relative">
|
||||
{/* Chart visual rendering */}
|
||||
{isValidChart ? (
|
||||
<NoteChartFromCode code={code} />
|
||||
) : (
|
||||
<div className="my-6 rounded-xl border border-dashed border-border bg-muted/30 p-6">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<div>
|
||||
<p className="font-medium">Invalid Chart</p>
|
||||
<p className="text-sm">This chart block contains invalid or empty data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit button - visible on hover */}
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={cn(
|
||||
'absolute top-2 right-2 p-2 rounded-lg bg-background/80 backdrop-blur border border-border shadow-sm opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
'hover:bg-background hover:shadow-md'
|
||||
)}
|
||||
title="Edit chart source"
|
||||
>
|
||||
<Code className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Chart type indicator */}
|
||||
{isValidChart && (
|
||||
<div className="absolute top-2 left-2 px-2 py-1 rounded bg-background/80 backdrop-blur border border-border shadow-sm opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<BarChart3 className="w-3 h-3" />
|
||||
<span>Chart</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
80
memento-note/lib/ai/services/chart-suggestion.service.ts
Normal file
80
memento-note/lib/ai/services/chart-suggestion.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Chart Suggestion Service
|
||||
* Frontend service for calling the AI chart suggestions API
|
||||
*/
|
||||
|
||||
export interface ChartSuggestion {
|
||||
type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'radar' | 'funnel' | 'gauge'
|
||||
title: string
|
||||
data: { label: string; value: number }[]
|
||||
description: string
|
||||
rationale?: string
|
||||
}
|
||||
|
||||
export interface SuggestChartsResponse {
|
||||
suggestions: ChartSuggestion[]
|
||||
analyzedText: string
|
||||
detectedData: string
|
||||
hasData: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SuggestChartsRequest {
|
||||
content: string
|
||||
selection?: string | null
|
||||
noteId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the AI chart suggestions API
|
||||
* @param request - The request parameters
|
||||
* @returns Promise with the chart suggestions
|
||||
*/
|
||||
export async function suggestCharts(request: SuggestChartsRequest): Promise<SuggestChartsResponse> {
|
||||
try {
|
||||
const response = await fetch('/api/ai/suggest-charts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
return {
|
||||
suggestions: [],
|
||||
analyzedText: '',
|
||||
detectedData: '',
|
||||
hasData: false,
|
||||
error: errorData.error || `HTTP ${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data as SuggestChartsResponse
|
||||
} catch (error) {
|
||||
console.error('[suggestCharts] Network error:', error)
|
||||
return {
|
||||
suggestions: [],
|
||||
analyzedText: '',
|
||||
detectedData: '',
|
||||
hasData: false,
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a chart suggestion to markdown code block format
|
||||
* @param suggestion - The chart suggestion to convert
|
||||
* @returns Markdown code block string
|
||||
*/
|
||||
export function chartSuggestionToMarkdown(suggestion: ChartSuggestion): string {
|
||||
const lines = [
|
||||
suggestion.type,
|
||||
suggestion.title,
|
||||
...suggestion.data.map(d => `${d.label}: ${d.value}`),
|
||||
]
|
||||
return `\`\`\`chart\n${lines.join('\n')}\n\`\`\``
|
||||
}
|
||||
104
memento-note/lib/ai/tools/chart-suggestion.tool.ts
Normal file
104
memento-note/lib/ai/tools/chart-suggestion.tool.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Chart Suggestion Tool for Notes
|
||||
* AI analyzes note content and suggests appropriate chart types with data
|
||||
*/
|
||||
|
||||
import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import { toolRegistry } from './registry'
|
||||
|
||||
// Chart suggestion data structures
|
||||
export interface ChartSuggestion {
|
||||
type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'radar' | 'funnel' | 'gauge'
|
||||
title: string
|
||||
data: { label: string; value: number }[]
|
||||
description: string
|
||||
rationale?: string
|
||||
}
|
||||
|
||||
export interface SuggestChartsResponse {
|
||||
suggestions: ChartSuggestion[]
|
||||
analyzedText: string
|
||||
detectedData: string
|
||||
hasData: boolean
|
||||
}
|
||||
|
||||
toolRegistry.register({
|
||||
name: 'suggest_charts',
|
||||
description: 'Analyze content and suggest appropriate chart types with extracted data',
|
||||
isInternal: true,
|
||||
buildTool: (ctx) =>
|
||||
tool({
|
||||
description: `Analyze the provided text content and suggest 3 appropriate chart types with extracted data.
|
||||
|
||||
Available chart types:
|
||||
- "bar": Vertical bar chart (best for comparing values across categories)
|
||||
- "horizontal-bar": Horizontal bar chart (best for long category labels)
|
||||
- "line": Line chart (best for trends over time or sequences)
|
||||
- "area": Area chart (filled line chart, best for showing magnitude over time)
|
||||
- "pie": Pie chart (best for showing proportions/percentages of a whole)
|
||||
- "radar": Radar chart (best for comparing multiple dimensions)
|
||||
- "funnel": Funnel chart (best for showing stages in a process)
|
||||
- "gauge": Gauge chart (best for single values vs a target)
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Extract ONLY numerical data present in the text - do NOT invent or fabricate values
|
||||
2. If fewer than 2 data points exist, return empty suggestions array with hasData=false
|
||||
3. Each suggestion MUST use the SAME extracted data - only the chart type differs
|
||||
4. Provide a clear rationale explaining WHY each chart type suits the data
|
||||
5. Generate meaningful labels - if the text provides context (months, categories, names), use those; otherwise use generic labels like "Item 1", "Item 2", etc.
|
||||
|
||||
Data extraction examples:
|
||||
- "Sales: Jan $5000, Feb $7500, Mar $6200" → [{label:"Jan",value:5000}, {label:"Feb",value:7500}, {label:"Mar",value:6200}]
|
||||
- "Product A: 45%, Product B: 30%, Product C: 25%" → [{label:"Product A",value:45}, {label:"Product B",value:30}, {label:"Product C",value:25}]
|
||||
- "Progress: Q1=10, Q2=25, Q3=40, Q4=60" → [{label:"Q1",value:10}, {label:"Q2",value:25}, {label:"Q3",value:40}, {label:"Q4",value:60}]
|
||||
|
||||
Output format:
|
||||
Return exactly 3 chart suggestions with different types. Order by relevance (most suitable first).
|
||||
|
||||
Example response for sales data:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"type": "bar",
|
||||
"title": "Sales by Month",
|
||||
"data": [{"label":"Jan","value":5000},{"label":"Feb","value":7500},{"label":"Mar","value":6200}],
|
||||
"description": "Bar chart comparing sales across months",
|
||||
"rationale": "Best for direct comparison of values between categories"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"title": "Sales Trend",
|
||||
"data": [{"label":"Jan","value":5000},{"label":"Feb","value":7500},{"label":"Mar","value":6200}],
|
||||
"description": "Line chart showing sales progression over time",
|
||||
"rationale": "Ideal for visualizing trends and changes over time periods"
|
||||
},
|
||||
{
|
||||
"type": "area",
|
||||
"title": "Sales Volume",
|
||||
"data": [{"label":"Jan","value":5000},{"label":"Feb","value":7500},{"label":"Mar","value":6200}],
|
||||
"description": "Area chart emphasizing sales magnitude",
|
||||
"rationale": "Similar to line but emphasizes volume/proportion visually"
|
||||
}
|
||||
],
|
||||
"analyzedText": "Sales: Jan $5000, Feb $7500, Mar $6200",
|
||||
"detectedData": "3 data points: sales figures for Jan, Feb, Mar",
|
||||
"hasData": true
|
||||
}`,
|
||||
inputSchema: z.object({
|
||||
content: z.string().describe('The full note content to analyze for chart data'),
|
||||
selection: z.string().optional().describe('Optional selected text - if provided, analyze only this instead of full content'),
|
||||
}),
|
||||
execute: async ({ content, selection }) => {
|
||||
const textToAnalyze = selection && selection.trim() ? selection.trim() : content.trim()
|
||||
|
||||
// This will be processed by the AI model
|
||||
// The AI will extract data and generate suggestions
|
||||
return {
|
||||
textToAnalyze,
|
||||
// The actual suggestion generation happens in the AI response
|
||||
// This tool provides the context for the AI to work with
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user