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:
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { Menu } from 'lucide-react'
|
import { Menu } from 'lucide-react'
|
||||||
import { SettingsNav } from '@/components/settings'
|
import { SettingsNav } from '@/components/settings'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export default function SettingsLayout({
|
export default function SettingsLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-dark-paper">
|
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-dark-paper">
|
||||||
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
|
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
|
||||||
@@ -15,16 +17,16 @@ export default function SettingsLayout({
|
|||||||
<button
|
<button
|
||||||
className="md:hidden p-2 -ms-1 text-ink/70 hover:bg-ink/5 rounded-lg transition-colors shrink-0 mt-1"
|
className="md:hidden p-2 -ms-1 text-ink/70 hover:bg-ink/5 rounded-lg transition-colors shrink-0 mt-1"
|
||||||
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
||||||
aria-label="Ouvrir la navigation"
|
aria-label={t('settings.title')}
|
||||||
>
|
>
|
||||||
<Menu size={22} />
|
<Menu size={22} />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">
|
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">
|
||||||
Paramètres
|
{t('settings.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60 mt-4">
|
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60 mt-4">
|
||||||
Configuration & Préférences
|
{t('settings.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
24
memento-note/app/api/ai/search-overview/route.ts
Normal file
24
memento-note/app/api/ai/search-overview/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { searchOverviewService } from '@/lib/ai/services/search-overview.service'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { query, results, language } = await request.json()
|
||||||
|
if (!query?.trim() || !Array.isArray(results)) {
|
||||||
|
return NextResponse.json({ error: 'query and results required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const overview = await searchOverviewService.generate(query, results, language)
|
||||||
|
|
||||||
|
return NextResponse.json(overview)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Search Overview] Error:', error)
|
||||||
|
return NextResponse.json({ error: error.message, hasRelevantInfo: false, answer: '' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ import {
|
|||||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
|
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
|
||||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3
|
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -1677,7 +1677,8 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
const [coords, setCoords] = useState({ top: 0, left: 0 })
|
const [coords, setCoords] = useState({ top: 0, left: 0 })
|
||||||
const [previewCoords, setPreviewCoords] = useState({ top: 0, left: 0, side: 'right' as 'right' | 'left' })
|
const [previewCoords, setPreviewCoords] = useState({ top: 0, left: 0, side: 'right' as 'right' | 'left' })
|
||||||
const [aiLoading, setAiLoading] = useState(false)
|
const [aiLoading, setAiLoading] = useState(false)
|
||||||
const [aiWriterOpen, setAiWriterOpen] = useState(false)
|
const [aiWriterMode, setAiWriterMode] = useState(false)
|
||||||
|
const aiWriterModeRef = useRef(false)
|
||||||
const [aiWriterPrompt, setAiWriterPrompt] = useState('')
|
const [aiWriterPrompt, setAiWriterPrompt] = useState('')
|
||||||
const [aiWriterLoading, setAiWriterLoading] = useState(false)
|
const [aiWriterLoading, setAiWriterLoading] = useState(false)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -1736,9 +1737,48 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
]
|
]
|
||||||
|
|
||||||
const closeMenu = useCallback(() => {
|
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 deleteSlashText = useCallback(() => {
|
||||||
const { from, to } = editor.state.selection
|
const { from, to } = editor.state.selection
|
||||||
const textBefore = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n')
|
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) {
|
if (item.isImage) {
|
||||||
deleteSlashText(); closeMenu(); onInsertImage(editor)
|
deleteSlashText(); closeMenu(); onInsertImage(editor)
|
||||||
} else if (item.isAi && item.aiOption === 'write') {
|
} else if (item.isAi && item.aiOption === 'write') {
|
||||||
deleteSlashText(); closeMenu()
|
aiWriterModeRef.current = true
|
||||||
setAiWriterOpen(true)
|
setAiWriterMode(true)
|
||||||
setAiWriterPrompt('')
|
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) {
|
} else if (item.isAi && item.aiOption) {
|
||||||
deleteSlashText(); closeMenu(); setAiLoading(true)
|
deleteSlashText(); closeMenu(); setAiLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -1797,43 +1841,6 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
}
|
}
|
||||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent])
|
}, [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
|
// Charger les favoris fréquents lors de l'ouverture
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@@ -1913,6 +1920,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isOpen) return
|
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 === '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 === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return }
|
||||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||||
@@ -1973,7 +1981,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClick = (e: MouseEvent) => {
|
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()
|
closeMenu()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1985,6 +1993,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
const handler = () => {
|
const handler = () => {
|
||||||
// Ignore events fired while user clicks inside the menu
|
// Ignore events fired while user clicks inside the menu
|
||||||
if (menuInteracting.current) return
|
if (menuInteracting.current) return
|
||||||
|
if (aiWriterModeRef.current) return // Synchronous check via ref
|
||||||
const { from, empty } = editor.state.selection
|
const { from, empty } = editor.state.selection
|
||||||
if (!empty) { if (isOpen) closeMenu(); return }
|
if (!empty) { if (isOpen) closeMenu(); return }
|
||||||
const text = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n')
|
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) {
|
if (m) {
|
||||||
setQuery(m[1])
|
setQuery(m[1])
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0)
|
||||||
if (!isOpen) { setIsOpen(true); setActiveCategory(null) } // reset category filter on fresh open
|
if (!isOpen) { setIsOpen(true); setActiveCategory(null) }
|
||||||
}
|
}
|
||||||
else if (isOpen) closeMenu()
|
else if (isOpen) closeMenu()
|
||||||
}
|
}
|
||||||
editor.on('update', handler)
|
editor.on('update', handler)
|
||||||
editor.on('selectionUpdate', handler)
|
editor.on('selectionUpdate', handler)
|
||||||
return () => { editor.off('update', handler); editor.off('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
|
let flatIndex = -1
|
||||||
const sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0)
|
const sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0)
|
||||||
@@ -2021,6 +2030,42 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
|
|
||||||
return createPortal(
|
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
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className="notion-slash-menu"
|
className="notion-slash-menu"
|
||||||
@@ -2112,6 +2157,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
<div
|
<div
|
||||||
@@ -2121,51 +2167,6 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
<SlashPreview itemTitle={selectedItem.title} />
|
<SlashPreview itemTitle={selectedItem.title} />
|
||||||
</div>
|
</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
|
document.body
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
Command,
|
Command,
|
||||||
FileText,
|
FileText,
|
||||||
|
Sparkles,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
@@ -64,6 +66,8 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
|||||||
const [savedQueries, setSavedQueries] = useState<string[]>([])
|
const [savedQueries, setSavedQueries] = useState<string[]>([])
|
||||||
const [results, setResults] = useState<Note[]>([])
|
const [results, setResults] = useState<Note[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [overview, setOverview] = useState<string | null>(null)
|
||||||
|
const [overviewLoading, setOverviewLoading] = useState(false)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -137,6 +141,36 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
|||||||
}
|
}
|
||||||
}, [query, searchInTrash])
|
}, [query, searchInTrash])
|
||||||
|
|
||||||
|
// Fetch AI overview after results load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim() || results.length === 0) {
|
||||||
|
setOverview(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setOverviewLoading(true)
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const topResults = results.slice(0, 5).map(r => ({
|
||||||
|
title: r.title || 'Sans titre',
|
||||||
|
snippet: r.content?.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400) || '',
|
||||||
|
}))
|
||||||
|
const res = await fetch('/api/ai/search-overview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: query.trim(), results: topResults }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setOverview(data.hasRelevantInfo ? data.answer : null)
|
||||||
|
} catch {
|
||||||
|
setOverview(null)
|
||||||
|
} finally {
|
||||||
|
setOverviewLoading(false)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [query, results])
|
||||||
|
|
||||||
// Reset selected index when results change
|
// Reset selected index when results change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0)
|
||||||
@@ -501,6 +535,25 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
|||||||
{/* Left — results list */}
|
{/* Left — results list */}
|
||||||
<div className="w-[45%] h-full border-r border-border/40 dark:border-zinc-800 flex flex-col bg-[#FAF9F5]/30 dark:bg-[#121212]/30 overflow-hidden">
|
<div className="w-[45%] h-full border-r border-border/40 dark:border-zinc-800 flex flex-col bg-[#FAF9F5]/30 dark:bg-[#121212]/30 overflow-hidden">
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||||
|
{/* AI Overview */}
|
||||||
|
{(overviewLoading || overview) && (
|
||||||
|
<div className="mb-2 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.03] p-3" dir="auto">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<Sparkles size={12} className="text-brand-accent" />
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-widest text-brand-accent">
|
||||||
|
{overviewLoading ? 'Analyse...' : 'Réponse IA'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{overviewLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 size={12} className="animate-spin text-brand-accent" />
|
||||||
|
<span className="text-[11px] text-muted-foreground">Synthèse des résultats...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[12px] leading-relaxed text-foreground/80">{overview}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{filteredMatches.map((m, idx) => {
|
{filteredMatches.map((m, idx) => {
|
||||||
const isSelected = idx === selectedIndex
|
const isSelected = idx === selectedIndex
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -632,6 +632,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
const [trashCount, setTrashCount] = useState(0)
|
const [trashCount, setTrashCount] = useState(0)
|
||||||
|
|
||||||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||||||
|
// Ref stable pour éviter les stale closures dans les handlers de drop
|
||||||
|
const draggedIdRef = useRef<string | null>(null)
|
||||||
const [orderedNotebooks, setOrderedNotebooks] = useState<Notebook[]>([])
|
const [orderedNotebooks, setOrderedNotebooks] = useState<Notebook[]>([])
|
||||||
const dragOverId = useRef<string | null>(null)
|
const dragOverId = useRef<string | null>(null)
|
||||||
const isSavingRef = useRef(false)
|
const isSavingRef = useRef(false)
|
||||||
@@ -852,61 +854,85 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
|
|
||||||
// ── Drag state ──
|
// ── Drag state ──
|
||||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||||
const [dropAction, setDropAction] = useState<'into' | null>(null)
|
const [dropAction, setDropAction] = useState<'into' | 'before' | 'after' | null>(null)
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent, notebookId: string) => {
|
const handleDragStart = useCallback((e: React.DragEvent, notebookId: string) => {
|
||||||
|
// Stocker dans le ref ET dans le state
|
||||||
|
// Le ref est toujours frais dans les handlers async (pas de stale closure)
|
||||||
|
draggedIdRef.current = notebookId
|
||||||
setDraggedId(notebookId)
|
setDraggedId(notebookId)
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
e.dataTransfer.setData('text/plain', notebookId)
|
e.dataTransfer.setData('text/plain', notebookId)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = useCallback(() => {
|
||||||
|
draggedIdRef.current = null
|
||||||
setDraggedId(null)
|
setDraggedId(null)
|
||||||
dragOverId.current = null
|
dragOverId.current = null
|
||||||
isSavingRef.current = false
|
isSavingRef.current = false
|
||||||
setDropTarget(null)
|
setDropTarget(null)
|
||||||
setDropAction(null)
|
setDropAction(null)
|
||||||
setOrderedNotebooks(sortedNotebooks)
|
setOrderedNotebooks(sortedNotebooks)
|
||||||
}
|
}, [sortedNotebooks])
|
||||||
|
|
||||||
const handleDropOnNotebook = async (e: React.DragEvent, targetId: string) => {
|
const handleDropOnNotebook = useCallback(async (e: React.DragEvent, targetId: string, action: 'into' | 'before' | 'after') => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
const dragId = draggedIdRef.current || e.dataTransfer.getData('text/plain')
|
||||||
|
draggedIdRef.current = null
|
||||||
|
setDraggedId(null)
|
||||||
setDropTarget(null)
|
setDropTarget(null)
|
||||||
setDropAction(null)
|
setDropAction(null)
|
||||||
const dragId = draggedId || e.dataTransfer.getData('text/plain')
|
|
||||||
if (!dragId || dragId === targetId) {
|
|
||||||
setDraggedId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setDraggedId(null)
|
|
||||||
dragOverId.current = null
|
dragOverId.current = null
|
||||||
|
if (!dragId || dragId === targetId) return
|
||||||
try {
|
try {
|
||||||
await moveNotebookToParent(dragId, targetId)
|
if (action === 'into') {
|
||||||
|
await moveNotebookToParent(dragId, targetId)
|
||||||
|
} else {
|
||||||
|
// before/after : placer au même niveau que la cible et réordonner
|
||||||
|
const target = orderedNotebooks.find(nb => nb.id === targetId)
|
||||||
|
const newParentId = target?.parentId ?? null
|
||||||
|
|
||||||
|
// 1. Mettre à jour le parentId si nécessaire
|
||||||
|
const dragged = orderedNotebooks.find(nb => nb.id === dragId)
|
||||||
|
if (dragged?.parentId !== newParentId) {
|
||||||
|
await moveNotebookToParent(dragId, newParentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Recalculer l'ordre des siblings
|
||||||
|
const siblings = orderedNotebooks
|
||||||
|
.filter(nb => (nb.parentId ?? null) === newParentId && nb.id !== dragId)
|
||||||
|
const targetIdx = siblings.findIndex(nb => nb.id === targetId)
|
||||||
|
const insertAt = action === 'before' ? targetIdx : targetIdx + 1
|
||||||
|
siblings.splice(insertAt, 0, { id: dragId } as typeof siblings[0])
|
||||||
|
// Passer en tri manuel pour que l'ordre soit respecté visuellement
|
||||||
|
setSortOrder('manual')
|
||||||
|
await updateNotebookOrderOptimistic(siblings.map(nb => nb.id))
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
|
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
|
||||||
setOrderedNotebooks(sortedNotebooks)
|
setOrderedNotebooks(sortedNotebooks)
|
||||||
}
|
}
|
||||||
}
|
}, [moveNotebookToParent, updateNotebookOrderOptimistic, orderedNotebooks, sortedNotebooks, t])
|
||||||
|
|
||||||
const handleDropToRoot = async (e: React.DragEvent) => {
|
const handleDropToRoot = useCallback(async (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
// Lire depuis le ref (toujours à jour, pas de stale closure)
|
||||||
|
const dragId = draggedIdRef.current || e.dataTransfer.getData('text/plain')
|
||||||
|
// Nettoyer APRÈS avoir lu la valeur
|
||||||
|
draggedIdRef.current = null
|
||||||
|
setDraggedId(null)
|
||||||
setDropTarget(null)
|
setDropTarget(null)
|
||||||
setDropAction(null)
|
setDropAction(null)
|
||||||
const dragId = draggedId || e.dataTransfer.getData('text/plain')
|
|
||||||
if (!dragId) {
|
|
||||||
setDraggedId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setDraggedId(null)
|
|
||||||
dragOverId.current = null
|
dragOverId.current = null
|
||||||
|
if (!dragId) return
|
||||||
try {
|
try {
|
||||||
await moveNotebookToParent(dragId, null)
|
await moveNotebookToParent(dragId, null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
|
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
|
||||||
setOrderedNotebooks(sortedNotebooks)
|
setOrderedNotebooks(sortedNotebooks)
|
||||||
}
|
}
|
||||||
}
|
}, [moveNotebookToParent, sortedNotebooks, t])
|
||||||
|
|
||||||
const sortLabels: Record<SortOrder, string> = {
|
const sortLabels: Record<SortOrder, string> = {
|
||||||
newest: t('sidebar.sortNewest'),
|
newest: t('sidebar.sortNewest'),
|
||||||
@@ -1033,8 +1059,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!draggedId || draggedId === notebook.id) return
|
if (!draggedId || draggedId === notebook.id) return
|
||||||
e.dataTransfer.dropEffect = 'move'
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const y = e.clientY - rect.top
|
||||||
|
const pct = y / rect.height
|
||||||
setDropTarget(notebook.id)
|
setDropTarget(notebook.id)
|
||||||
setDropAction('into')
|
if (pct < 0.3) setDropAction('before')
|
||||||
|
else if (pct > 0.7) setDropAction('after')
|
||||||
|
else setDropAction('into')
|
||||||
}}
|
}}
|
||||||
onDragLeave={(e) => {
|
onDragLeave={(e) => {
|
||||||
if (e.currentTarget.contains(e.relatedTarget as Node)) return
|
if (e.currentTarget.contains(e.relatedTarget as Node)) return
|
||||||
@@ -1046,15 +1077,24 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleDropOnNotebook(e, notebook.id)
|
const action = (dropTarget === notebook.id ? dropAction : null) ?? 'into'
|
||||||
|
handleDropOnNotebook(e, notebook.id, action as 'into' | 'before' | 'after')
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg transition-colors',
|
'relative rounded-lg transition-colors',
|
||||||
dropTarget === notebook.id && dropAction === 'into' && draggedId && draggedId !== notebook.id
|
dropTarget === notebook.id && dropAction === 'into' && draggedId && draggedId !== notebook.id
|
||||||
&& 'bg-brand-accent/10 ring-1 ring-brand-accent/30',
|
&& 'bg-brand-accent/10 ring-1 ring-brand-accent/30',
|
||||||
isDragging && 'opacity-50'
|
isDragging && 'opacity-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* Indicateur "déposer avant" */}
|
||||||
|
{dropTarget === notebook.id && dropAction === 'before' && draggedId && draggedId !== notebook.id && (
|
||||||
|
<div className="absolute -top-0.5 start-0 end-0 h-0.5 bg-brand-accent rounded-full z-10 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
{/* Indicateur "déposer après" */}
|
||||||
|
{dropTarget === notebook.id && dropAction === 'after' && draggedId && draggedId !== notebook.id && (
|
||||||
|
<div className="absolute -bottom-0.5 start-0 end-0 h-0.5 bg-brand-accent rounded-full z-10 pointer-events-none" />
|
||||||
|
)}
|
||||||
<SidebarCarnetItem
|
<SidebarCarnetItem
|
||||||
carnet={{
|
carnet={{
|
||||||
id: notebook.id,
|
id: notebook.id,
|
||||||
@@ -1481,17 +1521,27 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
|
|
||||||
<div className="my-3 h-px bg-border/40" />
|
<div className="my-3 h-px bg-border/40" />
|
||||||
|
|
||||||
|
{/* Zone de dépôt racine — toujours présente pour éviter tout décalage DOM */}
|
||||||
|
<div
|
||||||
|
className={`mb-2 h-10 rounded-lg border-2 border-dashed flex items-center justify-center gap-2 text-[11px] font-medium transition-all duration-150 ${
|
||||||
|
draggedId
|
||||||
|
? 'border-brand-accent/40 bg-brand-accent/5 text-brand-accent/70 opacity-100 cursor-copy pointer-events-auto'
|
||||||
|
: 'border-transparent bg-transparent text-transparent opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
onDrop={handleDropToRoot}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); e.stopPropagation() }}
|
||||||
|
onDragEnter={(e) => { e.preventDefault() }}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
|
{t('sidebar.dropToRoot')}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="space-y-0.5 min-h-[60px]"
|
className="space-y-0.5 min-h-[60px]"
|
||||||
onDrop={handleDropToRoot}
|
onDrop={handleDropToRoot}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{renderCarnetTree(undefined, 0)}
|
{renderCarnetTree(undefined, 0)}
|
||||||
{draggedId && (
|
|
||||||
<div className="h-10 rounded-lg border-2 border-dashed border-brand-accent/20 flex items-center justify-center text-[11px] text-brand-accent/50">
|
|
||||||
{t('sidebar.dropToRoot')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
57
memento-note/lib/ai/services/search-overview.service.ts
Normal file
57
memento-note/lib/ai/services/search-overview.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { getChatProvider } from '../factory'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
|
||||||
|
export interface SearchOverviewResult {
|
||||||
|
answer: string
|
||||||
|
hasRelevantInfo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SearchOverviewService {
|
||||||
|
async generate(
|
||||||
|
query: string,
|
||||||
|
results: Array<{ title: string; snippet: string }>,
|
||||||
|
language?: string
|
||||||
|
): Promise<SearchOverviewResult> {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return { answer: '', hasRelevantInfo: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = language || 'fr'
|
||||||
|
const langName = lang === 'fr' ? 'français' : lang === 'fa' ? 'فارسی' : 'English'
|
||||||
|
|
||||||
|
const topResults = results.slice(0, 8)
|
||||||
|
const context = topResults.map((r, i) =>
|
||||||
|
`[Note ${i + 1}: ${r.title}]\n${r.snippet.slice(0, 300)}`
|
||||||
|
).join('\n\n---\n\n')
|
||||||
|
|
||||||
|
const prompt = `Tu es un assistant de recherche. Réponds à la question de l'utilisateur en te basant UNIQUEMENT sur les notes fournies ci-dessous.
|
||||||
|
|
||||||
|
LANGUE DE RÉPONSE : ${langName}
|
||||||
|
|
||||||
|
QUESTION : "${query}"
|
||||||
|
|
||||||
|
NOTES TROUVÉES :
|
||||||
|
${context}
|
||||||
|
|
||||||
|
RÈGLES :
|
||||||
|
- Réponds en ${langName} UNIQUEMENT
|
||||||
|
- Sois concis : 2-4 phrases maximum
|
||||||
|
- Si les notes contiennent l'info, réponds directement
|
||||||
|
- Si les notes ne contiennent PAS l'info, réponds exactement : "NO_RELEVANT_INFO"
|
||||||
|
- Ne dis pas "selon les notes" — réponds naturellement
|
||||||
|
- Cite les titres des notes pertinentes entre parenthèses à la fin si utile`
|
||||||
|
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getChatProvider(config)
|
||||||
|
const raw = await provider.generateText(prompt)
|
||||||
|
|
||||||
|
const answer = raw.trim()
|
||||||
|
if (answer === 'NO_RELEVANT_INFO' || answer.length < 10) {
|
||||||
|
return { answer: '', hasRelevantInfo: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { answer, hasRelevantInfo: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchOverviewService = new SearchOverviewService()
|
||||||
Reference in New Issue
Block a user