From a4238dc2041353d6724b8eef0b0eeda23320e864 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Fri, 19 Jun 2026 19:42:37 +0000 Subject: [PATCH] feat: AI Overview recherche + AI Writer inline streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- memento-note/app/(main)/settings/layout.tsx | 8 +- .../app/api/ai/search-overview/route.ts | 24 +++ memento-note/components/rich-text-editor.tsx | 183 +++++++++--------- memento-note/components/search-modal.tsx | 53 +++++ memento-note/components/sidebar.tsx | 110 ++++++++--- .../ai/services/search-overview.service.ts | 57 ++++++ 6 files changed, 311 insertions(+), 124 deletions(-) create mode 100644 memento-note/app/api/ai/search-overview/route.ts create mode 100644 memento-note/lib/ai/services/search-overview.service.ts diff --git a/memento-note/app/(main)/settings/layout.tsx b/memento-note/app/(main)/settings/layout.tsx index 2267f60..5ec76e2 100644 --- a/memento-note/app/(main)/settings/layout.tsx +++ b/memento-note/app/(main)/settings/layout.tsx @@ -2,12 +2,14 @@ import { Menu } from 'lucide-react' import { SettingsNav } from '@/components/settings' +import { useLanguage } from '@/lib/i18n' export default function SettingsLayout({ children, }: { children: React.ReactNode }) { + const { t } = useLanguage() return (
@@ -15,16 +17,16 @@ export default function SettingsLayout({

- Paramètres + {t('settings.title')}

- Configuration & Préférences + {t('settings.description')}

diff --git a/memento-note/app/api/ai/search-overview/route.ts b/memento-note/app/api/ai/search-overview/route.ts new file mode 100644 index 0000000..318aa1d --- /dev/null +++ b/memento-note/app/api/ai/search-overview/route.ts @@ -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 }) + } +} diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index e0b8356..78167bb 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -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(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(/

\s*<\/p>/g, '') + .replace(/(]*>)\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 ? ( +

e.stopPropagation()} + dir="auto" + > +
+ + 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 && } +
+ {!aiWriterLoading && aiWriterPrompt.trim() && ( + + )} +
+ ) : (
+ )} {showPreview && (
)} - - {aiWriterOpen && createPortal( -
{ - 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" - > - - 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 && } - {!aiWriterLoading && aiWriterPrompt.trim() && ( - - )} -
, - document.body - )} , document.body ) diff --git a/memento-note/components/search-modal.tsx b/memento-note/components/search-modal.tsx index c10028b..da4a692 100644 --- a/memento-note/components/search-modal.tsx +++ b/memento-note/components/search-modal.tsx @@ -12,6 +12,8 @@ import { HelpCircle, Command, FileText, + Sparkles, + Loader2, } from 'lucide-react' import { useRouter } from 'next/navigation' import { useNotebooks } from '@/context/notebooks-context' @@ -64,6 +66,8 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) { const [savedQueries, setSavedQueries] = useState([]) const [results, setResults] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [overview, setOverview] = useState(null) + const [overviewLoading, setOverviewLoading] = useState(false) const [selectedIndex, setSelectedIndex] = useState(0) const inputRef = useRef(null) @@ -137,6 +141,36 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) { } }, [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 useEffect(() => { setSelectedIndex(0) @@ -501,6 +535,25 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) { {/* Left — results list */}
+ {/* AI Overview */} + {(overviewLoading || overview) && ( +
+
+ + + {overviewLoading ? 'Analyse...' : 'Réponse IA'} + +
+ {overviewLoading ? ( +
+ + Synthèse des résultats... +
+ ) : ( +

{overview}

+ )} +
+ )} {filteredMatches.map((m, idx) => { const isSelected = idx === selectedIndex return ( diff --git a/memento-note/components/sidebar.tsx b/memento-note/components/sidebar.tsx index 22ea3e4..234df14 100644 --- a/memento-note/components/sidebar.tsx +++ b/memento-note/components/sidebar.tsx @@ -632,6 +632,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) const [trashCount, setTrashCount] = useState(0) const [draggedId, setDraggedId] = useState(null) + // Ref stable pour éviter les stale closures dans les handlers de drop + const draggedIdRef = useRef(null) const [orderedNotebooks, setOrderedNotebooks] = useState([]) const dragOverId = useRef(null) const isSavingRef = useRef(false) @@ -852,61 +854,85 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) // ── Drag state ── const [dropTarget, setDropTarget] = useState(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) e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', notebookId) - } + }, []) - const handleDragEnd = () => { + const handleDragEnd = useCallback(() => { + draggedIdRef.current = null setDraggedId(null) dragOverId.current = null isSavingRef.current = false setDropTarget(null) setDropAction(null) 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.stopPropagation() + const dragId = draggedIdRef.current || e.dataTransfer.getData('text/plain') + draggedIdRef.current = null + setDraggedId(null) setDropTarget(null) setDropAction(null) - const dragId = draggedId || e.dataTransfer.getData('text/plain') - if (!dragId || dragId === targetId) { - setDraggedId(null) - return - } - setDraggedId(null) dragOverId.current = null + if (!dragId || dragId === targetId) return 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) { toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed')) setOrderedNotebooks(sortedNotebooks) } - } + }, [moveNotebookToParent, updateNotebookOrderOptimistic, orderedNotebooks, sortedNotebooks, t]) - const handleDropToRoot = async (e: React.DragEvent) => { + const handleDropToRoot = useCallback(async (e: React.DragEvent) => { 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) setDropAction(null) - const dragId = draggedId || e.dataTransfer.getData('text/plain') - if (!dragId) { - setDraggedId(null) - return - } - setDraggedId(null) dragOverId.current = null + if (!dragId) return try { await moveNotebookToParent(dragId, null) } catch (err) { toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed')) setOrderedNotebooks(sortedNotebooks) } - } + }, [moveNotebookToParent, sortedNotebooks, t]) const sortLabels: Record = { newest: t('sidebar.sortNewest'), @@ -1033,8 +1059,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) e.stopPropagation() if (!draggedId || draggedId === notebook.id) return e.dataTransfer.dropEffect = 'move' + const rect = e.currentTarget.getBoundingClientRect() + const y = e.clientY - rect.top + const pct = y / rect.height setDropTarget(notebook.id) - setDropAction('into') + if (pct < 0.3) setDropAction('before') + else if (pct > 0.7) setDropAction('after') + else setDropAction('into') }} onDragLeave={(e) => { if (e.currentTarget.contains(e.relatedTarget as Node)) return @@ -1046,15 +1077,24 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) onDrop={(e) => { e.preventDefault() 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( - 'rounded-lg transition-colors', + 'relative rounded-lg transition-colors', dropTarget === notebook.id && dropAction === 'into' && draggedId && draggedId !== notebook.id && 'bg-brand-accent/10 ring-1 ring-brand-accent/30', isDragging && 'opacity-50' )} > + {/* Indicateur "déposer avant" */} + {dropTarget === notebook.id && dropAction === 'before' && draggedId && draggedId !== notebook.id && ( +
+ )} + {/* Indicateur "déposer après" */} + {dropTarget === notebook.id && dropAction === 'after' && draggedId && draggedId !== notebook.id && ( +
+ )} + {/* Zone de dépôt racine — toujours présente pour éviter tout décalage DOM */} +
{ e.preventDefault(); e.stopPropagation() }} + onDragEnter={(e) => { e.preventDefault() }} + > + + {t('sidebar.dropToRoot')} +
+
e.preventDefault()} > {renderCarnetTree(undefined, 0)} - {draggedId && ( -
- {t('sidebar.dropToRoot')} -
- )}
diff --git a/memento-note/lib/ai/services/search-overview.service.ts b/memento-note/lib/ai/services/search-overview.service.ts new file mode 100644 index 0000000..f77dedd --- /dev/null +++ b/memento-note/lib/ai/services/search-overview.service.ts @@ -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 { + 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()