fix(chart): improve error handling and color variety

- Add quotaExceeded flag to response for better error UX
- Show dedicated quota exceeded state with upgrade button
- Improve AI prompt to better detect data patterns
- Add chart type-specific colors (blue, indigo, emerald, violet, etc.)
- Replace generic primary/10 colors with varied accent colors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-23 09:19:52 +00:00
parent a122a0eade
commit 18ffd76c1e
16 changed files with 1042 additions and 134 deletions

View File

@@ -90,11 +90,23 @@ export async function POST(req: Request) {
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.
DATA EXTRACTION RULES:
- Extract ANY numerical data present in the text
- Look for patterns like: "X: 123", "X = 123", "X is 123", "X (123)", "123X", "X $123", "123%"
- Accept partial data (e.g., if only 2 values exist, that's still valid for a simple chart)
- 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
CHART TYPE SELECTION:
- bar: Comparing values across categories (default choice)
- horizontal-bar: Categories with long labels
- line: Time series or sequential data
- area: Time series where magnitude matters
- pie: Parts of a whole (percentages or proportions)
- radar: Comparing multiple dimensions
- funnel: Stages or conversion steps
- gauge: Single value vs target
Response format (JSON):
{

View File

@@ -225,7 +225,25 @@ Only use tools if you need more information. Never invent note IDs or URLs.
- document_search: Searches attached PDF documents for the current note/notebook. Use when the user asks about documents or files.
- task_extract: Extracts action items from notes and creates a synthesis note. Use when the user asks to extract tasks or TODOs.
- note_find_and_update: Finds a note by search query and appends/prepends/replaces content. Use when the user says "find the note about X and add Y to it".
- insert_chart: Generates a chart (bar, line, area, pie, radar) and inserts it directly into the note. Use when the user asks "make a chart", "create a graph", "visualize this data", "show me a chart of X". Chart types: bar (comparisons), horizontal-bar (long labels), line/area (trends), pie (proportions), radar (comparisons).`,
- insert_chart: Generates a chart and inserts it directly into the note. Use when the user asks "make a chart", "create a graph", "visualize this data", "show me a chart of X".
IMPORTANT: Chart format MUST be exactly:
\`\`\`chart
{type}
{title}
{label}: {value}
{label}: {value}
\`\`\`
Example for sales chart:
\`\`\`chart
bar
Sales by Month
Jan: 5000
Feb: 7500
Mar: 6200
\`\`\`
Available types: bar, horizontal-bar, line, area, pie, radar. NEVER use Mermaid or other formats.`,
},
fr: {
contextWithNotes: `## Notes et documents de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Pour les documents PDF, cite le nom du fichier et la page, ex: "Le chiffre d'affaires est de 5M$ (📄 rapport.pdf p.12)". Ne recopie pas mot pour mot — reformule.`,
@@ -260,7 +278,25 @@ Tu as accès à : note_search, note_read, note_find_and_update, document_search,
- document_search : Recherche dans les documents PDF attachés à la note/au carnet.
- task_extract : Extrait les tâches/action items des notes et crée une note de synthèse.
- note_find_and_update : Trouve une note par recherche textuelle et ajoute/prépose/remplace du contenu. Utilise quand l'utilisateur dit "trouve la note sur X et ajoute-y Y".
- insert_chart : Génère un graphique (barres, ligne, aire, circulaire, radar) et l'insère directement dans la note. Utilise quand l'utilisateur demande "fais un graphique", "crée un chart", "visualise ces données", "montre-moi un chart de X". Types : bar (comparaisons), horizontal-bar (labels longs), line/area (tendances), pie (proportions), radar (comparaisons).`,
- insert_chart : Génère un graphique et l'insère directement dans la note. Utilise quand l'utilisateur demande "fais un graphique", "crée un chart", "visualise ces données".
IMPORTANT : Le format du graphique DOIT être exactement :
\`\`\`chart
{type}
{titre}
{label}: {valeur}
{label}: {valeur}
\`\`\`
Exemple pour un graphique de ventes :
\`\`\`chart
bar
Ventes par mois
Jan: 5000
Fév: 7500
Mar: 6200
\`\`\`
Types disponibles : bar, horizontal-bar, line, area, pie, radar. JAMAIS utiliser Mermaid ou d'autres formats.`,
},
fa: {
contextWithNotes: `## یادداشت‌های کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشت‌های بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید.`,
@@ -352,8 +388,17 @@ Focus ONLY on this note unless asked otherwise.`
}
const chatTools = noteContext
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, notebookId: notebookId || undefined })
: toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, notebookId: notebookId || undefined })
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, notebookId: notebookId || undefined, noteId })
: toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, notebookId: notebookId || undefined, noteId })
// Detect if user is asking for a chart/visualization to force tool usage
const lastMessage = currentMessage.toLowerCase()
const chartKeywords = [
'chart', 'graph', 'graphique', 'graphe', 'charte', 'visuali', 'diagramme',
'plot', 'courbe', 'histogram', 'bar', 'pie', 'line', 'area', 'radar',
'données', 'donnée', 'data', 'stat', 'mrr', 'arr', 'revenu', 'sales', 'vente'
]
const wantsChart = chartKeywords.some(k => lastMessage.includes(k))
const { result, usedByok } = await runLaneWithBillingUser(
'chat',
@@ -365,6 +410,7 @@ Focus ONLY on this note unless asked otherwise.`
system: systemPrompt,
messages: incomingMessages,
tools: chatTools,
toolChoice: wantsChart && chatTools.insert_chart ? { type: 'tool', toolName: 'insert_chart' } : undefined,
stopWhen: stepCountIs(5),
onFinish: async (final) => {
const userContent = incomingMessages[incomingMessages.length - 1].content

View File

@@ -110,10 +110,8 @@ export default async function RootLayout({
>
<head />
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable}`}>
<Script
id="theme-early"
strategy="worker"
dangerouslySetInnerHTML={{ __html: getThemeScript(userSettings.theme) }}
<script
dangerouslySetInnerHTML={{ __html: getThemeScript(userSettings.theme, userSettings.accentColor) }}
/>
<Script
id="sw-cleanup"

View File

@@ -4,9 +4,21 @@ 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 { BarChart3, X, Search, AlertCircle, Upgrade } from 'lucide-react'
import { cn } from '@/lib/utils'
// Chart type to color mapping for visual variety
const CHART_TYPE_COLORS: Record<string, string> = {
bar: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
'horizontal-bar': 'bg-indigo-500/10 text-indigo-600 border-indigo-500/20',
line: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
area: 'bg-teal-500/10 text-teal-600 border-teal-500/20',
pie: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
radar: 'bg-fuchsia-500/10 text-fuchsia-600 border-fuchsia-500/20',
funnel: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
gauge: 'bg-rose-500/10 text-rose-600 border-rose-500/20',
}
interface ChartSuggestionsDialogProps {
isOpen: boolean
content: string
@@ -107,7 +119,7 @@ export function ChartSuggestionsDialog({
{/* 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" />
<BarChart3 className="w-5 h-5 text-blue-500" />
<div>
<h2 className="text-lg font-semibold">Chart Suggestions</h2>
<p className="text-sm text-muted-foreground">
@@ -142,6 +154,22 @@ export function ChartSuggestionsDialog({
<p className="text-muted-foreground">Analyzing your content for chart data...</p>
</div>
</div>
) : response?.quotaExceeded ? (
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<Upgrade className="w-12 h-12 mx-auto mb-4 text-orange-500" />
<h3 className="text-lg font-semibold mb-2">AI Quota Exceeded</h3>
<p className="text-muted-foreground mb-4">
{response.error || 'You have reached your AI usage limit.'}
</p>
<button
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={() => (window.location.href = '/settings/billing')}
>
Upgrade Plan
</button>
</div>
</div>
) : response?.error ? (
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
@@ -186,8 +214,8 @@ export function ChartSuggestionsDialog({
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'
? (CHART_TYPE_COLORS[suggestion.type]?.replace('/10', '/20') || 'border-blue-500 bg-blue-500/5')
: 'border-border hover:border-blue-500/30'
)}
>
{/* Mini thumbnail */}
@@ -199,7 +227,10 @@ export function ChartSuggestionsDialog({
{/* Chart type badge */}
<div className="mb-2">
<span className="text-xs font-medium px-2 py-1 bg-primary/10 text-primary rounded-full">
<span className={cn(
'text-xs font-medium px-2 py-1 rounded-full border',
CHART_TYPE_COLORS[suggestion.type] || CHART_TYPE_COLORS.bar
)}>
{suggestion.type}
</span>
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import type { CSSProperties } from 'react'
import dynamic from 'next/dynamic'
import type { PresentationSpec, SlideSpec, Palette } from '@/lib/types/presentation'
@@ -213,6 +213,8 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
const cardBorder = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'
const accentBar: CSSProperties = { width: 48, height: 4, background: palette.accent, borderRadius: 2, marginBottom: 24, flexShrink: 0 }
const content = slide.content ?? []
switch (layout) {
// ── TITLE ────────────────────────────────────────────────────────────
case 'title':
@@ -234,10 +236,10 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
return (
<div style={{ width: '100%', height: '100%', background: isDark ? palette.primary : palette.primary, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '52px 80px', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', right: 40, bottom: -30, fontSize: 200, fontWeight: 900, color: 'rgba(255,255,255,0.05)', lineHeight: 1, letterSpacing: '-0.06em', userSelect: 'none' as const, pointerEvents: 'none' as const }}>
{slide.content[0] ?? String(index).padStart(2, '0')}
{content[0] ?? String(index).padStart(2, '0')}
</div>
<span style={{ display: 'inline-block', background: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.7)', fontSize: 12, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase' as const, padding: '6px 16px', borderRadius: 100, marginBottom: 20, alignSelf: 'flex-start' }}>
Section {slide.content[0] ?? String(index).padStart(2, '0')}
Section {content[0] ?? String(index).padStart(2, '0')}
</span>
<h2 style={{ color: '#fff', fontSize: 44, fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 1.05, margin: 0, maxWidth: 780 }}>{slide.title}</h2>
{slide.subtitle && <p style={{ color: 'rgba(255,255,255,0.55)', fontSize: 18, marginTop: 12 }}>{slide.subtitle}</p>}
@@ -262,7 +264,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title || 'Sommaire'}</h2>
<div style={accentBar} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{slide.content.map((item, i) => (
{content.map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '12px 18px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
<span style={{ fontWeight: 700, fontSize: 14, letterSpacing: '0.08em', color: palette.accent, minWidth: 28 }}>{String(i + 1).padStart(2, '0')}</span>
<span style={{ fontSize: 16, fontWeight: 500, color: text }}>{item}</span>
@@ -279,7 +281,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
<div style={accentBar} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, justifyContent: 'center' }}>
{slide.content.map((item, i) => (
{content.map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 16, padding: '4px 0' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: palette.accent, marginTop: 8, flexShrink: 0 }} />
<span style={{ fontSize: 18, lineHeight: 1.6, color: text }}>{item}</span>
@@ -291,9 +293,9 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
// ── TWO COLUMN ──────────────────────────────────────────────────────
case 'two-column': {
const mid = Math.ceil(slide.content.length / 2)
const left = slide.content.slice(0, mid)
const right = slide.content.slice(mid)
const mid = Math.ceil(content.length / 2)
const left = content.slice(0, mid)
const right = content.slice(mid)
const heads = (slide.subtitle ?? '/').split('/')
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
@@ -318,7 +320,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
// ── CARDS ────────────────────────────────────────────────────────────
case 'cards': {
const items = slide.content.slice(0, 6)
const items = content.slice(0, 6)
const cols = items.length <= 2 ? 2 : items.length <= 3 ? 3 : items.length === 4 ? 2 : 3
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
@@ -345,7 +347,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
// ── STATS ────────────────────────────────────────────────────────────
case 'stats': {
const items = slide.content.slice(0, 4)
const items = content.slice(0, 4)
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
@@ -375,7 +377,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title || 'En résumé'}</h2>
<div style={accentBar} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, justifyContent: 'center' }}>
{slide.content.slice(0, 6).map((item, i) => (
{content.slice(0, 6).map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '14px 20px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
<div style={{ width: 26, height: 26, minWidth: 26, background: palette.accent, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: '#fff', fontWeight: 900 }}></div>
<span style={{ fontSize: 16, lineHeight: 1.45, color: text }}>{item}</span>
@@ -421,13 +423,13 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
: <div style={{ color: muted, fontSize: 14 }}>No image</div>
}
</div>
{slide.content[0] && <p style={{ margin: '12px 0 0', fontSize: 13, textAlign: 'center', color: muted }}>{slide.content[0]}</p>}
{content[0] && <p style={{ margin: '12px 0 0', fontSize: 13, textAlign: 'center', color: muted }}>{content[0]}</p>}
</div>
)
// ── TIMELINE ────────────────────────────────────────────────────────
case 'timeline': {
const items = slide.content.slice(0, 8)
const items = content.slice(0, 8)
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
@@ -455,7 +457,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
// ── KPI DASHBOARD ───────────────────────────────────────────────────
case 'kpi-dashboard': {
const items = slide.content.slice(0, 6)
const items = content.slice(0, 6)
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
@@ -484,8 +486,8 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
// ── DATA TABLE ──────────────────────────────────────────────────────
case 'data-table': {
const headers = slide.tableHeaders ?? (slide.content[0]?.split('|').map(s => s.trim()) ?? [])
const rows = slide.tableRows ?? slide.content.slice(headers === slide.tableHeaders ? 0 : 1).map(row => row.split('|').map(s => s.trim()))
const headers = slide.tableHeaders ?? (content[0]?.split('|').map(s => s.trim()) ?? [])
const rows = slide.tableRows ?? content.slice(headers === slide.tableHeaders ? 0 : 1).map(row => row.split('|').map(s => s.trim()))
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
@@ -523,7 +525,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
<div style={accentBar} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, justifyContent: 'center' }}>
{slide.content.map((item, i) => (
{content.map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 16, padding: '4px 0' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: palette.accent, marginTop: 8, flexShrink: 0 }} />
<span style={{ fontSize: 18, lineHeight: 1.6, color: text }}>{item}</span>
@@ -535,6 +537,127 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
}
}
// ── Normalize slide formats (adapter) ──────────────────────────────────────────
function normalizeSlide(slide: any, index: number): SlideSpec {
if (!slide) return { title: '', content: [], layout: 'content' }
if (Array.isArray(slide.content) && slide.layout !== undefined) {
return slide as SlideSpec
}
const type = slide.type ?? (index === 0 ? 'title' : 'content')
switch (type) {
case 'title':
return {
title: slide.title ?? '',
subtitle: slide.subtitle,
content: [],
layout: 'title',
notes: slide.notes
}
case 'bullets':
return {
title: slide.title ?? '',
content: slide.items ?? [],
layout: 'content',
notes: slide.notes
}
case 'chart':
return {
title: slide.title ?? '',
subtitle: slide.subtitle,
content: [],
layout: 'chart',
chart: {
type: slide.chartType ?? 'bar',
data: slide.data ?? []
},
notes: slide.notes
}
case 'stats':
return {
title: slide.title ?? '',
content: (slide.stats ?? []).map((s: any) => `${s.value} - ${s.label}`),
layout: 'stats',
notes: slide.notes
}
case 'table':
return {
title: slide.title ?? '',
content: [],
tableHeaders: slide.headers ?? [],
tableRows: slide.rows ?? [],
layout: 'data-table',
notes: slide.notes
}
case 'cards':
return {
title: slide.title ?? '',
content: (slide.cards ?? []).map((c: any) => `${c.title} : ${c.description}`),
layout: 'cards',
notes: slide.notes
}
case 'timeline':
return {
title: slide.title ?? '',
content: (slide.events ?? []).map((e: any) => `${e.date} : ${e.title}${e.description ? ' — ' + e.description : ''}`),
layout: 'timeline',
notes: slide.notes
}
case 'quote':
return {
title: slide.quote ?? '',
subtitle: slide.author ? `${slide.author}${slide.context ? ' (' + slide.context + ')' : ''}` : '',
content: [],
layout: 'quote',
notes: slide.notes
}
case 'comparison':
return {
title: slide.title ?? '',
subtitle: `${slide.left?.title ?? ''} / ${slide.right?.title ?? ''}`,
content: [...(slide.left?.points ?? []), ...(slide.right?.points ?? [])],
layout: 'two-column',
notes: slide.notes
}
case 'equation':
return {
title: slide.title ?? '',
subtitle: slide.explanation,
content: (slide.equations ?? []).map((eq: any) => `${eq.latex}${eq.label ? ' — ' + eq.label : ''}`),
layout: 'content',
notes: slide.notes
}
case 'image':
return {
title: slide.title ?? '',
imageUrl: slide.url,
content: slide.caption ? [slide.caption] : [],
layout: 'image',
notes: slide.notes
}
case 'summary':
return {
title: slide.title ?? '',
content: slide.items ?? [],
layout: 'summary',
notes: slide.notes
}
default:
return {
title: slide.title ?? '',
content: slide.content ?? [],
layout: 'content',
subtitle: slide.subtitle,
imageUrl: slide.imageUrl,
notes: slide.notes,
chart: slide.chart,
mermaid: slide.mermaid,
tableHeaders: slide.tableHeaders,
tableRows: slide.tableRows
}
}
}
// ══════════════════════════════════════════════════════════════════════════════
// MAIN RENDERER — Pure React + CSS transitions (no Reveal.js dependency)
// ══════════════════════════════════════════════════════════════════════════════
@@ -544,8 +667,14 @@ export interface SlidesRendererProps {
export function SlidesRenderer({ spec }: SlidesRendererProps) {
const [current, setCurrent] = useState(0)
const [showNotes, setShowNotes] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const total = spec.slides.length
const normalizedSlides = useMemo(() => {
return (spec.slides ?? []).map((slide, i) => normalizeSlide(slide, i))
}, [spec.slides])
const total = normalizedSlides.length
const { palette } = resolvePalette(spec)
const radius = resolveRadius(spec.style)
const isDark = palette.isDark
@@ -561,6 +690,7 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') { e.preventDefault(); next() }
if (e.key === 'ArrowLeft' || e.key === 'Backspace') { e.preventDefault(); prev() }
if (e.key === 'n' || e.key === 'N') { e.preventDefault(); setShowNotes(n => !n) }
}
let startX = 0
@@ -606,6 +736,8 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
boxShadow: isDark ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
})
const currentSlideNotes = normalizedSlides[current]?.notes
return (
<div
ref={containerRef}
@@ -620,7 +752,7 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
>
{/* Slides */}
<div style={{ position: 'absolute', inset: 0 }}>
{spec.slides.map((slide, i) => {
{normalizedSlides.map((slide, i) => {
const offset = i - current
return (
<div
@@ -656,9 +788,65 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
<div style={{ height: '100%', width: `${((current + 1) / total) * 100}%`, background: palette.accent, transition: 'width 0.4s ease', borderRadius: '0 2px 2px 0' }} />
</div>
{/* Slide counter */}
<div style={{ position: 'absolute', bottom: 12, right: 16, zIndex: 40, fontSize: 12, fontWeight: 600, color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.35)', background: isDark ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.8)', padding: '3px 10px', borderRadius: 100, backdropFilter: 'blur(4px)' }}>
{current + 1} / {total}
{/* Presenter Speaker Notes Overlay */}
{showNotes && currentSlideNotes && (
<div
style={{
position: 'absolute',
bottom: 24,
left: 24,
right: 24,
padding: '16px 20px',
borderRadius: radius || '12px',
background: isDark ? 'rgba(15, 23, 42, 0.85)' : 'rgba(255, 255, 255, 0.9)',
border: `1px solid ${isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)'}`,
backdropFilter: 'blur(16px)',
color: isDark ? '#f8fafc' : '#0f172a',
zIndex: 100,
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.25)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: palette.accent }}>
Notes de présentation
</span>
<button
onClick={() => setShowNotes(false)}
style={{ background: 'none', border: 'none', color: isDark ? '#94a3b8' : '#64748b', cursor: 'pointer', fontSize: 13, padding: 4 }}
title="Masquer les notes"
>
</button>
</div>
<p style={{ margin: 0, fontSize: 14, lineHeight: 1.6, fontWeight: 500 }}>
{currentSlideNotes}
</p>
</div>
)}
{/* Controls: Slide counter & Speaker notes toggle */}
<div style={{ position: 'absolute', bottom: 12, right: 16, zIndex: 40, display: 'flex', gap: 8 }}>
{currentSlideNotes && (
<button
onClick={() => setShowNotes(!showNotes)}
style={{
fontSize: 11, fontWeight: 700,
color: showNotes ? '#fff' : (isDark ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.65)'),
background: showNotes ? palette.accent : (isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)'),
border: `1px solid ${showNotes ? 'transparent' : (isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)')}`,
padding: '4px 12px', borderRadius: 100, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6, transition: 'all 0.2s',
backdropFilter: 'blur(4px)',
}}
title="Bascule des notes de présentation (Raccourci: N)"
>
<span>📝</span>
<span>Notes</span>
</button>
)}
<div style={{ fontSize: 12, fontWeight: 600, color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.35)', background: isDark ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.8)', padding: '3px 10px', borderRadius: 100, backdropFilter: 'blur(4px)', border: `1px solid ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
{current + 1} / {total}
</div>
</div>
</div>
)

View File

@@ -3,15 +3,50 @@
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { Loader2, Network, Filter, X, ExternalLink, Maximize2 } from 'lucide-react'
import {
Loader2,
Network,
Filter,
X,
ExternalLink,
Maximize2,
Calendar,
Clock,
Link2,
FileText,
Check,
Tag,
Sparkles,
ChevronRight,
BookOpen
} from 'lucide-react'
import DOMPurify from 'isomorphic-dompurify'
import { markdownToHtml } from '@/lib/markdown-to-html'
import { useLanguage } from '@/lib/i18n'
import { LabelBadge } from './label-badge'
import { NoteChecklist } from './note-checklist'
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false })
const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), {
ssr: false,
loading: () => <div className="h-20 w-full animate-pulse bg-concrete/5 rounded" />
})
interface GraphNode { id: string; title: string; notebookId: string | null; createdAt: string; degree: number }
interface GraphEdge { source: string; target: string; weight: number; type: string }
interface Cluster { id: string; name: string }
interface RawData { nodes: GraphNode[]; edges: GraphEdge[]; clusters: Cluster[] }
interface NotePreview { id: string; title: string; content: string; createdAt: string }
interface NotePreview {
id: string
title: string | null
content: string
createdAt: string | Date
updatedAt?: string | Date
labels?: string[] | null
type?: 'text' | 'markdown' | 'richtext' | 'checklist'
checkItems?: { id: string; text: string; checked: boolean }[] | null
isMarkdown?: boolean
}
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
@@ -29,6 +64,39 @@ export function NoteGraphView() {
const [previewLoading, setPreviewLoading] = useState(false)
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(null)
const { t } = useLanguage()
const plainText = useCallback((html: string | null | undefined) =>
(html ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/#{1,6}\s/g, '')
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
.replace(/_{1,2}([^_]+)_{1,2}/g, '$1')
.replace(/`[^`]+`/g, '')
.replace(/!?\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\s+/g, ' ').trim().slice(0, 400), [])
const htmlContent = useMemo(() => {
if (!notePreview?.content) return ''
const isMarkdown = notePreview.type === 'markdown' || notePreview.isMarkdown || (!notePreview.content.includes('<') && !notePreview.content.includes('</'))
let rawHtml = notePreview.content
if (isMarkdown) {
rawHtml = markdownToHtml(notePreview.content)
}
return DOMPurify.sanitize(rawHtml)
}, [notePreview])
const wordCount = useMemo(() => {
if (!notePreview?.content) return 0
const text = plainText(notePreview.content)
return text.split(/\s+/).filter(Boolean).length
}, [notePreview, plainText])
const charCount = useMemo(() => {
if (!notePreview?.content) return 0
return plainText(notePreview.content).length
}, [notePreview, plainText])
// ─── Resize ───────────────────────────────────────────────────────────────
useEffect(() => {
const el = containerRef.current
@@ -172,15 +240,7 @@ export function NoteGraphView() {
graphRef.current?.zoomToFit(400, 50)
}, [])
const plainText = (html: string | null | undefined) =>
(html ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/#{1,6}\s/g, '')
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
.replace(/_{1,2}([^_]+)_{1,2}/g, '$1')
.replace(/`[^`]+`/g, '')
.replace(/!?\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\s+/g, ' ').trim().slice(0, 400)
// ─── Cluster painting (stable ref, no deps) ──────────────────────────────
const dataRef = useRef<{ nodes: any[]; colorMap: Map<string|null,string>; clusters: Cluster[] }>({ nodes: [], colorMap: new Map(), clusters: [] })
@@ -240,10 +300,10 @@ export function NoteGraphView() {
{/* Header */}
<div className="px-5 py-3 flex items-center gap-4 shrink-0 border-b border-border/40 bg-white">
<Network size={16} className="text-indigo-500" />
<h1 className="text-sm font-semibold text-ink">Vue en graphe</h1>
<h1 className="text-sm font-semibold text-ink">{t('graphView.title')}</h1>
{rawData && (
<span className="text-[10px] text-concrete/50 font-medium">
{rawData.nodes.length} notes · {rawData.edges.length} liens
{t('graphView.notesCount', { count: rawData.nodes.length })} · {t('graphView.connectionsCount', { count: rawData.edges.length })}
</span>
)}
<div className="flex-1" />
@@ -251,7 +311,7 @@ export function NoteGraphView() {
<Filter size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-concrete/40" />
<input
type="text"
placeholder="Filtrer…"
placeholder={t('graphView.searchPlaceholder')}
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
className="pl-7 pr-7 py-1.5 bg-white border border-border/60 rounded-md text-xs text-ink outline-none focus:border-indigo-400 w-44 placeholder:text-concrete/40"
@@ -324,7 +384,7 @@ export function NoteGraphView() {
{!loading && !error && graphData.nodes.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-concrete/40">
<Network size={32} />
<p className="text-xs">Aucune note trouvée</p>
<p className="text-xs">{t('graphView.noNotesFound')}</p>
</div>
)}
@@ -332,24 +392,24 @@ export function NoteGraphView() {
{!loading && graphData.nodes.length > 0 && (
<button
onClick={handleZoomToFit}
className="absolute top-4 right-4 z-10 flex items-center gap-1.5 px-3 py-1.5 bg-white border border-border/50 rounded-md text-[11px] text-ink font-medium shadow-sm hover:bg-gray-50 transition-colors"
className="absolute top-4 right-4 z-10 flex items-center gap-1.5 px-3 py-1.5 bg-white border border-border/50 rounded-md text-[11px] text-ink font-medium shadow-sm hover:bg-gray-50 transition-colors animate-fade-in"
>
<Maximize2 size={12} />
Vue globale
{t('graphView.globalView')}
</button>
)}
{/* Cluster legend (Interactive Notebook Filter) */}
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
<div className="absolute top-4 left-4 z-10 flex flex-col gap-2 max-h-[50vh] overflow-y-auto pr-1">
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">Carnets</span>
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">{t('graphView.notebooks')}</span>
{selectedNotebookId && (
<button
onClick={() => setSelectedNotebookId(null)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-rose-200 text-rose-600 rounded-full shadow-sm hover:bg-rose-50 transition-all text-[9px] font-semibold w-fit"
>
<X size={10} />
Réinitialiser
{t('graphView.resetFilter')}
</button>
)}
<div className="flex flex-col gap-1.5">
@@ -381,8 +441,8 @@ export function NoteGraphView() {
{/* Legend of relationship types */}
{!loading && !error && graphData.nodes.length > 0 && (
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm max-w-xs">
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider mb-1">Types de liaisons</h3>
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm max-w-xs select-none">
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider mb-1">{t('graphView.relationshipTypes')}</h3>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
@@ -410,44 +470,149 @@ export function NoteGraphView() {
{/* Note detail panel */}
{selectedNode && (
<div className="absolute inset-y-0 right-0 w-80 bg-white border-l border-border/50 flex flex-col shadow-xl z-20">
<div className="flex items-start justify-between p-4 border-b border-border/30">
<div className="flex-1 min-w-0 pr-2">
<h2 className="text-sm font-semibold text-ink leading-tight">{selectedNode.title}</h2>
<p className="text-[10px] text-concrete/50 mt-1">
{new Date(selectedNode.createdAt).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
{' · '}{selectedNode.degree} connexion{selectedNode.degree !== 1 ? 's' : ''}
</p>
<div className="absolute inset-y-0 right-0 w-80 backdrop-blur-md bg-white/95 dark:bg-stone-900/95 border-l border-border/40 flex flex-col shadow-[0_8px_30px_rgb(0,0,0,0.06)] z-20 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between p-5 border-b border-border/40">
<div className="flex-1 min-w-0 pr-3">
{selectedNotebookName && (
<button
onClick={() => setSelectedNotebookId(selectedNode.notebookId === selectedNotebookId ? null : selectedNode.notebookId)}
className="mt-2 inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold bg-slate-100 hover:bg-slate-200 text-concrete transition-colors border border-border/30 hover:scale-105 transition-all"
className="mb-2 inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider bg-slate-100 hover:bg-slate-200/80 text-concrete transition-all border border-border/20 hover:scale-105"
>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: colorMap.get(selectedNode.notebookId) ?? '#94a3b8' }} />
{selectedNotebookName}
</button>
)}
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-100 leading-snug tracking-tight select-all">
{selectedNode.title || <span className="italic text-concrete/40">{t('notes.untitled')}</span>}
</h2>
</div>
<button onClick={() => setSelectedNode(null)} className="p-1 rounded text-concrete/40 hover:text-ink hover:bg-black/5">
<button
onClick={() => setSelectedNode(null)}
className="p-1.5 rounded-full text-concrete/40 hover:text-slate-800 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-stone-800 transition-all"
>
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{previewLoading ? (
<Loader2 size={16} className="animate-spin text-concrete/30 mx-auto mt-8" />
) : (
<p className="text-xs text-concrete/70 leading-relaxed">
{plainText(notePreview?.content) || <span className="italic text-concrete/30">Note vide</span>}
</p>
{/* Quick Metadata Info */}
<div className="px-5 py-3.5 bg-slate-50/50 dark:bg-stone-950/20 border-b border-border/30 grid grid-cols-2 gap-y-2 gap-x-4 text-[10px] text-concrete/60 select-none">
<div className="flex items-center gap-1.5">
<Calendar size={11} className="text-concrete/40 shrink-0" />
<span className="truncate" title={new Date(selectedNode.createdAt).toLocaleString()}>
{new Date(selectedNode.createdAt).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</div>
<div className="flex items-center gap-1.5">
<Link2 size={11} className="text-concrete/40 shrink-0" />
<span>
{t(selectedNode.degree === 1 ? 'graphView.connections' : 'graphView.connectionsPlural', { count: selectedNode.degree })}
</span>
</div>
{!previewLoading && notePreview && (
<>
<div className="flex items-center gap-1.5">
<FileText size={11} className="text-concrete/40 shrink-0" />
<span>{t('graphView.preview.words', { count: wordCount })}</span>
</div>
{notePreview.updatedAt && (
<div className="flex items-center gap-1.5 col-span-2 border-t border-border/20 pt-1.5 mt-0.5">
<Clock size={11} className="text-concrete/40 shrink-0" />
<span className="truncate">
{t('graphView.preview.updated')}{' '}
{new Date(notePreview.updatedAt).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
</div>
)}
</>
)}
</div>
<div className="p-3 border-t border-border/30">
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{previewLoading ? (
/* Sleek Skeleton Loader */
<div className="space-y-4 animate-pulse">
<div className="h-4 bg-stone-200/60 dark:bg-stone-800/60 rounded w-3/4" />
<div className="space-y-2">
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-full" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-11/12" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-4/5" />
</div>
<div className="space-y-2 pt-2">
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-full" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-5/6" />
</div>
</div>
) : !notePreview || (!notePreview.content && (!notePreview.checkItems || notePreview.checkItems.length === 0)) ? (
<div className="flex flex-col items-center justify-center py-16 text-concrete/30 gap-2.5">
<FileText size={32} className="stroke-[1.2] text-concrete/20" />
<p className="text-xs italic font-medium">{t('graphView.preview.emptyNote')}</p>
</div>
) : (
<>
{/* Note Content Renderer */}
{notePreview.type === 'checklist' && notePreview.checkItems && notePreview.checkItems.length > 0 ? (
<div className="space-y-2 select-none">
<NoteChecklist
items={notePreview.checkItems}
onToggleItem={() => {}}
/>
</div>
) : notePreview.type === 'markdown' || notePreview.isMarkdown ? (
<div className="text-xs text-slate-600 dark:text-stone-300">
<MarkdownContent
content={notePreview.content}
className="prose-h1:text-sm prose-h1:font-bold prose-h1:text-slate-800 dark:prose-h1:text-stone-100 prose-h1:mt-3 prose-h1:mb-1 prose-h2:text-xs prose-h2:font-bold prose-h2:text-slate-700 dark:prose-h2:text-stone-200 prose-h2:mt-2 prose-h2:mb-1 prose-p:text-xs prose-p:leading-relaxed prose-p:mb-2 prose-ul:list-disc prose-ul:pl-4 prose-ol:list-decimal prose-ol:pl-4"
/>
</div>
) : (
<div
className="text-xs text-slate-600 dark:text-stone-300 space-y-2 leading-relaxed break-words
[&_h1]:text-sm [&_h1]:font-bold [&_h1]:text-slate-800 dark:[&_h1]:text-stone-100 [&_h1]:mt-4 [&_h1]:mb-1.5 [&_h1]:first:mt-0
[&_h2]:text-xs [&_h2]:font-bold [&_h2]:text-slate-700 dark:[&_h2]:text-stone-200 [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:first:mt-0
[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-slate-600 dark:[&_h3]:text-stone-300 [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:first:mt-0
[&_p]:mb-2 [&_p]:last:mb-0
[&_ul]:list-disc [&_ul]:pl-4 [&_ul]:mb-2
[&_ol]:list-decimal [&_ol]:pl-4 [&_ol]:mb-2
[&_li]:mb-0.5
[&_strong]:font-semibold [&_strong]:text-slate-800 dark:[&_strong]:text-stone-100
[&_em]:italic
[&_code]:px-1 [&_code]:py-0.5 [&_code]:bg-slate-100 dark:[&_code]:bg-stone-850 [&_code]:rounded [&_code]:font-mono [&_code]:text-[10px]
[&_pre]:p-2.5 [&_pre]:bg-slate-900 [&_pre]:text-slate-100 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre]:font-mono [&_pre]:text-[10px] [&_pre]:my-2
[&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 dark:[&_blockquote]:border-stone-700 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-500 [&_blockquote]:my-2
[&_a]:text-indigo-600 dark:[&_a]:text-indigo-400 [&_a]:underline [&_a]:hover:text-indigo-500"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
)}
{/* Refined Tags list */}
{Array.isArray(notePreview.labels) && notePreview.labels.length > 0 && (
<div className="border-t border-border/20 pt-4 mt-4 select-none">
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-800 dark:text-stone-300 uppercase tracking-wider mb-2">
<Tag size={10} className="text-concrete/40 shrink-0" />
<span>{t('graphView.preview.tags')}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{notePreview.labels.map((label: string) => (
<LabelBadge key={label} label={label} variant="default" />
))}
</div>
</div>
)}
</>
)}
</div>
{/* Premium Action Footer */}
<div className="p-4 border-t border-border/40 bg-slate-50/50 dark:bg-stone-950/20">
<button
onClick={() => router.push(`/notes/${selectedNode.id}`)}
className="w-full flex items-center justify-center gap-2 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-md transition-colors"
className="group w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-brand-accent hover:bg-brand-accent/90 text-white active:scale-[0.98] text-xs font-semibold rounded-lg shadow-sm transition-all duration-200"
>
<ExternalLink size={12} />
Ouvrir la note
<BookOpen size={12} className="group-hover:scale-110 transition-transform" />
<span>{t('graphView.preview.openNote')}</span>
<ChevronRight size={12} className="group-hover:translate-x-0.5 transition-transform ml-0.5" />
</button>
</div>
</div>

View File

@@ -1027,6 +1027,38 @@ You MUST use the task_extract tool. Do NOT respond with text, call the tool dire
},
}
function extractJsonFromText(text: string): any {
if (!text) return null
// Try direct parsing first
try {
const parsed = JSON.parse(text.trim())
if (parsed && typeof parsed === 'object') return parsed
} catch (e) {}
// Try extracting markdown code block
const jsonBlockRegex = /```json\s*([\s\S]*?)\s*```/i
const match = text.match(jsonBlockRegex)
if (match && match[1]) {
try {
const parsed = JSON.parse(match[1].trim())
if (parsed && typeof parsed === 'object') return parsed
} catch (e) {}
}
// Try extracting any { ... } or [ ... ] block
const braceRegex = /(\{[\s\S]*\}|\[[\s\S]*\])/
const braceMatch = text.match(braceRegex)
if (braceMatch && braceMatch[1]) {
try {
const parsed = JSON.parse(braceMatch[1].trim())
if (parsed && typeof parsed === 'object') return parsed
} catch (e) {}
}
return null
}
// --- Tool-Use Agent ---
async function executeToolUseAgent(
@@ -1354,6 +1386,15 @@ async function executeToolUseAgent(
const duration = Date.now() - startTime
// Check if AI already created a note via note_create tool
// Or if excalidraw/slide generator created a canvas
let existingNoteId: string | null = null
let canvasId: string | null = null
const scrapedUrls: string[] = []
let specificToolCalled = false
let fallbackSuccess = false
let parsedFallbackJson: any = null
// Détecte si le modèle ne supporte pas le function calling
// (il retourne le JSON de l'outil comme texte brut au lieu de l'exécuter)
const totalToolCallsCheck = result.steps.reduce((acc, s) => acc + s.toolCalls.length, 0)
@@ -1371,16 +1412,96 @@ async function executeToolUseAgent(
}
if (agentType === 'slide-generator' || agentType === 'excalidraw-generator') {
const toolName = agentType === 'slide-generator' ? 'generate_slides' : 'generate_excalidraw'
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'failure',
log: lang === 'fr'
? `L'IA n'a pas appelé l'outil ${toolName}. Le modèle a répondu avec du texte au lieu de générer le fichier. Modèle: "${sysConfig.AI_MODEL_CHAT}". Essayez un modèle compatible avec le function calling.`
: `The AI did not call the ${toolName} tool. The model responded with text instead of generating the file. Model: "${sysConfig.AI_MODEL_CHAT}". Try a model that supports function calling.`,
parsedFallbackJson = extractJsonFromText(result.text)
if (parsedFallbackJson) {
try {
if (agentType === 'slide-generator') {
let slides: any[] = []
let title = agent.name || "Présentation"
let theme = agent.slideTheme || "architectural-saas"
if (Array.isArray(parsedFallbackJson)) {
slides = parsedFallbackJson
} else if (parsedFallbackJson && typeof parsedFallbackJson === 'object') {
if (Array.isArray(parsedFallbackJson.slides)) {
slides = parsedFallbackJson.slides
} else if (parsedFallbackJson.slides && typeof parsedFallbackJson.slides === 'object') {
// nested structure support
} else {
if (parsedFallbackJson.type) {
slides = [parsedFallbackJson]
} else {
const arrays = Object.values(parsedFallbackJson).filter(val => Array.isArray(val))
if (arrays.length > 0) {
slides = arrays[0] as any[]
}
}
}
if (typeof parsedFallbackJson.title === 'string') title = parsedFallbackJson.title
if (typeof parsedFallbackJson.theme === 'string') theme = parsedFallbackJson.theme
}
const registered = toolRegistry.get('generate_slides')
if (registered) {
console.log('[AgentExecutor] Running manual fallback execution for generate_slides')
const slideTool = registered.buildTool(ctx)
const executionResult = await slideTool.execute({ title, theme, slides })
if (executionResult && executionResult.success && executionResult.canvasId) {
canvasId = executionResult.canvasId
specificToolCalled = true
fallbackSuccess = true
}
}
} else {
let diagramStr = ""
let title = agent.name || "Diagramme"
if (parsedFallbackJson && typeof parsedFallbackJson === 'object') {
if (typeof parsedFallbackJson.diagram === 'string') {
diagramStr = parsedFallbackJson.diagram
if (typeof parsedFallbackJson.title === 'string') title = parsedFallbackJson.title
} else if (parsedFallbackJson.diagram && typeof parsedFallbackJson.diagram === 'object') {
diagramStr = JSON.stringify(parsedFallbackJson.diagram)
if (typeof parsedFallbackJson.title === 'string') title = parsedFallbackJson.title
} else {
diagramStr = JSON.stringify(parsedFallbackJson)
if (typeof parsedFallbackJson.title === 'string') title = parsedFallbackJson.title
}
} else if (typeof parsedFallbackJson === 'string') {
diagramStr = parsedFallbackJson
}
if (diagramStr) {
const registered = toolRegistry.get('generate_excalidraw')
if (registered) {
console.log('[AgentExecutor] Running manual fallback execution for generate_excalidraw')
const excalidrawTool = registered.buildTool(ctx)
const executionResult = await excalidrawTool.execute({ title, diagram: diagramStr })
if (executionResult && executionResult.success && executionResult.canvasId) {
canvasId = executionResult.canvasId
specificToolCalled = true
fallbackSuccess = true
}
}
}
}
} catch (err) {
console.error('[AgentExecutor] Fallback execution failed:', err)
}
})
return { success: false, actionId, error: `AI did not call ${toolName} tool` }
}
if (!fallbackSuccess) {
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'failure',
log: lang === 'fr'
? `L'IA n'a pas appelé l'outil ${toolName}. Le modèle a répondu avec du texte au lieu de générer le fichier. Modèle: "${sysConfig.AI_MODEL_CHAT}". Essayez un modèle compatible avec le function calling.`
: `The AI did not call the ${toolName} tool. The model responded with text instead of generating the file. Model: "${sysConfig.AI_MODEL_CHAT}". Try a model that supports function calling.`,
}
})
return { success: false, actionId, error: `AI did not call ${toolName} tool` }
}
}
}
@@ -1395,12 +1516,27 @@ async function executeToolUseAgent(
})),
}))
// Check if AI already created a note via note_create tool
// Or if excalidraw/slide generator created a canvas
let existingNoteId: string | null = null
let canvasId: string | null = null
const scrapedUrls: string[] = []
let specificToolCalled = false
if (fallbackSuccess) {
toolLog.push({
step: toolLog.length + 1,
text: "Manual JSON parsing & fallback execution succeeded.",
toolCalls: [{
id: "fallback",
type: "function",
function: {
name: agentType === 'slide-generator' ? 'generate_slides' : 'generate_excalidraw',
arguments: JSON.stringify(parsedFallbackJson),
}
}] as any,
toolResults: [{
toolCallId: "fallback",
toolName: agentType === 'slide-generator' ? 'generate_slides' : 'generate_excalidraw',
type: "tool-result",
result: { success: true, canvasId }
}] as any
})
}
const requiredTool = isFileGenerator
? (agentType === 'slide-generator' ? ['generate_slides'] : ['generate_excalidraw'])
: null

