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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user