'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, ChevronUp, HelpCircle, Loader2, Network, Sparkles, Link2 } from 'lucide-react' import { cn } from '@/lib/utils' import { useLanguage } from '@/lib/i18n' import { useNotebooks } from '@/context/notebooks-context' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { NOTE_CHANGE_EVENT } from '@/lib/note-change-sync' import { openNoteInNewTab } from '@/lib/navigation/open-note' import { SEMANTIC_SIMILARITY_FLOOR, semanticOrbitRadius, semanticProximityPercent, semanticProximityRatio, } from '@/lib/ai/semantic-proximity' interface NetworkNote { id: string title: string | null notebookId: string | null } interface NetworkLink { id: string note: NetworkNote contextSnippet: string | null } interface UnlinkedMention { title: string snippet: string } interface SemanticConnection { noteId: string title: string | null notebookId: string | null similarity: number excerpt: string } interface EmbedHost { note: NetworkNote blockIds: string[] } type OrbitRelationship = 'backlink' | 'outbound' | 'mention' | 'semantic' | 'embed' interface OrbitNode { key: string id?: string title: string color: string notebookName: string relationship: OrbitRelationship snippet?: string | null similarity?: number } interface NoteNetworkTabProps { noteId: string noteTitle: string } const MAX_GRAPH_NODES = 10 const MAX_LIST_ITEMS = 5 const DEFAULT_COLOR = '#71717A' const SEMANTIC_COLOR = '#7C3AED' const CX = 160 const CY = 110 function orbitRadius(relationship: OrbitRelationship): number { switch (relationship) { case 'outbound': return 52 case 'backlink': return 68 case 'embed': return 78 case 'semantic': return 88 default: return 94 } } function cleanSnippet(text: string, max = 140): string { const plain = text .replace(/<[^>]+>/g, ' ') .replace(/\|+/g, ' ') .replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1') .replace(/\s+/g, ' ') .trim() if (!plain) return '' if (plain.length <= max) return plain return `${plain.slice(0, max).trim()}…` } function SectionHelp({ label, help }: { label: string; help: string }) { return (

{label}

