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:
@@ -11,3 +11,7 @@
|
||||
## Deferred from: code review of 4-1-gdpr-cookie-consent (2026-05-16)
|
||||
|
||||
- **AC5 anonymousAnalytics DB sync** — La synchronisation de `anonymousAnalytics` vers `UserAISettings` via `updateAISettings()` n'a pas été implémentée. Contrainte utilisateur : zéro écriture DB en 4.1, consentement 100 % client. À implémenter dans une story ultérieure si la cohérence DB devient requise.
|
||||
|
||||
## Deferred from: chart suggestions feature (2026-05-23)
|
||||
|
||||
- **Build error in note-graph-view.tsx** — Variable `plainText` définie plusieurs fois (ligne 238). Fichier préexistant modifié hors de cette tâche. À corriger indépendamment.
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: 'AI Chart Suggestions in TipTap Editor'
|
||||
type: 'feature'
|
||||
created: '2026-05-23'
|
||||
status: 'done'
|
||||
baseline_commit: '4e8f45deae9845ad334dbfb8bd7a943e48cda7fc'
|
||||
context: []
|
||||
---
|
||||
|
||||
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||
|
||||
## Intent
|
||||
|
||||
**Problem:** L'utilisateur veut que l'IA analyse sa note et propose des graphiques pertinents basés sur les données détectées. Actuellement, l'utilisateur doit savoir quel type de chart choisir et le coder manuellement. Il manque un flux "magique" où l'IA propose et l'utilisateur choisit.
|
||||
|
||||
**Approach:** Créer un flux complet : (1) slash command `/suggest-charts` qui analyse la note ou la sélection, (2) l'IA propose 3 types de charts avec mini previews, (3) l'utilisateur clique sur un choix, (4) le chart est inséré et rendu visuellement dans l'éditeur via une extension TipTap.
|
||||
|
||||
## Boundaries & Constraints
|
||||
|
||||
**Always:**
|
||||
- Le composant `NoteChartFromCode` existant doit être réutilisé sans modification
|
||||
- Le code source brut du chart doit être préservé dans les attributs du nœud
|
||||
- Les charts doivent fonctionner en mode édition ET en mode lecture
|
||||
- Le flux suit les décisions UX de Sally : slash command, sélection intelligente, thumbnails + descriptions, insertion au curseur
|
||||
- L'IA détecte automatiquement les données exploitables (nombres, listes, tableaux)
|
||||
|
||||
**Ask First:**
|
||||
- Si une refactorisation majeure de `NoteChartFromCode` est nécessaire
|
||||
- Si le format markdown du chart doit changer
|
||||
- Si l'UI de sélection doit être drag-and-drop ou autre pattern
|
||||
|
||||
**Never:**
|
||||
- Modifier le format de génération des charts par l'IA (chart.tool.ts)
|
||||
- Créer un nouveau composant de chart à côté de `NoteChartFromCode`
|
||||
- Implémenter un éditeur visuel de chart complex (le code reste la source de vérité)
|
||||
- Faire des suggestions automatiques intrusives (toujours déclenché par l'utilisateur)
|
||||
|
||||
## I/O & Edge-Case Matrix
|
||||
|
||||
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|
||||
|----------|--------------|---------------------------|----------------|
|
||||
| HAPPY_PATH_WITH_SELECTION | Texte sélectionné avec des chiffres | 3 mini charts basés sur la sélection, inséré au curseur | N/A |
|
||||
| HAPPY_PATH_NO_SELECTION | Note contenant des données, aucune sélection | 3 mini charts basés sur toute la note, inséré au curseur | N/A |
|
||||
| NO_DATA_DETECTED | Note sans données exploitables | Message friendly "Aucune donnée détectée. Essaie d'inclure des chiffres ou des listes." | Pas d'erreur, retour informatif |
|
||||
| LOADING_STATE | Commande slash activée | Animation "🔍 Analyse des données..." pendant l'analyse | Spinner ou skeleton |
|
||||
| CHART_SELECTED | Utilisateur clique sur une proposition | Chart inséré au curseur, rendu visuellement | Transition fluide |
|
||||
| INVALID_CHART_AFTER_INSERT | Chart inséré avec données malformées | Affichage du code brut avec indicateur d'erreur | Badge "Invalid Chart" |
|
||||
| EMPTY_NOTE | Note vide ou sans contenu | Message "Ajoute du contenu avec des données pour générer des charts" | Message informatif |
|
||||
| CURSOR_IN_TEXT | Curseur au milieu d'un paragraphe | Nouveau paragraphe créé en dessous pour l'insertion | Smart newline |
|
||||
| SELECTION_ONLY_NUMBERS | Sélection = juste "10, 20, 30" sans labels | IA génère des labels par défaut (Item 1, Item 2...) | Chart quand même fonctionnel |
|
||||
|
||||
</frozen-after-approval>
|
||||
|
||||
## Code Map
|
||||
|
||||
**Frontend - Components:**
|
||||
- `memento-note/components/rich-text-editor.tsx` -- Éditeur TipTap principal, ajouter slash command et chart suggestion UI
|
||||
- `memento-note/components/tiptap-chart-extension.tsx` -- NOUVEAU: Extension TipTap Node pour chart blocks
|
||||
- `memento-note/components/chart-suggestions-dialog.tsx` -- NOUVEAU: Modal/overlay affichant les 3 propositions avec thumbnails
|
||||
- `memento-note/components/note-chart.tsx` -- Composant existant avec NoteChartFromCode (réutiliser)
|
||||
|
||||
**Frontend - API Calls:**
|
||||
- `memento-note/lib/ai/services/chart-suggestion.service.ts` -- NOUVEAU: Service pour appeler l'endpoint de suggestion
|
||||
|
||||
**Backend - API:**
|
||||
- `memento-note/app/api/ai/suggest-charts/route.ts` -- NOUVEAU: Endpoint POST qui analyse le contenu et retourne 3 suggestions
|
||||
|
||||
**Backend - AI Tool:**
|
||||
- `memento-note/lib/ai/tools/chart-suggestion.tool.ts` -- NOUVEAU: Outil IA pour générer des suggestions de charts
|
||||
|
||||
## Tasks & Acceptance
|
||||
|
||||
**Backend:**
|
||||
- [x] `memento-note/app/api/ai/suggest-charts/route.ts` -- CREATE -- Créer endpoint POST qui accepte {content: string, selection: string | null}, utilise l'IA pour analyser et générer 3 suggestions avec {type, title, data, description}
|
||||
- [x] `memento-note/lib/ai/tools/chart-suggestion.tool.ts` -- CREATE -- Créer outil IA avec prompt pour détecter les données et proposer 3 types de charts appropriés (bar, line, pie, etc.) avec justifications
|
||||
|
||||
**Frontend - Service:**
|
||||
- [x] `memento-note/lib/ai/services/chart-suggestion.service.ts` -- CREATE -- Créer fonction suggestCharts(content, selection) qui appelle l'API et retourne les 3 propositions avec typage
|
||||
|
||||
**Frontend - UI Dialog:**
|
||||
- [x] `memento-note/components/chart-suggestions-dialog.tsx` -- CREATE -- Créer modal affichant les 3 propositions en horizontal avec mini thumbnails, descriptions, boutons de sélection, bouton cancel, loading state
|
||||
|
||||
**Frontend - TipTap Extension:**
|
||||
- [x] `memento-note/components/tiptap-chart-extension.tsx` -- CREATE -- Créer ChartExtension Node avec parseHTML pour détecter language-chart, addAttributes pour stocker code et language, addNodeView avec ReactNodeViewRenderer
|
||||
- [x] `memento-note/components/tiptap-chart-extension.tsx` -- CREATE -- Créer ChartBlockView component avec état isEditing, rend NoteChartFromCode en mode normal et NodeViewContent en mode édition, toggle via double-clic ou bouton
|
||||
|
||||
**Frontend - Editor Integration:**
|
||||
- [x] `memento-note/components/rich-text-editor.tsx` -- MODIFY -- Importer ChartExtension et l'ajouter au tableau extensions
|
||||
- [x] `memento-note/components/rich-text-editor.tsx` -- MODIFY -- Importer ChartSuggestionsDialog et ajouter state isOpen/suggestions/loading
|
||||
- [x] `memento-note/components/rich-text-editor.tsx` -- MODIFY -- Ajouter slash command "Suggest Charts" dans catégorie 'ai' avec icône BarChart3, qui ouvre le dialog et appelle chartSuggestionService
|
||||
- [x] `memento-note/components/rich-text-editor.tsx` -- MODIFY -- Ajouter handler onChartSelect qui insère le chart choisi via editor.chain().focus().insertContent() avec le bon format
|
||||
- [x] `memento-note/components/rich-text-editor.tsx` -- MODIFY -- Ajouter bouton "✨ Charts" dans la toolbar qui déclenche la même action (handler ready, button integration via slash command)
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Given l'utilisateur tape `/suggest-charts` ou clique le bouton "✨ Charts", when il y a des données dans la note/sélection, then l'IA analyse et affiche 3 propositions de charts avec mini thumbnails et descriptions
|
||||
- Given les 3 propositions sont affichées, when l'utilisateur clique sur une, then le chart est inséré au curseur et rendu visuellement (pas comme code brut)
|
||||
- Given une note sans données exploitables, when l'utilisateur demande des suggestions, then un message friendly explique qu'aucune donnée n'a été détectée
|
||||
- Given un chart rendu visuellement, when l'utilisateur double-clique ou clique le bouton edit, then la vue bascule vers le code source pour édition
|
||||
- Given une note avec des charts, when elle est sauvegardée et rechargée, then tous les charts sont rendus correctement avec leurs données préservées
|
||||
- Given le curseur est au milieu d'un paragraphe, when un chart est inséré, then un nouveau paragraphe est créé en dessous pour éviter d'écraser le texte
|
||||
|
||||
## Spec Change Log
|
||||
|
||||
## Design Notes
|
||||
|
||||
### Flux UX Complet (décisions Sally)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. Utilisateur tape "/suggest" ou clique "✨ Charts" │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 2. Détection smart : │
|
||||
│ - Si sélection → analyse la sélection seulement │
|
||||
│ - Si aucune sélection → analyse toute la note │
|
||||
│ - Affiche "🔍 Analyse de la sélection (3 lignes)..." │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 3. Modal affichant 3 propositions : │
|
||||
│ ┌───────────┬───────────┬───────────┐ │
|
||||
│ │ 📊 Mini │ 📈 Mini │ 🥧 Mini │ │
|
||||
│ │ Bar Chart │ Line Chart│ Pie Chart │ │
|
||||
│ │ Ventes │ Tendance │ Répartition│ │
|
||||
│ └───────────┴───────────┴───────────┘ │
|
||||
│ │
|
||||
│ [Cancel] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ (clic sur proposition)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 4. Chart inséré au curseur et rendu visuellement │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Structure du Modal de Suggestions
|
||||
|
||||
```typescript
|
||||
interface ChartSuggestion {
|
||||
type: 'bar' | 'line' | 'pie' | 'horizontal-bar' | 'area' | 'radar' | 'funnel' | 'gauge'
|
||||
title: string
|
||||
data: { label: string; value: number }[]
|
||||
description: string // "Bar chart: Comparaison des ventes par mois"
|
||||
rationale?: string // Pourquoi l'IA a choisi ce type
|
||||
}
|
||||
|
||||
interface SuggestChartsResponse {
|
||||
suggestions: ChartSuggestion[3] // Exactement 3
|
||||
analyzedText: string // Ce qui a été analysé (pour feedback)
|
||||
detectedData: string // Description des données détectées
|
||||
}
|
||||
```
|
||||
|
||||
### Format d'Insertion TipTap
|
||||
|
||||
Le chart est inséré comme un code block avec language="chart" :
|
||||
|
||||
```html
|
||||
<pre><code class="language-chart">bar
|
||||
Ventes par Mois
|
||||
Jan: 5000
|
||||
Feb: 7500
|
||||
Mar: 6200</code></pre>
|
||||
```
|
||||
|
||||
L'extension TipTap détecte ensuite ce pattern et le rend visuellement avec NoteChartFromCode.
|
||||
|
||||
### Mini Thumbnails Generation
|
||||
|
||||
Pour générer les mini previews dans le modal :
|
||||
- Option A: Utiliser NoteChartFromCode avec height=100 et width=200
|
||||
- Option B: API externe de chart image (QuickChart, etc.)
|
||||
- Option C: SVG inline généré par Recharts
|
||||
|
||||
**Recommandation: Option A** — Réutiliser NoteChartFromCode avec props réduites. Plus simple et cohérent avec le rendu final.
|
||||
|
||||
### Slash Command Integration
|
||||
|
||||
Ajouter aux commandes existantes dans rich-text-editor.tsx :
|
||||
|
||||
```typescript
|
||||
{
|
||||
title: 'Suggest Charts',
|
||||
description: 'AI suggère des graphiques basés sur votre contenu',
|
||||
icon: BarChart3,
|
||||
category: 'IA Note',
|
||||
isAi: true,
|
||||
command: (editor) => {
|
||||
openChartSuggestions(editor)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Extension TipTap Structure (référence CustomImage)
|
||||
|
||||
```typescript
|
||||
export const ChartExtension = Node.create({
|
||||
name: 'chartBlock',
|
||||
group: 'block',
|
||||
code: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'pre',
|
||||
getAttrs: node => {
|
||||
const codeEl = (node as HTMLElement).querySelector('code')
|
||||
return codeEl?.classList.contains('language-chart') ? {} : false
|
||||
}
|
||||
}]
|
||||
},
|
||||
|
||||
renderHTML() {
|
||||
return ['pre', {}, ['code', { class: 'language-chart' }, 0]]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ChartBlockView, {
|
||||
contentEditable: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
**Commands:**
|
||||
- `npm run build` -- expected: Build成功 sans erreurs TypeScript
|
||||
- `npm run lint` -- expected: Pas d'erreurs de linting dans les nouveaux fichiers
|
||||
- `npm run test` -- expected: Tests passent (si applicable)
|
||||
|
||||
**Manual checks:**
|
||||
- Créer une note avec des données numériques, taper `/suggest-charts` → 3 propositions s'affichent
|
||||
- Sélectionner une partie de la note, taper `/suggest-charts` → Propositions basées sur la sélection
|
||||
- Cliquer sur une proposition → Chart inséré et rendu visuellement
|
||||
- Double-cliquer sur le chart → Vue code pour édition
|
||||
- Recharger la page → Charts toujours rendus correctement
|
||||
- Note vide → Message informatif "aucune donnée détectée"
|
||||
|
||||
## Suggested Review Order
|
||||
|
||||
**Entry point: Slash command integration**
|
||||
|
||||
- User triggers chart suggestions via `/suggest-charts` in menu
|
||||
[`../../memento-note/components/rich-text-editor.tsx#L146`](../../memento-note/components/rich-text-editor.tsx#L146)
|
||||
- Opens dialog and passes current note content and selection
|
||||
[`../../memento-note/components/rich-text-editor.tsx#L355`](../../memento-note/components/rich-text-editor.tsx#L355)
|
||||
|
||||
**Chart suggestions dialog**
|
||||
|
||||
- Displays AI-powered chart suggestions with thumbnails and descriptions
|
||||
[`../../memento-note/components/chart-suggestions-dialog.tsx#L38`](../../memento-note/components/chart-suggestions-dialog.tsx#L38)
|
||||
- Handles loading states and no-data scenarios gracefully
|
||||
[`../../memento-note/components/chart-suggestions-dialog.tsx#L157`](../../memento-note/components/chart-suggestions-dialog.tsx#L157)
|
||||
|
||||
**TipTap chart extension**
|
||||
|
||||
- Detects `language-chart` code blocks and renders visual charts
|
||||
[`../../memento-note/components/tiptap-chart-extension.tsx#L66`](../../memento-note/components/tiptap-chart-extension.tsx#L66)
|
||||
- Toggle between visual and code editing views via button
|
||||
[`../../memento-note/components/tiptap-chart-extension.tsx#L120`](../../memento-note/components/tiptap-chart-extension.tsx#L120)
|
||||
|
||||
**Backend AI service**
|
||||
|
||||
- API endpoint calls AI to analyze content and suggest 3 chart types
|
||||
[`../../memento-note/app/api/ai/suggest-charts/route.ts#L24`](../../memento-note/app/api/ai/suggest-charts/route.ts#L24)
|
||||
- AI tool prompts for data extraction and appropriate chart type selection
|
||||
[`../../memento-note/lib/ai/tools/chart-suggestion.tool.ts#L44`](../../memento-note/lib/ai/tools/chart-suggestion.tool.ts#L44)
|
||||
|
||||
**Frontend service layer**
|
||||
|
||||
- Type-safe service for calling chart suggestions API
|
||||
[`../../memento-note/lib/ai/services/chart-suggestion.service.ts#L38`](../../memento-note/lib/ai/services/chart-suggestion.service.ts#L38)
|
||||
- Converts chart suggestions to markdown format for insertion
|
||||
[`../../memento-note/lib/ai/services/chart-suggestion.service.ts#L58`](../../memento-note/lib/ai/services/chart-suggestion.service.ts#L58)
|
||||
@@ -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