View File

@@ -17,6 +17,7 @@ export interface SuggestChartsResponse {
detectedData: string
hasData: boolean
error?: string
quotaExceeded?: boolean
}
export interface SuggestChartsRequest {
@@ -42,12 +43,16 @@ export async function suggestCharts(request: SuggestChartsRequest): Promise<Sugg
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
const isQuotaError = response.status === 402 || errorData.error === 'QUOTA_EXCEEDED'
return {
suggestions: [],
analyzedText: '',
detectedData: '',
hasData: false,
error: errorData.error || `HTTP ${response.status}`,
error: isQuotaError
? 'AI quota exceeded. Please upgrade your plan to continue using AI features.'
: (errorData.error || `HTTP ${response.status}`),
quotaExceeded: isQuotaError,
}
}

View File

@@ -8,14 +8,14 @@ import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
// Simple chart insertion tool - does everything in one call
// Simple chart generation tool - returns markdown chart
toolRegistry.register({
name: 'insert_chart',
description: 'Generate a chart and insert it directly into a note. Use when the user asks for a chart, graph, or visualization.',
description: 'Generate a chart and return it as markdown. Use when the user asks for a chart, graph, or visualization.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: `Generate a chart and insert it directly into the note content.
description: `Generate a chart as markdown that will be rendered as an interactive chart.
Available chart types:
- "bar": Vertical bar chart (default for comparisons)
@@ -25,11 +25,9 @@ Available chart types:
- "pie": Pie chart (use for proportions/percentages)
- "radar": Radar chart (use for comparing multiple dimensions)
IMPORTANT: When the user asks for a chart/graph/visualization:
1. Extract the data from the note or user request
2. Choose the appropriate chart type based on the data
3. Generate the chart markdown using this format:
CRITICAL - NEVER use Mermaid, flowchart, or other markdown diagram formats. ALWAYS use this tool to generate charts.
Chart format:
\`\`\`chart
{chartType}
{title}
@@ -47,38 +45,22 @@ Feb: 7500
Mar: 6200
\`\`\`
4. Call this tool with the noteId, the chart markdown, and where to insert it`,
Example for "show MRR progression":
\`\`\`chart
line
MRR Growth
Month 1: 2000
Month 2: 4500
Month 3: 8900
\`\`\``,
inputSchema: z.object({
noteId: z.string().describe('The note ID to update'),
chartMarkdown: z.string().describe('The complete chart markdown block to insert (including the ```chart fences)'),
insertLocation: z.enum(['append', 'prepend']).default('append').describe('append: add to end, prepend: add to start'),
chartMarkdown: z.string().describe('The complete chart markdown block to render (including the ```chart fences)'),
}),
execute: async ({ noteId, chartMarkdown, insertLocation }) => {
try {
const note = await prisma.note.findFirst({
where: { id: noteId, userId: ctx.userId },
select: { content: true },
})
if (!note) return { error: 'Note not found' }
const updatedContent = insertLocation === 'append'
? `${note.content}\n\n${chartMarkdown}`
: `${chartMarkdown}\n\n${note.content}`
await prisma.note.update({
where: { id: noteId },
data: { content: updatedContent },
})
return {
success: true,
message: `Chart ${insertLocation === 'append' ? 'appended' : 'prepended'} to note.`,
chartMarkdown,
}
} catch (e: any) {
return { success: false, error: `Failed: ${e.message}` }
execute: async ({ chartMarkdown }) => {
return {
chartMarkdown,
message: 'Chart generated successfully',
}
},
}),
})
})