{help}

) } function InteractiveOrbitGraph({ nodes, extraCount, centerTitle, t, relationshipLabel, similarityFloor, }: { nodes: OrbitNode[] extraCount: number centerTitle: string t: (key: string, params?: Record) => string relationshipLabel: (rel: OrbitRelationship) => string similarityFloor: number }) { const svgRef = useRef(null) const [offsets, setOffsets] = useState>({}) const [activeKey, setActiveKey] = useState(null) const [hoveredKey, setHoveredKey] = useState(null) const dragRef = useRef<{ key: string pointerId: number startX: number startY: number baseDx: number baseDy: number moved: boolean } | null>(null) const basePositions = useMemo(() => { return nodes.map((node, i) => { const angle = i * (nodes.length > 0 ? (2 * Math.PI) / nodes.length : 0) - Math.PI / 2 const r = node.relationship === 'semantic' && node.similarity != null ? semanticOrbitRadius(node.similarity, similarityFloor) : orbitRadius(node.relationship) return { key: node.key, x: CX + r * Math.cos(angle), y: CY + r * 0.88 * Math.sin(angle), } }) }, [nodes, similarityFloor]) const nodePosition = useCallback((key: string, baseX: number, baseY: number) => { const o = offsets[key] || { dx: 0, dy: 0 } return { x: baseX + o.dx, y: baseY + o.dy } }, [offsets]) const clientToSvg = useCallback((clientX: number, clientY: number) => { const svg = svgRef.current if (!svg) return { x: 0, y: 0 } const pt = svg.createSVGPoint() pt.x = clientX pt.y = clientY const ctm = svg.getScreenCTM() if (!ctm) return { x: 0, y: 0 } return pt.matrixTransform(ctm.inverse()) }, []) const endDrag = useCallback((pointerId: number) => { const d = dragRef.current if (!d || d.pointerId !== pointerId) return dragRef.current = null setActiveKey(null) }, []) const startDrag = useCallback(( nodeKey: string, pointerId: number, clientX: number, clientY: number, baseDx: number, baseDy: number, ) => { const pt = clientToSvg(clientX, clientY) dragRef.current = { key: nodeKey, pointerId, startX: pt.x, startY: pt.y, baseDx, baseDy, moved: false, } setActiveKey(nodeKey) }, [clientToSvg]) useEffect(() => { const onMove = (e: PointerEvent) => { const d = dragRef.current if (!d || e.pointerId !== d.pointerId) return e.preventDefault() const pt = clientToSvg(e.clientX, e.clientY) const dx = d.baseDx + (pt.x - d.startX) const dy = d.baseDy + (pt.y - d.startY) if (!d.moved && Math.hypot(pt.x - d.startX, pt.y - d.startY) > 4) { d.moved = true } setOffsets(prev => ({ ...prev, [d.key]: { dx, dy } })) } const onUp = (e: PointerEvent) => { const d = dragRef.current if (!d || e.pointerId !== d.pointerId) return const moved = d.moved const node = nodes.find(n => n.key === d.key) endDrag(e.pointerId) if (!moved && node?.id) { openNoteInNewTab(node.id) } } window.addEventListener('pointermove', onMove, { passive: false }) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onUp) return () => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onUp) } }, [clientToSvg, endDrag, nodes]) const hoveredNode = nodes.find(n => n.key === hoveredKey) || null return (
{basePositions.map((base, i) => { const node = nodes[i] const { x, y } = nodePosition(node.key, base.x, base.y) const isSemantic = node.relationship === 'semantic' const isMention = node.relationship === 'mention' const stroke = isSemantic ? SEMANTIC_COLOR : isMention ? '#94A3B8' : '#A47148' const proximity = isSemantic && node.similarity != null ? semanticProximityRatio(node.similarity, similarityFloor) : null return ( ) })} {basePositions.map((base, i) => { const node = nodes[i] const { x, y } = nodePosition(node.key, base.x, base.y) const isActive = activeKey === node.key || hoveredKey === node.key const canOpen = !!node.id return ( setHoveredKey(node.key)} onPointerLeave={() => { if (activeKey !== node.key) setHoveredKey(null) }} onPointerDown={(e) => { if (!canOpen) return e.preventDefault() e.stopPropagation() const o = offsets[node.key] || { dx: 0, dy: 0 } startDrag(node.key, e.pointerId, e.clientX, e.clientY, o.dx, o.dy) e.currentTarget.setPointerCapture(e.pointerId) }} /> {node.title.length > 13 ? `${node.title.slice(0, 12)}…` : node.title} ) })} {extraCount > 0 && (

{t('documentInfo.network.moreNodes', { count: extraCount })}

)}
{hoveredNode ? (
{hoveredNode.notebookName} {relationshipLabel(hoveredNode.relationship)}

{hoveredNode.title}

{hoveredNode.similarity != null && (

{t('documentInfo.network.affinityLine', { percentage: semanticProximityPercent(hoveredNode.similarity, similarityFloor), })}

)} {hoveredNode.snippet && (

{cleanSnippet(hoveredNode.snippet)}

)} {hoveredNode.id && (

{t('documentInfo.network.clickToOpen')}

)}
) : (

{t('documentInfo.network.dragHint')}

)}
{t('documentInfo.network.legendCenter')} {t('documentInfo.network.legendSemantic')} {t('documentInfo.network.legendWiki')}
) } export function NoteNetworkTab({ noteId, noteTitle }: NoteNetworkTabProps) { const { t } = useLanguage() const { notebooks } = useNotebooks() const [loading, setLoading] = useState(true) const [backlinks, setBacklinks] = useState([]) const [outbound, setOutbound] = useState([]) const [unlinkedMentions, setUnlinkedMentions] = useState([]) const [semanticConnections, setSemanticConnections] = useState([]) const [embedHosts, setEmbedHosts] = useState([]) const [consentRequired, setConsentRequired] = useState(false) const [similarityFloor, setSimilarityFloor] = useState(SEMANTIC_SIMILARITY_FLOOR) const [helpOpen, setHelpOpen] = useState(false) const [showAllSemantic, setShowAllSemantic] = useState(false) const [showWiki, setShowWiki] = useState(true) const [refreshKey, setRefreshKey] = useState(0) const loadNetwork = useCallback(() => { if (!noteId) return setLoading(true) fetch(`/api/notes/${noteId}/network`) .then(r => r.json()) .then(data => { setBacklinks(data.backlinks || []) setOutbound(data.outbound || []) setUnlinkedMentions(data.unlinkedMentions || []) setSemanticConnections(data.semanticConnections || []) setEmbedHosts(data.embedHosts || []) setConsentRequired(!!data.consentRequired) setSimilarityFloor(typeof data.similarityFloor === 'number' ? data.similarityFloor : SEMANTIC_SIMILARITY_FLOOR) }) .catch(() => { setBacklinks([]) setOutbound([]) setUnlinkedMentions([]) setSemanticConnections([]) setEmbedHosts([]) }) .finally(() => setLoading(false)) }, [noteId]) useEffect(() => { loadNetwork() }, [loadNetwork, refreshKey]) useEffect(() => { const onNoteChange = (event: Event) => { const detail = (event as CustomEvent).detail if (detail?.type === 'updated' && detail.note?.id === noteId) { setRefreshKey(k => k + 1) } } window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange) return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange) }, [noteId]) const colorForNotebook = (notebookId: string | null) => notebooks.find(n => n.id === notebookId)?.color || DEFAULT_COLOR const notebookNameFor = (notebookId: string | null) => notebooks.find(n => n.id === notebookId)?.name || t('documentInfo.network.unknownNotebook') const sortedSemantic = useMemo( () => [...semanticConnections].sort((a, b) => b.similarity - a.similarity), [semanticConnections] ) const graphNodes = useMemo(() => { const nodes: OrbitNode[] = [] const seen = new Set() const push = (node: OrbitNode) => { if (node.id) { if (seen.has(node.id)) return seen.add(node.id) } nodes.push(node) } outbound.forEach(link => { if (!link.note) return push({ key: `out-${link.id}`, id: link.note.id, title: link.note.title || t('documentInfo.network.untitled'), color: colorForNotebook(link.note.notebookId), notebookName: notebookNameFor(link.note.notebookId), relationship: 'outbound', snippet: link.contextSnippet, }) }) backlinks.forEach(link => { if (!link.note) return push({ key: `in-${link.id}`, id: link.note.id, title: link.note.title || t('documentInfo.network.untitled'), color: colorForNotebook(link.note.notebookId), notebookName: notebookNameFor(link.note.notebookId), relationship: 'backlink', snippet: link.contextSnippet, }) }) sortedSemantic.forEach(conn => { push({ key: `sem-${conn.noteId}`, id: conn.noteId, title: conn.title || t('documentInfo.network.untitled'), color: SEMANTIC_COLOR, notebookName: notebookNameFor(conn.notebookId), relationship: 'semantic', snippet: conn.excerpt, similarity: conn.similarity, }) }) embedHosts.forEach((host, i) => { push({ key: `embed-${i}-${host.note.id}`, id: host.note.id, title: host.note.title || t('documentInfo.network.untitled'), color: colorForNotebook(host.note.notebookId), notebookName: notebookNameFor(host.note.notebookId), relationship: 'embed', snippet: t('documentInfo.network.embedSnippetOne'), }) }) return nodes }, [sortedSemantic, outbound, backlinks, embedHosts, notebooks, t]) const orbitNodes = graphNodes.slice(0, MAX_GRAPH_NODES) const extraCount = Math.max(0, graphNodes.length - MAX_GRAPH_NODES) const relationshipLabel = (rel: OrbitRelationship) => { switch (rel) { case 'backlink': return t('documentInfo.network.inboundShort') case 'outbound': return t('documentInfo.network.outboundShort') case 'semantic': return t('documentInfo.network.semanticShort') case 'embed': return t('documentInfo.network.embedShort') default: return t('documentInfo.network.mentionShort') } } const hasWiki = backlinks.length > 0 || outbound.length > 0 || unlinkedMentions.length > 0 const visibleSemantic = showAllSemantic ? sortedSemantic : sortedSemantic.slice(0, MAX_LIST_ITEMS) if (loading) { return (

{t('documentInfo.loading')}

) } const isEmpty = !hasWiki && sortedSemantic.length === 0 && embedHosts.length === 0 return (

{t('documentInfo.network.graphTitle')}

{t('documentInfo.network.intro')}

{helpOpen && (

{t('documentInfo.network.helpTitle')}

{t('documentInfo.network.helpGraph')}

{t('documentInfo.network.helpSemantic')}

{t('documentInfo.network.helpWiki')}

{t('documentInfo.network.helpEmbed')}

)}
{isEmpty ? (

{t('documentInfo.network.empty')}

{consentRequired && (

{t('documentInfo.network.consentHint')}

)}
) : ( <> {orbitNodes.length > 0 && ( )} {sortedSemantic.length > 0 && (
{t('documentInfo.network.semanticListTitle')}
{sortedSemantic.length}
{visibleSemantic.map(conn => ( ))}
{sortedSemantic.length > MAX_LIST_ITEMS && ( )}
)} {embedHosts.length > 0 && (
{t('documentInfo.network.embedListTitle')}
{embedHosts.map(host => ( ))}
)} {hasWiki && (
{showWiki && (
{backlinks.length > 0 && ( )} {outbound.length > 0 && ( )} {unlinkedMentions.length > 0 && (

{t('documentInfo.network.unlinkedList', { count: unlinkedMentions.length })}

{unlinkedMentions.map((m, i) => (
[[{m.title}]] {m.snippet && cleanSnippet(m.snippet) && ( {cleanSnippet(m.snippet, 100)} )}
))}
)} {backlinks.length === 0 && outbound.length === 0 && unlinkedMentions.length === 0 && (

{t('documentInfo.network.noWikiYet')}

)}
)}
)} )}
) } function WikiList({ title, help, links, onOpen, untitled, }: { title: string help: string links: NetworkLink[] onOpen: (id: string) => void untitled: string }) { return (

{title}

{links.map(link => ( ))}
) }