feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
This commit is contained in:
@@ -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 “Re-analyser” 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 été 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'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 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'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'a été 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user