View File

@@ -13,6 +13,7 @@ export interface ToolContext {
actionId?: string
conversationId?: string
notebookId?: string
noteId?: string
webSearch?: boolean
config: Record<string, string>
}

View File

@@ -43,6 +43,21 @@ export const PALETTE_ALIASES: Record<string, string> = {
premium: 'platinum_white_gold', clean: 'vibrant_tech', stage: 'stage_dark',
architectural: 'architectural_mono', silk: 'minimal_silk',
black: 'keynote', white: 'platinum_white_gold', nuit: 'galaxy', sombre: 'stage_dark',
// Recipe explicit theme mappings
architectural_saas: 'architectural_mono',
midnight_cathedral: 'keynote',
aurora_borealis: 'galaxy',
tokyo_neon: 'vibrant_tech',
sunlit_gallery: 'bohemian',
clinical_precision: 'modern_wellness',
venture_pitch: 'vibrant_orange_mint',
forest_floor: 'forest_eco',
steel_glass: 'luxury_mystery',
cyberpunk_terminal: 'tech_night',
editorial_ink: 'vintage_academic',
coastal_morning: 'coastal_coral',
paper_studio: 'craft_artisan',
}
export const THEME_NAMES: Record<string, string> = {

View File

@@ -3,8 +3,9 @@
*/
import { normalizeThemeId } from './apply-document-theme'
export function getThemeScript(serverTheme: string = 'light') {
export function getThemeScript(serverTheme: string = 'light', serverAccentColor: string | null = null) {
const fallback = normalizeThemeId(serverTheme)
const defaultAccent = serverAccentColor || '#A47148'
return `
(function() {
try {
@@ -28,7 +29,8 @@ export function getThemeScript(serverTheme: string = 'light') {
if (theme === 'midnight') root.classList.add('dark');
}
var accentStored = localStorage.getItem('accent-color');
if (accentStored) root.style.setProperty('--color-brand-accent', accentStored);
var effectiveAccent = accentStored || ${JSON.stringify(defaultAccent)};
root.style.setProperty('--color-brand-accent', effectiveAccent);
} catch (e) {
console.error('Theme script error', e);
}

View File

@@ -2658,5 +2658,28 @@
"emptyState": "No versions available",
"selectVersion": "Select a version to preview its content",
"currentVersion": "current"
},
"graphView": {
"title": "Graph View",
"notesCount": "{count} notes",
"connectionsCount": "{count} connections",
"globalView": "Fit to View",
"searchPlaceholder": "Filter...",
"noNotesFound": "No notes found",
"notebooks": "Notebooks",
"resetFilter": "Reset",
"relationshipTypes": "Relationship Types",
"connections": "{count} connection",
"connectionsPlural": "{count} connections",
"preview": {
"openNote": "Open note",
"emptyNote": "Empty note",
"tags": "Tags",
"noTags": "No tags",
"words": "{count} words",
"chars": "{count} chars",
"created": "Created on",
"updated": "Updated on"
}
}
}

View File

@@ -2664,5 +2664,28 @@
"emptyState": "Aucune version disponible",
"selectVersion": "Sélectionnez une version pour prévisualiser son contenu",
"currentVersion": "actuelle"
},
"graphView": {
"title": "Vue en graphe",
"notesCount": "{count} notes",
"connectionsCount": "{count} liens",
"globalView": "Vue globale",
"searchPlaceholder": "Filtrer...",
"noNotesFound": "Aucune note trouvée",
"notebooks": "Carnets",
"resetFilter": "Réinitialiser",
"relationshipTypes": "Types de liaisons",
"connections": "{count} liaison",
"connectionsPlural": "{count} liaisons",
"preview": {
"openNote": "Ouvrir la note",
"emptyNote": "Note vide",
"tags": "Tags",
"noTags": "Aucun tag",
"words": "{count} mots",
"chars": "{count} ch.",
"created": "Créée le",
"updated": "Mise à jour le"
}
}
}