All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export - Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG) - Add document Q&A overlay with streaming chat and PDF preview - Add note attachments UI with status polling, grid layout, and auto-scroll - Add task extraction AI tool and agent executor improvements - Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings - Fix brainstorm 'Create Note' button: add success toast and redirect to created note - Fix memory echo notification infinite polling - Fix chat route to always include document_search tool - Add brainstorm i18n keys across all 14 locales - Add socket server for real-time brainstorm collaboration - Add hierarchical notebook selector and organize notebook dialog improvements - Add sidebar brainstorm section with session management - Update prisma schema with brainstorm tables, attachments, and document chunks
249 lines
13 KiB
TypeScript
249 lines
13 KiB
TypeScript
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import {
|
|
Network,
|
|
Lightbulb,
|
|
Layers,
|
|
Sparkles,
|
|
ArrowRight,
|
|
RefreshCw,
|
|
Trophy,
|
|
Zap,
|
|
Tag,
|
|
Link as LinkIcon
|
|
} from 'lucide-react';
|
|
import { Note, NoteCluster, BridgeNote, ConnectionSuggestion } from '../types';
|
|
import { runClustering, detectBridges, calculateCentroid } from '../services/clusteringService';
|
|
import { nameCluster, suggestBridgeIdeas } from '../services/geminiService';
|
|
import { NetworkGraph } from './NetworkGraph';
|
|
|
|
interface InsightsViewProps {
|
|
notes: Note[];
|
|
onUpdateNotes: (updatedNotes: Note[]) => void;
|
|
onNoteSelect: (noteId: string) => void;
|
|
}
|
|
|
|
export const InsightsView: React.FC<InsightsViewProps> = ({
|
|
notes,
|
|
onUpdateNotes,
|
|
onNoteSelect
|
|
}) => {
|
|
const [isCalculating, setIsCalculating] = useState(false);
|
|
const [clusters, setClusters] = useState<NoteCluster[]>([]);
|
|
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([]);
|
|
const [suggestions, setSuggestions] = useState<ConnectionSuggestion[]>([]);
|
|
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null);
|
|
|
|
const performAnalysis = async () => {
|
|
setIsCalculating(true);
|
|
try {
|
|
// 1. Run clustering
|
|
const { clusters: newClusters } = runClustering(notes);
|
|
|
|
// 2. Name clusters (first 5 unique notes per cluster)
|
|
const namedClusters = await Promise.all(newClusters.map(async (c) => {
|
|
const clusterNoteSummaries = notes
|
|
.filter(n => c.noteIds.includes(n.id))
|
|
.slice(0, 5)
|
|
.map(n => n.title);
|
|
|
|
const name = await nameCluster(clusterNoteSummaries);
|
|
const centroid = calculateCentroid(c.noteIds, notes);
|
|
|
|
return { ...c, name, centroid };
|
|
}));
|
|
|
|
// 3. Update notes with cluster IDs
|
|
const updatedNotes = notes.map(n => {
|
|
const cluster = namedClusters.find(c => c.noteIds.includes(n.id));
|
|
return { ...n, clusterId: cluster?.id };
|
|
});
|
|
onUpdateNotes(updatedNotes);
|
|
|
|
// 4. Detect bridges
|
|
const bridges = detectBridges(updatedNotes, namedClusters);
|
|
|
|
// 5. Build suggestions for isolated cluster pairs
|
|
// For demo, we'll just pick a few interesting pairs
|
|
const newSuggestions: ConnectionSuggestion[] = [];
|
|
if (namedClusters.length >= 2) {
|
|
// Find clusters with no mutual bridge notes or low connectivity
|
|
for (let i = 0; i < Math.min(namedClusters.length, 3); i++) {
|
|
for (let j = i + 1; j < Math.min(namedClusters.length, 3); j++) {
|
|
const cA = namedClusters[i];
|
|
const cB = namedClusters[j];
|
|
|
|
const cA_notes = updatedNotes.filter(n => cA.noteIds.includes(n.id)).map(n => n.title).join(', ');
|
|
const cB_notes = updatedNotes.filter(n => cB.noteIds.includes(n.id)).map(n => n.title).join(', ');
|
|
|
|
const bridgeIdeas = await suggestBridgeIdeas(cA.name, cB.name, cA_notes, cB_notes);
|
|
bridgeIdeas.forEach((idea, idx) => {
|
|
newSuggestions.push({
|
|
id: `suggestion-${i}-${j}-${idx}`,
|
|
...idea,
|
|
clusterAId: cA.id,
|
|
clusterBId: cB.id
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
setClusters(namedClusters);
|
|
setBridgeNotes(bridges);
|
|
setSuggestions(newSuggestions);
|
|
} catch (error) {
|
|
console.error("Analysis failed:", error);
|
|
} finally {
|
|
setIsCalculating(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (notes.some(n => n.embedding) && clusters.length === 0) {
|
|
performAnalysis();
|
|
}
|
|
}, [notes]);
|
|
|
|
const bridgeList = useMemo(() => {
|
|
return bridgeNotes.map(b => {
|
|
const note = notes.find(n => n.id === b.noteId);
|
|
return { ...b, title: note?.title || 'Unknown Note' };
|
|
});
|
|
}, [bridgeNotes, notes]);
|
|
|
|
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>
|
|
<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">
|
|
<Sparkles size={18} />
|
|
</div>
|
|
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Semantic Insights</h1>
|
|
</div>
|
|
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Discovering the hidden architecture of your knowledge</p>
|
|
</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>
|
|
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Left: Graph View */}
|
|
<div className="flex-[1.5] p-6 relative">
|
|
<NetworkGraph
|
|
notes={notes}
|
|
clusters={clusters}
|
|
bridgeNotes={bridgeNotes}
|
|
onNoteSelect={onNoteSelect}
|
|
/>
|
|
</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 */}
|
|
<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="flex items-center gap-2 text-indigo-500 mb-2">
|
|
<Layers size={14} />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest">Clusters</span>
|
|
</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="flex items-center gap-2 text-ochre mb-2">
|
|
<Trophy size={14} />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest">Bridge Notes</span>
|
|
</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">
|
|
<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>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{bridgeList.map(bridge => (
|
|
<motion.div
|
|
key={bridge.noteId}
|
|
whileHover={{ x: 4 }}
|
|
onClick={() => onNoteSelect(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"
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">{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>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{bridge.connectedClusterIds.map(cid => {
|
|
const c = clusters.find(cl => cl.id === cid);
|
|
return (
|
|
<div key={cid} className="flex items-center gap-1">
|
|
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c?.color }} />
|
|
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{c?.name}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</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>
|
|
</section>
|
|
|
|
{/* Connection Suggestions */}
|
|
<section>
|
|
<div className="flex items-center gap-2 mb-6 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>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{suggestions.map((s, idx) => (
|
|
<div key={s.id} 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-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 {clusters.find(c => c.id === s.clusterAId)?.name} & {clusters.find(c => c.id === s.clusterBId)?.name}</span>
|
|
</div>
|
|
<h4 className="text-base font-serif font-medium text-ink dark:text-dark-ink mb-2">{s.title}</h4>
|
|
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.description}</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.reasoning}</span>
|
|
</div>
|
|
</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>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|