feat: Organisateur IA — analyse carnet + tags + doublons + regroupements
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m37s
CI / Deploy production (on server) (push) Successful in 2m2s

- Service notebook-organizer.service.ts : analyse IA des notes
- Endpoint /api/ai/organize-notebook
- Dialog avec 4 sections :
  1. Résumé de l'état du carnet
  2. Tags suggérés (cliquable pour appliquer)
  3. Regroupements logiques par catégorie
  4. Détection de doublons avec explication
- Bouton 'Organiser' (Wand2) dans la barre du carnet
- i18n FR/EN complet
- Complète les 3 scénarios : Prof (wizard+exercices), Étudiant (wizard+planning), Ingénieur (organisateur)
This commit is contained in:
Antigravity
2026-06-14 20:16:01 +00:00
parent eff906d187
commit b9a80f9e64
6 changed files with 401 additions and 1 deletions

View File

@@ -20,7 +20,7 @@ import {
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays } from 'lucide-react'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2 } from 'lucide-react'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
@@ -31,6 +31,7 @@ import { useEditorUI } from '@/context/editor-ui-context'
import { NoteHistoryModal } from '@/components/note-history-modal'
import { CreateNotebookDialog } from '@/components/create-notebook-dialog'
import { StudyPlannerDialog } from '@/components/wizard/study-planner-dialog'
import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-dialog'
import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react'
@@ -136,6 +137,7 @@ export function HomeClient({
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
const [showOrganizer, setShowOrganizer] = useState(false)
const notebookFilter = searchParams.get('notebook')
const schemaHook = useNotebookSchema(notebookFilter)
@@ -1051,6 +1053,19 @@ export function HomeClient({
<span>{t('wizard.studyPlanner') || 'Planning'}</span>
</button>
)}
{searchParams.get('notebook') && (
<button
onClick={() => setShowOrganizer(true)}
disabled={!initialSettings.aiAssistantEnabled}
className={cn(
"flex items-center gap-2 text-[13px] font-medium transition-opacity",
initialSettings.aiAssistantEnabled ? "text-brand-accent hover:opacity-70" : "text-muted-foreground opacity-50 cursor-not-allowed"
)}
>
<Wand2 size={16} />
<span>{t('wizard.organizer') || 'Organiser'}</span>
</button>
)}
<button
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : s === 'alpha' ? 'manual' : 'newest')}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
@@ -1292,6 +1307,14 @@ export function HomeClient({
onClose={() => setShowStudyPlanner(false)}
/>
)}
{showOrganizer && currentNotebook && (
<NotebookOrganizerDialog
notebookId={currentNotebook.id}
notebookName={currentNotebook.name}
onClose={() => setShowOrganizer(false)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,196 @@
'use client'
import { useState } from 'react'
import { Sparkles, Loader2, X, Tag, Copy, FolderTree, AlertTriangle, Check } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface SuggestedTag { name: string; noteIds: string[] }
interface Duplicate { note1Id: string; note1Title: string; note2Id: string; note2Title: string; reason: string }
interface Category { name: string; noteIds: string[]; noteTitles: string[] }
interface OrgResult {
suggestedTags: SuggestedTag[]
duplicates: Duplicate[]
categories: Category[]
summary: string
}
export function NotebookOrganizerDialog({
notebookId,
notebookName,
onClose,
}: {
notebookId: string
notebookName: string
onClose: () => void
}) {
const { t } = useLanguage()
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<OrgResult | null>(null)
const [appliedTags, setAppliedTags] = useState<Set<string>>(new Set())
const handleAnalyze = async () => {
setLoading(true)
try {
const res = await fetch('/api/ai/organize-notebook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId }),
})
const data = await res.json()
if (!res.ok) {
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
} else {
setResult(data)
}
} catch (e: any) {
toast.error(e.message || 'Erreur')
} finally {
setLoading(false)
}
}
const applyTag = async (tagName: string, noteIds: string[]) => {
try {
for (const noteId of noteIds) {
await fetch(`/api/notes/${noteId}/properties`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags: [tagName] }),
}).catch(() => {})
}
setAppliedTags(prev => new Set(prev).add(tagName))
toast.success(t('wizard.tagApplied') || `Tag "${tagName}" appliqué à ${noteIds.length} notes`)
} catch {
toast.error('Erreur')
}
}
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" dir="auto" onClick={onClose}>
<div className="w-full max-w-2xl max-h-[85vh] rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-brand-accent/5 shrink-0">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-brand-accent" />
<h2 className="text-base font-semibold">{t('wizard.organizer') || 'Organiser avec l\'IA'}</h2>
</div>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted text-muted-foreground">
<X className="h-5 w-5" />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1">
{!result && !loading && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('wizard.organizerDesc') || `L'IA va analyser toutes les notes du carnet "${notebookName}" et proposer : tags, regroupements, et détection de doublons.`}
</p>
<button
onClick={handleAnalyze}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium"
>
<Sparkles className="h-4 w-4" />
{t('wizard.analyze') || 'Analyser le carnet'}
</button>
</div>
)}
{loading && (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<Loader2 className="h-8 w-8 animate-spin text-brand-accent" />
<p className="text-sm text-muted-foreground">{t('wizard.organizing') || 'Analyse des notes...'}</p>
</div>
)}
{result && (
<div className="space-y-6">
{/* Summary */}
<div className="rounded-lg bg-muted/30 p-4">
<p className="text-sm leading-relaxed">{result.summary}</p>
</div>
{/* Tags */}
{result.suggestedTags.length > 0 && (
<div className="space-y-2">
<h3 className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground flex items-center gap-1.5">
<Tag size={12} /> {t('wizard.suggestedTags') || 'Tags suggérés'}
</h3>
{result.suggestedTags.map((tag, i) => (
<div key={i} className="flex items-center justify-between p-3 rounded-lg border border-border/50 hover:bg-muted/20">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-full bg-brand-accent/10 text-brand-accent text-xs font-medium">
{tag.name}
</span>
<span className="text-[10px] text-muted-foreground">{tag.noteIds.length} notes</span>
</div>
<button
onClick={() => applyTag(tag.name, tag.noteIds)}
disabled={appliedTags.has(tag.name)}
className={cn(
'px-2 py-1 text-[10px] rounded-md font-medium transition-colors',
appliedTags.has(tag.name)
? 'bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400'
: 'bg-foreground/5 hover:bg-foreground/10 text-foreground'
)}
>
{appliedTags.has(tag.name) ? (<><Check size={10} className="inline" /> Appliqué</>) : (t('wizard.apply') || 'Appliquer')}
</button>
</div>
))}
</div>
)}
{/* Categories */}
{result.categories.length > 0 && (
<div className="space-y-2">
<h3 className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground flex items-center gap-1.5">
<FolderTree size={12} /> {t('wizard.categories') || 'Regroupements suggérés'}
</h3>
{result.categories.map((cat, i) => (
<div key={i} className="p-3 rounded-lg border border-border/50">
<div className="text-sm font-medium mb-1">{cat.name}</div>
<div className="flex flex-wrap gap-1">
{cat.noteTitles.map((title, j) => (
<span key={j} className="text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
{title.length > 35 ? title.slice(0, 35) + '...' : title}
</span>
))}
</div>
</div>
))}
</div>
)}
{/* Duplicates */}
{result.duplicates.length > 0 && (
<div className="space-y-2">
<h3 className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground flex items-center gap-1.5">
<AlertTriangle size={12} className="text-amber-500" /> {t('wizard.duplicates') || 'Doublons détectés'}
</h3>
{result.duplicates.map((dup, i) => (
<div key={i} className="p-3 rounded-lg border border-amber-200 dark:border-amber-900/50 bg-amber-50/50 dark:bg-amber-950/20">
<div className="flex items-center gap-2 mb-1">
<Copy size={12} className="text-amber-500" />
<span className="text-xs font-medium">{dup.note1Title}</span>
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-medium">{dup.note2Title}</span>
</div>
<p className="text-[10px] text-muted-foreground italic">{dup.reason}</p>
</div>
))}
</div>
)}
{result.suggestedTags.length === 0 && result.duplicates.length === 0 && result.categories.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
{t('wizard.noSuggestions') || 'Aucune suggestion — le carnet semble bien organisé.'}
</p>
)}
</div>
)}
</div>
</div>
</div>
)
}