fix: Memory Echo sans vol de scroll, badge IA sidebar, wizard langue

- Ne plus auto-scroller vers Memory Echo : pastille bas de page + reset scroll au titre
- Badges notes/carnets générés par l’IA dans la sidebar
- Wizard : langue du contenu = langue de la requête ; refresh carnet après création
- Voix : erreurs not-allowed/no-speech moins bruyantes
This commit is contained in:
Antigravity
2026-07-16 17:25:57 +00:00
parent 45297da333
commit 704fed1191
11 changed files with 672 additions and 210 deletions

View File

@@ -33,6 +33,7 @@ export async function POST(request: NextRequest) {
throw err throw err
} }
// language = UI locale (fallback only). Content language = language of the topic string.
const result = await notebookWizardService.generateCarnet( const result = await notebookWizardService.generateCarnet(
profile as WizardProfile, profile as WizardProfile,
topic, topic,
@@ -47,13 +48,20 @@ export async function POST(request: NextRequest) {
|| topic.trim().slice(0, 60) || topic.trim().slice(0, 60)
).slice(0, 80) ).slice(0, 80)
// Place new notebook at top of manual order (before existing min order)
const minOrder = await prisma.notebook.aggregate({
where: { userId: session.user.id, trashedAt: null },
_min: { order: true },
})
const nextOrder = (minOrder._min.order ?? 0) - 1
// 1. Create notebook // 1. Create notebook
const notebook = await prisma.notebook.create({ const notebook = await prisma.notebook.create({
data: { data: {
name: resolvedName, name: resolvedName,
icon: notebookIcon || '📚', icon: notebookIcon || '📚',
userId: session.user.id, userId: session.user.id,
order: 0, order: nextOrder,
}, },
}) })
@@ -95,6 +103,8 @@ export async function POST(request: NextRequest) {
notebookId: notebook.id, notebookId: notebook.id,
type: 'richtext', type: 'richtext',
order: noteIndex++, order: noteIndex++,
autoGenerated: true,
aiProvider: 'notebook-wizard',
}, },
}) })

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useCallback, type ReactNode } from 'react' import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { ChevronDown, ChevronUp, Sparkles, Link2, X, Loader2, HelpCircle } from 'lucide-react' import { ChevronDown, ChevronUp, Sparkles, Link2, X, Loader2, HelpCircle } from 'lucide-react'
import { LinkedNotePreviewDialog } from '@/components/linked-note-preview-dialog' import { LinkedNotePreviewDialog } from '@/components/linked-note-preview-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -12,7 +12,6 @@ import { toast } from 'sonner'
import { useNoteEditorContext } from '@/components/note-editor/note-editor-context' import { useNoteEditorContext } from '@/components/note-editor/note-editor-context'
import { stripHtmlToPlainText } from '@/lib/text/plain-text' import { stripHtmlToPlainText } from '@/lib/text/plain-text'
import { detectTextDirection } from '@/lib/clip/rtl-content' import { detectTextDirection } from '@/lib/clip/rtl-content'
import { SEMANTIC_SIMILARITY_FLOOR_CLIP } from '@/lib/ai/semantic-proximity'
interface ConnectionData { interface ConnectionData {
noteId: string noteId: string
@@ -106,6 +105,9 @@ export function MemoryEchoSection({
const [embeddingId, setEmbeddingId] = useState<string | null>(null) const [embeddingId, setEmbeddingId] = useState<string | null>(null)
const [helpOpen, setHelpOpen] = useState(false) const [helpOpen, setHelpOpen] = useState(false)
const [previewTarget, setPreviewTarget] = useState<PreviewTarget | null>(null) const [previewTarget, setPreviewTarget] = useState<PreviewTarget | null>(null)
const sectionRef = useRef<HTMLDivElement>(null)
const [sectionInView, setSectionInView] = useState(false)
const [cueDismissed, setCueDismissed] = useState(false)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -113,6 +115,8 @@ export function MemoryEchoSection({
setConsentRequired(false) setConsentRequired(false)
setConnections([]) setConnections([])
setRetroRefs([]) setRetroRefs([])
setCueDismissed(false)
setSectionInView(false)
const load = async () => { const load = async () => {
try { try {
@@ -154,18 +158,24 @@ export function MemoryEchoSection({
} }
}, [noteId]) }, [noteId])
// Scroll doux vers la section quand une forte connexion apparaît (une fois par note) // Observe section visibility — never auto-scroll; cue only when off-screen
useEffect(() => { useEffect(() => {
if (isLoading || connections.length === 0) return const el = sectionRef.current
const top = connections[0] if (!el) return
if (!top || top.similarity < SEMANTIC_SIMILARITY_FLOOR_CLIP) return
const key = `memory-echo-scroll-${noteId}` const observer = new IntersectionObserver(
if (sessionStorage.getItem(key)) return ([entry]) => {
sessionStorage.setItem(key, '1') setSectionInView(entry.isIntersecting && entry.intersectionRatio > 0.08)
requestAnimationFrame(() => { },
document.getElementById('memory-echo-section')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) { root: null, threshold: [0, 0.08, 0.2, 0.5] },
}) )
}, [isLoading, connections, noteId]) observer.observe(el)
return () => observer.disconnect()
}, [noteId, isLoading, connections.length, retroRefs.length, consentRequired, isVisible])
const scrollToSection = useCallback(() => {
sectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [])
const handleEmbed = useCallback(async (conn: ConnectionData) => { const handleEmbed = useCallback(async (conn: ConnectionData) => {
setEmbeddingId(conn.noteId) setEmbeddingId(conn.noteId)
@@ -251,8 +261,29 @@ export function MemoryEchoSection({
const topConnection = connections[0] const topConnection = connections[0]
const restConnections = connections.slice(1) const restConnections = connections.slice(1)
// Floating cue: signal activity below without stealing the title viewport
const showBottomCue =
!cueDismissed &&
!sectionInView &&
(isLoading || hasConnections || hasRetro || consentRequired)
const cueLabel = isLoading
? t('memoryEcho.editorSection.bottomCueLoading')
: hasConnections
? connections.length === 1
? t('memoryEcho.editorSection.bottomCueFoundOne')
: t('memoryEcho.editorSection.bottomCueFound', { count: connections.length })
: hasRetro
? t('memoryEcho.editorSection.bottomCueRetro', { count: retroRefs.length })
: t('memoryEcho.editorSection.bottomCueConsent')
return ( return (
<div id="memory-echo-section" className="mt-10 space-y-6 scroll-mt-24"> <>
<div
id="memory-echo-section"
ref={sectionRef}
className="mt-10 space-y-6 scroll-mt-24"
>
{isLoading && ( {isLoading && (
<div <div
className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-5 animate-pulse space-y-3" className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-5 animate-pulse space-y-3"
@@ -551,6 +582,47 @@ export function MemoryEchoSection({
/> />
)} )}
</div> </div>
{showBottomCue && (
<div
className={cn(
'fixed bottom-6 left-1/2 z-40 -translate-x-1/2',
'flex items-center gap-1 rounded-full border border-indigo-500/25',
'bg-background/95 shadow-lg shadow-indigo-500/10 backdrop-blur-md',
'pl-3 pr-1.5 py-1.5 max-w-[min(92vw,22rem)]',
'animate-in fade-in slide-in-from-bottom-2 duration-300',
)}
role="status"
aria-live="polite"
>
<button
type="button"
onClick={scrollToSection}
className="flex min-w-0 flex-1 items-center gap-2 rounded-full px-1 py-0.5 text-left transition-colors hover:bg-indigo-500/5"
title={t('memoryEcho.editorSection.bottomCueScroll')}
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-indigo-500" />
) : (
<Sparkles className="h-3.5 w-3.5 shrink-0 text-indigo-500" />
)}
<span className="truncate text-xs font-medium text-foreground/90">
{cueLabel}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-indigo-500/80 animate-bounce" />
</button>
<button
type="button"
onClick={() => setCueDismissed(true)}
className="shrink-0 rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t('memoryEcho.editorSection.bottomCueDismiss')}
title={t('memoryEcho.editorSection.bottomCueDismiss')}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</>
) )
} }

View File

@@ -23,7 +23,7 @@ import { NoteAttachments } from '@/components/note-attachments'
import { DocumentQAOverlay } from '@/components/document-qa-overlay' import { DocumentQAOverlay } from '@/components/document-qa-overlay'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider' import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel' import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
import { MemoryEchoSection } from '@/components/memory-echo-section' import { MemoryEchoSection } from '@/components/memory-echo-section'
@@ -40,6 +40,14 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const [attachmentsCount, setAttachmentsCount] = useState(0) const [attachmentsCount, setAttachmentsCount] = useState(0)
const [uploadTrigger, setUploadTrigger] = useState(0) const [uploadTrigger, setUploadTrigger] = useState(0)
const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>() const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>()
const scrollRootRef = useRef<HTMLDivElement>(null)
// Always open a note at the top (title visible) — never inherit previous scroll
useEffect(() => {
const el = scrollRootRef.current
if (!el) return
el.scrollTop = 0
}, [note.id])
const fetchNotesByIds = async (noteIds: string[]) => { const fetchNotesByIds = async (noteIds: string[]) => {
const notes = await Promise.all(noteIds.map(async (id) => { const notes = await Promise.all(noteIds.map(async (id) => {
@@ -67,7 +75,10 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
{/* ── outer container ── */} {/* ── outer container ── */}
<div className="flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden"> <div className="flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden">
{/* ── main scrollable column ── */} {/* ── main scrollable column ── */}
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0"> <div
ref={scrollRootRef}
className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0"
>
{/* TOOLBAR */} {/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} /> <NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />

View File

@@ -117,6 +117,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
const { state: voiceState, toggle: toggleVoice, isSupported: voiceSupported } = useVoiceTranscription({ const { state: voiceState, toggle: toggleVoice, isSupported: voiceSupported } = useVoiceTranscription({
onTranscript: handleTranscript, onTranscript: handleTranscript,
onError: (message) => {
toast.error(message)
},
}) })
// ── Markdown export ─────────────────────────────────────────────────────── // ── Markdown export ───────────────────────────────────────────────────────

View File

@@ -48,6 +48,7 @@ import { Notebook, Note } from '@/lib/types'
import { toast } from 'sonner' import { toast } from 'sonner'
import { motion, AnimatePresence } from 'motion/react' import { motion, AnimatePresence } from 'motion/react'
import { getNoteDisplayTitle } from '@/lib/note-preview' import { getNoteDisplayTitle } from '@/lib/note-preview'
import { listAiCreatedNotebookIds, isRecentlyCreated } from '@/lib/ai-created-highlight'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
const CreateNotebookDialog = dynamic(() => import('./create-notebook-dialog').then(m => ({ default: m.CreateNotebookDialog })), { ssr: false }) const CreateNotebookDialog = dynamic(() => import('./create-notebook-dialog').then(m => ({ default: m.CreateNotebookDialog })), { ssr: false })
@@ -79,14 +80,20 @@ function NoteLink({
title, title,
isActive, isActive,
isPinned, isPinned,
autoGenerated,
isRecent,
onClick, onClick,
}: { }: {
title: string title: string
isActive: boolean isActive: boolean
isPinned?: boolean isPinned?: boolean
/** Note créée par l'IA (agents, wizard, …) */
autoGenerated?: boolean
/** Créée récemment (< 48h) — point discret « nouveau » */
isRecent?: boolean
onClick: () => void onClick: () => void
}) { }) {
const { language } = useLanguage() const { language, t } = useLanguage()
const slideX = language === 'fa' || language === 'ar' ? 10 : -10 const slideX = language === 'fa' || language === 'ar' ? 10 : -10
return ( return (
<motion.button <motion.button
@@ -97,14 +104,38 @@ function NoteLink({
'w-full flex items-center gap-2 ps-6 pe-3 py-1.5 text-[11px] transition-all rounded-lg text-start', 'w-full flex items-center gap-2 ps-6 pe-3 py-1.5 text-[11px] transition-all rounded-lg text-start',
isActive isActive
? 'bg-white dark:bg-white/10 shadow-sm border border-border/50 text-foreground font-semibold' ? 'bg-white dark:bg-white/10 shadow-sm border border-border/50 text-foreground font-semibold'
: autoGenerated
? 'text-foreground/90 hover:text-foreground hover:bg-brand-accent/5 border border-transparent'
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5', : 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
autoGenerated && !isActive && 'bg-brand-accent/[0.04]',
)} )}
title={autoGenerated ? (t('sidebar.aiGeneratedNote') || 'Note générée par l\'IA') : undefined}
> >
{autoGenerated ? (
<Sparkles
size={12}
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-brand-accent/80')}
aria-hidden
/>
) : (
<FileText <FileText
size={12} size={12}
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')} className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')}
/> />
)}
<span className="truncate flex-1">{title}</span> <span className="truncate flex-1">{title}</span>
{autoGenerated && (
<span className="shrink-0 text-[8px] font-bold uppercase tracking-wider text-brand-accent/90 bg-brand-accent/10 px-1.5 py-0.5 rounded">
{t('sidebar.aiBadge') || 'IA'}
</span>
)}
{isRecent && !autoGenerated && (
<span
className="shrink-0 w-1.5 h-1.5 rounded-full bg-brand-accent"
title={t('sidebar.recentNote') || 'Récemment créée'}
aria-label={t('sidebar.recentNote') || 'Récemment créée'}
/>
)}
{isPinned && <Pin size={10} className="text-amber-500 fill-amber-500 shrink-0" />} {isPinned && <Pin size={10} className="text-amber-500 fill-amber-500 shrink-0" />}
</motion.button> </motion.button>
) )
@@ -347,6 +378,7 @@ function SidebarCarnetItem({
onDelete, onDelete,
onTogglePin, onTogglePin,
isPinned, isPinned,
isAiCreated,
children, children,
isDragging, isDragging,
dragHandleProps, dragHandleProps,
@@ -358,7 +390,7 @@ function SidebarCarnetItem({
}: { }: {
carnet: { id: string; name: string; initial: string; isPrivate?: boolean } carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
isActive: boolean isActive: boolean
notes: { id: string; title: string; isPinned?: boolean }[] notes: { id: string; title: string; isPinned?: boolean; autoGenerated?: boolean; isRecent?: boolean }[]
activeNoteId: string | null activeNoteId: string | null
onCarnetClick: () => void onCarnetClick: () => void
onNoteClick: (noteId: string, carnetId: string) => void onNoteClick: (noteId: string, carnetId: string) => void
@@ -367,6 +399,8 @@ function SidebarCarnetItem({
onDelete: () => void onDelete: () => void
onTogglePin: () => void onTogglePin: () => void
isPinned: boolean isPinned: boolean
/** Carnet créé via wizard IA (repère temporaire) */
isAiCreated?: boolean
children?: React.ReactNode children?: React.ReactNode
isDragging?: boolean isDragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLDivElement> dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
@@ -436,8 +470,13 @@ function SidebarCarnetItem({
onDoubleClick={(e) => { e.stopPropagation(); onRename() }} onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
className={cn( 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 overflow-hidden', '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' isActive
? 'bg-white dark:bg-white/10 shadow-sm border border-border/40'
: isAiCreated
? 'hover:bg-brand-accent/10 border border-brand-accent/20 bg-brand-accent/[0.06]'
: 'hover:bg-white/40 dark:hover:bg-white/5',
)} )}
title={isAiCreated ? (t('sidebar.aiGeneratedNotebook') || 'Carnet créé avec l\'IA') : undefined}
> >
{isActive && ( {isActive && (
<motion.div <motion.div
@@ -446,6 +485,9 @@ function SidebarCarnetItem({
transition={{ type: 'spring', stiffness: 300, damping: 30 }} transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/> />
)} )}
{isAiCreated && !isActive && (
<span className="absolute -start-1 w-1 h-4 bg-brand-accent/70 rounded-full" aria-hidden />
)}
<div className={cn( <div className={cn(
'w-5 h-5 flex items-center justify-center shrink-0 transition-colors', 'w-5 h-5 flex items-center justify-center shrink-0 transition-colors',
isActive ? 'text-brand-accent' : 'text-muted-foreground/80', isActive ? 'text-brand-accent' : 'text-muted-foreground/80',
@@ -460,6 +502,12 @@ function SidebarCarnetItem({
)}> )}>
{carnet.name} {carnet.name}
</span> </span>
{isAiCreated && (
<span className="shrink-0 inline-flex items-center gap-0.5 text-[8px] font-bold uppercase tracking-wider text-brand-accent bg-brand-accent/15 px-1.5 py-0.5 rounded">
<Sparkles size={9} aria-hidden />
{t('sidebar.aiBadge') || 'IA'}
</span>
)}
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />} {carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
{!isActive && hasActiveDescendant && ( {!isActive && hasActiveDescendant && (
<span className="w-1.5 h-1.5 rounded-full bg-brand-accent/70 shrink-0" /> <span className="w-1.5 h-1.5 rounded-full bg-brand-accent/70 shrink-0" />
@@ -573,6 +621,8 @@ function SidebarCarnetItem({
key={note.id} key={note.id}
title={note.title} title={note.title}
isPinned={note.isPinned} isPinned={note.isPinned}
autoGenerated={note.autoGenerated}
isRecent={note.isRecent}
isActive={activeNoteId === note.id} isActive={activeNoteId === note.id}
onClick={() => onNoteClick(note.id, carnet.id)} onClick={() => onNoteClick(note.id, carnet.id)}
/> />
@@ -635,7 +685,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const [isRenaming, setIsRenaming] = useState(false) const [isRenaming, setIsRenaming] = useState(false)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()) const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set()) const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string; isPinned?: boolean }[]>>({}) const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string; isPinned?: boolean; autoGenerated?: boolean; isRecent?: boolean }[]>>({})
const [aiNotebookIds, setAiNotebookIds] = useState<Set<string>>(() => new Set())
const [activeView, setActiveView] = useState<NavigationView>('notebooks') const [activeView, setActiveView] = useState<NavigationView>('notebooks')
const [sortOrder, setSortOrder] = useState<SortOrder>('newest') const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
const [showSortMenu, setShowSortMenu] = useState(false) const [showSortMenu, setShowSortMenu] = useState(false)
@@ -886,6 +937,18 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks]) const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks])
// Repères carnets IA (localStorage TTL 7j)
useEffect(() => {
const refresh = () => setAiNotebookIds(listAiCreatedNotebookIds())
refresh()
window.addEventListener('memento-ai-created-changed', refresh)
window.addEventListener('storage', refresh)
return () => {
window.removeEventListener('memento-ai-created-changed', refresh)
window.removeEventListener('storage', refresh)
}
}, [])
useEffect(() => { useEffect(() => {
if (!notebookIdsKey) return if (!notebookIdsKey) return
let cancelled = false let cancelled = false
@@ -897,6 +960,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
id: n.id, id: n.id,
title: getNoteDisplayTitle(n, t('notes.untitled')), title: getNoteDisplayTitle(n, t('notes.untitled')),
isPinned: n.isPinned, isPinned: n.isPinned,
autoGenerated: Boolean(n.autoGenerated),
isRecent: isRecentlyCreated(n.createdAt, 48),
})) }))
return [nb.id, mapped] as const return [nb.id, mapped] as const
}) })
@@ -1261,6 +1326,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
onDelete={() => setDeletingNotebook(notebook)} onDelete={() => setDeletingNotebook(notebook)}
onTogglePin={() => togglePin(notebook.id)} onTogglePin={() => togglePin(notebook.id)}
isPinned={pinnedIds.has(notebook.id)} isPinned={pinnedIds.has(notebook.id)}
isAiCreated={aiNotebookIds.has(notebook.id)}
isDragging={isDragging} isDragging={isDragging}
level={level} level={level}
isExpanded={isExpanded} isExpanded={isExpanded}
@@ -1273,7 +1339,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</motion.div> </motion.div>
) )
}) })
}, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename]) }, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, aiNotebookIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
return ( return (
<> <>
@@ -1977,8 +2043,34 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{showAiWizard && ( {showAiWizard && (
<AiNotebookWizard <AiNotebookWizard
onClose={() => setShowAiWizard(false)} onClose={() => setShowAiWizard(false)}
onComplete={(notebookId: string) => { onComplete={async (notebookId: string) => {
// 1) Marquer IA + expand dans la sidebar
try {
const { markAiCreatedNotebook } = await import('@/lib/ai-created-highlight')
markAiCreatedNotebook(notebookId)
} catch { /* ignore */ }
setAiNotebookIds((prev) => new Set(prev).add(notebookId))
setExpandedIds((prev) => {
const next = new Set(prev)
next.add(notebookId)
return next
})
// 2) Recharger la liste des carnets (sinon le carnet n'apparaît qu'après F5)
await refreshNotebooks()
// 3) Forcer le rechargement des notes de ce carnet dans la sidebar
try {
const notes = await getAllNotes(false, notebookId)
const mapped = notes.map((n: Note) => ({
id: n.id,
title: getNoteDisplayTitle(n, t('notes.untitled')),
isPinned: n.isPinned,
autoGenerated: Boolean(n.autoGenerated),
isRecent: true,
}))
setNotebookNotes((prev) => ({ ...prev, [notebookId]: mapped }))
} catch { /* ignore */ }
setShowAiWizard(false) setShowAiWizard(false)
// 4) Ouvrir le carnet
router.push(`/home?notebook=${notebookId}`) router.push(`/home?notebook=${notebookId}`)
}} }}
/> />

View File

@@ -1,10 +1,11 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useRef } from 'react'
import { GraduationCap, BookOpen, Wrench, Sparkles, Loader2, X, ArrowRight, ArrowLeft, Check, FileText } from 'lucide-react' import { GraduationCap, BookOpen, Wrench, Sparkles, Loader2, X, ArrowRight, ArrowLeft, Check, FileText } from 'lucide-react'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
import { markAiCreatedNotebook } from '@/lib/ai-created-highlight'
type WizardProfile = 'student' | 'teacher' | 'engineer' type WizardProfile = 'student' | 'teacher' | 'engineer'
@@ -42,7 +43,7 @@ const PROFILES: ProfileOption[] = [
const LEVELS = ['wizard.levelBeginner', 'wizard.levelIntermediate', 'wizard.levelAdvanced', 'wizard.levelExpert'] const LEVELS = ['wizard.levelBeginner', 'wizard.levelIntermediate', 'wizard.levelAdvanced', 'wizard.levelExpert']
export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; onComplete: (notebookId: string) => void }) { export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; onComplete: (notebookId: string) => void | Promise<void> }) {
const { t, language } = useLanguage() const { t, language } = useLanguage()
const [step, setStep] = useState<0 | 1 | 2>(0) const [step, setStep] = useState<0 | 1 | 2>(0)
const [profile, setProfile] = useState<WizardProfile | null>(null) const [profile, setProfile] = useState<WizardProfile | null>(null)
@@ -52,6 +53,25 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [progressMsg, setProgressMsg] = useState('') const [progressMsg, setProgressMsg] = useState('')
const [success, setSuccess] = useState<{ notebookId: string; notebookName: string; noteTitles: string[] } | null>(null) const [success, setSuccess] = useState<{ notebookId: string; notebookName: string; noteTitles: string[] } | null>(null)
const [opening, setOpening] = useState(false)
const completedRef = useRef(false)
const finishAndOpen = async (notebookId: string, notebookName: string) => {
if (completedRef.current) return
completedRef.current = true
setOpening(true)
try {
markAiCreatedNotebook(notebookId)
const toastMsg = (t('wizard.createdToast') || 'Carnet « {name} » prêt — notes IA disponibles')
.replace('{name}', notebookName)
toast.success(toastMsg)
await onComplete(notebookId)
} catch (e: any) {
completedRef.current = false
setOpening(false)
toast.error(e?.message || t('general.error') || 'Erreur')
}
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (!profile || !topic.trim()) return if (!profile || !topic.trim()) return
@@ -68,7 +88,6 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
level: t(LEVELS[level]), level: t(LEVELS[level]),
count, count,
language, language,
// Server generates a short title from the topic; do not force raw prompt as name
}), }),
}) })
@@ -87,14 +106,16 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
return return
} }
setProgressMsg(t('wizard.progressCreating') || 'Création du carnet et des notes...') const notebookId = data.notebookId as string
const notebookName = (data.notebookName || topic) as string
setSuccess({ setSuccess({
notebookId: data.notebookId, notebookId,
notebookName: data.notebookName || topic, notebookName,
noteTitles: data.noteTitles || [], noteTitles: data.noteTitles || [],
}) })
setLoading(false) setLoading(false)
// Ouvre tout de suite : refresh sidebar + navigation (plus besoin de F5)
void finishAndOpen(notebookId, notebookName)
} catch (e: any) { } catch (e: any) {
toast.error(e.message || 'Erreur') toast.error(e.message || 'Erreur')
setLoading(false) setLoading(false)
@@ -159,12 +180,19 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
))} ))}
</div> </div>
<button <button
onClick={() => onComplete(success.notebookId)} type="button"
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" disabled={opening}
onClick={() => void finishAndOpen(success.notebookId, success.notebookName)}
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 disabled:opacity-60"
> >
<Sparkles className="h-4 w-4" /> {opening ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{t('wizard.openNotebook') || 'Ouvrir le carnet'} {opening
? (t('wizard.openingNotebook') || 'Ouverture…')
: (t('wizard.openNotebook') || 'Ouvrir le carnet')}
</button> </button>
<p className="text-[11px] text-muted-foreground text-center max-w-sm">
{t('wizard.autoOpenHint') || 'Ouverture automatique du carnet et mise à jour de la barre latérale…'}
</p>
</div> </div>
) : loading ? ( ) : loading ? (
<div className="flex flex-col items-center justify-center py-16 gap-4"> <div className="flex flex-col items-center justify-center py-16 gap-4">

View File

@@ -47,8 +47,22 @@ export function useVoiceTranscription({ onTranscript, onError, lang = 'fr-FR' }:
} }
rec.onerror = (event: SpeechRecognitionErrorEvent) => { rec.onerror = (event: SpeechRecognitionErrorEvent) => {
console.error('[voice] SpeechRecognition error:', event.error) // "not-allowed" = permission micro refusée — attendu, pas une erreur applicative
onError?.(event.error === 'not-allowed' ? 'Microphone non autorisé.' : `Erreur: ${event.error}`) // "aborted" / "no-speech" = fin normale / silence — ne pas alarmer
const benign = event.error === 'not-allowed' || event.error === 'aborted' || event.error === 'no-speech'
if (!benign) {
console.warn('[voice] SpeechRecognition error:', event.error)
}
if (event.error === 'not-allowed') {
onError?.('Microphone non autorisé. Autorisez le micro dans le navigateur pour dicter.')
setState('idle')
return
}
if (event.error === 'aborted' || event.error === 'no-speech') {
setState('idle')
return
}
onError?.(`Erreur: ${event.error}`)
setState('error') setState('error')
} }

