'use client' import { useState, useEffect, useCallback, type ReactNode } from 'react' import { ChevronDown, ChevronUp, Sparkles, Link2, X, Loader2, HelpCircle } from 'lucide-react' import { LinkedNotePreviewDialog } from '@/components/linked-note-preview-dialog' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { useLanguage } from '@/lib/i18n/LanguageProvider' import type { BlockSuggestion } from '@/components/block-picker' import { toast } from 'sonner' import { useNoteEditorContext } from '@/components/note-editor/note-editor-context' import { stripHtmlToPlainText } from '@/lib/text/plain-text' import { detectTextDirection } from '@/lib/clip/rtl-content' import { SEMANTIC_SIMILARITY_FLOOR_CLIP } from '@/lib/ai/semantic-proximity' interface ConnectionData { noteId: string title: string | null content: string createdAt: Date similarity: number daysApart: number } interface LiveBlockRefHost { targetNoteId: string targetNoteTitle: string notebookName: string blockIds: string[] createdAt: string } interface MemoryEchoSectionProps { noteId: string onCompareNotes?: (noteIds: string[], meta?: { similarity?: number }) => void onMergeNotes?: (noteIds: string[]) => void } interface PreviewTarget { noteId: string title: string | null excerpt: string } function excerpt(text: string, max = 150): string { const plain = stripHtmlToPlainText(text) if (plain.length <= max) return plain return `${plain.slice(0, max).trim()}…` } function isRtlText(text: string): boolean { return detectTextDirection(text) === 'rtl' } async function resolveBlockForEmbed(sourceNoteId: string, hint: string): Promise<{ block: BlockSuggestion; mode: 'live' | 'citation' } | null> { const params = new URLSearchParams({ noteId: sourceNoteId, hint }) const res = await fetch(`/api/blocks/resolve?${params}`) if (!res.ok) return null const data = await res.json() if (!data.block) return null return { block: data.block as BlockSuggestion, mode: data.mode === 'live' ? 'live' : 'citation' } } function dispatchLiveBlockInsert(block: BlockSuggestion) { window.dispatchEvent(new CustomEvent('memento-insert-live-block', { detail: { block } })) } function dispatchCitationInsert(payload: { noteId: string; noteTitle: string; excerpt: string }) { window.dispatchEvent(new CustomEvent('memento-insert-citation', { detail: { ...payload, atEnd: true } })) } function ActionWithHelp({ label, help, children, }: { label: string help: string children: ReactNode }) { return ( {children}

{label}

{help}

) } export function MemoryEchoSection({ noteId, onCompareNotes, onMergeNotes, }: MemoryEchoSectionProps) { const { t } = useLanguage() const editorCtx = useNoteEditorContext() const [connections, setConnections] = useState([]) const [retroRefs, setRetroRefs] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isExpanded, setIsExpanded] = useState(false) const [isVisible, setIsVisible] = useState(true) const [consentRequired, setConsentRequired] = useState(false) const [embeddingId, setEmbeddingId] = useState(null) const [helpOpen, setHelpOpen] = useState(false) const [previewTarget, setPreviewTarget] = useState(null) useEffect(() => { let cancelled = false setIsLoading(true) setConsentRequired(false) setConnections([]) setRetroRefs([]) const load = async () => { try { const [connRes, retroRes] = await Promise.all([ fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=10`), fetch(`/api/notes/${noteId}/live-block-refs`), ]) if (cancelled) return if (connRes.status === 403) { setConsentRequired(true) setConnections([]) } else if (connRes.ok) { const data = await connRes.json() setConnections(data.connections || []) } else { setConnections([]) } if (retroRes.ok) { const data = await retroRes.json() setRetroRefs(data.refs || []) } else { setRetroRefs([]) } } catch (error) { console.error('[MemoryEchoSection] Failed to fetch:', error) } finally { if (!cancelled) setIsLoading(false) } } // Lazy load léger : ne bloque pas l'ouverture de la note const timer = window.setTimeout(() => { void load() }, 400) return () => { cancelled = true window.clearTimeout(timer) } }, [noteId]) // Scroll doux vers la section quand une forte connexion apparaît (une fois par note) useEffect(() => { if (isLoading || connections.length === 0) return const top = connections[0] if (!top || top.similarity < SEMANTIC_SIMILARITY_FLOOR_CLIP) return const key = `memory-echo-scroll-${noteId}` if (sessionStorage.getItem(key)) return sessionStorage.setItem(key, '1') requestAnimationFrame(() => { document.getElementById('memory-echo-section')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }) }, [isLoading, connections, noteId]) const handleEmbed = useCallback(async (conn: ConnectionData) => { setEmbeddingId(conn.noteId) try { const hint = (conn.content || '').trim().slice(0, 12000) const resolved = await resolveBlockForEmbed(conn.noteId, hint) const noteTitle = conn.title || t('memoryEcho.comparison.untitled') if (resolved?.mode === 'live' && resolved.block.blockId) { const block = resolved.block if (noteId) { try { await fetch('/api/blocks/embed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceNoteId: block.noteId, blockId: block.blockId, targetNoteId: noteId, }), }) } catch { // non bloquant } } const insertedLive = editorCtx.richTextEditorRef.current?.insertLiveBlock(block, { atEnd: true }) if (!insertedLive) { dispatchLiveBlockInsert(block) } if (insertedLive || editorCtx.richTextEditorRef.current?.getEditor()) { toast.success(t('memoryEcho.editorSection.embedSuccess')) } else { toast.error(t('memoryEcho.editorSection.embedFailed')) } return } const citationText = stripHtmlToPlainText( resolved?.block.content || conn.content || hint ).slice(0, 1200) if (!citationText.trim()) { toast.error(t('memoryEcho.editorSection.embedFailed')) return } const payload = { noteId: conn.noteId, noteTitle, excerpt: citationText } if (editorCtx.state.isMarkdown) { const quoted = citationText.split('\n').map(line => `> ${line}`).join('\n') const mdCitation = isRtlText(citationText) ? `\n\n
\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n\n
\n` : `\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n` editorCtx.actions.setContent(editorCtx.state.content + mdCitation) toast.success(t('memoryEcho.editorSection.citationSuccess')) return } const inserted = editorCtx.richTextEditorRef.current?.insertCitation(payload, { atEnd: true }) if (inserted) { toast.success(t('memoryEcho.editorSection.citationSuccess')) return } dispatchCitationInsert(payload) toast.success(t('memoryEcho.editorSection.citationSuccess')) } catch { toast.error(t('memoryEcho.editorSection.embedFailed')) } finally { setEmbeddingId(null) } }, [t, noteId, editorCtx]) if (!isVisible) return null const hasConnections = connections.length > 0 const hasRetro = retroRefs.length > 0 if (!isLoading && !hasConnections && !hasRetro && !consentRequired) { return null } const topConnection = connections[0] const restConnections = connections.slice(1) return (
{isLoading && (
{t('memoryEcho.editorSection.badgeLabel')}
{t('memoryEcho.editorSection.loading')}
)} {!isLoading && consentRequired && (
{t('memoryEcho.editorSection.consentRequired')}
)} {!isLoading && hasConnections && topConnection && (
{t('memoryEcho.editorSection.badgeLabel')}
{t('memoryEcho.editorSection.affinityBadge', { percentage: Math.round(topConnection.similarity * 100), })}

{t('memoryEcho.editorSection.intro')}

{helpOpen && (

{t('memoryEcho.editorSection.helpTitle')}

  • {t('memoryEcho.editorSection.viewLinkedNote')} — {t('memoryEcho.editorSection.helpView')}
  • {t('memoryEcho.editorSection.embedPassage')} — {t('memoryEcho.editorSection.helpCite')}
  • {t('memoryEcho.editorSection.compare')} — {t('memoryEcho.editorSection.helpCompare')}
  • {t('memoryEcho.editorSection.merge')} — {t('memoryEcho.editorSection.helpMerge')}
)}
« {excerpt(topConnection.content)} »
{t('memoryEcho.editorSection.detectedIn', { title: topConnection.title || t('memoryEcho.comparison.untitled'), })}
{onCompareNotes && ( )} {onMergeNotes && ( )}
{restConnections.length > 0 && (
{isExpanded && (
{restConnections.map(conn => (

{conn.title || t('memoryEcho.comparison.untitled')}

{Math.round(conn.similarity * 100)}%

« {excerpt(conn.content, 120)} »

))}
)}
)}
)} {!isLoading && hasRetro && (
{t('memoryEcho.editorSection.retroTitle')}

{t('memoryEcho.editorSection.retroDescription', { count: retroRefs.length })}

{retroRefs.map(ref => ( ))}
)} {previewTarget && ( setPreviewTarget(null)} noteId={previewTarget.noteId} initialTitle={previewTarget.title} initialExcerpt={previewTarget.excerpt} citationLoading={embeddingId === previewTarget.noteId} onInsertCitation={() => { void handleEmbed({ noteId: previewTarget.noteId, title: previewTarget.title, content: previewTarget.excerpt || connections.find(c => c.noteId === previewTarget.noteId)?.content || '', createdAt: new Date(), similarity: connections.find(c => c.noteId === previewTarget.noteId)?.similarity ?? 0, daysApart: 0, }) }} onCompare={ onCompareNotes ? () => onCompareNotes([noteId, previewTarget.noteId], { similarity: connections.find(c => c.noteId === previewTarget.noteId)?.similarity, }) : undefined } onMerge={ onMergeNotes ? () => onMergeNotes([noteId, previewTarget.noteId]) : undefined } /> )}
) } /** @deprecated Use MemoryEchoSection */ export { MemoryEchoSection as EditorConnectionsSection }