fix: exercices dans menu GraduationCap + équations KaTeX + refresh liste
- Menu déroulant GraduationCap : Flashcards + Exercices réunis - Fix: language non défini dans toolbar (useLanguage destructuring) - Fix: équations 658071 → KaTeX dans exercices (preprocessMathInHtml partagé) - lib/text/math-preprocess.ts : utilitaire partagé wizard + exercices - Toast avec bouton 'Voir' pour rafraîchir après création exercices - emitNoteChange pour rafraîchir la liste - i18n FR/EN
This commit is contained in:
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { exerciseGeneratorService } from '@/lib/ai/services/exercise-generator.service'
|
import { exerciseGeneratorService } from '@/lib/ai/services/exercise-generator.service'
|
||||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||||
|
import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -54,13 +55,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const ex = exercises[i]
|
const ex = exercises[i]
|
||||||
const difficultyEmoji = ex.difficulty === 'facile' ? '🟢' : ex.difficulty === 'moyen' ? '🟡' : '🔴'
|
const difficultyEmoji = ex.difficulty === 'facile' ? '🟢' : ex.difficulty === 'moyen' ? '🟡' : '🔴'
|
||||||
|
|
||||||
const content = `
|
const rawContent = `
|
||||||
<div data-type="callout-block" data-callout-type="warning"><p><strong>${exerciseLabel} ${i + 1}</strong> — ${difficultyEmoji} ${ex.difficulty}</p></div>
|
<div data-type="callout-block" data-callout-type="warning"><p><strong>${exerciseLabel} ${i + 1}</strong> — ${difficultyEmoji} ${ex.difficulty}</p></div>
|
||||||
<h2>Énoncé</h2>
|
<h2>Énoncé</h2>
|
||||||
<p>${ex.question}</p>
|
<p>${ex.question}</p>
|
||||||
<div data-type="toggle-block" data-opened="false"><p><strong>${answerLabel}</strong> — cliquer pour révéler</p><h3>Solution</h3><p>${ex.answer}</p></div>
|
<div data-type="toggle-block" data-opened="false"><p><strong>${answerLabel}</strong> — cliquer pour révéler</p><h3>Solution</h3><p>${ex.answer}</p></div>
|
||||||
`.trim()
|
`.trim()
|
||||||
|
|
||||||
|
const content = preprocessMathInHtml(rawContent)
|
||||||
|
|
||||||
const created = await prisma.note.create({
|
const created = await prisma.note.create({
|
||||||
data: {
|
data: {
|
||||||
title: `${exerciseLabel} ${i + 1} — ${note.title || ''}`,
|
title: `${exerciseLabel} ${i + 1} — ${note.title || ''}`,
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export function ContextualAIChat({
|
|||||||
const [translateTarget, setTranslateTarget] = useState('')
|
const [translateTarget, setTranslateTarget] = useState('')
|
||||||
|
|
||||||
// Generate slides / diagram state
|
// Generate slides / diagram state
|
||||||
const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | 'exercises' | null>(null)
|
const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null)
|
||||||
const [generateProgress, setGenerateProgress] = useState(0)
|
const [generateProgress, setGenerateProgress] = useState(0)
|
||||||
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
|
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
|
||||||
const [customLangInput, setCustomLangInput] = useState('')
|
const [customLangInput, setCustomLangInput] = useState('')
|
||||||
@@ -1228,54 +1228,6 @@ export function ContextualAIChat({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Générateur d'exercices ── */}
|
|
||||||
{noteId && (
|
|
||||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
|
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
|
||||||
<PenTool size={80} className="text-brand-accent" />
|
|
||||||
</div>
|
|
||||||
<div className="relative space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><PenTool size={18} /></div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.exercises') || 'Générer des exercices'}</h5>
|
|
||||||
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.exercisesHint') || '5 exercices avec corrigés'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-foreground/50 leading-relaxed">
|
|
||||||
{t('ai.generate.exercisesDesc') || "L'IA crée 5 exercices basés sur cette note, avec des niveaux de difficulté variés et des corrigés détaillés."}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
if (!noteId) return
|
|
||||||
setGenerateLoading('exercises')
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ai/generate-exercises', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ noteId, count: 5, language: t('ai.language') === 'فارسی' ? 'fa' : 'fr' }),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok && data.exercises) {
|
|
||||||
toast.success(`${data.exercises.length} ${t('ai.generate.exercisesCreated') || 'exercices créés !'}`)
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || 'Erreur')
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(e.message || 'Erreur')
|
|
||||||
} finally {
|
|
||||||
setGenerateLoading(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!!generateLoading}
|
|
||||||
className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{generateLoading === 'exercises' ? <Loader2 size={14} className="animate-spin" /> : <><PenTool size={14} className="opacity-80" /> {t('ai.generate.generateExercises') || 'Générer'}</>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Personas IA ── */}
|
{/* ── Personas IA ── */}
|
||||||
<PersonasPanel noteTitle={noteTitle} noteContent={noteContent} />
|
<PersonasPanel noteTitle={noteTitle} noteContent={noteContent} />
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ interface NoteEditorToolbarProps {
|
|||||||
|
|
||||||
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
||||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
|
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
|
||||||
const { t } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const [isConverting, setIsConverting] = useState(false)
|
const [isConverting, setIsConverting] = useState(false)
|
||||||
const [shareOpen, setShareOpen] = useState(false)
|
const [shareOpen, setShareOpen] = useState(false)
|
||||||
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
|
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
|
||||||
@@ -236,6 +236,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [generatingExercises, setGeneratingExercises] = useState(false)
|
const [generatingExercises, setGeneratingExercises] = useState(false)
|
||||||
|
const [showEduMenu, setShowEduMenu] = useState(false)
|
||||||
const handleGenerateExercises = async () => {
|
const handleGenerateExercises = async () => {
|
||||||
if (generatingExercises) return
|
if (generatingExercises) return
|
||||||
setGeneratingExercises(true)
|
setGeneratingExercises(true)
|
||||||
@@ -249,7 +250,16 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
||||||
} else {
|
} else {
|
||||||
toast.success(t('richTextEditor.exercisesGenerated') || `${data.exercises?.length || 0} exercices créés !`)
|
toast.success(`${data.exercises?.length || 0} ${t('richTextEditor.exercisesGenerated') || 'exercices créés dans ce carnet !'}`, {
|
||||||
|
action: {
|
||||||
|
label: t('richTextEditor.seeExercises') || 'Voir',
|
||||||
|
onClick: () => window.location.reload(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Emit events so the note list refreshes
|
||||||
|
for (const ex of data.exercises || []) {
|
||||||
|
emitNoteChange({ type: 'created', noteId: ex.id, notebookId: note.notebookId })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || 'Erreur')
|
toast.error(e.message || 'Erreur')
|
||||||
@@ -389,14 +399,48 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<div className="relative">
|
||||||
title={t('flashcards.toolbarGenerate')}
|
<button
|
||||||
aria-label={t('flashcards.toolbarGenerate')}
|
title={t('flashcards.toolbarGenerate')}
|
||||||
onClick={() => setFlashcardsOpen(true)}
|
aria-label={t('flashcards.toolbarGenerate')}
|
||||||
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
onClick={() => setShowEduMenu(v => !v)}
|
||||||
>
|
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||||
<GraduationCap size={16} />
|
>
|
||||||
</button>
|
<GraduationCap size={16} />
|
||||||
|
</button>
|
||||||
|
{showEduMenu && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setShowEduMenu(false)} />
|
||||||
|
<div className="absolute top-full right-0 mt-1 z-50 w-56 rounded-xl border border-border bg-card shadow-xl overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400">
|
||||||
|
<GraduationCap size={16} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowEduMenu(false); handleGenerateExercises() }}
|
||||||
|
disabled={generatingExercises}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
|
||||||
|
>
|
||||||
|
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
|
||||||
|
{generatingExercises ? <Loader2Icon size={16} className="animate-spin" /> : <PenTool size={16} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{t('richTextEditor.generateExercises') || 'Générer des exercices'}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!readOnly && voiceSupported && (
|
{!readOnly && voiceSupported && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getChatProvider } from '../factory'
|
import { getChatProvider } from '../factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
|
||||||
|
|
||||||
export interface GeneratedNote {
|
export interface GeneratedNote {
|
||||||
title: string
|
title: string
|
||||||
@@ -173,40 +174,3 @@ Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const notebookWizardService = new NotebookWizardService()
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
36
memento-note/lib/text/math-preprocess.ts
Normal file
36
memento-note/lib/text/math-preprocess.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Convertit les délimiteurs LaTeX en nœuds TipTap pour le rendu KaTeX.
|
||||||
|
* À appliquer sur tout contenu HTML généré par l'IA avant stockage en DB.
|
||||||
|
*/
|
||||||
|
export 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
|
||||||
|
}
|
||||||
@@ -2559,6 +2559,7 @@
|
|||||||
"pdfExportLoading": "Generating PDF...",
|
"pdfExportLoading": "Generating PDF...",
|
||||||
"pdfExportSuccess": "PDF ready!",
|
"pdfExportSuccess": "PDF ready!",
|
||||||
"generateExercises": "Generate exercises",
|
"generateExercises": "Generate exercises",
|
||||||
|
"generateExercisesHint": "5 exercises + answers",
|
||||||
"exercisesLoading": "Generating exercises...",
|
"exercisesLoading": "Generating exercises...",
|
||||||
"exercisesGenerated": "exercises created!",
|
"exercisesGenerated": "exercises created!",
|
||||||
"aiGenerateExercises": "Generate exercises",
|
"aiGenerateExercises": "Generate exercises",
|
||||||
|
|||||||
@@ -2563,6 +2563,7 @@
|
|||||||
"pdfExportLoading": "Génération du PDF...",
|
"pdfExportLoading": "Génération du PDF...",
|
||||||
"pdfExportSuccess": "PDF prêt !",
|
"pdfExportSuccess": "PDF prêt !",
|
||||||
"generateExercises": "Générer des exercices",
|
"generateExercises": "Générer des exercices",
|
||||||
|
"generateExercisesHint": "5 exercices + corrigés",
|
||||||
"exercisesLoading": "Génération des exercices...",
|
"exercisesLoading": "Génération des exercices...",
|
||||||
"exercisesGenerated": "exercices créés !",
|
"exercisesGenerated": "exercices créés !",
|
||||||
"aiGenerateExercises": "Générer des exercices",
|
"aiGenerateExercises": "Générer des exercices",
|
||||||
|
|||||||
Reference in New Issue
Block a user