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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user