feat: Wizard IA + Export PDF + fixes blocs
- 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:
135
memento-note/app/api/ai/notebook-wizard/route.ts
Normal file
135
memento-note/app/api/ai/notebook-wizard/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
317
memento-note/components/wizard/ai-notebook-wizard.tsx
Normal file
317
memento-note/components/wizard/ai-notebook-wizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
212
memento-note/lib/ai/services/notebook-wizard.service.ts
Normal file
212
memento-note/lib/ai/services/notebook-wizard.service.ts
Normal 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, '"')
|
||||
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, '"')
|
||||
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, '"')
|
||||
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, '"')
|
||||
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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user