feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-05-24 18:57:33 +00:00
parent e2672cd2c2
commit e881004c77
63 changed files with 5729 additions and 563 deletions

View File

@@ -1,10 +1,23 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { NetworkGraph } from '@/components/network-graph'
import { useRouter } from 'next/navigation'
import { motion } from 'motion/react'
import { Sparkles, RefreshCw, Layers, Trophy, Zap, Lightbulb } from 'lucide-react'
import { motion, AnimatePresence } from 'motion/react'
import {
Sparkles,
RefreshCw,
Layers,
Trophy,
Zap,
Lightbulb,
Sliders,
CheckCircle2,
Clock,
AlertCircle,
ChevronRight,
Database,
} from 'lucide-react'
interface Note {
id: string
@@ -53,21 +66,51 @@ export default function InsightsPage() {
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
const [loading, setLoading] = useState(true)
const [isCalculating, setIsCalculating] = useState(false)
const [isReindexing, setIsReindexing] = useState(false)
const [embeddingStats, setEmbeddingStats] = useState<{ indexed: number; total: number } | null>(null)
const [isStale, setIsStale] = useState(false)
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
const [lastSyncTime, setLastSyncTime] = useState<string>('')
useEffect(() => {
loadInitialData()
}, [])
// ─── Données calculées ───────────────────────────────────────────────────────
const selectedCluster = useMemo(
() => clusters.find(c => c.id === selectedClusterId) ?? null,
[clusters, selectedClusterId]
)
const selectedClusterNotes = useMemo(
() => (selectedCluster ? notes.filter(n => selectedCluster.noteIds.includes(n.id)) : []),
[notes, selectedCluster]
)
const isolatedClusters = useMemo(() => {
const networkedIds = new Set(
bridgeNotes.flatMap(b => b.clustersConnected.map(cid => String(cid)))
)
return clusters.filter(c => !networkedIds.has(c.id))
}, [clusters, bridgeNotes])
const bridgeList = useMemo(
() => bridgeNotes.map(b => ({ ...b, title: b.note?.title || 'Note sans titre' })),
[bridgeNotes]
)
// ─── Chargement initial ──────────────────────────────────────────────────────
const loadInitialData = async () => {
setLoading(true)
try {
// First, try to get cached clusters
const res = await fetch('/api/clusters')
if (res.ok) {
const data = await res.json()
// Check if we have clusters
if (data.clusters && data.clusters.length > 0) {
if (data.clusters?.length > 0) {
const clustersWithColors = data.clusters.map((c: Cluster, i: number) => ({
...c,
id: c.clusterId.toString(),
@@ -75,26 +118,35 @@ export default function InsightsPage() {
}))
setNotes(data.notes || [])
setClusters(clustersWithColors)
setIsStale(!!data.stale)
// Load bridge notes
const bridgeRes = await fetch('/api/bridge-notes?details=true')
if (bridgeRes.ok) {
const bridgeData = await bridgeRes.json()
setBridgeNotes(bridgeData.bridgeNotes || [])
// Bridge notes incluses dans la réponse GET /clusters (enrichies)
if (data.bridgeNotes?.length > 0) {
setBridgeNotes(data.bridgeNotes)
} else {
const bridgeRes = await fetch('/api/bridge-notes?details=true')
if (bridgeRes.ok) {
const bridgeData = await bridgeRes.json()
setBridgeNotes(bridgeData.bridgeNotes || [])
}
}
// Load suggestions
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json()
setSuggestions(suggestionsData.suggestions || [])
}
setLastSyncTime(
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') {
setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes })
}
} else {
// No clusters - trigger calculation if we have enough notes
if (data.totalNotes >= 10) {
await performAnalysis()
} else {
// Not enough notes - show empty state
setIsStale(false)
if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') {
setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes })
}
}
}
@@ -105,6 +157,29 @@ export default function InsightsPage() {
}
}
const handleReindexEmbeddings = async () => {
setIsReindexing(true)
try {
const res = await fetch('/api/notes/reindex', { method: 'POST' })
if (!res.ok) throw new Error('reindex failed')
const data = await res.json()
setEmbeddingStats(prev => ({
indexed: data.count ?? prev?.indexed ?? 0,
total: data.total ?? prev?.total ?? notes.length,
}))
setLastSyncTime(
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
setIsStale(true)
} catch (error) {
console.error('Error reindexing embeddings:', error)
} finally {
setIsReindexing(false)
}
}
// ─── Analyse (POST) ──────────────────────────────────────────────────────────
const performAnalysis = async () => {
setIsCalculating(true)
try {
@@ -124,13 +199,23 @@ export default function InsightsPage() {
setNotes(data.notes || [])
setClusters(clustersWithColors)
setBridgeNotes(data.bridgeNotes || [])
setIsStale(false)
// Load suggestions (they were generated during POST)
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json()
setSuggestions(suggestionsData.suggestions || [])
}
setLastSyncTime(
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
if (data.notes?.length) {
setEmbeddingStats(prev => ({
indexed: prev?.indexed ?? data.notes.length,
total: data.notes.length,
}))
}
}
} catch (error) {
console.error('Error running analysis:', error)
@@ -140,131 +225,420 @@ export default function InsightsPage() {
}
const handleNoteClick = (noteId: string) => {
router.push(`/home?note=${noteId}`)
router.push(`/home?openNote=${noteId}`)
}
const bridgeList = bridgeNotes.map(b => ({
...b,
title: b.note?.title || 'Unknown Note'
}))
// ─── Rendu ───────────────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col bg-paper dark:bg-[#0D0D0D] overflow-hidden">
{/* Header */}
<div className="p-8 border-b border-border/40 flex items-center justify-between backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-[#0D0D0D] overflow-hidden">
{/* ── Header ── */}
<div className="p-6 sm:p-8 border-b border-border/20 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sticky top-0 bg-[#F9F8F6]/80 dark:bg-[#0D0D0D]/80 backdrop-blur-md z-30 shrink-0">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center text-indigo-500">
<div className="w-8 h-8 rounded-lg bg-ochre/10 flex items-center justify-center text-ochre">
<Sparkles size={18} />
</div>
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Semantic Insights</h1>
<h1 className="text-xl sm:text-2xl font-serif font-medium text-ink dark:text-dark-ink">
Analyses & Cartographie
</h1>
</div>
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Discovering the hidden architecture of your knowledge</p>
<p className="text-[10px] text-concrete tracking-[0.25em] uppercase font-bold">
Modèles sémantiques & clusters de connaissances
</p>
</div>
<div className="flex items-center justify-between sm:justify-end gap-3">
{/* Tab switcher mobile */}
<div className="flex lg:hidden p-1 bg-black/5 dark:bg-white/5 rounded-xl shrink-0">
<button
onClick={() => setViewMode('graph')}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all ${
viewMode === 'graph'
? 'bg-white dark:bg-black text-ink dark:text-dark-ink shadow-sm'
: 'text-concrete'
}`}
>
Réseau
</button>
<button
onClick={() => setViewMode('dashboard')}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all ${
viewMode === 'dashboard'
? 'bg-white dark:bg-black text-ink dark:text-dark-ink shadow-sm'
: 'text-concrete'
}`}
>
Analyses
</button>
</div>
<button
onClick={performAnalysis}
disabled={isCalculating}
className="flex items-center gap-2 px-5 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50 shadow-sm"
>
{isCalculating ? (
<RefreshCw size={13} className="animate-spin" />
) : (
<RefreshCw size={13} />
)}
{isCalculating ? 'Calcul...' : 'Re-analyser'}
</button>
</div>
<button
onClick={performAnalysis}
disabled={isCalculating}
className="flex items-center gap-2 px-6 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
>
{isCalculating ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isCalculating ? 'Mapping...' : 'Re-sync Network'}
</button>
</div>
{/* Loading State */}
{/* ── Chargement ── */}
{loading && (
<div className="flex-1 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center"
className="text-center space-y-4"
>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500 mx-auto mb-4"></div>
<p className="text-concrete">Analyzing your notes...</p>
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-ochre mx-auto" />
<p className="text-sm text-concrete">Chargement de vos clusters...</p>
</motion.div>
</div>
)}
{/* Empty State - only if truly no notes */}
{/* ── État vide ── */}
{!loading && clusters.length === 0 && !isCalculating && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-md">
<div className="w-24 h-24 rounded-full bg-concrete/10 flex items-center justify-center mx-auto mb-6">
<Sparkles size={40} className="text-concrete/40" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center max-w-sm px-6"
>
<div className="w-20 h-20 rounded-2xl bg-ochre/10 flex items-center justify-center mx-auto mb-6">
<Sparkles size={32} className="text-ochre/60" />
</div>
<h3 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-2">
Discover your knowledge clusters
<h3 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-3">
Vos notes forment des thèmes
</h3>
<p className="text-concrete mb-6">
Click "Re-sync Network" to analyze your notes and find hidden connections
<p className="text-sm text-concrete leading-relaxed mb-6">
Cliquez sur &ldquo;Re-analyser&rdquo; pour découvrir les groupes sémantiques de vos notes
et les connexions cachées entre vos idées.
</p>
</div>
<button
onClick={performAnalysis}
disabled={isCalculating}
className="inline-flex items-center gap-2 px-6 py-3 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
>
<RefreshCw size={14} />
Analyser mes notes
</button>
</motion.div>
</div>
)}
{/* Main Content */}
{!loading && clusters.length > 0 && (
<div className="flex-1 flex overflow-hidden">
{/* Left: Graph View */}
<div className="flex-[1.5] p-6 relative">
{/* ── Calcul en cours ── */}
{isCalculating && !loading && (
<div className="flex-1 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center space-y-6 max-w-xs"
>
<div className="w-16 h-16 rounded-2xl bg-ochre/10 flex items-center justify-center mx-auto">
<RefreshCw size={28} className="text-ochre animate-spin" />
</div>
<div className="space-y-2">
<p className="text-sm font-semibold text-ink dark:text-dark-ink">
Analyse en cours...
</p>
<p className="text-xs text-concrete leading-relaxed">
Calcul des similarités sémantiques et détection des clusters de connaissances
</p>
</div>
</motion.div>
</div>
)}
{/* ── Contenu principal ── */}
{!loading && clusters.length > 0 && !isCalculating && (
<div className="flex-1 flex overflow-hidden min-h-0">
{/* ── Graphe (gauche) ── */}
<div
className={`flex-[1.4] p-6 relative min-h-0 ${
viewMode === 'graph' ? 'block' : 'hidden lg:block'
}`}
>
<NetworkGraph
notes={notes}
clusters={clusters}
bridgeNotes={bridgeNotes}
onNoteSelect={handleNoteClick}
selectedClusterId={selectedClusterId}
onClusterSelect={setSelectedClusterId}
/>
</div>
{/* Right: Insight Dashboard */}
<div className="flex-1 border-l border-border/40 flex flex-col h-full bg-paper/50 dark:bg-black/10 backdrop-blur-sm overflow-hidden">
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar space-y-12">
{/* Stats Summary */}
{/* ── Dashboard (droite) ── */}
<div
className={`flex-1 border-l border-border/20 flex flex-col min-h-0 overflow-hidden bg-[#fcfbfa] dark:bg-zinc-900/10 backdrop-blur-sm ${
viewMode === 'dashboard' ? 'flex' : 'hidden lg:flex'
}`}
>
<div className="p-6 sm:p-8 flex-1 overflow-y-auto custom-scrollbar space-y-10">
{/* Avertissement d'obsolescence (stale banner) */}
{isStale && !isCalculating && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-4 rounded-2xl bg-amber-500/10 border border-amber-500/20 text-amber-800 dark:text-amber-300 text-xs flex items-center justify-between gap-4 shadow-sm"
>
<div className="flex items-center gap-2.5">
<AlertCircle size={16} className="shrink-0 text-amber-500" />
<span>
Vos notes ont é modifiées. Mettez à jour vos insights pour une cartographie sémantique précise.
</span>
</div>
<button
onClick={performAnalysis}
className="px-3.5 py-2 bg-amber-500 text-white dark:text-zinc-950 font-bold uppercase tracking-wider text-[10px] rounded-lg hover:scale-105 active:scale-95 transition-all shrink-0 shadow-sm"
>
Mettre à jour
</button>
</motion.div>
)}
{/* ① Panneau d'inspection cluster */}
<AnimatePresence>
{selectedCluster && (
<motion.div
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
className="p-6 rounded-2xl bg-white dark:bg-zinc-800 border-2 border-ochre/30 shadow-md relative overflow-hidden"
>
<div
className="absolute top-0 left-0 w-1.5 h-full"
style={{ backgroundColor: selectedCluster.color }}
/>
<div className="flex items-center justify-between gap-4 mb-4 pl-3">
<div className="space-y-0.5">
<span className="text-[9px] font-bold uppercase tracking-widest text-ochre">
Focus Cluster Activé
</span>
<h3 className="text-base font-serif font-semibold text-ink dark:text-dark-ink">
{selectedCluster.name || `Cluster ${selectedCluster.clusterId}`}
</h3>
</div>
<button
onClick={() => setSelectedClusterId(null)}
className="p-1 px-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-[10px] font-bold rounded-lg uppercase tracking-wider transition-colors shrink-0"
>
Fermer
</button>
</div>
<div className="pl-3 space-y-3">
<p className="text-xs text-concrete">
Cet ensemble thématique réunit{' '}
<span className="font-semibold text-ink dark:text-dark-ink">
{selectedClusterNotes.length} notes
</span>
. Cliquez pour accéder directement :
</p>
<div className="space-y-1.5 max-h-[180px] overflow-y-auto custom-scrollbar pr-1">
{selectedClusterNotes.map(note => (
<button
key={note.id}
onClick={() => handleNoteClick(note.id)}
className="w-full text-left p-2.5 rounded-lg bg-black/[0.03] hover:bg-black/[0.07] dark:bg-white/[0.03] dark:hover:bg-white/[0.07] text-xs font-medium text-ink dark:text-dark-ink flex items-center justify-between gap-3 group transition-all"
>
<span className="truncate group-hover:translate-x-0.5 transition-transform">
{note.title || 'Note sans titre'}
</span>
<ChevronRight size={12} className="text-concrete shrink-0" />
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ② Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
<div className="p-5 rounded-2xl bg-white dark:bg-zinc-800/40 border border-border/40 shadow-sm flex flex-col justify-between">
<div className="flex items-center gap-2 text-indigo-500 mb-2">
<Layers size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Clusters</span>
<span className="text-[10px] font-bold uppercase tracking-widest">
Clusters Actifs
</span>
</div>
<div>
<div className="text-2xl font-serif font-semibold text-ink dark:text-dark-ink">
{clusters.length}
</div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
Détectés sans à priori
</p>
</div>
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{clusters.length}</div>
</div>
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
<div className="p-5 rounded-2xl bg-white dark:bg-zinc-800/40 border border-border/40 shadow-sm flex flex-col justify-between">
<div className="flex items-center gap-2 text-ochre mb-2">
<Trophy size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Bridge Notes</span>
<span className="text-[10px] font-bold uppercase tracking-widest">
Notes-Ponts
</span>
</div>
<div>
<div className="text-2xl font-serif font-semibold text-ink dark:text-dark-ink">
{bridgeNotes.length}
</div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
Passerelles d&apos;idées
</p>
</div>
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
</div>
</div>
{/* Bridge Notes Section */}
<section>
<div className="flex items-center gap-2 mb-6 px-1">
{/* ③ Système de Recalcul */}
<section className="p-5 rounded-2xl bg-white dark:bg-zinc-800 border border-border/40 shadow-sm space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Sliders size={15} className="text-ochre" />
<h4 className="text-[11px] font-black uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
Système de Recalcul
</h4>
</div>
<span className="flex items-center gap-1 text-[9.5px] font-bold text-emerald-500 uppercase">
<CheckCircle2 size={11} /> Synchronisé
</span>
</div>
<div className="grid grid-cols-2 gap-4 pt-1">
<div className="space-y-1">
<span className="text-[9px] text-concrete block">CRON PLANIFIÉ</span>
<p className="text-xs text-ink dark:text-dark-ink font-semibold flex items-center gap-1.5">
<Clock size={12} className="opacity-50" /> Quotidien (04:00)
</p>
</div>
<div className="space-y-1">
<span className="text-[9px] text-concrete block">DERNIÈRE SYNC</span>
<p className="text-xs text-ink dark:text-dark-ink font-bold font-mono">
{lastSyncTime || '—'}
</p>
</div>
</div>
<div className="pt-2 border-t border-border/10 space-y-3">
<div className="flex justify-between items-center text-[10px]">
<span className="text-concrete">Notes indexées (texte complet) :</span>
<span className="font-bold font-mono text-ink dark:text-dark-ink">
{embeddingStats
? `${embeddingStats.indexed} / ${embeddingStats.total}`
: '—'}
</span>
</div>
<p className="text-[8px] text-concrete italic block leading-relaxed">
Chaque note est convertie en texte brut intégral puis découpée en chunks si
nécessaire (ex. 17&nbsp;679 caractères plusieurs vecteurs fusionnés). Aucune
limite artificielle à 200 ou 800 caractères pour la similarité.
</p>
<button
type="button"
onClick={() => void handleReindexEmbeddings()}
disabled={isReindexing || isCalculating}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-ochre/30 bg-ochre/5 hover:bg-ochre/10 text-[10px] font-bold uppercase tracking-widest text-ochre disabled:opacity-50 transition-colors"
>
{isReindexing ? (
<RefreshCw size={13} className="animate-spin" />
) : (
<Database size={13} />
)}
{isReindexing ? 'Indexation…' : 'Recalculer les embeddings'}
</button>
<span className="text-[8px] text-concrete italic block leading-relaxed">
« Re-analyser » réindexe aussi les embeddings puis regénère les clusters.
</span>
</div>
</section>
{/* ④ Clusters Isolés */}
<section className="space-y-4">
<div className="flex items-center justify-between gap-4 px-1">
<div className="flex items-center gap-2">
<AlertCircle size={15} className="text-rose-400" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
Clusters Isolés ({isolatedClusters.length})
</h3>
</div>
<span className="text-[9px] text-concrete italic">Sans points d&apos;accroche</span>
</div>
<div className="space-y-2">
{isolatedClusters.map(c => (
<motion.div
key={c.id}
whileHover={{ y: -1 }}
onClick={() => setSelectedClusterId(c.id)}
className="p-3.5 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-black/10 dark:hover:border-white/10 flex items-center justify-between cursor-pointer transition-all"
>
<div className="flex items-center gap-2.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
<span className="text-xs font-medium text-ink dark:text-dark-ink">
{c.name || `Cluster ${c.clusterId}`}
</span>
</div>
<span className="text-[10px] text-rose-500 font-semibold uppercase tracking-wider bg-rose-500/5 px-2.5 py-0.5 rounded-full border border-rose-500/10">
Non connecté
</span>
</motion.div>
))}
{isolatedClusters.length === 0 && (
<div className="p-4 bg-white dark:bg-zinc-800 rounded-xl text-xs text-concrete text-center italic border border-border/20">
Tous les clusters thématiques sont liés par au moins un point de passage sémantique !
</div>
)}
</div>
</section>
{/* ⑤ Notes-Ponts Influentes */}
<section className="space-y-4">
<div className="flex items-center gap-2 px-1">
<Zap size={16} className="text-ochre" />
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Powerful Bridge Notes</h3>
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
Notes-Ponts Influentes
</h3>
</div>
<div className="space-y-3">
{bridgeList.map((bridge) => (
{bridgeList.map(bridge => (
<motion.div
key={bridge.noteId}
whileHover={{ x: 4 }}
onClick={() => handleNoteClick(bridge.noteId)}
className="p-4 rounded-xl bg-white dark:bg-white/5 border border-border hover:border-ochre/40 transition-all cursor-pointer group"
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-ochre/40 hover:shadow-sm transition-all cursor-pointer group"
>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
<div className="flex items-center justify-between mb-2 gap-4">
<h4 className="text-xs font-semibold text-ink dark:text-dark-ink truncate flex-1 group-hover:text-ochre transition-colors">
{bridge.title}
</h4>
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
<span className="text-[9.5px] font-bold text-ochre bg-ochre/5 border border-ochre/10 px-2.5 py-0.5 rounded-full shrink-0">
Lien : {(bridge.bridgeScore * 100).toFixed(0)}%
</span>
</div>
<div className="flex items-center gap-2">
{bridge.clusterNames?.map((name, i) => {
const cluster = clusters.find(c => c.name === name)
<div className="flex flex-wrap gap-1.5 pt-1.5 border-t border-black/5 dark:border-white/5">
{bridge.clustersConnected.map(cid => {
const cluster = clusters.find(c => c.id === String(cid))
return (
<div key={i} className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: cluster?.color || '#cbd5e1' }} />
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{name}</span>
<div
key={cid}
onClick={e => {
e.stopPropagation()
setSelectedClusterId(String(cid))
}}
className="flex items-center gap-1.5 px-2 py-0.5 bg-black/[0.02] dark:bg-white/[0.02] border border-border/30 rounded-md hover:border-concrete/40 transition-colors cursor-pointer"
>
<div
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: cluster?.color || '#cbd5e1' }}
/>
<span className="text-[9.5px] text-concrete font-medium uppercase tracking-wider">
{cluster?.name || `Cluster ${cid}`}
</span>
</div>
)
})}
@@ -272,61 +646,76 @@ export default function InsightsPage() {
</motion.div>
))}
{bridgeList.length === 0 && !isCalculating && (
<div className="text-xs text-concrete italic">No significant bridge notes found yet. Deepen your research to find new connections.</div>
<div className="text-xs text-concrete italic text-center p-6 bg-white dark:bg-zinc-800 rounded-xl border border-border/20">
Aucune note-pont significative n&apos;a é détectée. Créez des notes
transversales pour forger de nouveaux liens créatifs.
</div>
)}
</div>
</section>
{/* Connection Suggestions */}
<section>
<div className="flex items-center gap-2 mb-6 px-1">
{/* ⑥ Opportunités de Connexion */}
<section className="space-y-4">
<div className="flex items-center gap-2 px-1">
<Lightbulb size={16} className="text-indigo-500" />
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Missing Links (AI Generated)</h3>
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
Opportunités de Connexion
</h3>
</div>
<div className="space-y-4">
{suggestions.map((s, idx) => (
<div key={`${s.clusterAId}-${s.clusterBId}`} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 to-transparent border border-indigo-500/10 hover:border-indigo-500/30 transition-all">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-4">
<div className="flex -space-x-2">
<div className="w-6 h-6 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[10px] text-white">A</div>
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
</div>
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
Bridging {s.clusterAName} & {s.clusterBName}
</span>
{suggestions.map(s => (
<div
key={`${s.clusterAId}-${s.clusterBId}`}
className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 via-transparent to-transparent border border-indigo-500/10 hover:border-indigo-500/20 transition-all shadow-sm"
>
<div className="flex items-center gap-3 mb-4">
<div className="flex -space-x-2 shrink-0">
<div className="w-5 h-5 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[9px] text-white font-bold">
A
</div>
<h4 className="text-base font-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.suggestedContent}</p>
<div className="p-3 bg-white/40 dark:bg-white/5 rounded-xl border border-border/40 text-[10px] italic text-concrete flex gap-2">
<Zap size={12} className="shrink-0" />
<span>{s.justification}</span>
<div className="w-5 h-5 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[9px] text-white font-bold">
B
</div>
</div>
<span className="text-[9px] font-bold uppercase tracking-wider text-indigo-500/70 truncate">
Relier {s.clusterAName} & {s.clusterBName}
</span>
</div>
<h4 className="text-sm font-semibold text-ink dark:text-dark-ink mb-2">
{s.suggestedTitle}
</h4>
<p className="text-xs text-muted-ink leading-relaxed mb-4">
{s.suggestedContent}
</p>
<div className="p-3.5 bg-white/60 dark:bg-zinc-800 rounded-xl border border-border/20 text-[10.5px] italic text-concrete flex gap-2">
<Zap size={13} className="shrink-0 text-ochre mt-0.5" />
<span>{s.justification}</span>
</div>
</div>
))}
{suggestions.length === 0 && !isCalculating && (
<div className="text-center py-8 text-concrete">
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
<p className="text-sm">No connection suggestions yet</p>
<p className="text-xs mt-1">All your clusters may already be connected!</p>
</div>
)}
{isCalculating && (
<div className="animate-pulse space-y-4">
{[1, 2].map(i => (
<div key={i} className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10" />
<div
key={i}
className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10"
/>
))}
</div>
)}
{!isCalculating && suggestions.length === 0 && (
<div className="text-xs text-concrete text-center italic p-6 border border-border/20 bg-white/40 dark:bg-zinc-800 rounded-xl">
Toutes vos thématiques clés sont déjà formidablement interconnectées !
</div>
)}
</div>
</section>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { auth } from '@/auth'
import { getAIProvider } from '@/lib/ai/factory'
import { parseNote, getHashColor } from '@/lib/utils'
import { upsertNoteEmbedding } from '@/lib/embeddings'
import { embeddingService } from '@/lib/ai/services/embedding.service'
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
@@ -122,7 +123,7 @@ async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?
/** Sync both Note.labels (JSON) AND labelRelations for a single note.
* Also cleans up orphan labels in the same notebook scope. */
async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) {
export async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) {
const uniqueNames = [...new Set(labelNames.map(n => n.trim()).filter(Boolean))]
const labelRows = await syncLabels(userId, uniqueNames, notebookId)
const labelIds = labelRows.map(l => l.id)
@@ -444,9 +445,7 @@ export async function createNote(data: {
// Use setImmediate-like pattern to not block the response
; (async () => {
try {
const bgConfig = await getSystemConfig()
const provider = getAIProvider(bgConfig)
const embedding = await provider.getEmbeddings(content)
const { embedding } = await embeddingService.generateNoteEmbedding(data.title, content)
if (embedding) {
await upsertNoteEmbedding(noteId, embedding)
}
@@ -574,10 +573,10 @@ export async function updateNote(id: string, data: {
if (data.content !== undefined) {
const noteId = id
const content = data.content;
const title = data.title !== undefined ? data.title : oldNote?.title ?? null;
(async () => {
try {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(content);
const { embedding } = await embeddingService.generateNoteEmbedding(title, content)
if (embedding) {
await upsertNoteEmbedding(noteId, embedding);
}
@@ -928,11 +927,10 @@ export async function syncAllEmbeddings() {
noteEmbedding: { is: null }
}
})
const provider = getAIProvider(await getSystemConfig());
for (const note of notesToSync) {
if (!note.content) continue;
try {
const embedding = await provider.getEmbeddings(note.content);
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
if (embedding) {
await upsertNoteEmbedding(note.id, embedding)
updatedCount++;

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { extractArticleFromHtml } from '@/lib/clip/extract-article'
import { analyzeClipContent } from '@/lib/clip/analyze-clip'
import { resolveClipLocale, wrapClipPlainParagraph } from '@/lib/clip/rtl-content'
function isBlockedUrl(url: string): boolean {
try {
const parsed = new URL(url)
const hostname = parsed.hostname.toLowerCase()
const blocked = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '169.254.169.254']
if (blocked.includes(hostname)) return true
if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.')) return true
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true
return false
} catch {
return true
}
}
async function fetchPageHtml(url: string): Promise<string | null> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MementoClipper/1.0)',
Accept: 'text/html,application/xhtml+xml',
},
signal: controller.signal,
redirect: 'follow',
})
if (!response.ok) return null
const ct = response.headers.get('content-type') || ''
if (!ct.includes('text/html') && !ct.includes('application/xhtml')) return null
return await response.text()
} catch {
return null
} finally {
clearTimeout(timeoutId)
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const url = typeof body.url === 'string' ? body.url.trim() : ''
const htmlInput = typeof body.html === 'string' ? body.html : ''
const selection = typeof body.selection === 'string' ? body.selection.trim() : ''
if (!url || isBlockedUrl(url)) {
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 })
}
let title = ''
let textContent = ''
let contentHtml = ''
if (body.mode === 'link') {
title = new URL(url).hostname
textContent = url
contentHtml = `<p><a href="${url}" rel="noopener noreferrer">${url}</a></p>`
} else if (body.mode === 'selection' && selection) {
title = typeof body.title === 'string' ? body.title : new URL(url).hostname
textContent = selection
const locale = resolveClipLocale(url, title, selection)
contentHtml = wrapClipPlainParagraph(selection, locale)
} else {
const html = htmlInput || (await fetchPageHtml(url))
if (!html) {
return NextResponse.json({ error: 'Could not fetch page content' }, { status: 422 })
}
const extracted = extractArticleFromHtml(html, url)
if (!extracted) {
return NextResponse.json({ error: 'Could not extract readable article' }, { status: 422 })
}
title = extracted.title || (typeof body.title === 'string' ? body.title : '')
textContent = extracted.textContent
contentHtml = extracted.content
}
const analysis = await analyzeClipContent({ url, title, textContent })
return NextResponse.json({
title: analysis.title,
summary: analysis.summary,
tags: analysis.tags,
readingTime: analysis.readingTimeMinutes,
content: contentHtml,
excerpt: textContent.slice(0, 500),
})
} catch (error) {
console.error('[POST /api/clip/analyze]', error)
return NextResponse.json({ error: 'Analysis failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
/** Liste hiérarchique des carnets pour le clipper (extension). */
export async function GET(_request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const notebooks = await prisma.notebook.findMany({
where: { userId: session.user.id, trashedAt: null },
select: { id: true, name: true, parentId: true, color: true },
orderBy: [{ order: 'asc' }, { name: 'asc' }],
})
return NextResponse.json({ notebooks })
} catch (error) {
console.error('[GET /api/clip/notebooks]', error)
return NextResponse.json({ error: 'Failed to load notebooks' }, { status: 500 })
}
}

View File

@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { syncNoteLabels } from '@/app/actions/notes'
import { createNotification } from '@/app/actions/notifications'
import { buildClipSourceFooter, clipFooterLocaleTag } from '@/lib/clip/extract-article'
import { resolveClipLocale, wrapClipArticleHtml, applyRtlToHtmlBlocks } from '@/lib/clip/rtl-content'
import { embeddingService } from '@/lib/ai/services/embedding.service'
import { upsertNoteEmbedding } from '@/lib/embeddings'
import DOMPurify from 'isomorphic-dompurify'
function isBlockedUrl(url: string): boolean {
try {
const parsed = new URL(url)
const hostname = parsed.hostname.toLowerCase()
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true
return ['localhost', '127.0.0.1', '0.0.0.0', '::1'].includes(hostname)
} catch {
return true
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const url = typeof body.url === 'string' ? body.url.trim() : ''
const title = typeof body.title === 'string' ? body.title.trim().slice(0, 300) : null
const rawContent = typeof body.content === 'string' ? body.content : ''
const summary = typeof body.summary === 'string' ? body.summary.trim() : ''
const notebookId = typeof body.notebookId === 'string' ? body.notebookId : null
const tags = Array.isArray(body.tags)
? body.tags.filter((t: unknown): t is string => typeof t === 'string').slice(0, 5)
: []
if (!url || isBlockedUrl(url)) {
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 })
}
if (!rawContent.trim()) {
return NextResponse.json({ error: 'Content required' }, { status: 400 })
}
await prisma.user.upsert({
where: { id: session.user.id },
update: {
...(session.user.email ? { email: session.user.email } : {}),
...(session.user.name !== undefined ? { name: session.user.name } : {}),
},
create: {
id: session.user.id,
email: session.user.email || `user-${session.user.id}@local.momento`,
name: session.user.name || null,
},
})
const domain = new URL(url).hostname.replace(/^www\./, '')
const locale = resolveClipLocale(url, title || '', summary, rawContent.replace(/<[^>]+>/g, ' '))
const sanitizedContent = DOMPurify.sanitize(rawContent)
const rtlBlocks = applyRtlToHtmlBlocks(sanitizedContent, locale)
const summaryBlock = summary
? `<p dir="${locale.direction}"${locale.lang ? ` lang="${locale.lang}"` : ''}><em>${DOMPurify.sanitize(summary)}</em></p>`
: ''
const footer = buildClipSourceFooter(domain, new Date(), clipFooterLocaleTag(locale.lang))
const bodyHtml = rtlBlocks.includes('clip-article--rtl')
? rtlBlocks
: wrapClipArticleHtml(rtlBlocks, locale)
const fullContent = `${summaryBlock}${bodyHtml}${footer}`
const note = await prisma.note.create({
data: {
userId: session.user.id,
title: title || domain,
content: fullContent,
type: 'richtext',
notebookId,
sourceUrl: url,
autoGenerated: true,
...(locale.lang ? { language: locale.lang } : {}),
},
})
void (async () => {
try {
const { embedding } = await embeddingService.generateNoteEmbedding(
title || domain,
fullContent,
)
if (embedding?.length) {
await upsertNoteEmbedding(note.id, embedding)
}
} catch (error) {
console.error('[clip/save] embedding generation failed:', error)
}
})()
if (tags.length > 0) {
await syncNoteLabels(note.id, tags, notebookId, session.user.id)
}
const noteUrl = `/home?openNote=${encodeURIComponent(note.id)}`
await createNotification({
userId: session.user.id,
type: 'clip',
title: title || domain,
message: summary || undefined,
actionUrl: noteUrl,
relatedId: note.id,
})
return NextResponse.json({ noteId: note.id, noteUrl })
} catch (error) {
console.error('[POST /api/clip/save]', error)
return NextResponse.json({ error: 'Save failed' }, { status: 500 })
}
}

View File

@@ -7,6 +7,7 @@ import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
/**
* GET /api/clusters
* Get all clusters for the current user.
* Returns cached clusters + bridge notes enriched with cluster names.
*/
export async function GET(request: NextRequest) {
try {
@@ -17,9 +18,10 @@ export async function GET(request: NextRequest) {
const userId = session.user.id
// Check for cached results
const cached = await clusteringService.getCachedClusters(userId)
if (cached) {
// Check for stored results (even if stale/périmés)
const stored = await clusteringService.getStoredClusters(userId)
if (stored) {
const cached = stored.clusters
// Fetch notes with their cluster assignments
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
@@ -29,20 +31,65 @@ export async function GET(request: NextRequest) {
// Get cluster member mappings
const clusterMembers = await prisma.clusterMember.findMany({
where: { userId },
select: { noteId: true, clusterId: true }
select: { noteId: true, clusterId: true, isCentral: true }
})
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, cm.clusterId]))
const notesWithClusters = notes.map(n => ({
...n,
clusterId: noteClusterMap.get(n.id)
}))
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, { clusterId: cm.clusterId, isCentral: cm.isCentral }]))
const notesWithClusters = notes.map(n => {
const mapping = noteClusterMap.get(n.id)
return {
...n,
clusterId: mapping?.clusterId,
isCentral: mapping?.isCentral || false
}
})
// Fetch bridge notes with enrichment (cluster names + note details)
const bridgeNotesData = await prisma.bridgeNote.findMany({
where: { userId },
orderBy: { bridgeScore: 'desc' },
take: 10
})
let enrichedBridgeNotes: object[] = []
if (bridgeNotesData.length > 0) {
const bridgeNoteIds = bridgeNotesData.map(b => b.noteId)
const bridgeNoteDetails = await prisma.note.findMany({
where: { id: { in: bridgeNoteIds } },
select: { id: true, title: true, content: true }
})
const bridgeNoteDetailsMap = new Map(bridgeNoteDetails.map(n => [n.id, n]))
enrichedBridgeNotes = bridgeNotesData.map(b => {
const clustersConnected = JSON.parse(b.clustersConnected) as number[]
return {
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected,
clusterNames: clustersConnected.map(
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`
),
note: bridgeNoteDetailsMap.get(b.noteId)
}
})
}
const embeddingCountRow = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
`SELECT COUNT(*) FROM "NoteEmbedding" ne
INNER JOIN "Note" n ON n.id = ne."noteId"
WHERE n."userId" = $1 AND n."trashedAt" IS NULL`,
userId
)
return NextResponse.json({
clusters: cached,
notes: notesWithClusters,
bridgeNotes: enrichedBridgeNotes,
cached: true,
totalNotes: cached.reduce((sum, c) => sum + c.noteIds.length, 0)
stale: stored.stale,
lastCalculated: stored.lastCalculated,
totalNotes: notes.length,
embeddingCount: Number(embeddingCountRow[0]?.count || 0),
})
}
@@ -61,6 +108,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
clusters: [],
notes: [],
bridgeNotes: [],
totalNotes: notesCount,
embeddingCount: Number(embeddingCount[0]?.count || 0),
needsCalculation: true
@@ -77,6 +125,7 @@ export async function GET(request: NextRequest) {
/**
* POST /api/clusters
* Trigger a full recalculation of clusters and bridge notes.
* Returns clusters + bridge notes enriched for immediate display.
*/
export async function POST(request: NextRequest) {
try {
@@ -86,66 +135,77 @@ export async function POST(request: NextRequest) {
}
const userId = session.user.id
const body = await request.json().catch(() => ({}))
const force = Boolean(body?.force)
// Use the PROPER clustering service (DBSCAN algorithm)
// 0. Indexer / réindexer les embeddings (texte complet, multi-chunks)
await clusteringService.ensureEmbeddings(userId, { force: force || true })
// 1. Run DBSCAN clustering
const results = await clusteringService.clusterNotes(userId)
if (results.clusters.length === 0) {
return NextResponse.json({
clusters: [],
bridgeNotes: [],
message: 'Could not generate clusters. Notes may be too diverse or not enough.',
noiseCount: results.noiseCount
})
}
// Generate cluster names using AI
// 2. Generate cluster names with AI
for (const cluster of results.clusters) {
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
}
// Save clustering results
// 3. Save clustering results
await clusteringService.saveClusteringResults(userId, results)
// Detect and save bridge notes
// 4. Detect and save bridge notes
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
// Generate and save bridge suggestions
// 5. Generate and save bridge suggestions
const suggestions = await bridgeNotesService.generateBridgeSuggestions(userId)
await bridgeNotesService.saveBridgeSuggestions(userId, suggestions)
// Fetch notes with their cluster assignments
// 6. Fetch notes with cluster assignments
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
select: { id: true, title: true, content: true }
})
// Get cluster member mappings
const clusterMembers = await prisma.clusterMember.findMany({
where: { userId },
select: { noteId: true, clusterId: true }
select: { noteId: true, clusterId: true, isCentral: true }
})
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, cm.clusterId]))
const notesWithClusters = notes.map(n => ({
...n,
clusterId: noteClusterMap.get(n.id)
}))
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, { clusterId: cm.clusterId, isCentral: cm.isCentral }]))
const notesWithClusters = notes.map(n => {
const mapping = noteClusterMap.get(n.id)
return {
...n,
clusterId: mapping?.clusterId,
isCentral: mapping?.isCentral || false
}
})
// Get enriched bridge notes with note details
// 7. Enrich bridge notes with cluster names + note details
const noteMap = new Map(notes.map(n => [n.id, n]))
const enrichedBridgeNotes = bridgeNotes.slice(0, 10).map(b => ({
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected: b.clustersConnected,
clusterNames: b.clusterNames,
note: notes.find(n => n.id === b.noteId)
note: noteMap.get(b.noteId)
}))
return NextResponse.json({
clusters: results.clusters,
notes: notesWithClusters,
bridgeNotes: enrichedBridgeNotes,
totalNotes: results.clusters.reduce((sum, c) => sum + c.noteIds.length, 0) + results.noiseCount,
totalNotes:
results.clusters.reduce((sum, c) => sum + c.noteIds.length, 0) + results.noiseCount,
noiseCount: results.noiseCount,
message: `Generated ${results.clusters.length} clusters with ${bridgeNotes.length} bridge notes`
})

View File

@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
/**
* GET /api/insights/graph
*
* Retourne les similarités cosinus pairwise calculées depuis les embeddings pgvector
* pour TOUS les membres des clusters (intra-cluster) + les échos Memory Echo (inter-cluster).
*
* Structure de réponse :
* {
* pairs: [{ sourceId, targetId, similarity, type: 'cluster' | 'echo' }],
* membershipScores: { [noteId]: number }
* }
*
* - pairs.cluster : paires au sein du même cluster, score = similarité cosinus réelle
* - pairs.echo : paires Memory Echo non-rejetées, score = similarityScore stocké
* - membershipScores : score de centralité de chaque note dans son cluster (de ClusterMember)
*/
export async function GET(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// 1. Charger les membres de clusters avec leur score de centralité
const clusterMembers = await prisma.clusterMember.findMany({
where: { userId },
select: { noteId: true, clusterId: true, membershipScore: true }
})
if (clusterMembers.length === 0) {
return NextResponse.json({ pairs: [], membershipScores: {} })
}
// Construire la map noteId -> clusterId
const noteToCluster = new Map<string, number>()
const membershipScores: Record<string, number> = {}
const clusterToNotes = new Map<number, string[]>()
for (const m of clusterMembers) {
noteToCluster.set(m.noteId, m.clusterId)
membershipScores[m.noteId] = m.membershipScore
if (!clusterToNotes.has(m.clusterId)) clusterToNotes.set(m.clusterId, [])
clusterToNotes.get(m.clusterId)!.push(m.noteId)
}
const allNoteIds = clusterMembers.map(m => m.noteId)
// 2. Calculer les similarités cosinus pairwise intra-cluster via pgvector
// On utilise une requête SQL qui calcule toutes les paires d'un même cluster en une fois
const intraClusterPairs = await prisma.$queryRawUnsafe<
Array<{ sourceId: string; targetId: string; similarity: number; clusterId: number }>
>(
`SELECT
e1."noteId" AS "sourceId",
e2."noteId" AS "targetId",
1 - (e1.embedding::vector <=> e2.embedding::vector) AS similarity,
cm1."clusterId" AS "clusterId"
FROM "NoteEmbedding" e1
INNER JOIN "NoteEmbedding" e2 ON e1."noteId" < e2."noteId"
INNER JOIN "ClusterMember" cm1 ON cm1."noteId" = e1."noteId" AND cm1."userId" = $1
INNER JOIN "ClusterMember" cm2 ON cm2."noteId" = e2."noteId" AND cm2."userId" = $1
WHERE cm1."clusterId" = cm2."clusterId"
AND e1."noteId" = ANY($2::text[])
AND e2."noteId" = ANY($2::text[])`,
userId,
allNoteIds
)
// 3. Récupérer les échos Memory Echo non-rejetés entre notes clusterisées
const echoInsights = await prisma.memoryEchoInsight.findMany({
where: {
userId,
dismissed: false,
note1Id: { in: allNoteIds },
note2Id: { in: allNoteIds }
},
select: {
note1Id: true,
note2Id: true,
similarityScore: true
}
})
// 4. Construire la liste finale des paires
const pairs: Array<{
sourceId: string
targetId: string
similarity: number
type: 'cluster' | 'echo'
clusterId?: number
}> = []
// Paires intra-cluster
for (const p of intraClusterPairs) {
pairs.push({
sourceId: p.sourceId,
targetId: p.targetId,
similarity: Math.max(0, Math.min(1, p.similarity)),
type: 'cluster',
clusterId: p.clusterId
})
}
// Paires Memory Echo (entre clusters différents souvent, mais peut être intra aussi)
const existingPairKeys = new Set(pairs.map(p => `${p.sourceId}--${p.targetId}`))
for (const echo of echoInsights) {
const key1 = `${echo.note1Id}--${echo.note2Id}`
const key2 = `${echo.note2Id}--${echo.note1Id}`
// Ajouter uniquement si pas déjà couvert par intra-cluster
if (!existingPairKeys.has(key1) && !existingPairKeys.has(key2)) {
pairs.push({
sourceId: echo.note1Id,
targetId: echo.note2Id,
similarity: Math.max(0, Math.min(1, echo.similarityScore)),
type: 'echo'
})
}
}
return NextResponse.json({ pairs, membershipScores })
} catch (error) {
console.error('[/api/insights/graph] Error:', error)
return NextResponse.json(
{ error: 'Failed to compute semantic graph', details: String(error) },
{ status: 500 }
)
}
}

View File

@@ -24,7 +24,7 @@ export async function POST(req: NextRequest) {
for (let i = 0; i < notes.length; i += BATCH_SIZE) {
const batch = notes.slice(i, i + BATCH_SIZE)
const results = await Promise.allSettled(
batch.map(note => semanticSearchService.indexNote(note.id))
batch.map(note => semanticSearchService.indexNote(note.id, { force: true }))
)
for (const r of results) {

View File

@@ -1187,6 +1187,113 @@ html.font-system * {
border-end-end-radius: 4px;
}
/* --- Clipped RTL content (persan, arabe) --- */
.notion-editor-wrapper .ProseMirror .clip-article--rtl,
.notion-editor-wrapper .ProseMirror [dir='rtl'],
.fullpage-editor .ProseMirror .clip-article--rtl,
.fullpage-editor .ProseMirror [dir='rtl'] {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
line-height: 1.85;
}
.notion-editor-wrapper .ProseMirror p[dir='rtl'],
.notion-editor-wrapper .ProseMirror h1[dir='rtl'],
.notion-editor-wrapper .ProseMirror h2[dir='rtl'],
.notion-editor-wrapper .ProseMirror h3[dir='rtl'],
.notion-editor-wrapper .ProseMirror blockquote[dir='rtl'],
.fullpage-editor .ProseMirror p[dir='rtl'],
.fullpage-editor .ProseMirror h1[dir='rtl'],
.fullpage-editor .ProseMirror h2[dir='rtl'],
.fullpage-editor .ProseMirror h3[dir='rtl'],
.fullpage-editor .ProseMirror blockquote[dir='rtl'] {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
line-height: 1.85;
}
.notion-editor-wrapper .ProseMirror .clip-article--rtl p,
.notion-editor-wrapper .ProseMirror .clip-article--rtl li,
.notion-editor-wrapper .ProseMirror .clip-article--rtl h1,
.notion-editor-wrapper .ProseMirror .clip-article--rtl h2,
.notion-editor-wrapper .ProseMirror .clip-article--rtl h3,
.fullpage-editor .ProseMirror .clip-article--rtl p,
.fullpage-editor .ProseMirror .clip-article--rtl li,
.fullpage-editor .ProseMirror .clip-article--rtl h1,
.fullpage-editor .ProseMirror .clip-article--rtl h2,
.fullpage-editor .ProseMirror .clip-article--rtl h3 {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
}
/* Titres d'articles dans les listes (structure BBC Persian : ul > li > h2) */
.notion-editor-wrapper .ProseMirror .clip-article--rtl li > h1,
.notion-editor-wrapper .ProseMirror .clip-article--rtl li > h2,
.notion-editor-wrapper .ProseMirror .clip-article--rtl li > h3,
.notion-editor-wrapper .ProseMirror li[dir='rtl'] > h1,
.notion-editor-wrapper .ProseMirror li[dir='rtl'] > h2,
.notion-editor-wrapper .ProseMirror li[dir='rtl'] > h3,
.fullpage-editor .ProseMirror .clip-article--rtl li > h1,
.fullpage-editor .ProseMirror .clip-article--rtl li > h2,
.fullpage-editor .ProseMirror .clip-article--rtl li > h3,
.fullpage-editor .ProseMirror li[dir='rtl'] > h1,
.fullpage-editor .ProseMirror li[dir='rtl'] > h2,
.fullpage-editor .ProseMirror li[dir='rtl'] > h3 {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
letter-spacing: 0;
}
.notion-editor-wrapper .ProseMirror .clip-article--rtl blockquote,
.fullpage-editor .ProseMirror .clip-article--rtl blockquote {
border-inline-start: none;
border-inline-end: 3px solid var(--primary);
padding-inline-start: 0;
padding-inline-end: 1em;
}
/* RTL lists — puces à droite, texte aligné */
.notion-editor-wrapper .ProseMirror .clip-article--rtl ul,
.notion-editor-wrapper .ProseMirror .clip-article--rtl ol,
.fullpage-editor .ProseMirror .clip-article--rtl ul,
.fullpage-editor .ProseMirror .clip-article--rtl ol,
.notion-editor-wrapper .ProseMirror ul[dir='rtl'],
.notion-editor-wrapper .ProseMirror ol[dir='rtl'],
.fullpage-editor .ProseMirror ul[dir='rtl'],
.fullpage-editor .ProseMirror ol[dir='rtl'],
.notion-editor-wrapper .ProseMirror ul:has(> li[dir='rtl']),
.notion-editor-wrapper .ProseMirror ol:has(> li[dir='rtl']),
.fullpage-editor .ProseMirror ul:has(> li[dir='rtl']),
.fullpage-editor .ProseMirror ol:has(> li[dir='rtl']) {
direction: rtl;
text-align: right;
marker-side: match-parent;
padding-inline-start: 0;
padding-inline-end: 1.5rem;
list-style-position: outside;
}
.notion-editor-wrapper .ProseMirror .clip-article--rtl li,
.notion-editor-wrapper .ProseMirror li[dir='rtl'],
.fullpage-editor .ProseMirror .clip-article--rtl li,
.fullpage-editor .ProseMirror li[dir='rtl'] {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
}
.notion-editor-wrapper .ProseMirror .clip-article--rtl li > p,
.notion-editor-wrapper .ProseMirror li[dir='rtl'] > p,
.fullpage-editor .ProseMirror .clip-article--rtl li > p,
.fullpage-editor .ProseMirror li[dir='rtl'] > p {
direction: rtl;
text-align: right;
}
/* --- Code --- */
.notion-editor-wrapper .ProseMirror code {
background: var(--muted);
@@ -2007,6 +2114,22 @@ html.font-system * {
line-height: 1.4 !important;
}
/* RTL headings — override serif editorial styles (chiffres persans, flux bidi) */
.fullpage-editor .ProseMirror h1[dir='rtl'],
.fullpage-editor .ProseMirror h2[dir='rtl'],
.fullpage-editor .ProseMirror h3[dir='rtl'],
.fullpage-editor .tiptap h1[dir='rtl'],
.fullpage-editor .tiptap h2[dir='rtl'],
.fullpage-editor .tiptap h3[dir='rtl'],
.fullpage-editor .ProseMirror .clip-article--rtl h1,
.fullpage-editor .ProseMirror .clip-article--rtl h2,
.fullpage-editor .ProseMirror .clip-article--rtl h3 {
direction: rtl !important;
text-align: right !important;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
letter-spacing: 0 !important;
}
/* ──────────────────────────────────────────────
SONNER TOASTS — Architectural Grid Styling
────────────────────────────────────────────── */