feat: Wizard IA + Export PDF + fixes blocs
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped

- Wizard IA: création de carnet complet (étudiant/prof/ingénieur)
  - 3 profils, choix niveau, nombre de notes (3-12)
  - Étapes numérotées, messages de progression, écran de succès
  - Notes riches avec callouts, toggles, équations, colonnes
  - Structured View auto-créé avec propriétés Statut/Difficulté
  - Embeddings en arrière-plan
- Export PDF: clone le DOM réel, rend KaTeX, préserve couleurs callouts
- Fix: boutons toggle/callout en hover-only
- Fix: parsing JSON robuste (backslashes LaTeX)
- Fix: flushSync warning (queueMicrotask)
- Fix: drag handle clamp viewport
- Bouton wizard dans la sidebar ( à côté du +)
- i18n FR/EN complet
This commit is contained in:
Antigravity
2026-06-14 19:51:02 +00:00
parent f7b62009cf
commit 940c3daf62
10 changed files with 915 additions and 33 deletions

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { notebookWizardService, type WizardProfile } from '@/lib/ai/services/notebook-wizard.service'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { profile, topic, level, count, language, notebookName, notebookIcon } = await request.json()
if (!profile || !topic?.trim()) {
return NextResponse.json({ error: 'Profile and topic are required' }, { status: 400 })
}
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
return NextResponse.json(
{ error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded', upgradeTier: err.upgradeTier },
{ status: 402 },
)
}
throw err
}
const result = await notebookWizardService.generateCarnet(
profile as WizardProfile,
topic,
{ level, count, language: language || 'fr' }
)
// 1. Create notebook
const notebook = await prisma.notebook.create({
data: {
name: notebookName || topic,
icon: notebookIcon || '📚',
userId: session.user.id,
order: 0,
},
})
// 2. Create schema
const schema = await prisma.notebookSchema.create({
data: { notebookId: notebook.id },
})
// 3. Create properties
const defaultProperties = [
{ name: 'Statut', type: 'select', options: ['À réviser', 'En cours', 'Maîtrisé'] },
{ name: 'Difficulté', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] },
]
const propertiesToCreate = result.schemaProperties?.length ? result.schemaProperties : defaultProperties
const propertyMap = new Map<string, string>()
for (let i = 0; i < propertiesToCreate.length; i++) {
const p = propertiesToCreate[i]
const created = await prisma.notebookProperty.create({
data: {
schemaId: schema.id,
name: p.name,
type: p.type,
options: p.options ? JSON.stringify(p.options) : null,
position: i,
},
})
propertyMap.set(p.name, created.id)
}
// 4. Create notes
let noteIndex = 0
for (const note of result.notes) {
const created = await prisma.note.create({
data: {
title: note.title,
content: note.content,
userId: session.user.id,
notebookId: notebook.id,
type: 'richtext',
order: noteIndex++,
},
})
// 5. Set default property values
if (note.difficulty && propertyMap.has('Difficulté')) {
const difficultyMap: Record<string, string> = {
facile: 'Facile', moyen: 'Moyen', difficile: 'Difficile',
easy: 'Facile', medium: 'Moyen', hard: 'Difficile',
}
const val = difficultyMap[note.difficulty?.toLowerCase() || ''] || 'Moyen'
await prisma.noteProperty.create({
data: { noteId: created.id, propertyId: propertyMap.get('Difficulté')!, value: val },
}).catch(() => {})
}
if (propertyMap.has('Statut')) {
await prisma.noteProperty.create({
data: { noteId: created.id, propertyId: propertyMap.get('Statut')!, value: 'À réviser' },
}).catch(() => {})
}
// 6. Background embedding
void (async () => {
try {
const { embeddingService } = await import('@/lib/ai/services/embedding.service')
const { upsertNoteEmbedding } = await import('@/lib/embeddings')
const { chunkIndexingService } = await import('@/lib/ai/services/chunk-indexing.service')
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
await upsertNoteEmbedding(created.id, embedding)
await chunkIndexingService.indexNote(created.id, note.title, note.content)
} catch (e) {
console.error('[Wizard] Background embedding failed:', e)
}
})()
}
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json({
notebookId: notebook.id,
notebookName: notebook.name,
noteCount: result.notes.length,
noteTitles: result.notes.map(n => n.title),
})
} catch (error: any) {
console.error('[Notebook Wizard] Error:', error)
return NextResponse.json({ error: error.message || 'Failed to generate notebook' }, { status: 500 })
}
}

View File

