Files
Momento/memento-note/components/memory-echo-section.tsx
Antigravity e881004c77
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped
feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
2026-05-24 18:57:33 +00:00

559 lines
22 KiB
TypeScript

'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 (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-left">
<p className="font-medium mb-0.5">{label}</p>
<p className="text-background/80">{help}</p>
</TooltipContent>
</Tooltip>
)
}
export function MemoryEchoSection({
noteId,
onCompareNotes,
onMergeNotes,
}: MemoryEchoSectionProps) {
const { t } = useLanguage()
const editorCtx = useNoteEditorContext()
const [connections, setConnections] = useState<ConnectionData[]>([])
const [retroRefs, setRetroRefs] = useState<LiveBlockRefHost[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isExpanded, setIsExpanded] = useState(false)
const [isVisible, setIsVisible] = useState(true)
const [consentRequired, setConsentRequired] = useState(false)
const [embeddingId, setEmbeddingId] = useState<string | null>(null)
const [helpOpen, setHelpOpen] = useState(false)
const [previewTarget, setPreviewTarget] = useState<PreviewTarget | null>(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<div dir="rtl" lang="fa">\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n\n</div>\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 (
<div id="memory-echo-section" className="mt-10 space-y-6 scroll-mt-24">
{isLoading && (
<div
className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-5 animate-pulse space-y-3"
aria-busy="true"
>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-indigo-400" />
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-indigo-500/60">
{t('memoryEcho.editorSection.badgeLabel')}
</span>
</div>
<div className="h-4 bg-indigo-500/10 rounded w-3/4" />
<div className="h-16 bg-indigo-500/5 rounded border-l-2 border-indigo-500/20" />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('memoryEcho.editorSection.loading')}
</div>
</div>
)}
{!isLoading && consentRequired && (
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/[0.04] p-4 text-sm text-muted-foreground">
{t('memoryEcho.editorSection.consentRequired')}
</div>
)}
{!isLoading && hasConnections && topConnection && (
<div className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Sparkles className="h-4 w-4 text-indigo-500 animate-pulse shrink-0" />
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-indigo-600/80 dark:text-indigo-400/80 truncate">
{t('memoryEcho.editorSection.badgeLabel')}
</span>
<button
type="button"
onClick={() => setHelpOpen(v => !v)}
className="p-0.5 rounded-md text-indigo-500/70 hover:text-indigo-600 hover:bg-indigo-500/10 transition-colors"
aria-expanded={helpOpen}
aria-label={t('memoryEcho.editorSection.helpToggle')}
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center gap-1">
<span className="text-[11px] font-mono font-semibold px-2 py-0.5 rounded-full bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 border border-indigo-500/15">
{t('memoryEcho.editorSection.affinityBadge', {
percentage: Math.round(topConnection.similarity * 100),
})}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => setIsVisible(false)}
title={t('memoryEcho.editorSection.close')}
>
<X className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground hidden sm:block">
{t('memoryEcho.editorSection.intro')}
</p>
{helpOpen && (
<div className="rounded-xl border border-indigo-500/15 bg-indigo-500/[0.04] p-4 space-y-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground">{t('memoryEcho.editorSection.helpTitle')}</p>
<ul className="space-y-2.5 list-none pl-0">
<li><strong className="text-foreground">{t('memoryEcho.editorSection.viewLinkedNote')}</strong> {t('memoryEcho.editorSection.helpView')}</li>
<li><strong className="text-foreground">{t('memoryEcho.editorSection.embedPassage')}</strong> {t('memoryEcho.editorSection.helpCite')}</li>
<li><strong className="text-foreground">{t('memoryEcho.editorSection.compare')}</strong> {t('memoryEcho.editorSection.helpCompare')}</li>
<li><strong className="text-foreground">{t('memoryEcho.editorSection.merge')}</strong> {t('memoryEcho.editorSection.helpMerge')}</li>
</ul>
</div>
)}
<blockquote
className={cn(
'border-indigo-500/20 pl-3 font-serif italic text-sm leading-relaxed text-foreground/85',
isRtlText(topConnection.content) ? 'border-r-2 border-l-0 pr-3 pl-0 text-right' : 'border-l-2',
)}
dir={isRtlText(topConnection.content) ? 'rtl' : undefined}
lang={isRtlText(topConnection.content) ? 'fa' : undefined}
>
« {excerpt(topConnection.content)} »
</blockquote>
<div className="flex items-center justify-between gap-2 pt-1 border-t border-indigo-500/10 flex-wrap text-[11px]">
<span className="text-muted-foreground min-w-0 truncate">
{t('memoryEcho.editorSection.detectedIn', {
title: topConnection.title || t('memoryEcho.comparison.untitled'),
})}
</span>
<div className="flex items-center gap-3 shrink-0">
<ActionWithHelp
label={t('memoryEcho.editorSection.viewLinkedNote')}
help={t('memoryEcho.editorSection.helpView')}
>
<button
type="button"
className="font-semibold text-muted-foreground hover:text-foreground hover:underline transition-colors"
onClick={() => setPreviewTarget({
noteId: topConnection.noteId,
title: topConnection.title,
excerpt: excerpt(topConnection.content, 500),
})}
>
{t('memoryEcho.editorSection.view')}
</button>
</ActionWithHelp>
<ActionWithHelp
label={t('memoryEcho.editorSection.embedPassage')}
help={t('memoryEcho.editorSection.helpCite')}
>
<button
type="button"
disabled={embeddingId === topConnection.noteId}
className="inline-flex items-center gap-1 font-bold text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
onClick={() => void handleEmbed(topConnection)}
>
<Link2 className="h-3 w-3" />
{embeddingId === topConnection.noteId
? t('memoryEcho.editorSection.embedding')
: t('memoryEcho.editorSection.embedPassage')}
</button>
</ActionWithHelp>
{onCompareNotes && (
<ActionWithHelp
label={t('memoryEcho.editorSection.compare')}
help={t('memoryEcho.editorSection.helpCompare')}
>
<button
type="button"
className="font-semibold text-muted-foreground hover:text-foreground hover:underline transition-colors"
onClick={() => onCompareNotes([noteId, topConnection.noteId], { similarity: topConnection.similarity })}
>
{t('memoryEcho.editorSection.compare')}
</button>
</ActionWithHelp>
)}
{onMergeNotes && (
<ActionWithHelp
label={t('memoryEcho.editorSection.merge')}
help={t('memoryEcho.editorSection.helpMerge')}
>
<button
type="button"
className="font-semibold text-purple-600 dark:text-purple-400 hover:underline transition-colors"
onClick={() => onMergeNotes([noteId, topConnection.noteId])}
>
{t('memoryEcho.editorSection.merge')}
</button>
</ActionWithHelp>
)}
</div>
</div>
{restConnections.length > 0 && (
<div className="pt-2 border-t border-indigo-500/10">
<button
type="button"
className="flex items-center gap-1.5 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={() => setIsExpanded(v => !v)}
>
{isExpanded
? t('memoryEcho.editorSection.hideAll', { count: connections.length })
: t('memoryEcho.editorSection.showAll', { count: connections.length })}
{isExpanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
{isExpanded && (
<div className="mt-3 space-y-2 max-h-[280px] overflow-y-auto">
{restConnections.map(conn => (
<div
key={conn.noteId}
className="rounded-xl border border-border/60 p-3 bg-background/60 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium flex-1">
{conn.title || t('memoryEcho.comparison.untitled')}
</h4>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-indigo-500/10 text-indigo-700 dark:text-indigo-300">
{Math.round(conn.similarity * 100)}%
</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 font-serif italic">
« {excerpt(conn.content, 120)} »
</p>
<div className="flex items-center gap-3 text-[11px]">
<button
type="button"
className="font-semibold text-muted-foreground hover:text-foreground hover:underline"
onClick={() => setPreviewTarget({
noteId: conn.noteId,
title: conn.title,
excerpt: excerpt(conn.content, 500),
})}
>
{t('memoryEcho.editorSection.view')}
</button>
<button
type="button"
disabled={embeddingId === conn.noteId}
className="inline-flex items-center gap-1 font-bold text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
onClick={() => void handleEmbed(conn)}
>
<Link2 className="h-3 w-3" />
{t('memoryEcho.editorSection.embedPassage')}
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{!isLoading && hasRetro && (
<div className="rounded-2xl border border-blue-500/10 bg-blue-500/[0.02] p-5 space-y-3">
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-60" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[10px] font-bold uppercase tracking-[0.22em] text-blue-600/80 dark:text-blue-400/80">
{t('memoryEcho.editorSection.retroTitle')}
</span>
</div>
<p className="text-sm text-muted-foreground">
{t('memoryEcho.editorSection.retroDescription', { count: retroRefs.length })}
</p>
<div className="space-y-2">
{retroRefs.map(ref => (
<button
key={ref.targetNoteId}
type="button"
onClick={() => setPreviewTarget({
noteId: ref.targetNoteId,
title: ref.targetNoteTitle,
excerpt: '',
})}
className={cn(
'w-full text-left rounded-xl border border-border/50 p-3',
'hover:bg-muted/40 transition-colors flex items-center gap-3'
)}
>
<Link2 className="h-4 w-4 text-blue-500 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{ref.targetNoteTitle || t('memoryEcho.comparison.untitled')}
</p>
{ref.notebookName && (
<p className="text-[11px] text-muted-foreground truncate">{ref.notebookName}</p>
)}
</div>
</button>
))}
</div>
</div>
)}
{previewTarget && (
<LinkedNotePreviewDialog
isOpen
onClose={() => 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
}
/>
)}
</div>
)
}
/** @deprecated Use MemoryEchoSection */
export { MemoryEchoSection as EditorConnectionsSection }