View File

@@ -0,0 +1,59 @@
/**
* Mise en évidence temporaire des carnets créés via IA (wizard, etc.).
* Les notes ont déjà `autoGenerated` en base ; les carnets n'ont pas ce flag
* → on stocke un repère local (TTL 7 jours) pour les retrouver facilement.
*/
const STORAGE_KEY = 'memento-ai-created-notebooks'
const TTL_MS = 7 * 24 * 60 * 60 * 1000
type Entry = { id: string; at: number }
function readEntries(): Entry[] {
if (typeof window === 'undefined') return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as Entry[]
if (!Array.isArray(parsed)) return []
const now = Date.now()
return parsed.filter((e) => e?.id && typeof e.at === 'number' && now - e.at < TTL_MS)
} catch {
return []
}
}
function writeEntries(entries: Entry[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.slice(0, 50)))
} catch {
/* quota / private mode */
}
}
/** Marque un carnet comme créé via IA (repère sidebar ~7 jours). */
export function markAiCreatedNotebook(notebookId: string) {
if (!notebookId) return
const now = Date.now()
const next = [{ id: notebookId, at: now }, ...readEntries().filter((e) => e.id !== notebookId)]
writeEntries(next)
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('memento-ai-created-changed'))
}
}
export function isAiCreatedNotebook(notebookId: string): boolean {
return readEntries().some((e) => e.id === notebookId)
}
export function listAiCreatedNotebookIds(): Set<string> {
return new Set(readEntries().map((e) => e.id))
}
/** Note créée récemment (pour point « nouveau » optionnel). */
export function isRecentlyCreated(createdAt: Date | string | null | undefined, hours = 48): boolean {
if (!createdAt) return false
const t = typeof createdAt === 'string' ? new Date(createdAt).getTime() : createdAt.getTime()
if (Number.isNaN(t)) return false
return Date.now() - t < hours * 60 * 60 * 1000
}