@@ -19,7 +19,7 @@ import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer
} from 'lucide-react'
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
import { NoteShareDialog } from './note-share-dialog'
@@ -89,6 +89,124 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
}
}
const handleExportPdf = async () => {
const editor = richTextEditorRef?.current?.getEditor()
if (!editor) return
const title = state.title || note.title || 'Note'
toast.loading(t('richTextEditor.pdfExportLoading') || 'Génération du PDF...', { id: 'pdf-export' })
try {
const editorEl = document.querySelector('.ProseMirror') as HTMLElement
if (!editorEl) throw new Error('Editor not found')
// Clone the editor DOM to process it for print
const clone = editorEl.cloneNode(true) as HTMLElement
// Remove all action buttons, toolbars, and UI elements
clone.querySelectorAll('button, .drag-handle, [contenteditable="false"], .opacity-0, .group-hover\\:opacity-100').forEach(el => el.remove())
clone.querySelectorAll('[title]').forEach(el => {
const title = el.getAttribute('title')
if (title && (title.includes('Supprimer') || title.includes('Delete') || title.includes('Désactiver') || title.includes('Disable') || title.includes('Modifier') || title.includes('Edit'))) {
el.remove()
}
})
// Render KaTeX equations properly
const katex = (await import('katex')).default
clone.querySelectorAll('.math-equation-block').forEach(el => {
const latex = el.getAttribute('data-latex') || el.textContent || ''
try {
el.innerHTML = katex.renderToString(latex, { displayMode: true, throwOnError: false })
} catch {}
})
clone.querySelectorAll('.inline-math').forEach(el => {
const latex = el.getAttribute('data-latex') || el.textContent || ''
try {
el.innerHTML = katex.renderToString(latex, { displayMode: false, throwOnError: false })
} catch {}
})
// Force show all toggle content
clone.querySelectorAll('[class*="hidden"]').forEach(el => {
(el as HTMLElement).style.display = 'block'
})
// Apply callout colors as inline styles (Tailwind classes won't work in print window)
clone.querySelectorAll('[data-callout-type]').forEach(el => {
const type = el.getAttribute('data-callout-type')
const colors: Record<string, { bg: string; border: string }> = {
info: { bg: '#eff6ff', border: '#93c5fd' },
warning: { bg: '#fffbeb', border: '#fcd34d' },
tip: { bg: '#faf5ff', border: '#c4b5fd' },
success: { bg: '#f0fdf4', border: '#86efac' },
danger: { bg: '#fef2f2', border: '#fca5a5' },
}
const c = colors[type || 'info'] || colors.info
const inner = el.querySelector('div')
if (inner) {
(inner as HTMLElement).style.background = c.bg
(inner as HTMLElement).style.borderColor = c.border
}
})
const cloneHtml = clone.innerHTML
const printWindow = window.open('', '_blank')
if (!printWindow) {
toast.error(t('richTextEditor.pdfExportBlocked') || 'Popup bloqué', { id: 'pdf-export' })
return
}
printWindow.document.write(`<!DOCTYPE html><html dir="auto"><head><meta charset="utf-8"><title>${title}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<style>
@page { margin: 1.5cm; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 720px; margin: 0 auto; line-height: 1.7; color: #1a1a1a; font-size: 14px; }
h1 { font-size: 1.8em; border-bottom: 2px solid #e5e5e5; padding-bottom: 0.3em; margin-bottom: 0.5em; }
h2 { font-size: 1.4em; margin-top: 1.5em; }
h3 { font-size: 1.2em; margin-top: 1.2em; }
p { margin: 0.6em 0; }
ul, ol { padding-left: 1.5em; }
li { margin: 0.3em 0; }
blockquote { border-left: 3px solid #d4d4d4; padding-left: 1em; color: #555; font-style: italic; margin: 0.8em 0; }
pre { background: #f5f5f5; padding: 0.8em; border-radius: 6px; overflow-x: auto; font-size: 13px; }
code { font-family: 'SF Mono', Menlo, monospace; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ddd; padding: 0.5em 0.8em; text-align: left; }
th { background: #f5f5f5; font-weight: 600; }
img { max-width: 100%; height: auto; border-radius: 4px; }
.callout-block { padding: 0.8em 1em; border-radius: 8px; border-left: 4px solid; margin: 0.8em 0; }
.callout-block[data-callout-type="info"] { background: #eff6ff; border-color: #3b82f6; }
.callout-block[data-callout-type="warning"] { background: #fffbeb; border-color: #f59e0b; }
.callout-block[data-callout-type="tip"] { background: #faf5ff; border-color: #8b5cf6; }
.callout-block[data-callout-type="success"] { background: #f0fdf4; border-color: #22c55e; }
.callout-block[data-callout-type="danger"] { background: #fef2f2; border-color: #ef4444; }
.toggle-block { border: 1px solid #e5e5e5; border-radius: 8px; margin: 0.8em 0; overflow: hidden; }
.toggle-block > div:first-child { background: #f9f9f9; padding: 0.5em 0.8em; font-weight: 600; font-size: 0.85em; }
.toggle-content { padding: 0.5em 0.8em; }
.columns-block { display: flex; gap: 16px; margin: 0.8em 0; }
.columns-block > div { flex: 1; }
.math-equation-block { margin: 1em 0; text-align: center; }
.outline-block { border: 1px solid #e5e5e5; border-radius: 8px; padding: 0.8em; margin: 1em 0; }
.link-preview-block { border: 1px solid #e5e5e5; border-radius: 8px; overflow: hidden; margin: 0.8em 0; }
.link-preview-searchable { display: none !important; }
a { color: #A47148; text-decoration: none; }
a:hover { text-decoration: underline; }
@media print { body { margin: 0; max-width: 100%; } }
</style></head><body><h1>${title}</h1>${cloneHtml}</body></html>`)
printWindow.document.close()
setTimeout(() => {
printWindow.focus()
printWindow.print()
toast.success(t('richTextEditor.pdfExportSuccess') || 'PDF prêt !', { id: 'pdf-export' })
}, 800)
} catch (e: any) {
toast.error(e.message || 'Erreur export PDF', { id: 'pdf-export' })
}
}
// ── Markdown import ───────────────────────────────────────────────────────
const openMarkdownImport = () => {
const input = document.createElement('input')
@@ -334,6 +452,10 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<FileDown className="h-4 w-4 me-2" />
{t('richTextEditor.exportMarkdown')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportPdf}>
<Printer className="h-4 w-4 me-2" />
{t('richTextEditor.exportPdf') || 'Exporter en PDF'}
</DropdownMenuItem>
<DropdownMenuItem onClick={openMarkdownImport}>
<FileUp className="h-4 w-4 me-2" />
{t('richTextEditor.importMarkdown')}

View File

@@ -746,11 +746,15 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
useEffect(() => {
if (editor && content !== undefined && content !== lastEmittedContent.current) {
editor.commands.setContent(content || '')
lastEmittedContent.current = content || ''
// TipTap #7338 : dir explicite rtl sur listes (pas auto) après chargement HTML
requestAnimationFrame(() => {
applyClipRtlDirection(editor, { sourceUrl })
const html = content || ''
lastEmittedContent.current = html
queueMicrotask(() => {
if (!editor.isDestroyed) {
editor.commands.setContent(html)
requestAnimationFrame(() => {
applyClipRtlDirection(editor, { sourceUrl })
})
}
})
}
if (content !== undefined) {

View File

@@ -49,6 +49,7 @@ import { toast } from 'sonner'
import { motion, AnimatePresence } from 'motion/react'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { CreateNotebookDialog } from './create-notebook-dialog'
import { AiNotebookWizard } from './wizard/ai-notebook-wizard'
import { NotificationPanel } from './notification-panel'
import {
DropdownMenu,
@@ -423,7 +424,7 @@ function SidebarCarnetItem({
onClick={onCarnetClick}
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
className={cn(
'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative',
'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative overflow-hidden',
isActive ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' : 'hover:bg-white/40 dark:hover:bg-white/5'
)}
>
@@ -454,7 +455,15 @@ function SidebarCarnetItem({
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
{/* Compteur de notes (toujours visible, à droite du nom) */}
{notes.length > 0 && (
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover/item:text-concrete transition-colors shrink-0 ms-auto">
{notes.length}
</span>
)}
{/* Boutons d'action en absolu, toujours à droite */}
<div className="absolute end-0 top-0 bottom-0 flex items-center gap-1 pe-1 ps-6 opacity-0 group-hover/item:opacity-100 transition-opacity bg-gradient-to-l from-[var(--color-paper,white)] dark:from-[var(--color-paper,#1a1a1a)] from-60% to-transparent">
{isPinned && (
<span className="text-brand-accent" title={t('notebook.pinnedFrozenTooltip')}>
<Pin size={9} className="opacity-70" />
@@ -481,12 +490,6 @@ function SidebarCarnetItem({
>
<Trash2 size={10} />
</button>
{notes.length > 0 && (
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover/item:text-concrete transition-colors">
{notes.length}
</span>
)}
</div>
</motion.div>
</div>
@@ -586,6 +589,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const { notebooks, trashNotebook, updateNotebookOrderOptimistic, moveNotebookToParent, refreshNotebooks } = useNotebooks()
const { open: openSearch } = useSearchModal()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [showAiWizard, setShowAiWizard] = useState(false)
const [createParentId, setCreateParentId] = useState<string | null>(null)
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
const [renameValue, setRenameValue] = useState('')
@@ -1360,6 +1364,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
>
<Plus size={15} />
</button>
<button
type="button"
onClick={() => setShowAiWizard(true)}
className="p-1 hover:bg-brand-accent/10 rounded transition-all text-concrete hover:text-brand-accent"
title={t('wizard.title')}
>
<Sparkles size={14} />
</button>
<div className="relative">
<button
type="button"
@@ -1639,6 +1651,16 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
parentNotebookId={createParentId}
/>
{showAiWizard && (
<AiNotebookWizard
onClose={() => setShowAiWizard(false)}
onComplete={() => {
setShowAiWizard(false)
window.location.reload()
}}
/>
)}
{/* Rename Dialog */}
<AnimatePresence>
{renamingNotebook && (

View File

@@ -80,7 +80,7 @@ const CalloutView = ({ node, updateAttributes, deleteNode, getPos, editor }: any
}
return (
<NodeViewWrapper className="callout-block my-2" dir="auto">
<NodeViewWrapper className="callout-block group/callout my-2" dir="auto">
<div className={cn('flex items-start gap-2 rounded-lg border px-3 py-2.5', styles.bg, styles.border)}>
<div className="flex flex-col items-center gap-1 pt-0.5">
<CalloutTypePicker
@@ -91,7 +91,7 @@ const CalloutView = ({ node, updateAttributes, deleteNode, getPos, editor }: any
<div className="flex-1 min-w-0 text-sm leading-relaxed">
<NodeViewContent className="callout-content" />
</div>
<div className="flex flex-shrink-0 items-center gap-0.5 pt-0.5">
<div className="flex flex-shrink-0 items-center gap-0.5 pt-0.5 opacity-0 group-hover/callout:opacity-100 transition-opacity">
<button
onClick={unwrap}
contentEditable={false}

View File

@@ -33,7 +33,7 @@ const ToggleView = ({ node, updateAttributes, deleteNode, getPos, editor }: any)
}
return (
<NodeViewWrapper className="toggle-block my-1 rounded-lg border border-border/50 bg-muted/20" dir="auto">
<NodeViewWrapper className="toggle-block group/toggle my-1 rounded-lg border border-border/50 bg-muted/20" dir="auto">
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/30">
<button
onClick={toggle}
@@ -53,22 +53,24 @@ const ToggleView = ({ node, updateAttributes, deleteNode, getPos, editor }: any)
>
{opened ? t('richTextEditor.toggleOpened') : t('richTextEditor.toggleClosed')}
</span>
<button
onClick={unwrap}
contentEditable={false}
className="flex-shrink-0 p-0.5 rounded hover:bg-muted text-muted-foreground"
title={t('richTextEditor.toggleUnwrap')}
>
<X className="h-3.5 w-3.5" />
</button>
<button
onClick={deleteNode}
contentEditable={false}
className="flex-shrink-0 p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title={t('richTextEditor.toggleDelete')}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<div className="flex items-center gap-0.5 opacity-0 group-hover/toggle:opacity-100 transition-opacity">
<button
onClick={unwrap}
contentEditable={false}
className="p-0.5 rounded hover:bg-muted text-muted-foreground"
title={t('richTextEditor.toggleUnwrap')}
>
<X className="h-3.5 w-3.5" />
</button>
<button
onClick={deleteNode}
contentEditable={false}
className="p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title={t('richTextEditor.toggleDelete')}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className={cn('px-3 py-2', !opened && 'hidden')}>

View File

@@ -0,0 +1,317 @@
'use client'
import { useState } from 'react'
import { GraduationCap, BookOpen, Wrench, Sparkles, Loader2, X, ArrowRight, ArrowLeft, Check, FileText } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
type WizardProfile = 'student' | 'teacher' | 'engineer'
interface ProfileOption {
id: WizardProfile
icon: typeof GraduationCap
titleKey: string
descKey: string
placeholderKey: string
}
const PROFILES: ProfileOption[] = [
{
id: 'student',
icon: GraduationCap,
titleKey: 'wizard.profileStudent',
descKey: 'wizard.profileStudentDesc',
placeholderKey: 'wizard.topicStudentPlaceholder',
},
{
id: 'teacher',
icon: BookOpen,
titleKey: 'wizard.profileTeacher',
descKey: 'wizard.profileTeacherDesc',
placeholderKey: 'wizard.topicTeacherPlaceholder',
},
{
id: 'engineer',
icon: Wrench,
titleKey: 'wizard.profileEngineer',
descKey: 'wizard.profileEngineerDesc',
placeholderKey: 'wizard.topicEngineerPlaceholder',
},
]
const LEVELS = ['wizard.levelBeginner', 'wizard.levelIntermediate', 'wizard.levelAdvanced', 'wizard.levelExpert']
export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; onComplete: (notebookId: string) => void }) {
const { t, language } = useLanguage()
const [step, setStep] = useState<0 | 1 | 2>(0)
const [profile, setProfile] = useState<WizardProfile | null>(null)
const [topic, setTopic] = useState('')
const [level, setLevel] = useState(1)
const [count, setCount] = useState(6)
const [loading, setLoading] = useState(false)
const [progressMsg, setProgressMsg] = useState('')
const [success, setSuccess] = useState<{ notebookId: string; notebookName: string; noteTitles: string[] } | null>(null)
const handleSubmit = async () => {
if (!profile || !topic.trim()) return
setLoading(true)
setProgressMsg(t('wizard.progressGenerating') || 'Génération du contenu par l\'IA...')
try {
setProgressMsg(t('wizard.progressCalling') || 'Appel de l\'IA...')
const res = await fetch('/api/ai/notebook-wizard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profile,
topic: topic.trim(),
level: t(LEVELS[level]),
count,
language,
notebookName: topic.trim(),
}),
})
setProgressMsg(t('wizard.progressParsing') || 'Analyse de la réponse...')
const data = await res.json()
if (!res.ok) {
if (data.errorKey === 'ai.featureLocked') {
toast.error(t('ai.featureLocked') || 'Cette fonctionnalité nécessite un plan supérieur')
} else if (data.errorKey === 'ai.quotaExceeded') {
toast.error(t('ai.quotaExceeded') || 'Quota IA dépassé')
} else {
toast.error(data.error || 'Erreur')
}
setLoading(false)
return
}
setProgressMsg(t('wizard.progressCreating') || 'Création du carnet et des notes...')
setSuccess({
notebookId: data.notebookId,
notebookName: data.notebookName || topic,
noteTitles: data.noteTitles || [],
})
setLoading(false)
} catch (e: any) {
toast.error(e.message || 'Erreur')
setLoading(false)
}
}
const selectedProfile = PROFILES.find(p => p.id === profile)
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 rounded-2xl border border-border bg-card shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-brand-accent/5">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-brand-accent" />
<h2 className="text-base font-semibold">{t('wizard.title')}</h2>
</div>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted text-muted-foreground">
<X className="h-5 w-5" />
</button>
</div>
{/* Progress bar */}
<div className="flex items-center px-6 pt-3 gap-2">
{[0, 1, 2].map(s => (
<div key={s} className="flex items-center gap-2 flex-1">
<div className={cn(
'w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold transition-colors flex-shrink-0',
s <= step ? 'bg-brand-accent text-white' : 'bg-border text-muted-foreground'
)}>
{s + 1}
</div>
{s < 2 && <div className={cn('h-0.5 flex-1 rounded-full transition-colors', s < step ? 'bg-brand-accent' : 'bg-border')} />}
</div>
))}
</div>
{/* Content */}
<div className="p-6 min-h-[360px]">
{success ? (
<div className="flex flex-col items-center py-6 gap-4">
<div className="w-14 h-14 rounded-full bg-green-100 dark:bg-green-950/40 flex items-center justify-center">
<Check className="h-7 w-7 text-green-600 dark:text-green-400" />
</div>
<div className="text-center">
<h3 className="text-base font-semibold mb-1">
{t('wizard.created') || 'Carnet créé :'} <span className="text-brand-accent">{success.notebookName}</span>
</h3>
<p className="text-sm text-muted-foreground">
{success.noteTitles.length} {t('wizard.notesCreated') || 'notes créées'}
</p>
</div>
<div className="w-full max-h-48 overflow-y-auto rounded-xl border border-border bg-muted/20 p-3 space-y-1.5">
{success.noteTitles.map((title, i) => (
<div key={i} className="flex items-center gap-2 text-sm py-1">
<FileText size={14} className="text-brand-accent flex-shrink-0" />
<span className="truncate">{title}</span>
</div>
))}
</div>
<button
onClick={() => onComplete(success.notebookId)}
className="flex items-center gap-2 px-5 py-2.5 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.openNotebook') || 'Ouvrir le carnet'}
</button>
</div>
) : loading ? (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<Loader2 className="h-10 w-10 animate-spin text-brand-accent" />
<p className="text-sm text-muted-foreground text-center max-w-sm">
{progressMsg}
</p>
<div className="flex flex-wrap gap-2 justify-center mt-2">
{['Toggle', 'Callout', 'Math', 'Colonnes', 'Sommaire', 'Link Preview'].map(tag => (
<span key={tag} className="text-[10px] px-2 py-0.5 rounded-full bg-brand-accent/10 text-brand-accent font-medium">
{tag}
</span>
))}
</div>
</div>
) : step === 0 ? (
/* Step 1: Choose profile */
<div className="space-y-3">
<p className="text-sm text-muted-foreground mb-4">{t('wizard.chooseProfile')}</p>
{PROFILES.map(p => (
<button
key={p.id}
onClick={() => { setProfile(p.id); setStep(1) }}
className="w-full flex items-start gap-3 p-4 rounded-xl border border-border hover:border-brand-accent/40 hover:bg-brand-accent/[0.03] transition-all text-left group"
>
<div className="w-10 h-10 rounded-lg bg-brand-accent/10 flex items-center justify-center flex-shrink-0 group-hover:bg-brand-accent/20 transition-colors">
<p.icon className="h-5 w-5 text-brand-accent" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold mb-0.5">{t(p.titleKey)}</h3>
<p className="text-xs text-muted-foreground leading-relaxed">{t(p.descKey)}</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-brand-accent transition-colors mt-1" />
</button>
))}
</div>
) : step === 1 ? (
/* Step 2: Topic + options */
<div className="space-y-5">
<div>
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground mb-2 block">
{t('wizard.topic')}
</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && topic.trim()) { setStep(2) } }}
placeholder={selectedProfile ? t(selectedProfile.placeholderKey) : ''}
autoFocus
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-brand-accent/30"
/>
</div>
<div>
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground mb-2 block">
{t('wizard.level')}
</label>
<div className="flex gap-2">
{LEVELS.map((lvlKey, i) => (
<button
key={lvlKey}
onClick={() => setLevel(i)}
className={cn(
'flex-1 py-2 rounded-lg text-xs font-medium border transition-all',
level === i
? 'border-brand-accent bg-brand-accent/10 text-brand-accent'
: 'border-border text-muted-foreground hover:border-foreground/20'
)}
>
{t(lvlKey)}
</button>
))}
</div>
</div>
<div>
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground mb-2 block">
{t('wizard.noteCount')}: {count}
</label>
<input
type="range"
min={3}
max={12}
value={count}
onChange={(e) => setCount(Number(e.target.value))}
className="w-full accent-brand-accent"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-1">
<span>3</span>
<span>12</span>
</div>
</div>
<div className="flex justify-between pt-2">
<button onClick={() => setStep(0)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-4 w-4" />
{t('general.back') || 'Retour'}
</button>
<button
onClick={() => setStep(2)}
disabled={!topic.trim()}
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 disabled:opacity-40 transition-colors font-medium"
>
{t('general.continue') || 'Continuer'}
<ArrowRight className="h-4 w-4" />
</button>
</div>
</div>
) : (
/* Step 3: Confirm */
<div className="space-y-5">
<div className="rounded-xl border border-border bg-muted/20 p-4 space-y-3">
<div className="flex items-center gap-2">
{selectedProfile && <selectedProfile.icon className="h-4 w-4 text-brand-accent" />}
<span className="text-sm font-medium">{selectedProfile && t(selectedProfile.titleKey)}</span>
</div>
<div className="space-y-1.5 text-sm">
<div className="flex justify-between"><span className="text-muted-foreground">{t('wizard.topic')}</span><span className="font-medium">{topic}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">{t('wizard.level')}</span><span className="font-medium">{t(LEVELS[level])}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">{t('wizard.noteCount')}</span><span className="font-medium">{count} {t('wizard.notes')}</span></div>
</div>
</div>
<div className="rounded-lg bg-brand-accent/5 border border-brand-accent/20 p-3">
<p className="text-xs text-muted-foreground leading-relaxed">
{t('wizard.confirmHint') || 'L\'IA va créer un carnet avec des notes riches : encadrés, sections repliables, formules mathématiques, colonnes de comparaison, et plus.'}
</p>
</div>
<div className="flex justify-between pt-2">
<button onClick={() => setStep(1)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-4 w-4" />
{t('general.back') || 'Retour'}
</button>
<button
onClick={handleSubmit}
className="flex items-center gap-2 px-5 py-2.5 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.generate') || 'Générer le carnet'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,212 @@
import { getChatProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
export interface GeneratedNote {
title: string
content: string
difficulty?: 'facile' | 'moyen' | 'difficile'
}
export interface GeneratedCarnet {
notes: GeneratedNote[]
schemaProperties: Array<{ name: string; type: string; options?: string[] }>
}
export type WizardProfile = 'student' | 'teacher' | 'engineer'
export class NotebookWizardService {
async generateCarnet(
profile: WizardProfile,
topic: string,
options?: { level?: string; count?: number; language?: string }
): Promise<GeneratedCarnet> {
const lang = options?.language || 'fr'
const count = options?.count || 6
const prompts = this.buildPrompt(profile, topic, lang, count, options?.level)
const config = await getSystemConfig()
const provider = getChatProvider(config)
const raw = await provider.generateText(prompts)
return this.parseResponse(raw, profile)
}
private buildPrompt(
profile: WizardProfile,
topic: string,
lang: string,
count: number,
level?: string
): string {
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang
const profileContext = {
student: `Tu crées des notes de cours pour un étudiant. Le contenu doit être pédagogique, clair, avec des exemples.`,
teacher: `Tu crées la structure d'un cours pour un professeur. Chaque note est un chapitre avec des sections à remplir, des objectifs, et des exercices.`,
engineer: `Tu crées une documentation technique. Le contenu doit être précis, structuré, avec des spécifications et des références.`,
}[profile]
return `Tu es un expert pédagogue et créateur de contenu de haut niveau. ${profileContext}
Sujet : "${topic}"
${level ? `Niveau : ${level}` : ''}
Langue : ${langName}
Nombre de notes à créer : ${count}
CRÉE ${count} NOTES COMPLÈTES ET DÉTAILLÉES sur ce sujet. Chaque note doit faire ENTRE 800 ET 1500 MOTS minimum. C'est non négociable.
Le contenu doit être :
- COMPLET et APPROFONDI — pas de résumé superficiel
- Structuré avec des titres <h2> et <h3> pour les sections
- Des paragraphes développés avec des explications détaillées
- Des exemples concrets et des cas d'usage
- Des définitions précises dans des encadrés callout
${profile === 'student' ? '- Des "Points clés à retenir" en callout tip\n- Des "Pièges à éviter" en callout danger\n- Des exemples d\'application' : ''}
${profile === 'teacher' ? '- Des objectifs pédagogiques en début de note\n- Des sections "Exercices" avec 5 questions détaillées\n- Des corrigés indicatifs' : ''}
${profile === 'engineer' ? '- Des spécifications techniques précises\n- Des tableaux comparatifs\n- Des références aux normes et standards' : ''}
FORMAT DE SORTIE — JSON UNIQUEMENT :
\`\`\`json
{
"notes": [
{
"title": "Titre détaillé et descriptif",
"difficulty": "facile",
"content": "<h2>Introduction</h2><p>Plusieurs paragraphes détaillés...</p>..."
}
],
"schemaProperties": [
{ "name": "Statut", "type": "select", "options": ["À réviser", "En cours", "Maîtrisé"] },
{ "name": "Difficulté", "type": "select", "options": ["Facile", "Moyen", "Difficile"] }
]
}
\`\`\`
BLOCS HTML DISPONIBLES — UTILISE-LES ABONDAMMENT :
1. **Callout** (encadré coloré) :
<div data-type="callout-block" data-callout-type="info"><p>Définition importante...</p></div>
Types : info, warning, tip, success, danger
2. **Toggle** (section repliable pour détails) :
<div data-type="toggle-block" data-opened="true"><p>Cliquer pour voir les détails</p><p>Contenu détaillé...</p></div>
3. **Math** (formules LaTeX) — UTILISE LES BALISES DIRECTEMENT, pas de $$ :
Block : <div data-type="math-equation" data-latex="E = mc^2"></div>
Inline : <span data-type="inline-math" data-latex="x^2">x^2</span>
N'UTILISE JAMAIS $$ ou $ comme délimiteur — utilise TOUJOURS les balises HTML ci-dessus.
4. **Colonnes** (comparaison côte à côte) :
<div data-type="columns" cols="2"><div data-type="column" index="0"><p>Concept A...</p></div><div data-type="column" index="1"><p>Concept B...</p></div></div>
5. **Sommaire** (début de note) :
<div data-type="outline-block"></div>
6. HTML standard : <h1>/<h2>/<h3>, <p>, <ul>/<ol>/<li>, <table>, <blockquote>, <pre><code>
IMPORTANT : Chaque note DOIT faire entre 800 et 1500 mots. Sois exhaustif. Développe chaque concept avec des exemples, des explications, et du contexte. N'écris pas de résumés courts.
Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
}
private parseResponse(raw: string, profile: WizardProfile): GeneratedCarnet {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
// Extract the JSON object boundaries
const start = jsonStr.indexOf('{')
const end = jsonStr.lastIndexOf('}')
if (start >= 0 && end > start) {
jsonStr = jsonStr.slice(start, end + 1)
}
let parsed: any
try {
parsed = JSON.parse(jsonStr)
} catch {
// Fix common AI JSON issues: unescaped backslashes in LaTeX
try {
const fixed = jsonStr
.replace(/\\(?!["\\\/bfnrtu])/g, '\\\\') // Escape lone backslashes (LaTeX \frac etc.)
.replace(/\n/g, '\\n') // Fix raw newlines in strings
parsed = JSON.parse(fixed)
} catch {
try {
// Last resort: extract notes manually with regex
const notes: GeneratedNote[] = []
const titleMatches = [...jsonStr.matchAll(/"title"\s*:\s*"([^"]+)"/g)]
const contentMatches = [...jsonStr.matchAll(/"content"\s*:\s*"([\s\S]*?)"(?:,|\s*})/g)]
for (let i = 0; i < titleMatches.length; i++) {
notes.push({
title: titleMatches[i][1],
content: contentMatches[i]?.[1]?.replace(/\\n/g, '\n').replace(/\\"/g, '"') || '<p></p>',
difficulty: i % 3 === 0 ? 'facile' : i % 3 === 1 ? 'moyen' : 'difficile',
})
}
if (notes.length === 0) throw new Error('No notes found in response')
return {
notes,
schemaProperties: [
{ name: 'Statut', type: 'select', options: ['À réviser', 'En cours', 'Maîtrisé'] },
{ name: 'Difficulté', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] },
],
}
} catch {
throw new Error('Failed to parse AI response')
}
}
}
const notes: GeneratedNote[] = (parsed.notes || []).map((n: any) => ({
title: String(n.title || 'Sans titre'),
content: preprocessMathInHtml(String(n.content || '<p></p>')),
difficulty: n.difficulty || undefined,
}))
const schemaProperties = parsed.schemaProperties || [
{ name: 'Statut', type: 'select', options: ['À réviser', 'En cours', 'Maîtrisé'] },
{ name: 'Difficulté', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] },
]
return { notes, schemaProperties }
}
}
export const notebookWizardService = new NotebookWizardService()
/**
* Convertit les délimiteurs LaTeX ($$...$$ et $...$) en nœuds TipTap
* pour que les équations s'affichent correctement au chargement.
*/
function preprocessMathInHtml(html: string): string {
let result = html
// 1. \[...\] → block math
result = result.replace(/\\\[([\s\S]+?)\\\]/g, (_, latex) => {
const escaped = latex.trim().replace(/"/g, '&quot;')
return `</p><div data-type="math-equation" data-latex="${escaped}"></div><p>`
})
// 2. $$...$$ → block math
result = result.replace(/\$\$([\s\S]+?)\$\$/g, (_, latex) => {
const escaped = latex.trim().replace(/"/g, '&quot;')
return `</p><div data-type="math-equation" data-latex="${escaped}"></div><p>`
})
// 3. \(...\) → inline math
result = result.replace(/\\\(([\s\S]+?)\\\)/g, (_, latex) => {
const escaped = latex.trim().replace(/"/g, '&quot;')
return `<span data-type="inline-math" data-latex="${escaped}">${escaped}</span>`
})
// 4. $...$ → inline math (only single $ not followed by another $)
result = result.replace(/(?<!\$)\$(?!\$)([^\n$]+?)\$(?!\$)/g, (_, latex) => {
const escaped = latex.trim().replace(/"/g, '&quot;')
return `<span data-type="inline-math" data-latex="${escaped}">${escaped}</span>`
})
// 5. Clean up empty <p></p> tags
result = result.replace(/<p>\s*<\/p>/g, '')
return result
}

View File

@@ -2451,6 +2451,36 @@
"columnsAdd": "Add a column",
"columnsDelete": "Delete columns",
"columnsLabel": "columns",
"wizardTitle": "Create a notebook with AI",
"wizardChooseProfile": "What's your profile?",
"wizardProfileStudent": "Student",
"wizardProfileStudentDesc": "AI creates a structured course notebook with summaries, formulas and key takeaways",
"wizardProfileTeacher": "Teacher",
"wizardProfileTeacherDesc": "AI generates a course structure with chapters, exercises and learning objectives",
"wizardProfileEngineer": "Engineer / Professional",
"wizardProfileEngineerDesc": "AI creates organized technical documentation with specs and references",
"wizardTopicStudentPlaceholder": "e.g: Thermodynamics, Calculus, French Revolution...",
"wizardTopicTeacherPlaceholder": "e.g: Math 101, AP Physics, Algorithms...",
"wizardTopicEngineerPlaceholder": "e.g: Microservices architecture, Network security, ISO 27001...",
"wizardTopic": "Topic",
"wizardLevel": "Level",
"wizardLevelBeginner": "Beginner",
"wizardLevelIntermediate": "Intermediate",
"wizardLevelAdvanced": "Advanced",
"wizardLevelExpert": "Expert",
"wizardNoteCount": "Number of notes",
"wizardNotes": "notes",
"wizardConfirmHint": "AI will create a notebook with rich notes: callouts, collapsible sections, math formulas, comparison columns, outlines and links.",
"wizardGenerate": "Generate notebook",
"wizardLoading": "AI is creating your notebook with structured notes...",
"wizardProgressGenerating": "Generating content with AI...",
"wizardProgressCalling": "Calling AI...",
"wizardProgressParsing": "Parsing AI response...",
"wizardProgressCreating": "Creating notebook and notes...",
"wizardSuccess": "Notebook created successfully!",
"wizardCreated": "Notebook created:",
"wizardNotesCreated": "notes created",
"wizardOpenNotebook": "Open notebook",
"calloutDelete": "Delete callout",
"calloutUnwrap": "Disable callout",
"calloutInfo": "Information",
@@ -2524,6 +2554,10 @@
"Japonais": "Japanese"
},
"exportMarkdown": "Export as Markdown",
"exportPdf": "Export as PDF",
"pdfExportBlocked": "Popup blocked — allow popups to export as PDF",
"pdfExportLoading": "Generating PDF...",
"pdfExportSuccess": "PDF ready!",
"importMarkdown": "Import Markdown",
"markdownExportSuccess": "Note exported as Markdown",
"markdownExportError": "Failed to export note",

View File

@@ -2455,6 +2455,36 @@
"columnsAdd": "Ajouter une colonne",
"columnsDelete": "Supprimer les colonnes",
"columnsLabel": "colonnes",
"wizardTitle": "Créer un carnet avec l'IA",
"wizardChooseProfile": "Quel est votre profil ?",
"wizardProfileStudent": "Étudiant",
"wizardProfileStudentDesc": "L'IA crée un carnet de cours structuré avec résumés, formules et encadrés à retenir",
"wizardProfileTeacher": "Professeur",
"wizardProfileTeacherDesc": "L'IA génère la structure d'un cours avec chapitres, exercices et objectifs pédagogiques",
"wizardProfileEngineer": "Ingénieur / Professionnel",
"wizardProfileEngineerDesc": "L'IA crée une documentation technique organisée avec spécifications et références",
"wizardTopicStudentPlaceholder": "Ex: Thermodynamique, Calcul différentiel, Histoire de la Révolution...",
"wizardTopicTeacherPlaceholder": "Ex: Mathématiques L1, Physique-Chimie Terminale, Algorithmique...",
"wizardTopicEngineerPlaceholder": "Ex: Architecture microservices, Sécurité réseau, Norme ISO 27001...",
"wizardTopic": "Sujet",
"wizardLevel": "Niveau",
"wizardLevelBeginner": "Débutant",
"wizardLevelIntermediate": "Intermédiaire",
"wizardLevelAdvanced": "Avancé",
"wizardLevelExpert": "Expert",
"wizardNoteCount": "Nombre de notes",
"wizardNotes": "notes",
"wizardConfirmHint": "L'IA va créer un carnet avec des notes riches : encadrés, sections repliables, formules mathématiques, colonnes de comparaison, sommaires et liens.",
"wizardGenerate": "Générer le carnet",
"wizardLoading": "L'IA crée votre carnet avec des notes structurées...",
"wizardProgressGenerating": "Génération du contenu par l'IA...",
"wizardProgressCalling": "Appel de l'IA...",
"wizardProgressParsing": "Analyse de la réponse de l'IA...",
"wizardProgressCreating": "Création du carnet et des notes...",
"wizardSuccess": "Carnet créé avec succès !",
"wizardCreated": "Carnet créé :",
"wizardNotesCreated": "notes créées",
"wizardOpenNotebook": "Ouvrir le carnet",
"calloutDelete": "Supprimer l'encadré",
"calloutUnwrap": "Désactiver l'encadré",
"calloutInfo": "Information",
@@ -2528,6 +2558,10 @@
"Japonais": "Japonais"
},
"exportMarkdown": "Exporter en Markdown",
"exportPdf": "Exporter en PDF",
"pdfExportBlocked": "Popup bloqué — autorisez les popups pour exporter en PDF",
"pdfExportLoading": "Génération du PDF...",
"pdfExportSuccess": "PDF prêt !",
"importMarkdown": "Importer un Markdown",
"markdownExportSuccess": "Note exportée en Markdown",
"markdownExportError": "Échec de l'export de la note",