From 6123dcfba4663efaacc6748f2ea2721b97af21ac Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sun, 10 May 2026 18:52:05 +0000 Subject: [PATCH] feat: add AI-powered notebook organization with preview dialog --- memento-note/app/actions/organize-notebook.ts | 244 ++++++++++ memento-note/components/home-client.tsx | 25 + .../components/organize-notebook-dialog.tsx | 444 ++++++++++++++++++ 3 files changed, 713 insertions(+) create mode 100644 memento-note/app/actions/organize-notebook.ts create mode 100644 memento-note/components/organize-notebook-dialog.tsx diff --git a/memento-note/app/actions/organize-notebook.ts b/memento-note/app/actions/organize-notebook.ts new file mode 100644 index 0000000..72a1018 --- /dev/null +++ b/memento-note/app/actions/organize-notebook.ts @@ -0,0 +1,244 @@ +'use server' + +import { auth } from '@/auth' +import prisma from '@/lib/prisma' +import { getAIProvider } from '@/lib/ai/factory' +import { getSystemConfig } from '@/lib/config' +import { revalidatePath } from 'next/cache' + +export interface OrganizationNote { + id: string + title: string +} + +export interface OrganizationGroup { + name: string + isNew: boolean + existingId?: string + notes: OrganizationNote[] +} + +export interface OrganizationPlan { + notebookId: string + groups: OrganizationGroup[] +} + +export interface AnalyzeResult { + success: boolean + plan?: OrganizationPlan + error?: string +} + +export interface ExecuteResult { + success: boolean + created: number + moved: number + error?: string +} + +/** + * Analyze a notebook's notes with AI and suggest a sub-notebook organization plan. + */ +export async function analyzeNotebookForOrganization(notebookId: string): Promise { + const session = await auth() + if (!session?.user?.id) return { success: false, error: 'Non autorisé' } + + try { + // 1. Fetch the target notebook (ensure ownership) + const notebook = await prisma.notebook.findFirst({ + where: { id: notebookId, userId: session.user.id, trashedAt: null }, + select: { id: true, name: true }, + }) + if (!notebook) return { success: false, error: 'Carnet introuvable' } + + // 2. Fetch all notes in this notebook + const notes = await prisma.note.findMany({ + where: { + notebookId, + userId: session.user.id, + trashedAt: null, + isArchived: false, + }, + select: { id: true, title: true, content: true, labels: true }, + orderBy: { createdAt: 'asc' }, + }) + + if (notes.length < 2) { + return { + success: false, + error: 'Ce carnet contient moins de 2 notes — il n\'y a rien à organiser.', + } + } + + // 3. Fetch existing sub-notebooks + const existingSubs = await prisma.notebook.findMany({ + where: { parentId: notebookId, userId: session.user.id, trashedAt: null }, + select: { id: true, name: true }, + }) + + // 4. Build prompt context — truncate content to save tokens + const notesContext = notes.map((n, i) => { + const title = n.title || `Note sans titre ${i + 1}` + const content = (n.content || '').slice(0, 300).replace(/\n+/g, ' ') + let labelsStr = '' + if (n.labels) { + try { + const parsed = JSON.parse(n.labels as string) + if (Array.isArray(parsed)) labelsStr = parsed.join(', ') + } catch {} + } + return `[${n.id}] "${title}"${labelsStr ? ` (tags: ${labelsStr})` : ''} — ${content}` + }).join('\n') + + const existingSubsContext = existingSubs.length > 0 + ? `\nSous-carnets existants:\n${existingSubs.map(s => `- "${s.name}" (id: ${s.id})`).join('\n')}` + : '\nIl n\'y a pas encore de sous-carnets.' + + const prompt = `Tu es un assistant d'organisation de notes. Analyse les notes suivantes du carnet "${notebook.name}" et regroupe-les par thème en proposant des sous-carnets. +${existingSubsContext} + +Notes à organiser: +${notesContext} + +RÈGLES IMPORTANTES: +- Regroupe les notes par thème ou sujet similaire. +- Propose entre 2 et 6 groupes maximum. +- Si un sous-carnet existant correspond déjà à un thème, utilise-le (indique son id). +- Les noms de groupe doivent être courts (2-4 mots), clairs et en français. +- N'inclus PAS toutes les notes si certaines sont trop générales ou ne correspondent à aucun groupe clair — laisse-les de côté. +- Réponds UNIQUEMENT avec du JSON valide, sans markdown, sans explication. + +Format de réponse JSON attendu: +{ + "groups": [ + { + "name": "Nom du sous-carnet", + "existingId": "id-si-sous-carnet-existant-ou-null", + "noteIds": ["id1", "id2", "id3"] + } + ] +}` + + // 5. Call AI + const config = await getSystemConfig() + const provider = getAIProvider(config) + const rawResponse = await provider.generateText(prompt) + + // 6. Parse AI response + let parsed: { groups: Array<{ name: string; existingId?: string | null; noteIds: string[] }> } + try { + // Clean possible markdown code blocks + const cleaned = rawResponse.trim().replace(/^```json\n?/, '').replace(/\n?```$/, '') + parsed = JSON.parse(cleaned) + } catch { + return { success: false, error: 'L\'IA n\'a pas pu générer un plan valide. Réessayez.' } + } + + if (!parsed?.groups || !Array.isArray(parsed.groups)) { + return { success: false, error: 'Réponse IA invalide.' } + } + + // 7. Build the OrganizationPlan + const noteMap = new Map(notes.map(n => [n.id, n])) + const existingSubMap = new Map(existingSubs.map(s => [s.id, s])) + + const groups: OrganizationGroup[] = parsed.groups + .filter(g => g.name && Array.isArray(g.noteIds) && g.noteIds.length > 0) + .map(g => { + const existingId = g.existingId && existingSubMap.has(g.existingId) ? g.existingId : undefined + const groupNotes = g.noteIds + .filter(id => noteMap.has(id)) + .map(id => { + const n = noteMap.get(id)! + return { id: n.id, title: n.title || 'Note sans titre' } + }) + return { + name: g.name, + isNew: !existingId, + existingId, + notes: groupNotes, + } + }) + .filter(g => g.notes.length > 0) + + if (groups.length === 0) { + return { success: false, error: 'L\'IA n\'a pas trouvé de groupes thématiques distincts.' } + } + + return { success: true, plan: { notebookId, groups } } + } catch (err) { + console.error('[organize-notebook] Analysis error:', err) + return { success: false, error: 'Une erreur s\'est produite lors de l\'analyse.' } + } +} + +/** + * Execute an approved organization plan: + * - Creates missing sub-notebooks + * - Moves notes to the correct sub-notebook + */ +export async function executeNotebookOrganization(plan: OrganizationPlan): Promise { + const session = await auth() + if (!session?.user?.id) return { success: false, created: 0, moved: 0, error: 'Non autorisé' } + + try { + // Verify notebook ownership + const notebook = await prisma.notebook.findFirst({ + where: { id: plan.notebookId, userId: session.user.id, trashedAt: null }, + select: { id: true }, + }) + if (!notebook) return { success: false, created: 0, moved: 0, error: 'Carnet introuvable' } + + let created = 0 + let moved = 0 + + for (const group of plan.groups) { + let targetNotebookId: string + + if (!group.isNew && group.existingId) { + // Use existing sub-notebook + targetNotebookId = group.existingId + } else { + // Create new sub-notebook + const highestOrder = await prisma.notebook.findFirst({ + where: { parentId: plan.notebookId, userId: session.user.id }, + orderBy: { order: 'desc' }, + select: { order: true }, + }) + const nextOrder = (highestOrder?.order ?? -1) + 1 + + const newSub = await prisma.notebook.create({ + data: { + name: group.name.trim(), + icon: '📁', + color: '#75B2D6', + order: nextOrder, + parentId: plan.notebookId, + userId: session.user.id, + }, + }) + targetNotebookId = newSub.id + created++ + } + + // Move notes + const noteIds = group.notes.map(n => n.id) + if (noteIds.length > 0) { + await prisma.note.updateMany({ + where: { + id: { in: noteIds }, + userId: session.user.id, + }, + data: { notebookId: targetNotebookId }, + }) + moved += noteIds.length + } + } + + revalidatePath('/') + return { success: true, created, moved } + } catch (err) { + console.error('[organize-notebook] Execute error:', err) + return { success: false, created: 0, moved: 0, error: 'Erreur lors de l\'exécution du plan.' } + } +} diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx index 13604fa..4c20d55 100644 --- a/memento-note/components/home-client.tsx +++ b/memento-note/components/home-client.tsx @@ -42,6 +42,10 @@ const NotebookSummaryDialog = dynamic( () => import('@/components/notebook-summary-dialog').then(m => ({ default: m.NotebookSummaryDialog })), { ssr: false } ) +const OrganizeNotebookDialog = dynamic( + () => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })), + { ssr: false } +) type InitialSettings = { showRecentNotes: boolean @@ -88,6 +92,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { const [autoLabelOpen, setAutoLabelOpen] = useState(false) const [summaryDialogOpen, setSummaryDialogOpen] = useState(false) const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false) + const [organizeNotebookOpen, setOrganizeNotebookOpen] = useState(false) const [selectedTagIds, setSelectedTagIds] = useState([]) const [isTagsExpanded, setIsTagsExpanded] = useState(false) const [tagSearchQuery, setTagSearchQuery] = useState('') @@ -591,6 +596,17 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { {t('notes.reorganize') || 'Réorganiser les notes'} )} + + {currentNotebook && initialSettings.aiAssistantEnabled && ( + + )}
{searchParams.get('notebook') && ( @@ -804,6 +820,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { parentNotebookId={searchParams.get('notebook')} /> )} + {currentNotebook && ( + router.refresh()} + /> + )}
) } diff --git a/memento-note/components/organize-notebook-dialog.tsx b/memento-note/components/organize-notebook-dialog.tsx new file mode 100644 index 0000000..6a6a1e9 --- /dev/null +++ b/memento-note/components/organize-notebook-dialog.tsx @@ -0,0 +1,444 @@ +'use client' + +import { useState, useCallback } from 'react' +import { motion, AnimatePresence } from 'motion/react' +import { Sparkles, X, CheckCircle2, FolderPlus, Folder, ChevronDown, ChevronUp, Loader2, AlertCircle, Check } from 'lucide-react' +import { cn } from '@/lib/utils' +import { + analyzeNotebookForOrganization, + executeNotebookOrganization, + type OrganizationGroup, + type OrganizationPlan, +} from '@/app/actions/organize-notebook' +import { toast } from 'sonner' + +interface OrganizeNotebookDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + notebookId: string + notebookName: string + onDone?: () => void +} + +type Step = 'idle' | 'analyzing' | 'preview' | 'executing' | 'done' + +export function OrganizeNotebookDialog({ + open, + onOpenChange, + notebookId, + notebookName, + onDone, +}: OrganizeNotebookDialogProps) { + const [step, setStep] = useState('idle') + const [plan, setPlan] = useState(null) + const [editableGroups, setEditableGroups] = useState([]) + const [expandedGroups, setExpandedGroups] = useState>(new Set()) + const [error, setError] = useState(null) + const [result, setResult] = useState<{ created: number; moved: number } | null>(null) + + const handleAnalyze = useCallback(async () => { + setStep('analyzing') + setError(null) + setPlan(null) + + const res = await analyzeNotebookForOrganization(notebookId) + + if (!res.success || !res.plan) { + setError(res.error ?? 'Erreur inconnue') + setStep('idle') + return + } + + setPlan(res.plan) + setEditableGroups(res.plan.groups.map(g => ({ ...g, notes: [...g.notes] }))) + setExpandedGroups(new Set(res.plan.groups.map((_, i) => i))) + setStep('preview') + }, [notebookId]) + + const handleRenameGroup = (idx: number, name: string) => { + setEditableGroups(prev => prev.map((g, i) => i === idx ? { ...g, name } : g)) + } + + const handleToggleNote = (groupIdx: number, noteId: string) => { + setEditableGroups(prev => prev.map((g, i) => { + if (i !== groupIdx) return g + const has = g.notes.some(n => n.id === noteId) + return { + ...g, + notes: has ? g.notes.filter(n => n.id !== noteId) : g.notes, + } + })) + } + + const handleRemoveGroup = (idx: number) => { + setEditableGroups(prev => prev.filter((_, i) => i !== idx)) + } + + const toggleExpand = (idx: number) => { + setExpandedGroups(prev => { + const next = new Set(prev) + next.has(idx) ? next.delete(idx) : next.add(idx) + return next + }) + } + + const handleExecute = useCallback(async () => { + if (!plan) return + setStep('executing') + + const finalPlan: OrganizationPlan = { + notebookId: plan.notebookId, + groups: editableGroups.filter(g => g.notes.length > 0 && g.name.trim()), + } + + const res = await executeNotebookOrganization(finalPlan) + + if (!res.success) { + setError(res.error ?? 'Erreur inconnue') + setStep('preview') + return + } + + setResult({ created: res.created, moved: res.moved }) + setStep('done') + toast.success(`Carnet organisé — ${res.created} sous-carnet(s) créé(s), ${res.moved} note(s) déplacée(s)`) + onDone?.() + }, [plan, editableGroups, onDone]) + + const handleClose = () => { + if (step === 'analyzing' || step === 'executing') return + onOpenChange(false) + // Reset after close animation + setTimeout(() => { + setStep('idle') + setPlan(null) + setEditableGroups([]) + setError(null) + setResult(null) + }, 300) + } + + if (!open) return null + + const totalNotes = editableGroups.reduce((acc, g) => acc + g.notes.length, 0) + const newSubNbs = editableGroups.filter(g => g.isNew).length + + return ( + + {open && ( + <> + {/* Backdrop */} + + + {/* Panel */} + e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

Organiser le carnet

+

{notebookName}

+
+
+ +
+ + {/* Content */} +
+ + + {/* IDLE */} + {step === 'idle' && ( + + {error && ( +
+ +

{error}

+
+ )} +
+

+ L'IA va analyser les notes de ce carnet et vous proposer un plan de réorganisation en sous-carnets thématiques. +

+
    + {[ + 'Regroupement par sujet ou thème', + 'Création de sous-carnets manquants', + 'Aperçu complet avant modification', + ].map(item => ( +
  • +
    + {item} +
  • + ))} +
+
+
+ )} + + {/* ANALYZING */} + {step === 'analyzing' && ( + +
+
+ +
+ +
+
+

Analyse en cours…

+

L'IA lit vos notes et identifie les thèmes

+
+
+ {[0, 1, 2].map(i => ( + + ))} +
+
+ )} + + {/* PREVIEW */} + {step === 'preview' && ( + + {/* Summary bar */} +
+ +

+ {editableGroups.length} groupe(s) · {totalNotes} note(s) · {newSubNbs} nouveau(x) sous-carnet(s) +

+
+ + {error && ( +
+ +

{error}

+
+ )} + + {/* Groups */} +
+ {editableGroups.map((group, idx) => ( + + {/* Group header */} +
+
+ {group.isNew + ? + : + } +
+ handleRenameGroup(idx, e.target.value)} + className="flex-1 bg-transparent text-[12px] font-semibold text-ink outline-none focus:text-blueprint transition-colors min-w-0" + /> + {group.isNew && ( + + Nouveau + + )} +
+ + +
+
+ + {/* Notes list */} + + {expandedGroups.has(idx) && ( + +
+ {group.notes.map(note => ( +
handleToggleNote(idx, note.id)} + > +
+ +
+ + {note.title || 'Note sans titre'} + +
+ ))} +
+
+ )} +
+ + {/* Collapsed count */} + {!expandedGroups.has(idx) && ( +
+ {group.notes.length} note(s) +
+ )} +
+ ))} +
+
+ )} + + {/* EXECUTING */} + {step === 'executing' && ( + + +
+

Organisation en cours…

+

Création des sous-carnets et déplacement des notes

+
+
+ )} + + {/* DONE */} + {step === 'done' && ( + +
+ +
+
+

Carnet organisé !

+ {result && ( +

+ {result.created} sous-carnet(s) créé(s) · {result.moved} note(s) déplacée(s) +

+ )} +
+
+ )} + +
+
+ + {/* Footer actions */} +
+ {step === 'idle' && ( + + )} + + {step === 'preview' && ( +
+ + +
+ )} + + {step === 'done' && ( + + )} +
+
+ + )} +
+ ) +}