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:
@@ -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):
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ToolContext {
|
||||
actionId?: string
|
||||
conversationId?: string
|
||||
notebookId?: string
|
||||
noteId?: string
|
||||
webSearch?: boolean
|
||||
config: Record<string, string>
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user