feat: AI Overview recherche + AI Writer inline streaming
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m6s
CI / Deploy production (on server) (push) Successful in 2m2s

- 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:
Antigravity
2026-06-19 19:42:37 +00:00
parent 4750686b9f
commit a4238dc204
6 changed files with 311 additions and 124 deletions

View File

@@ -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>

View 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 })
}
}

View File

@@ -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
) )

View File

@@ -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 (

View File

@@ -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>

View 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()