View File

@@ -1,11 +1,12 @@
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' import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
import { LanguageDetectionService } from './language-detection.service'
export interface GeneratedNote { export interface GeneratedNote {
title: string title: string
content: string content: string
difficulty?: 'facile' | 'moyen' | 'difficile' difficulty?: 'facile' | 'moyen' | 'difficile' | 'easy' | 'medium' | 'hard'
} }
export interface GeneratedCarnet { export interface GeneratedCarnet {
@@ -13,149 +14,355 @@ export interface GeneratedCarnet {
notebookName?: string notebookName?: string
notes: GeneratedNote[] notes: GeneratedNote[]
schemaProperties: Array<{ name: string; type: string; options?: string[] }> schemaProperties: Array<{ name: string; type: string; options?: string[] }>
/** Language actually used for generation (ISO 639-1). */
contentLanguage?: string
} }
export type WizardProfile = 'student' | 'teacher' | 'engineer' export type WizardProfile = 'student' | 'teacher' | 'engineer'
/** Word-count targets by level — Expert was failing (timeouts / truncated JSON). */ const LANG_NAMES: Record<string, string> = {
fr: 'français',
en: 'English',
fa: 'فارسی',
ar: 'العربية',
de: 'Deutsch',
es: 'Español',
it: 'Italiano',
pt: 'Português',
ru: 'Русский',
zh: '中文',
ja: '日本語',
ko: '한국어',
nl: 'Nederlands',
pl: 'Polski',
hi: 'हिन्दी',
}
/** Word-count targets by level */
function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } { function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } {
const l = (level || '').toLowerCase() const l = (level || '').toLowerCase()
if (/expert|avancé|advanced|fort/.test(l)) { if (/expert|avancé|advanced|fort/.test(l)) {
return { min: 350, max: 700, label: 'expert (350700 mots/note — dense mais JSON-fiable)' } return { min: 400, max: 800, label: 'expert (400800 words per note)' }
} }
if (/intermédiaire|intermediate|moyen/.test(l)) { if (/intermédiaire|intermediate|moyen/.test(l)) {
return { min: 250, max: 500, label: 'intermédiaire (250500 mots/note)' } return { min: 300, max: 550, label: 'intermediate (300550 words per note)' }
} }
if (/débutant|beginner|facile|easy/.test(l)) { if (/débutant|beginner|facile|easy/.test(l)) {
return { min: 150, max: 350, label: 'débutant (150350 mots/note)' } return { min: 250, max: 450, label: 'beginner (250450 words per note)' }
} }
return { min: 200, max: 450, label: 'standard (200450 mots/note)' } return { min: 300, max: 550, label: 'standard (300550 words per note)' }
}
function countWords(html: string): number {
const plain = html
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z]+;/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!plain) return 0
return plain.split(/\s+/).filter(Boolean).length
} }
export class NotebookWizardService { export class NotebookWizardService {
private languageDetection = new LanguageDetectionService()
/**
* Langue de sortie = langue du **sujet / requête** (pas l'UI), sauf si
* la requête est trop courte / ambiguë (tinyld confond souvent fr↔it).
*
* Ex. UI=fr + "I want a list of…" → en
* UI=fr + "calcul différentiel" → fr (accents / UI, pas "it" de tinyld)
*/
async resolveOutputLanguage(topic: string, uiLanguage?: string): Promise<string> {
const ui = (uiLanguage || 'fr').slice(0, 2).toLowerCase()
const text = (topic || '').trim()
if (!text) return ui
const wordCount = text.split(/\s+/).filter(Boolean).length
// Scripts non latins : fiables même sur peu de mots
if (/[\u0600-\u06FF]/.test(text)) {
if (/\b(است|های|می‌|برای|که|این)\b/.test(text)) return 'fa'
return 'ar'
}
if (/[\u4e00-\u9fff]/.test(text)) return 'zh'
if (/[\u3040-\u30ff]/.test(text)) return 'ja'
if (/[\uac00-\ud7af]/.test(text)) return 'ko'
if (/[\u0400-\u04ff]/.test(text)) return 'ru'
if (/[\u0900-\u097f]/.test(text)) return 'hi'
// Anglais explicite (phrases courantes)
if (/\b(i want|i need|i'd like|please|make me|list of|how to|for a student|for students|write in english)\b/i.test(text)) {
return 'en'
}
// Français explicite
if (/\b(je veux|j'aimerais|peux-tu|créer|génère|cours de|pour un étudiant|pour les étudiants|en français)\b/i.test(text)) {
return 'fr'
}
// Accents / digrammes typiquement français → fr (évite "calcul différentiel" → it)
if (/[àâäéèêëïîôùûüçœæ]/i.test(text) || /\b(différentiel|différentielle|mathématiques|géométrie|algèbre|fonction|limite|dérivée)\b/i.test(text)) {
return 'fr'
}
// Allemand (umlauts / ß)
if (/[äöüß]/i.test(text)) return 'de'
// Espagnol (ñ, ¿, ¡)
if (/[ñ¿¡]/i.test(text)) return 'es'
// Sujet court (≤ 4 mots) : tinyld est peu fiable (ex. "calcul différentiel" → it @ 5%)
// → on suit l'UI sauf signal anglais fort déjà géré ci-dessus
if (wordCount <= 4) {
return ui
}
// Texte plus long : détection, mais on ignore un résultat peu crédible
try {
const { language, confidence } = await this.languageDetection.detectLanguage(text)
const code = (language || '').slice(0, 2).toLowerCase()
if (code && code !== 'unknown' && confidence >= 0.75) {
// Romance court : si UI=fr et détecté it/es/pt/ro, rester en fr (faux positifs fréquents)
if (ui === 'fr' && ['it', 'es', 'pt', 'ro'].includes(code) && wordCount < 12) {
return 'fr'
}
return code
}
} catch {
/* fall through */
}
return ui
}
private langName(code: string): string {
return LANG_NAMES[code] || code
}
private languageRuleBlock(lang: string): string {
const name = this.langName(lang)
return `CRITICAL LANGUAGE RULE (non-negotiable):
- The USER REQUEST language is: ${name} (code: ${lang}).
- The app UI language is IRRELEVANT — do NOT follow UI language.
- Write ALL human-readable text in ${name}: notebookName, note titles, HTML body, callouts, examples, exercises, table headers, difficulty free-text if any.
- JSON keys stay in English (notebookName, notes, title, content, difficulty, schemaProperties).
- Do NOT mix languages. Do NOT answer in French if the request is English (and vice versa).
- Math LaTeX may stay standard (symbols), but surrounding explanations must be in ${name}.`
}
async generateCarnet( async generateCarnet(
profile: WizardProfile, profile: WizardProfile,
topic: string, topic: string,
options?: { level?: string; count?: number; language?: string } options?: { level?: string; count?: number; language?: string }
): Promise<GeneratedCarnet> { ): Promise<GeneratedCarnet> {
const lang = options?.language || 'fr' const uiLang = options?.language || 'fr'
// Cap count in expert mode to avoid oversized responses const contentLang = await this.resolveOutputLanguage(topic, uiLang)
const level = options?.level const level = options?.level
const isExpert = /expert/i.test(level || '') const isExpert = /expert/i.test(level || '')
const count = Math.min(options?.count || 6, isExpert ? 5 : 10) // Fewer notes + richer content (quality over stuffing one JSON)
const count = Math.min(options?.count || 6, isExpert ? 5 : 6)
const words = wordTargetsForLevel(level)
const prompts = this.buildPrompt(profile, topic, lang, count, level)
const config = await getSystemConfig() const config = await getSystemConfig()
const provider = getChatProvider(config) const provider = getChatProvider(config)
let raw: string
// Phase 1 — outline only (name + titles)
const plan = await this.generatePlan(provider, profile, topic, contentLang, count, level)
const schemaLabels = this.getSchemaLabels(contentLang)
// Phase 2 — one note at a time (avoids shallow multi-note dumps)
const notes: GeneratedNote[] = []
for (let i = 0; i < plan.titles.length; i++) {
const title = plan.titles[i]
const difficulty = i % 3 === 0 ? 'facile' : i % 3 === 1 ? 'moyen' : 'difficile'
let note = await this.generateOneNote(provider, {
profile,
topic,
title,
lang: contentLang,
level,
words,
difficulty,
noteIndex: i + 1,
totalNotes: plan.titles.length,
})
// Retry once if too short
if (countWords(note.content) < Math.max(120, Math.floor(words.min * 0.55))) {
note = await this.generateOneNote(provider, {
profile,
topic,
title,
lang: contentLang,
level,
words,
difficulty,
noteIndex: i + 1,
totalNotes: plan.titles.length,
forceLonger: true,
})
}
notes.push(note)
}
return {
notebookName: plan.notebookName || this.deriveNotebookName(topic, notes, contentLang),
notes,
schemaProperties: [
{ name: schemaLabels.status, type: 'select', options: [schemaLabels.statusTodo, schemaLabels.statusInProgress, schemaLabels.statusDone] },
{ name: schemaLabels.difficulty, type: 'select', options: [schemaLabels.easy, schemaLabels.medium, schemaLabels.hard] },
],
contentLanguage: contentLang,
}
}
private async generatePlan(
provider: { generateText: (p: string) => Promise<string> },
profile: WizardProfile,
topic: string,
lang: string,
count: number,
level?: string,
): Promise<{ notebookName: string; titles: string[] }> {
const prompt = `${this.languageRuleBlock(lang)}
You are planning a ${profile} notebook on the user request below.
User request / topic: "${topic}"
${level ? `Level: ${level}` : ''}
Produce a short JSON plan only.
Return ONLY:
\`\`\`json
{
"notebookName": "38 words, short, in ${this.langName(lang)}",
"titles": ["chapter title 1", "chapter title 2", "... exactly ${count} titles"]
}
\`\`\`
Rules:
- Exactly ${count} titles, progressive learning order.
- Titles in ${this.langName(lang)} only.
- notebookName ≠ full user sentence.`
const raw = await provider.generateText(prompt)
const parsed = this.parseJsonObject(raw)
const titles = Array.isArray(parsed?.titles)
? parsed.titles.map((t: unknown) => String(t || '').trim()).filter(Boolean).slice(0, count)
: []
while (titles.length < count) {
titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`)
}
return {
notebookName: String(parsed?.notebookName || '').trim().slice(0, 80),
titles,
}
}
private async generateOneNote(
provider: { generateText: (p: string) => Promise<string> },
opts: {
profile: WizardProfile
topic: string
title: string
lang: string
level?: string
words: { min: number; max: number; label: string }
difficulty: string
noteIndex: number
totalNotes: number
forceLonger?: boolean
},
): Promise<GeneratedNote> {
const { profile, topic, title, lang, level, words, difficulty, noteIndex, totalNotes, forceLonger } = opts
const profileExtra = {
student: `Pedagogical note for a student: worked examples, tip + danger callouts, at least 2 exercises with brief solution hints.`,
teacher: `Lesson plan style: learning objectives, main content, 45 exercises, indicative answers.`,
engineer: `Technical documentation: precise definitions, comparison table or specs, practical examples.`,
}[profile]
const prompt = `${this.languageRuleBlock(lang)}
You write ONE complete notebook chapter as HTML for a ${profile}.
${profileExtra}
Overall topic: "${topic}"
Chapter title: "${title}"
Chapter ${noteIndex}/${totalNotes}
${level ? `Level: ${level}` : ''}
Target length: ${words.label}
${forceLonger ? `WARNING: previous draft was TOO SHORT. Write at least ${words.min} words of real explanation — not a summary.` : ''}
Mandatory structure (all section headings and text in ${this.langName(lang)}):
1. Short intro (why this chapter matters)
2. Core definitions / concepts with <h2>/<h3>
3. At least one worked example (step by step)
4. Callouts: info definition + tip + danger (using HTML blocks below)
5. At least one math equation if the topic is mathematical
6. Closing: key takeaways list
7. For student/teacher: short exercises section
HTML blocks:
- Callout: <div data-type="callout-block" data-callout-type="info|tip|danger|warning|success"><p>...</p></div>
- Math: <div data-type="math-equation" data-latex="..."></div> (never $$)
- Toggle optional: <div data-type="toggle-block" data-opened="false"><p>title</p><p>body</p></div>
- table, ul/ol allowed
Return ONLY:
\`\`\`json
{
"title": "${title.replace(/"/g, '\\"')}",
"difficulty": "${difficulty}",
"content": "<h2>...</h2><p>...</p>..."
}
\`\`\`
content must be rich HTML, NOT empty, NOT a 2-paragraph summary.
Escape quotes inside JSON properly.`
const raw = await provider.generateText(prompt)
const parsed = this.parseJsonObject(raw)
const content = preprocessMathInHtml(String(parsed?.content || raw || '<p></p>'))
return {
title: String(parsed?.title || title).trim() || title,
content,
difficulty: (parsed?.difficulty as GeneratedNote['difficulty']) || (difficulty as GeneratedNote['difficulty']),
}
}
private parseJsonObject(raw: string): any {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
const start = jsonStr.indexOf('{')
const end = jsonStr.lastIndexOf('}')
if (start >= 0 && end > start) jsonStr = jsonStr.slice(start, end + 1)
try { try {
raw = await provider.generateText(prompts) return JSON.parse(jsonStr)
} catch (err) { } catch {
// Retry once with lighter prompt if expert/full generation fails try {
if (isExpert) { const fixed = jsonStr
const light = this.buildPrompt(profile, topic, lang, Math.min(count, 4), 'Avancé') .replace(/\\(?!["\\\/bfnrtu])/g, '\\\\')
raw = await provider.generateText(light) .replace(/\n/g, '\\n')
} else { return JSON.parse(fixed)
throw err } catch {
return null
}
} }
} }
const result = this.parseResponse(raw, profile, lang)
if (!result.notebookName?.trim()) {
result.notebookName = this.deriveNotebookName(topic, result.notes, lang)
}
return result
}
/** Fallback short name when the model omits notebookName. */
private deriveNotebookName(topic: string, notes: GeneratedNote[], lang: string): string { private deriveNotebookName(topic: string, notes: GeneratedNote[], lang: string): string {
const fromNote = notes[0]?.title?.trim() const fromNote = notes[0]?.title?.trim()
if (fromNote && fromNote.length <= 80 && fromNote.length >= 3) { if (fromNote && fromNote.length <= 80 && fromNote.length >= 3) {
// Prefer first chapter title cleaned of numbering
return fromNote.replace(/^\d+[\.\):\-]\s*/, '').slice(0, 80) return fromNote.replace(/^\d+[\.\):\-]\s*/, '').slice(0, 80)
} }
const cleaned = topic const cleaned = topic
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim() .trim()
.replace(/^(je veux|i want|please|peux-tu|can you|crée|create|génère|generate)\b[\s,:-]*/i, '') .replace(/^(je veux|i want|i need|please|peux-tu|can you|crée|create|génère|generate)\b[\s,:-]*/i, '')
if (cleaned.length > 0 && cleaned.length <= 60) return cleaned if (cleaned.length > 0 && cleaned.length <= 60) return cleaned
if (cleaned.length > 60) { if (cleaned.length > 60) {
const cut = cleaned.slice(0, 57) const cut = cleaned.slice(0, 57)
const lastSpace = cut.lastIndexOf(' ') const lastSpace = cut.lastIndexOf(' ')
return (lastSpace > 20 ? cut.slice(0, lastSpace) : cut) + '…' return (lastSpace > 20 ? cut.slice(0, lastSpace) : cut) + '…'
} }
return lang === 'fr' ? 'Nouveau carnet' : 'New notebook' return lang === 'fr' ? 'Nouveau carnet' : lang === 'en' ? 'New notebook' : 'Notebook'
}
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 === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang === 'it' ? 'Italiano' : lang === 'pt' ? 'Português' : lang === 'ru' ? 'Русский' : lang === 'zh' ? '中文' : lang === 'ja' ? '日本語' : lang === 'ko' ? '한국어' : lang === 'nl' ? 'Nederlands' : lang === 'pl' ? 'Polski' : lang === 'hi' ? 'हिन्दी' : lang
const words = wordTargetsForLevel(level)
const schemaLabels = this.getSchemaLabels(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 (intention de l'utilisateur, NE PAS recopier tel quel comme titre de carnet) : "${topic}"
${level ? `Niveau : ${level}` : ''}
Langue : ${langName}
Nombre de notes à créer : ${count}
Profondeur cible : ${words.label}
CRÉE ${count} NOTES COMPLÈTES sur ce sujet. Chaque note : environ ${words.min}${words.max} mots (qualité > longueur extrême).
IMPORTANT — NOM DU CARNET :
- Fournis "notebookName" : un titre COURT et PERTINENT (38 mots, max 60 caractères).
- Exemple : pour un long message sur la thermo HVAC → "Modélisation thermodynamique HVAC"
- N'utilise JAMAIS la phrase complète de l'utilisateur comme nom.
Le contenu doit être :
- Clair et structuré avec <h2> et <h3>
- Des exemples concrets
- Des définitions dans des callouts
${profile === 'student' ? '- Points clés en callout tip\n- Pièges à éviter en callout danger' : ''}
${profile === 'teacher' ? '- Objectifs pédagogiques\n- Section Exercices (35 questions)' : ''}
${profile === 'engineer' ? '- Spécifications techniques\n- Tableaux comparatifs si utile' : ''}
FORMAT DE SORTIE — JSON UNIQUEMENT (pas de markdown hors du bloc) :
\`\`\`json
{
"notebookName": "Titre court du carnet",
"notes": [
{
"title": "Titre de chapitre descriptif",
"difficulty": "facile",
"content": "<h2>Introduction</h2><p>...</p>..."
}
],
"schemaProperties": [
{ "name": "${schemaLabels.status}", "type": "select", "options": ["${schemaLabels.statusTodo}", "${schemaLabels.statusInProgress}", "${schemaLabels.statusDone}"] },
{ "name": "${schemaLabels.difficulty}", "type": "select", "options": ["${schemaLabels.easy}", "${schemaLabels.medium}", "${schemaLabels.hard}"] }
]
}
\`\`\`
BLOCS HTML DISPONIBLES :
1. Callout : <div data-type="callout-block" data-callout-type="info"><p>...</p></div> (info|warning|tip|success|danger)
2. Toggle : <div data-type="toggle-block" data-opened="true"><p>Titre</p><p>Contenu</p></div>
3. Math : <div data-type="math-equation" data-latex="E = mc^2"></div> — JAMAIS $$
4. Colonnes : <div data-type="columns" cols="2">...</div>
5. Sommaire : <div data-type="outline-block"></div>
6. HTML : <h2>/<h3>, <p>, <ul>/<ol>/<li>, <table>, <blockquote>, <pre><code>
Les "difficulty" doivent varier : facile/moyen/difficile.
Réponds UNIQUEMENT avec le JSON valide (échappement correct des guillemets dans le HTML).`
} }
private getSchemaLabels(lang: string) { private getSchemaLabels(lang: string) {
@@ -178,74 +385,6 @@ Réponds UNIQUEMENT avec le JSON valide (échappement correct des guillemets dan
} }
return map[lang] || map.en return map[lang] || map.en
} }
private parseResponse(raw: string, profile: WizardProfile, lang?: string): 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')
const fallbackLabels = this.getSchemaLabels(lang || 'fr')
return {
notes,
schemaProperties: [
{ name: fallbackLabels.status, type: 'select', options: [fallbackLabels.statusTodo, fallbackLabels.statusInProgress, fallbackLabels.statusDone] },
{ name: fallbackLabels.difficulty, type: 'select', options: [fallbackLabels.easy, fallbackLabels.medium, fallbackLabels.hard] },
],
}
} 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 defaultLabels = this.getSchemaLabels(lang || 'fr')
const schemaProperties = parsed.schemaProperties || [
{ name: defaultLabels.status, type: 'select', options: [defaultLabels.statusTodo, defaultLabels.statusInProgress, defaultLabels.statusDone] },
{ name: defaultLabels.difficulty, type: 'select', options: [defaultLabels.easy, defaultLabels.medium, defaultLabels.hard] },
]
const notebookName = typeof parsed.notebookName === 'string'
? parsed.notebookName.trim().slice(0, 80)
: undefined
return { notebookName, notes, schemaProperties }
}
} }
export const notebookWizardService = new NotebookWizardService() export const notebookWizardService = new NotebookWizardService()

View File

@@ -65,6 +65,10 @@
"freezePinnedNotebook": "Pin notebook sidebar order", "freezePinnedNotebook": "Pin notebook sidebar order",
"unfreezePinnedNotebook": "Unpin notebook sidebar order", "unfreezePinnedNotebook": "Unpin notebook sidebar order",
"newSubNotebook": "New sub-notebook", "newSubNotebook": "New sub-notebook",
"aiBadge": "AI",
"aiGeneratedNote": "AI-generated note",
"aiGeneratedNotebook": "Notebook created with AI — recently",
"recentNote": "Recently created",
"notebookEmpty": "Empty", "notebookEmpty": "Empty",
"renameNotebook": "Rename", "renameNotebook": "Rename",
"sharedNotebookBadge": "· Shared", "sharedNotebookBadge": "· Shared",
@@ -846,7 +850,14 @@
"hideAll": "Hide extra connections ({count})", "hideAll": "Hide extra connections ({count})",
"retroTitle": "Notes that cite this content", "retroTitle": "Notes that cite this content",
"retroDescription": "This passage is quoted in {count} other note(s):", "retroDescription": "This passage is quoted in {count} other note(s):",
"consentRequired": "Enable AI processing in Settings → AI to see semantic connections for this note." "consentRequired": "Enable AI processing in Settings → AI to see semantic connections for this note.",
"bottomCueLoading": "Memory Echo is scanning below…",
"bottomCueFound": "{count} related notes below",
"bottomCueFoundOne": "1 related note below",
"bottomCueRetro": "Cited in {count} note(s) below",
"bottomCueConsent": "AI connections available below",
"bottomCueScroll": "Scroll to Memory Echo",
"bottomCueDismiss": "Dismiss"
}, },
"fusion": { "fusion": {
"title": "🔗 Intelligent Fusion", "title": "🔗 Intelligent Fusion",
@@ -2733,6 +2744,9 @@
"created": "Notebook created:", "created": "Notebook created:",
"notesCreated": "notes created", "notesCreated": "notes created",
"openNotebook": "Open notebook", "openNotebook": "Open notebook",
"openingNotebook": "Opening…",
"autoOpenHint": "Opening the notebook and refreshing the sidebar…",
"createdToast": "Notebook « {name} » ready — AI notes available",
"studyPlanner": "Study Plan", "studyPlanner": "Study Plan",
"studyPlannerDesc": "AI creates a revision plan based on spaced repetition.", "studyPlannerDesc": "AI creates a revision plan based on spaced repetition.",
"examDate": "Exam date", "examDate": "Exam date",
@@ -4335,6 +4349,9 @@
"created": "Notebook created:", "created": "Notebook created:",
"notesCreated": "notes created", "notesCreated": "notes created",
"openNotebook": "Open notebook", "openNotebook": "Open notebook",
"openingNotebook": "Opening…",
"autoOpenHint": "Opening the notebook and refreshing the sidebar…",
"createdToast": "Notebook « {name} » ready — AI notes available",
"studyPlanner": "Study Plan", "studyPlanner": "Study Plan",
"studyPlannerDesc": "AI creates a revision plan based on spaced repetition.", "studyPlannerDesc": "AI creates a revision plan based on spaced repetition.",
"examDate": "Exam date", "examDate": "Exam date",

View File

@@ -65,6 +65,10 @@
"freezePinnedNotebook": "Figer l'état du carnet", "freezePinnedNotebook": "Figer l'état du carnet",
"unfreezePinnedNotebook": "Défiger l'état du carnet", "unfreezePinnedNotebook": "Défiger l'état du carnet",
"newSubNotebook": "Nouveau sous-carnet", "newSubNotebook": "Nouveau sous-carnet",
"aiBadge": "IA",
"aiGeneratedNote": "Note générée par l'IA",
"aiGeneratedNotebook": "Carnet créé avec l'IA — récemment",
"recentNote": "Note récente",
"notebookEmpty": "Vide", "notebookEmpty": "Vide",
"renameNotebook": "Renommer", "renameNotebook": "Renommer",
"sharedNotebookBadge": "· partagé", "sharedNotebookBadge": "· partagé",
@@ -852,7 +856,14 @@
"hideAll": "Masquer les connexions ({count})", "hideAll": "Masquer les connexions ({count})",
"retroTitle": "Notes qui citent ce contenu", "retroTitle": "Notes qui citent ce contenu",
"retroDescription": "Ce passage est repris dans {count} autre(s) note(s) :", "retroDescription": "Ce passage est repris dans {count} autre(s) note(s) :",
"consentRequired": "Activez le traitement IA dans Paramètres → IA pour voir les connexions sémantiques de cette note." "consentRequired": "Activez le traitement IA dans Paramètres → IA pour voir les connexions sémantiques de cette note.",
"bottomCueLoading": "Memory Echo analyse en bas…",
"bottomCueFound": "{count} notes liées plus bas",
"bottomCueFoundOne": "1 note liée plus bas",
"bottomCueRetro": "Cité dans {count} note(s) plus bas",
"bottomCueConsent": "Connexions IA disponibles plus bas",
"bottomCueScroll": "Aller à Memory Echo",
"bottomCueDismiss": "Masquer"
}, },
"fusion": { "fusion": {
"title": "🔗 Fusion intelligente", "title": "🔗 Fusion intelligente",
@@ -2739,6 +2750,9 @@
"created": "Carnet créé :", "created": "Carnet créé :",
"notesCreated": "notes créées", "notesCreated": "notes créées",
"openNotebook": "Ouvrir le carnet", "openNotebook": "Ouvrir le carnet",
"openingNotebook": "Ouverture…",
"autoOpenHint": "Ouverture automatique du carnet et mise à jour de la barre latérale…",
"createdToast": "Carnet « {name} » prêt — notes IA disponibles",
"studyPlanner": "Planning de révision", "studyPlanner": "Planning de révision",
"studyPlannerDesc": "L'IA crée un planning de révision basé sur la répétition espacée.", "studyPlannerDesc": "L'IA crée un planning de révision basé sur la répétition espacée.",
"examDate": "Date de l'examen", "examDate": "Date de l'examen",
@@ -4341,6 +4355,9 @@
"created": "Carnet créé :", "created": "Carnet créé :",
"notesCreated": "notes créées", "notesCreated": "notes créées",
"openNotebook": "Ouvrir le carnet", "openNotebook": "Ouvrir le carnet",
"openingNotebook": "Ouverture…",
"autoOpenHint": "Ouverture automatique du carnet et mise à jour de la barre latérale…",
"createdToast": "Carnet « {name} » prêt — notes IA disponibles",
"studyPlanner": "Planning de révision", "studyPlanner": "Planning de révision",
"studyPlannerDesc": "L'IA crée un planning de révision basé sur la répétition espacée.", "studyPlannerDesc": "L'IA crée un planning de révision basé sur la répétition espacée.",
"examDate": "Date de l'examen", "examDate": "Date de l'examen",