feat: AI Overview recherche + AI Writer inline streaming
- AI Overview : synthèse IA en haut des résultats de recherche (Ctrl+K) - Service search-overview.service.ts - Endpoint /api/ai/search-overview - Carte 'Réponse IA' avec Sparkles en haut du panneau gauche - Pur additif, ne modifie pas le classement des résultats - AI Writer inline : slash menu → 'Écrire avec l'IA' → champ inline - Mode 'write' dans paragraph-refactor.service.ts - Streaming paragraphe par paragraphe (120ms delay) - Nettoyage HTML (espaces vides supprimés) - Ref synchrone pour éviter fermeture du menu pendant la frappe - Fix: index slashCommands AI Writer corrigé - Fix: Loader2 import manquant - i18n FR/EN
This commit is contained in:
@@ -69,7 +69,7 @@ import {
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -1677,7 +1677,8 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 })
|
||||
const [previewCoords, setPreviewCoords] = useState({ top: 0, left: 0, side: 'right' as 'right' | 'left' })
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [aiWriterOpen, setAiWriterOpen] = useState(false)
|
||||
const [aiWriterMode, setAiWriterMode] = useState(false)
|
||||
const aiWriterModeRef = useRef(false)
|
||||
const [aiWriterPrompt, setAiWriterPrompt] = useState('')
|
||||
const [aiWriterLoading, setAiWriterLoading] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
@@ -1736,9 +1737,48 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
]
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsOpen(false); setQuery(''); setSelectedIndex(0); setActiveCategory(null)
|
||||
setIsOpen(false); setQuery(''); setSelectedIndex(0); setActiveCategory(null); setAiWriterMode(false); aiWriterModeRef.current = false
|
||||
}, [])
|
||||
|
||||
const handleAiWriterSubmit = useCallback(async () => {
|
||||
if (!aiWriterPrompt.trim()) return
|
||||
setAiWriterLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/reformulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: '',
|
||||
option: 'write',
|
||||
format: 'html',
|
||||
writePrompt: aiWriterPrompt.trim(),
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast.error(data.error || 'Erreur'); return }
|
||||
let html = data.reformulatedText || data.text || ''
|
||||
// Clean up excessive whitespace
|
||||
html = html
|
||||
.replace(/<p>\s*<\/p>/g, '')
|
||||
.replace(/(<p[^>]*>)\s+/g, '$1')
|
||||
.replace(/\s+(<\/p>)/g, '$1')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
|
||||
closeMenu()
|
||||
|
||||
// Stream the content paragraph by paragraph
|
||||
const paragraphs = html.match(/<(?:p|h[1-3]|ul|ol|div|blockquote|pre)[^>]*>[\s\S]*?<\/(?:p|h[1-3]|ul|ol|div|blockquote|pre)>/gi) || [html]
|
||||
for (let i = 0; i < paragraphs.length; i++) {
|
||||
editor.chain().focus().insertContent(paragraphs[i]).run()
|
||||
if (i < paragraphs.length - 1) {
|
||||
await new Promise(r => setTimeout(r, 120))
|
||||
}
|
||||
}
|
||||
} catch (e: any) { toast.error(e.message || 'Erreur') }
|
||||
finally { setAiWriterLoading(false) }
|
||||
}, [aiWriterPrompt, editor, closeMenu])
|
||||
|
||||
const deleteSlashText = useCallback(() => {
|
||||
const { from, to } = editor.state.selection
|
||||
const textBefore = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n')
|
||||
@@ -1767,9 +1807,13 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
if (item.isImage) {
|
||||
deleteSlashText(); closeMenu(); onInsertImage(editor)
|
||||
} else if (item.isAi && item.aiOption === 'write') {
|
||||
deleteSlashText(); closeMenu()
|
||||
setAiWriterOpen(true)
|
||||
aiWriterModeRef.current = true
|
||||
setAiWriterMode(true)
|
||||
setAiWriterPrompt('')
|
||||
deleteSlashText()
|
||||
const { from } = editor.state.selection
|
||||
const c = editor.view.coordsAtPos(from)
|
||||
setCoords({ top: c.bottom + 8, left: c.left })
|
||||
} else if (item.isAi && item.aiOption) {
|
||||
deleteSlashText(); closeMenu(); setAiLoading(true)
|
||||
try {
|
||||
@@ -1797,43 +1841,6 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
}
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent])
|
||||
|
||||
const handleAiWriterSubmit = useCallback(async () => {
|
||||
if (!aiWriterPrompt.trim() || !editor) return
|
||||
setAiWriterLoading(true)
|
||||
try {
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
const noteTitle = (editor.storage as any).structuredViewBlock?.noteTitle || ''
|
||||
const res = await fetch('/api/ai/reformulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: noteTitle,
|
||||
option: 'write',
|
||||
format: 'html',
|
||||
language: 'Francais',
|
||||
writePrompt: aiWriterPrompt.trim(),
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || 'Erreur')
|
||||
return
|
||||
}
|
||||
const html = data.reformulatedText || data.text || ''
|
||||
if (html) {
|
||||
editor.chain().focus().insertContent(html).run()
|
||||
}
|
||||
setAiWriterOpen(false)
|
||||
setAiWriterPrompt('')
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Erreur')
|
||||
} finally {
|
||||
setAiWriterLoading(false)
|
||||
}
|
||||
}, [aiWriterPrompt, editor, requestAiConsent])
|
||||
|
||||
// Charger les favoris fréquents lors de l'ouverture
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@@ -1913,6 +1920,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return
|
||||
if (aiWriterModeRef.current) return // Let the AI writer input handle keystrokes
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length); return }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return }
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
@@ -1973,7 +1981,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (isOpen && menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
if (isOpen && !aiWriterModeRef.current && menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
closeMenu()
|
||||
}
|
||||
}
|
||||
@@ -1985,6 +1993,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
const handler = () => {
|
||||
// Ignore events fired while user clicks inside the menu
|
||||
if (menuInteracting.current) return
|
||||
if (aiWriterModeRef.current) return // Synchronous check via ref
|
||||
const { from, empty } = editor.state.selection
|
||||
if (!empty) { if (isOpen) closeMenu(); return }
|
||||
const text = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n')
|
||||
@@ -1992,16 +2001,16 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
if (m) {
|
||||
setQuery(m[1])
|
||||
setSelectedIndex(0)
|
||||
if (!isOpen) { setIsOpen(true); setActiveCategory(null) } // reset category filter on fresh open
|
||||
if (!isOpen) { setIsOpen(true); setActiveCategory(null) }
|
||||
}
|
||||
else if (isOpen) closeMenu()
|
||||
}
|
||||
editor.on('update', handler)
|
||||
editor.on('selectionUpdate', handler)
|
||||
return () => { editor.off('update', handler); editor.off('selectionUpdate', handler) }
|
||||
}, [editor, isOpen, closeMenu])
|
||||
}, [editor, isOpen, closeMenu, aiWriterMode])
|
||||
|
||||
if (!isOpen || filtered.length === 0) return null
|
||||
if (!aiWriterMode && (!isOpen || filtered.length === 0)) return null
|
||||
|
||||
let flatIndex = -1
|
||||
const sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0)
|
||||
@@ -2021,6 +2030,42 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{aiWriterMode ? (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="notion-slash-menu"
|
||||
style={{ top: coords.top, left: coords.left, minWidth: '360px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
dir="auto"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/30">
|
||||
<Sparkles size={16} className="text-brand-accent flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={aiWriterPrompt}
|
||||
onChange={(e) => setAiWriterPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void handleAiWriterSubmit() }
|
||||
if (e.key === 'Escape') closeMenu()
|
||||
}}
|
||||
autoFocus
|
||||
disabled={aiWriterLoading}
|
||||
placeholder={t('richTextEditor.aiWriterPlaceholder') || 'Décris ce que tu veux écrire...'}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{aiWriterLoading && <Loader2 size={14} className="animate-spin text-brand-accent flex-shrink-0" />}
|
||||
</div>
|
||||
{!aiWriterLoading && aiWriterPrompt.trim() && (
|
||||
<button
|
||||
onClick={() => void handleAiWriterSubmit()}
|
||||
className="w-full px-3 py-2 text-xs font-medium text-brand-accent hover:bg-brand-accent/10 transition-colors text-left"
|
||||
>
|
||||
{t('general.send') || 'Générer'} ↵
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="notion-slash-menu"
|
||||
@@ -2112,6 +2157,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPreview && (
|
||||
<div
|
||||
@@ -2121,51 +2167,6 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
<SlashPreview itemTitle={selectedItem.title} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aiWriterOpen && createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] flex items-center gap-2 rounded-xl border border-brand-accent/30 bg-card shadow-xl px-3 py-2"
|
||||
style={{
|
||||
top: (() => {
|
||||
const { from } = editor.state.selection
|
||||
const c = editor.view.coordsAtPos(from)
|
||||
return c.bottom + 4
|
||||
})(),
|
||||
left: (() => {
|
||||
const { from } = editor.state.selection
|
||||
const c = editor.view.coordsAtPos(from)
|
||||
return Math.min(c.left, window.innerWidth - 420)
|
||||
})(),
|
||||
}}
|
||||
dir="auto"
|
||||
>
|
||||
<Sparkles size={16} className="text-brand-accent flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={aiWriterPrompt}
|
||||
onChange={(e) => setAiWriterPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleAiWriterSubmit() }
|
||||
if (e.key === 'Escape') { setAiWriterOpen(false); setAiWriterPrompt('') }
|
||||
}}
|
||||
autoFocus
|
||||
disabled={aiWriterLoading}
|
||||
placeholder={t('richTextEditor.aiWriterPlaceholder') || 'Décris ce que tu veux écrire...'}
|
||||
className="flex-1 min-w-[300px] bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{aiWriterLoading && <Loader2 size={14} className="animate-spin text-brand-accent flex-shrink-0" />}
|
||||
{!aiWriterLoading && aiWriterPrompt.trim() && (
|
||||
<button
|
||||
onClick={handleAiWriterSubmit}
|
||||
className="text-xs font-medium text-brand-accent hover:underline flex-shrink-0"
|
||||
>
|
||||
{t('general.send') || '→'}
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user