diff --git a/marm-console/artifacts/marm-console/src/components/knowledge/BuildAndDuplicates.tsx b/marm-console/artifacts/marm-console/src/components/knowledge/BuildAndDuplicates.tsx new file mode 100644 index 00000000..db5d507f --- /dev/null +++ b/marm-console/artifacts/marm-console/src/components/knowledge/BuildAndDuplicates.tsx @@ -0,0 +1,181 @@ +import { useState, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useBuildConcepts, useMarmConfig, useFilters, useConceptBuild, useConceptDuplicates } from '@/hooks/use-marm-queries'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Badge, Dialog, DialogContent, DialogHeader, DialogTitle, Table, TableHeader, TableRow, TableHead, TableBody, TableCell, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Label } from '@/components/ui/core'; +import { Play, AlertTriangle } from 'lucide-react'; +import type { ConceptBuildInput } from '@/lib/marm-types'; + +export function BuildConceptsDialog({ open, onOpenChange }: { open: boolean, onOpenChange: (o: boolean) => void }) { + const build = useBuildConcepts(); + const { baseUrl } = useMarmConfig(); + const queryClient = useQueryClient(); + const { data: filters } = useFilters(); + const [jobId, setJobId] = useState(null); + const { data: jobStatus } = useConceptBuild(jobId || ''); + const [scope, setScope] = useState<'session' | 'project' | 'all'>('session'); + const [scopeValue, setScopeValue] = useState(''); + const [confirmAll, setConfirmAll] = useState(false); + + const handleBuild = () => { + const input: ConceptBuildInput = + scope === 'session' ? { session_name: scopeValue } : + scope === 'project' ? { project: scopeValue } : + { search_all: true }; + build.mutate(input, { + onSuccess: (res) => setJobId(res.job_id) + }); + }; + + const isRunning = !!jobId && (!jobStatus || jobStatus.status === 'queued' || jobStatus.status === 'running'); + const canSubmit = scope === 'all' ? confirmAll : !!scopeValue; + + useEffect(() => { + if (!jobStatus || isRunning) return; + queryClient.invalidateQueries({ queryKey: ['conceptsSummary', baseUrl] }); + queryClient.invalidateQueries({ queryKey: ['conceptsSearch', baseUrl] }); + queryClient.invalidateQueries({ queryKey: ['conceptsGraph', baseUrl] }); + queryClient.invalidateQueries({ queryKey: ['duplicates', baseUrl] }); + }, [baseUrl, isRunning, jobStatus, queryClient]); + + useEffect(() => { + if (!open) { + setJobId(null); + setScope('session'); + setScopeValue(''); + setConfirmAll(false); + } + }, [open]); + + return ( + { if(!isRunning) onOpenChange(o); }}> + + + Build Concept Graph + + {!jobId ? ( +
+

+ Extract entities and relationships from unstructured memories. Pick exactly one scope. + This requires processing time against the LLM backend. +

+
+ + +
+ {scope === 'session' && ( + + )} + {scope === 'project' && ( + + )} + {scope === 'all' && ( + + )} + +
+ ) : ( +
+
+ Status + + {jobStatus?.status || 'Starting...'} + +
+ {jobStatus?.status === 'degraded' && ( +
+ +

Degraded mode: {jobStatus.error_code || 'Missing dependencies on server'}

+
+ )} +
+
+
{jobStatus?.entities_extracted || 0}
+
Entities
+
+
+
{jobStatus?.relationships_created || 0}
+
Relationships
+
+
+ {!isRunning && ( + + )} +
+ )} +
+
+ ); +} + +export function DuplicatesTab() { + const { data, isLoading } = useConceptDuplicates(); + + return ( +
+ + + Duplicate Candidates + Review potential concept duplicates based on similarity. (Read-only) + + + + + + Entity A + Entity B + Similarity + + + + {isLoading ? ( + Loading duplicates... + ) : data?.length === 0 ? ( + No duplicate candidates found. + ) : ( + data?.map((dup, i) => ( + + +
{dup.entity_a.name}
+ {dup.entity_a.type} +
+ +
{dup.entity_b.name}
+ {dup.entity_b.type} +
+ + {(dup.similarity * 100).toFixed(1)}% + +
+ )) + )} +
+
+
+
+
+ ); +} diff --git a/marm-console/artifacts/marm-console/src/components/knowledge/ExplorerTab.tsx b/marm-console/artifacts/marm-console/src/components/knowledge/ExplorerTab.tsx new file mode 100644 index 00000000..d572d86b --- /dev/null +++ b/marm-console/artifacts/marm-console/src/components/knowledge/ExplorerTab.tsx @@ -0,0 +1,340 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useConceptsSummary, useSearchConcepts, useNeighborhood, useConceptGraph, useConcept, useMarmConfig } from '@/hooks/use-marm-queries'; +import { Card, CardContent, CardHeader, Input, Button, Badge, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/core'; +import { Search, GitGraph, Network, AlertTriangle, X, ArrowLeft } from 'lucide-react'; +import type { Neighborhood, NeighborhoodNode, ConceptDetail } from '@/lib/marm-types'; +import { DEFAULT_HIDDEN_PREDICATES, typeColor, mergeNeighborhoods } from './shared'; +import { GraphViz } from './GraphViz'; + +function ProvenancePanel({ + node, + detail, + onClose, + onExpand, + onRecenter, + isExpanding, +}: { + node: NeighborhoodNode; + detail?: ConceptDetail; + onClose: () => void; + onExpand: () => void; + onRecenter: () => void; + isExpanding: boolean; +}) { + return ( +
+
+
+ +
{node.name}
+
+ +
+
+
+ {node.type} + {node.session_name && {node.session_name}} + {node.project && {node.project}} +
+
+
+
{node.mention_count}
+
Mentions
+
+
+
{node.degree ?? 0}
+
Connections
+
+
+
+
Linked code
+ {(detail?.linked_code ?? node.linked_code).length === 0 ? ( +

No linked code symbols.

+ ) : ( +
+ {(detail?.linked_code ?? node.linked_code).map((c, i) => ( +
+
{c.qualified_name}
+
{c.file_path}
+
+ ))} +
+ )} +
+
+
Source memories
+ {!detail ? ( +

Loading provenance...

+ ) : detail.source_memories.length === 0 ? ( +

No source memories are available.

+ ) : ( +
+ {detail.source_memories.map((memory) => ( +
+

{memory.content}

+

{memory.session_name}{memory.project ? ` · ${memory.project}` : ''}

+
+ ))} +
+ )} +
+
+
+ {node.hidden_neighbor_count > 0 && ( + + )} + +
+
+ ); +} + +export function ExplorerTab() { + const { data: summary } = useConceptsSummary(); + const { client } = useMarmConfig(); + const [q, setQ] = useState(''); + const [debouncedQ, setDebouncedQ] = useState(''); + const { data: searchResults, isLoading: searchLoading } = useSearchConcepts({ q: debouncedQ, limit: 10 }); + const [selectedId, setSelectedId] = useState(null); + const [direction, setDirection] = useState<'both' | 'incoming' | 'outgoing'>('both'); + const { + data: baseNeighborhood, + isError: neighborhoodError, + isLoading: neighborhoodLoading, + } = useNeighborhood(selectedId!, { depth: 2, direction }); + const { + data: overviewGraph, + isError: overviewError, + isLoading: overviewLoading, + } = useConceptGraph({ limit: 150 }, selectedId === null); + const [graph, setGraph] = useState(null); + const [focusedNode, setFocusedNode] = useState(null); + const [hiddenPredicates, setHiddenPredicates] = useState>(new Set(DEFAULT_HIDDEN_PREDICATES)); + const [hiddenTypes, setHiddenTypes] = useState>(new Set()); + const [expandingId, setExpandingId] = useState(null); + const { data: focusedDetail } = useConcept(focusedNode?.id ?? 0); + + useEffect(() => { + const t = setTimeout(() => setDebouncedQ(q), 300); + return () => clearTimeout(t); + }, [q]); + + // Working graph follows the mode: seed neighborhood or whole-graph overview. + useEffect(() => { + if (selectedId !== null && baseNeighborhood) { + setGraph(baseNeighborhood); + setFocusedNode(null); + } + }, [baseNeighborhood, selectedId]); + + useEffect(() => { + if (selectedId === null && overviewGraph) { + setGraph(overviewGraph); + setFocusedNode(null); + } + }, [overviewGraph, selectedId]); + + const predicates = useMemo(() => { + const set = new Set(); + graph?.edges.forEach(e => set.add(e.predicate)); + return Array.from(set).sort(); + }, [graph]); + + const typeCounts = useMemo(() => { + const counts = new Map(); + graph?.nodes.forEach(n => counts.set(n.type, (counts.get(n.type) || 0) + 1)); + return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]); + }, [graph]); + + const togglePredicate = (p: string) => { + setHiddenPredicates(prev => { + const next = new Set(prev); + if (next.has(p)) next.delete(p); else next.add(p); + return next; + }); + }; + + const toggleType = (t: string) => { + setHiddenTypes(prev => { + const next = new Set(prev); + if (next.has(t)) next.delete(t); else next.add(t); + return next; + }); + }; + + const handleExpand = async (node: NeighborhoodNode) => { + setExpandingId(node.id); + try { + const addition = await client.getConceptNeighborhood(node.id, { depth: 1, direction }); + setGraph(prev => prev ? mergeNeighborhoods(prev, addition) : addition); + } finally { + setExpandingId(null); + } + }; + + const graphKey = selectedId === null ? 'overview' : `seed-${selectedId}`; + const isLoading = selectedId === null ? overviewLoading : neighborhoodLoading; + const loadError = selectedId === null ? overviewError : neighborhoodError; + const isEmpty = (summary?.entities ?? 0) === 0; + + return ( +
+ {/* Left Col: Search & Summary */} +
+ + +
+
+
{summary?.entities.toLocaleString() || 0}
+
Nodes
+
+
+
{summary?.relationships.toLocaleString() || 0}
+
Edges
+
+
+
{summary?.code_links.toLocaleString() || 0}
+
Code Links
+
+
+
+
+ + + +
+ + setQ(e.target.value)} + /> +
+
+ +
+ {searchLoading ? ( +
Searching...
+ ) : searchResults?.length === 0 ? ( +
No entities found.
+ ) : ( + searchResults?.map(entity => ( + + )) + )} +
+
+
+
+ + {/* Right Col: Viz */} +
+
+ {selectedId !== null && ( + + )} + {selectedId !== null && ( + + )} + {typeCounts.map(([t, count]) => ( + + ))} + {typeCounts.length > 0 && predicates.length > 0 && ( + + )} + {predicates.map(p => ( + + ))} +
+
+ {isEmpty ? ( +
+ +

No knowledge graph yet

+

+ Run Build Concepts to extract entities and relationships from your stored memories, + then explore them here. +

+
+ ) : loadError ? ( +
+ +

{selectedId === null ? 'Could not load the knowledge graph.' : 'Could not load this neighborhood.'}

+
+ ) : graph && !isLoading ? ( + <> + + {focusedNode && ( + setFocusedNode(null)} + onExpand={() => handleExpand(focusedNode)} + onRecenter={() => setSelectedId(focusedNode.id)} + isExpanding={expandingId === focusedNode.id} + /> + )} + + ) : ( +
+ Loading graph... +
+ )} +
+
+
+ ); +} diff --git a/marm-console/artifacts/marm-console/src/components/knowledge/GraphViz.tsx b/marm-console/artifacts/marm-console/src/components/knowledge/GraphViz.tsx new file mode 100644 index 00000000..778be2c4 --- /dev/null +++ b/marm-console/artifacts/marm-console/src/components/knowledge/GraphViz.tsx @@ -0,0 +1,291 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import ForceGraph2D from 'react-force-graph-2d'; +import { forceCollide } from 'd3-force'; +import { Badge, Button } from '@/components/ui/core'; +import { Play, Pause, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; +import type { Neighborhood, NeighborhoodNode } from '@/lib/marm-types'; +import { typeColor, nodeRadius } from './shared'; + +export function GraphViz({ + neighborhood, + hiddenPredicates, + hiddenTypes, + onNodeClick, + focusedId, + expandingId, +}: { + neighborhood: Neighborhood; + hiddenPredicates: Set; + hiddenTypes: Set; + onNodeClick: (node: NeighborhoodNode) => void; + focusedId: number | null; + expandingId: number | null; +}) { + const containerRef = useRef(null); + const fgRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + const [hoverId, setHoverId] = useState(null); + const [paused, setPaused] = useState(false); + const didInitialFit = useRef(false); + const pinnedRef = useRef>(new Map()); + const reducedMotion = useMemo( + () => window.matchMedia('(prefers-reduced-motion: reduce)').matches, + [] + ); + + useEffect(() => { + if (!containerRef.current) return; + const obs = new ResizeObserver(entries => { + setDimensions({ + width: entries[0].contentRect.width, + height: entries[0].contentRect.height + }); + }); + obs.observe(containerRef.current); + return () => obs.disconnect(); + }, []); + + const gData = useMemo(() => { + const visibleNodeIds = new Set( + neighborhood.nodes.filter(n => !hiddenTypes.has(n.type)).map(n => n.id) + ); + const visibleEdges = neighborhood.edges.filter( + e => !hiddenPredicates.has(e.predicate) && visibleNodeIds.has(e.source) && visibleNodeIds.has(e.target) + ); + // Filtering edges out must not leave stranded dots: keep only nodes that + // still connect to something, plus the seed and the focused node. + const connected = new Set(); + visibleEdges.forEach(e => { connected.add(e.source); connected.add(e.target); }); + if (neighborhood.seed_id !== null) connected.add(neighborhood.seed_id); + if (focusedId !== null) connected.add(focusedId); + const nodes = neighborhood.nodes + .filter(n => visibleNodeIds.has(n.id) && connected.has(n.id)) + .map((n) => { + const pinned = pinnedRef.current.get(n.id); + return { + id: n.id, + name: n.name, + type: n.type, + degree: n.degree ?? n.mention_count, + hiddenNeighborCount: n.hidden_neighbor_count, + isSeed: n.id === neighborhood.seed_id, + ...(pinned ? { fx: pinned.x, fy: pinned.y } : {}), + }; + }); + return { + nodes, + links: visibleEdges.map((e) => ({ + source: e.source, + target: e.target, + predicate: e.predicate, + })), + }; + }, [neighborhood, hiddenPredicates, hiddenTypes, focusedId]); + + const adjacency = useMemo(() => { + const map = new Map>(); + gData.links.forEach((l: any) => { + const s = typeof l.source === 'object' ? l.source.id : l.source; + const t = typeof l.target === 'object' ? l.target.id : l.target; + if (!map.has(s)) map.set(s, new Set()); + if (!map.has(t)) map.set(t, new Set()); + map.get(s)!.add(t); + map.get(t)!.add(s); + }); + return map; + }, [gData]); + + const hoverNeighbors = hoverId !== null ? adjacency.get(hoverId) : null; + + useEffect(() => { + const fg = fgRef.current; + if (!fg) return; + fg.d3Force('charge')?.strength(-110).distanceMax(280); + fg.d3Force('link')?.distance((l: any) => (l.predicate === 'co_occurs_with' ? 62 : 44)); + fg.d3Force('collide', forceCollide((n: any) => nodeRadius(n.degree) + 4)); + }, [gData]); + + // Pin positions after the first settle so expansions don't re-layout + // everything, then frame the graph once. + const handleEngineStop = useCallback(() => { + const fg = fgRef.current; + if (!fg) return; + const nodes: any[] = fg.graphData?.().nodes || []; + nodes.forEach(n => { + if (typeof n.x === 'number' && typeof n.y === 'number') { + pinnedRef.current.set(n.id, { x: n.x, y: n.y }); + } + }); + if (!didInitialFit.current) { + didInitialFit.current = true; + fg.zoomToFit(reducedMotion ? 0 : 500, 60); + if (reducedMotion) { + fg.pauseAnimation(); + setPaused(true); + } + } + }, [reducedMotion]); + + const isDimmed = useCallback((id: number) => { + if (hoverId === null) return false; + return id !== hoverId && !hoverNeighbors?.has(id); + }, [hoverId, hoverNeighbors]); + + const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + const r = nodeRadius(node.degree); + const color = typeColor(node.type); + const dimmed = isDimmed(node.id); + const emphasized = node.id === hoverId || node.id === focusedId || node.isSeed; + + ctx.globalAlpha = dimmed ? 0.12 : 1; + + // Soft halo keeps hubs luminous on the near-black canvas. + if (!dimmed) { + ctx.beginPath(); + ctx.arc(node.x, node.y, r * 2, 0, 2 * Math.PI); + ctx.fillStyle = color; + ctx.globalAlpha = emphasized ? 0.28 : 0.13; + ctx.fill(); + ctx.globalAlpha = 1; + } + + ctx.beginPath(); + ctx.arc(node.x, node.y, r, 0, 2 * Math.PI); + ctx.fillStyle = color; + ctx.fill(); + + if (node.isSeed || node.id === focusedId) { + ctx.beginPath(); + ctx.arc(node.x, node.y, r + 1.6, 0, 2 * Math.PI); + ctx.strokeStyle = node.isSeed ? '#e2e8f0' : '#38bdf8'; + ctx.lineWidth = 1.4; + ctx.stroke(); + } + + // Labels: hubs always, everything when zoomed in or highlighted. + const showLabel = !dimmed && ( + emphasized || r * globalScale > 10 || globalScale > 2.4 + ); + if (showLabel) { + const fontSize = Math.max(11 / globalScale, 2.4); + ctx.font = `500 ${fontSize}px 'JetBrains Mono', monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + const labelY = node.y + r + 2.5 / globalScale; + ctx.lineWidth = 3 / globalScale; + ctx.strokeStyle = 'rgba(4, 8, 16, 0.92)'; + ctx.strokeText(node.name, node.x, labelY); + ctx.fillStyle = emphasized ? '#f1f5f9' : '#94a3b8'; + ctx.fillText(node.name, node.x, labelY); + + if (node.hiddenNeighborCount > 0) { + const badge = `+${node.hiddenNeighborCount}`; + ctx.font = `${Math.max(9 / globalScale, 2)}px 'JetBrains Mono', monospace`; + ctx.textBaseline = 'bottom'; + ctx.strokeText(badge, node.x + r + 5 / globalScale, node.y - r); + ctx.fillStyle = expandingId === node.id ? '#fbbf24' : '#38bdf8'; + ctx.fillText(badge, node.x + r + 5 / globalScale, node.y - r); + } + } + ctx.globalAlpha = 1; + }, [hoverId, focusedId, expandingId, isDimmed]); + + const paintPointerArea = useCallback((node: any, color: string, ctx: CanvasRenderingContext2D) => { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(node.x, node.y, nodeRadius(node.degree) + 5, 0, 2 * Math.PI); + ctx.fill(); + }, []); + + const linkColor = useCallback((link: any) => { + const s = typeof link.source === 'object' ? link.source.id : link.source; + const t = typeof link.target === 'object' ? link.target.id : link.target; + if (hoverId !== null) { + if (s === hoverId || t === hoverId) return 'rgba(56, 189, 248, 0.55)'; + return 'rgba(148, 163, 184, 0.04)'; + } + return link.predicate === 'co_occurs_with' + ? 'rgba(148, 163, 184, 0.08)' + : 'rgba(148, 163, 184, 0.18)'; + }, [hoverId]); + + const linkWidth = useCallback((link: any) => { + const s = typeof link.source === 'object' ? link.source.id : link.source; + const t = typeof link.target === 'object' ? link.target.id : link.target; + return hoverId !== null && (s === hoverId || t === hoverId) ? 1.4 : 0.6; + }, [hoverId]); + + const togglePause = () => { + const fg = fgRef.current; + if (!fg) return; + if (paused) fg.resumeAnimation(); else fg.pauseAnimation(); + setPaused(!paused); + }; + + const zoomBy = (factor: number) => { + const fg = fgRef.current; + if (!fg) return; + fg.zoom(fg.zoom() * factor, 200); + }; + + return ( +
+ {dimensions.width > 0 && ( + ''} + linkLabel={(l: any) => l.predicate} + nodeCanvasObjectMode={() => 'replace'} + nodeCanvasObject={paintNode} + nodePointerAreaPaint={paintPointerArea} + linkColor={linkColor} + linkWidth={linkWidth} + linkCurvature={0.12} + linkDirectionalArrowLength={(l: any) => (l.predicate === 'co_occurs_with' ? 0 : 2.6)} + linkDirectionalArrowRelPos={1} + onNodeHover={(node: any) => setHoverId(node ? node.id : null)} + onNodeClick={(node: any) => { + const full = neighborhood.nodes.find((n) => n.id === node.id); + if (full) onNodeClick(full); + }} + onBackgroundClick={() => setHoverId(null)} + onEngineStop={handleEngineStop} + cooldownTicks={120} + /> + )} +
+ + {gData.nodes.length} Nodes + + + {gData.links.length} Edges + + {neighborhood.truncated && ( + + Budget Truncated + + )} +
+
+ + + + +
+
+ Hover to trace connections · click a node for details +
+
+ ); +} diff --git a/marm-console/artifacts/marm-console/src/components/knowledge/shared.tsx b/marm-console/artifacts/marm-console/src/components/knowledge/shared.tsx new file mode 100644 index 00000000..71f13e3c --- /dev/null +++ b/marm-console/artifacts/marm-console/src/components/knowledge/shared.tsx @@ -0,0 +1,41 @@ +import type { Neighborhood, NeighborhoodNode, NeighborhoodEdge } from '@/lib/marm-types'; + +export const DEFAULT_HIDDEN_PREDICATES = new Set(['co_occurs_with']); + +// CVD-validated categorical palette for the dark canvas (dataviz six-checks, +// surface #040810). Identity is never color alone: labels + legend back it up. +const TYPE_COLORS: Record = { + concept: '#0284c7', + decision: '#8b5cf6', + pattern: '#d97706', + tool: '#059669', + person: '#ec4899', + error: '#ef4444', + org: '#ea580c', + product: '#65a30d', +}; +const OTHER_TYPE_COLOR = '#64748b'; + +export function typeColor(type: string): string { + return TYPE_COLORS[type] ?? OTHER_TYPE_COLOR; +} + +export function nodeRadius(degree: number): number { + return Math.min(2.5 + Math.sqrt(Math.max(degree, 1)) * 1.3, 13); +} + +export function mergeNeighborhoods(base: Neighborhood, addition: Neighborhood): Neighborhood { + const nodeMap = new Map(); + base.nodes.forEach(n => nodeMap.set(n.id, n)); + addition.nodes.forEach(n => nodeMap.set(n.id, n)); + const edgeMap = new Map(); + base.edges.forEach(e => edgeMap.set(e.id, e)); + addition.edges.forEach(e => edgeMap.set(e.id, e)); + return { + seed_id: base.seed_id, + nodes: Array.from(nodeMap.values()), + edges: Array.from(edgeMap.values()), + limits: base.limits, + truncated: base.truncated || addition.truncated, + }; +} diff --git a/marm-console/artifacts/marm-console/src/pages/Knowledge.tsx b/marm-console/artifacts/marm-console/src/pages/Knowledge.tsx index 6d078725..c611ac09 100644 --- a/marm-console/artifacts/marm-console/src/pages/Knowledge.tsx +++ b/marm-console/artifacts/marm-console/src/pages/Knowledge.tsx @@ -1,834 +1,8 @@ -import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; -import { useConceptsSummary, useSearchConcepts, useNeighborhood, useConceptGraph, useConcept, useBuildConcepts, useConceptBuild, useConceptDuplicates, useMarmConfig, useFilters } from '@/hooks/use-marm-queries'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription, Input, Button, Badge, Dialog, DialogContent, DialogHeader, DialogTitle, Tabs, TabsList, TabsTrigger, TabsContent, Table, TableHeader, TableRow, TableHead, TableBody, TableCell, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Label } from '@/components/ui/core'; -import { Search, GitGraph, Network, AlertTriangle, Layers, Play, Pause, X, ZoomIn, ZoomOut, Maximize2, ArrowLeft } from 'lucide-react'; -import ForceGraph2D from 'react-force-graph-2d'; -import { forceCollide } from 'd3-force'; -import { useQueryClient } from '@tanstack/react-query'; -import type { Neighborhood, NeighborhoodNode, NeighborhoodEdge, ConceptBuildInput, ConceptDetail } from '@/lib/marm-types'; - -const DEFAULT_HIDDEN_PREDICATES = new Set(['co_occurs_with']); - -// CVD-validated categorical palette for the dark canvas (dataviz six-checks, -// surface #040810). Identity is never color alone: labels + legend back it up. -const TYPE_COLORS: Record = { - concept: '#0284c7', - decision: '#8b5cf6', - pattern: '#d97706', - tool: '#059669', - person: '#ec4899', - error: '#ef4444', - org: '#ea580c', - product: '#65a30d', -}; -const OTHER_TYPE_COLOR = '#64748b'; - -function typeColor(type: string): string { - return TYPE_COLORS[type] ?? OTHER_TYPE_COLOR; -} - -function nodeRadius(degree: number): number { - return Math.min(2.5 + Math.sqrt(Math.max(degree, 1)) * 1.3, 13); -} - -function mergeNeighborhoods(base: Neighborhood, addition: Neighborhood): Neighborhood { - const nodeMap = new Map(); - base.nodes.forEach(n => nodeMap.set(n.id, n)); - addition.nodes.forEach(n => nodeMap.set(n.id, n)); - const edgeMap = new Map(); - base.edges.forEach(e => edgeMap.set(e.id, e)); - addition.edges.forEach(e => edgeMap.set(e.id, e)); - return { - seed_id: base.seed_id, - nodes: Array.from(nodeMap.values()), - edges: Array.from(edgeMap.values()), - limits: base.limits, - truncated: base.truncated || addition.truncated, - }; -} - -function GraphViz({ - neighborhood, - hiddenPredicates, - hiddenTypes, - onNodeClick, - focusedId, - expandingId, -}: { - neighborhood: Neighborhood; - hiddenPredicates: Set; - hiddenTypes: Set; - onNodeClick: (node: NeighborhoodNode) => void; - focusedId: number | null; - expandingId: number | null; -}) { - const containerRef = useRef(null); - const fgRef = useRef(null); - const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); - const [hoverId, setHoverId] = useState(null); - const [paused, setPaused] = useState(false); - const didInitialFit = useRef(false); - const pinnedRef = useRef>(new Map()); - const reducedMotion = useMemo( - () => window.matchMedia('(prefers-reduced-motion: reduce)').matches, - [] - ); - - useEffect(() => { - if (!containerRef.current) return; - const obs = new ResizeObserver(entries => { - setDimensions({ - width: entries[0].contentRect.width, - height: entries[0].contentRect.height - }); - }); - obs.observe(containerRef.current); - return () => obs.disconnect(); - }, []); - - const gData = useMemo(() => { - const visibleNodeIds = new Set( - neighborhood.nodes.filter(n => !hiddenTypes.has(n.type)).map(n => n.id) - ); - const visibleEdges = neighborhood.edges.filter( - e => !hiddenPredicates.has(e.predicate) && visibleNodeIds.has(e.source) && visibleNodeIds.has(e.target) - ); - // Filtering edges out must not leave stranded dots: keep only nodes that - // still connect to something, plus the seed and the focused node. - const connected = new Set(); - visibleEdges.forEach(e => { connected.add(e.source); connected.add(e.target); }); - if (neighborhood.seed_id !== null) connected.add(neighborhood.seed_id); - if (focusedId !== null) connected.add(focusedId); - const nodes = neighborhood.nodes - .filter(n => visibleNodeIds.has(n.id) && connected.has(n.id)) - .map((n) => { - const pinned = pinnedRef.current.get(n.id); - return { - id: n.id, - name: n.name, - type: n.type, - degree: n.degree ?? n.mention_count, - hiddenNeighborCount: n.hidden_neighbor_count, - isSeed: n.id === neighborhood.seed_id, - ...(pinned ? { fx: pinned.x, fy: pinned.y } : {}), - }; - }); - return { - nodes, - links: visibleEdges.map((e) => ({ - source: e.source, - target: e.target, - predicate: e.predicate, - })), - }; - }, [neighborhood, hiddenPredicates, hiddenTypes, focusedId]); - - const adjacency = useMemo(() => { - const map = new Map>(); - gData.links.forEach((l: any) => { - const s = typeof l.source === 'object' ? l.source.id : l.source; - const t = typeof l.target === 'object' ? l.target.id : l.target; - if (!map.has(s)) map.set(s, new Set()); - if (!map.has(t)) map.set(t, new Set()); - map.get(s)!.add(t); - map.get(t)!.add(s); - }); - return map; - }, [gData]); - - const hoverNeighbors = hoverId !== null ? adjacency.get(hoverId) : null; - - useEffect(() => { - const fg = fgRef.current; - if (!fg) return; - fg.d3Force('charge')?.strength(-110).distanceMax(280); - fg.d3Force('link')?.distance((l: any) => (l.predicate === 'co_occurs_with' ? 62 : 44)); - fg.d3Force('collide', forceCollide((n: any) => nodeRadius(n.degree) + 4)); - }, [gData]); - - // Pin positions after the first settle so expansions don't re-layout - // everything, then frame the graph once. - const handleEngineStop = useCallback(() => { - const fg = fgRef.current; - if (!fg) return; - const nodes: any[] = fg.graphData?.().nodes || []; - nodes.forEach(n => { - if (typeof n.x === 'number' && typeof n.y === 'number') { - pinnedRef.current.set(n.id, { x: n.x, y: n.y }); - } - }); - if (!didInitialFit.current) { - didInitialFit.current = true; - fg.zoomToFit(reducedMotion ? 0 : 500, 60); - if (reducedMotion) { - fg.pauseAnimation(); - setPaused(true); - } - } - }, [reducedMotion]); - - const isDimmed = useCallback((id: number) => { - if (hoverId === null) return false; - return id !== hoverId && !hoverNeighbors?.has(id); - }, [hoverId, hoverNeighbors]); - - const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { - const r = nodeRadius(node.degree); - const color = typeColor(node.type); - const dimmed = isDimmed(node.id); - const emphasized = node.id === hoverId || node.id === focusedId || node.isSeed; - - ctx.globalAlpha = dimmed ? 0.12 : 1; - - // Soft halo keeps hubs luminous on the near-black canvas. - if (!dimmed) { - ctx.beginPath(); - ctx.arc(node.x, node.y, r * 2, 0, 2 * Math.PI); - ctx.fillStyle = color; - ctx.globalAlpha = emphasized ? 0.28 : 0.13; - ctx.fill(); - ctx.globalAlpha = 1; - } - - ctx.beginPath(); - ctx.arc(node.x, node.y, r, 0, 2 * Math.PI); - ctx.fillStyle = color; - ctx.fill(); - - if (node.isSeed || node.id === focusedId) { - ctx.beginPath(); - ctx.arc(node.x, node.y, r + 1.6, 0, 2 * Math.PI); - ctx.strokeStyle = node.isSeed ? '#e2e8f0' : '#38bdf8'; - ctx.lineWidth = 1.4; - ctx.stroke(); - } - - // Labels: hubs always, everything when zoomed in or highlighted. - const showLabel = !dimmed && ( - emphasized || r * globalScale > 10 || globalScale > 2.4 - ); - if (showLabel) { - const fontSize = Math.max(11 / globalScale, 2.4); - ctx.font = `500 ${fontSize}px 'JetBrains Mono', monospace`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'top'; - const labelY = node.y + r + 2.5 / globalScale; - ctx.lineWidth = 3 / globalScale; - ctx.strokeStyle = 'rgba(4, 8, 16, 0.92)'; - ctx.strokeText(node.name, node.x, labelY); - ctx.fillStyle = emphasized ? '#f1f5f9' : '#94a3b8'; - ctx.fillText(node.name, node.x, labelY); - - if (node.hiddenNeighborCount > 0) { - const badge = `+${node.hiddenNeighborCount}`; - ctx.font = `${Math.max(9 / globalScale, 2)}px 'JetBrains Mono', monospace`; - ctx.textBaseline = 'bottom'; - ctx.strokeText(badge, node.x + r + 5 / globalScale, node.y - r); - ctx.fillStyle = expandingId === node.id ? '#fbbf24' : '#38bdf8'; - ctx.fillText(badge, node.x + r + 5 / globalScale, node.y - r); - } - } - ctx.globalAlpha = 1; - }, [hoverId, focusedId, expandingId, isDimmed]); - - const paintPointerArea = useCallback((node: any, color: string, ctx: CanvasRenderingContext2D) => { - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(node.x, node.y, nodeRadius(node.degree) + 5, 0, 2 * Math.PI); - ctx.fill(); - }, []); - - const linkColor = useCallback((link: any) => { - const s = typeof link.source === 'object' ? link.source.id : link.source; - const t = typeof link.target === 'object' ? link.target.id : link.target; - if (hoverId !== null) { - if (s === hoverId || t === hoverId) return 'rgba(56, 189, 248, 0.55)'; - return 'rgba(148, 163, 184, 0.04)'; - } - return link.predicate === 'co_occurs_with' - ? 'rgba(148, 163, 184, 0.08)' - : 'rgba(148, 163, 184, 0.18)'; - }, [hoverId]); - - const linkWidth = useCallback((link: any) => { - const s = typeof link.source === 'object' ? link.source.id : link.source; - const t = typeof link.target === 'object' ? link.target.id : link.target; - return hoverId !== null && (s === hoverId || t === hoverId) ? 1.4 : 0.6; - }, [hoverId]); - - const togglePause = () => { - const fg = fgRef.current; - if (!fg) return; - if (paused) fg.resumeAnimation(); else fg.pauseAnimation(); - setPaused(!paused); - }; - - const zoomBy = (factor: number) => { - const fg = fgRef.current; - if (!fg) return; - fg.zoom(fg.zoom() * factor, 200); - }; - - return ( -
- {dimensions.width > 0 && ( - ''} - linkLabel={(l: any) => l.predicate} - nodeCanvasObjectMode={() => 'replace'} - nodeCanvasObject={paintNode} - nodePointerAreaPaint={paintPointerArea} - linkColor={linkColor} - linkWidth={linkWidth} - linkCurvature={0.12} - linkDirectionalArrowLength={(l: any) => (l.predicate === 'co_occurs_with' ? 0 : 2.6)} - linkDirectionalArrowRelPos={1} - onNodeHover={(node: any) => setHoverId(node ? node.id : null)} - onNodeClick={(node: any) => { - const full = neighborhood.nodes.find((n) => n.id === node.id); - if (full) onNodeClick(full); - }} - onBackgroundClick={() => setHoverId(null)} - onEngineStop={handleEngineStop} - cooldownTicks={120} - /> - )} -
- - {gData.nodes.length} Nodes - - - {gData.links.length} Edges - - {neighborhood.truncated && ( - - Budget Truncated - - )} -
-
- - - - -
-
- Hover to trace connections · click a node for details -
-
- ); -} - -function BuildConceptsDialog({ open, onOpenChange }: { open: boolean, onOpenChange: (o: boolean) => void }) { - const build = useBuildConcepts(); - const { baseUrl } = useMarmConfig(); - const queryClient = useQueryClient(); - const { data: filters } = useFilters(); - const [jobId, setJobId] = useState(null); - const { data: jobStatus } = useConceptBuild(jobId || ''); - const [scope, setScope] = useState<'session' | 'project' | 'all'>('session'); - const [scopeValue, setScopeValue] = useState(''); - const [confirmAll, setConfirmAll] = useState(false); - - const handleBuild = () => { - const input: ConceptBuildInput = - scope === 'session' ? { session_name: scopeValue } : - scope === 'project' ? { project: scopeValue } : - { search_all: true }; - build.mutate(input, { - onSuccess: (res) => setJobId(res.job_id) - }); - }; - - const isRunning = jobStatus?.status === 'queued' || jobStatus?.status === 'running'; - const canSubmit = scope === 'all' ? confirmAll : !!scopeValue; - - useEffect(() => { - if (!jobStatus || isRunning) return; - queryClient.invalidateQueries({ queryKey: ['conceptsSummary', baseUrl] }); - queryClient.invalidateQueries({ queryKey: ['conceptsSearch', baseUrl] }); - queryClient.invalidateQueries({ queryKey: ['conceptsGraph', baseUrl] }); - queryClient.invalidateQueries({ queryKey: ['duplicates', baseUrl] }); - }, [baseUrl, isRunning, jobStatus, queryClient]); - - return ( - { if(!isRunning) onOpenChange(o); }}> - - - Build Concept Graph - - {!jobId ? ( -
-

- Extract entities and relationships from unstructured memories. Pick exactly one scope. - This requires processing time against the LLM backend. -

-
- - -
- {scope === 'session' && ( - - )} - {scope === 'project' && ( - - )} - {scope === 'all' && ( - - )} - -
- ) : ( -
-
- Status - - {jobStatus?.status || 'Starting...'} - -
- {jobStatus?.status === 'degraded' && ( -
- -

Degraded mode: {jobStatus.error_code || 'Missing dependencies on server'}

-
- )} -
-
-
{jobStatus?.entities_extracted || 0}
-
Entities
-
-
-
{jobStatus?.relationships_created || 0}
-
Relationships
-
-
- {!isRunning && ( - - )} -
- )} -
-
- ); -} - -function ProvenancePanel({ - node, - detail, - onClose, - onExpand, - onRecenter, - isExpanding, -}: { - node: NeighborhoodNode; - detail?: ConceptDetail; - onClose: () => void; - onExpand: () => void; - onRecenter: () => void; - isExpanding: boolean; -}) { - return ( -
-
-
- -
{node.name}
-
- -
-
-
- {node.type} - {node.session_name && {node.session_name}} - {node.project && {node.project}} -
-
-
-
{node.mention_count}
-
Mentions
-
-
-
{node.degree ?? 0}
-
Connections
-
-
-
-
Linked code
- {(detail?.linked_code ?? node.linked_code).length === 0 ? ( -

No linked code symbols.

- ) : ( -
- {(detail?.linked_code ?? node.linked_code).map((c, i) => ( -
-
{c.qualified_name}
-
{c.file_path}
-
- ))} -
- )} -
-
-
Source memories
- {!detail ? ( -

Loading provenance...

- ) : detail.source_memories.length === 0 ? ( -

No source memories are available.

- ) : ( -
- {detail.source_memories.map((memory) => ( -
-

{memory.content}

-

{memory.session_name}{memory.project ? ` · ${memory.project}` : ''}

-
- ))} -
- )} -
-
-
- {node.hidden_neighbor_count > 0 && ( - - )} - -
-
- ); -} - -function ExplorerTab() { - const { data: summary } = useConceptsSummary(); - const { client } = useMarmConfig(); - const [q, setQ] = useState(''); - const [debouncedQ, setDebouncedQ] = useState(''); - const { data: searchResults, isLoading: searchLoading } = useSearchConcepts({ q: debouncedQ, limit: 10 }); - const [selectedId, setSelectedId] = useState(null); - const [direction, setDirection] = useState<'both' | 'incoming' | 'outgoing'>('both'); - const { - data: baseNeighborhood, - isError: neighborhoodError, - isLoading: neighborhoodLoading, - } = useNeighborhood(selectedId!, { depth: 2, direction }); - const { - data: overviewGraph, - isError: overviewError, - isLoading: overviewLoading, - } = useConceptGraph({ limit: 150 }, selectedId === null); - const [graph, setGraph] = useState(null); - const [focusedNode, setFocusedNode] = useState(null); - const [hiddenPredicates, setHiddenPredicates] = useState>(new Set(DEFAULT_HIDDEN_PREDICATES)); - const [hiddenTypes, setHiddenTypes] = useState>(new Set()); - const [expandingId, setExpandingId] = useState(null); - const { data: focusedDetail } = useConcept(focusedNode?.id ?? 0); - - useEffect(() => { - const t = setTimeout(() => setDebouncedQ(q), 300); - return () => clearTimeout(t); - }, [q]); - - // Working graph follows the mode: seed neighborhood or whole-graph overview. - useEffect(() => { - if (selectedId !== null && baseNeighborhood) { - setGraph(baseNeighborhood); - setFocusedNode(null); - } - }, [baseNeighborhood, selectedId]); - - useEffect(() => { - if (selectedId === null && overviewGraph) { - setGraph(overviewGraph); - setFocusedNode(null); - } - }, [overviewGraph, selectedId]); - - const predicates = useMemo(() => { - const set = new Set(); - graph?.edges.forEach(e => set.add(e.predicate)); - return Array.from(set).sort(); - }, [graph]); - - const typeCounts = useMemo(() => { - const counts = new Map(); - graph?.nodes.forEach(n => counts.set(n.type, (counts.get(n.type) || 0) + 1)); - return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]); - }, [graph]); - - const togglePredicate = (p: string) => { - setHiddenPredicates(prev => { - const next = new Set(prev); - if (next.has(p)) next.delete(p); else next.add(p); - return next; - }); - }; - - const toggleType = (t: string) => { - setHiddenTypes(prev => { - const next = new Set(prev); - if (next.has(t)) next.delete(t); else next.add(t); - return next; - }); - }; - - const handleExpand = async (node: NeighborhoodNode) => { - setExpandingId(node.id); - try { - const addition = await client.getConceptNeighborhood(node.id, { depth: 1, direction }); - setGraph(prev => prev ? mergeNeighborhoods(prev, addition) : addition); - } finally { - setExpandingId(null); - } - }; - - const graphKey = selectedId === null ? 'overview' : `seed-${selectedId}`; - const isLoading = selectedId === null ? overviewLoading : neighborhoodLoading; - const loadError = selectedId === null ? overviewError : neighborhoodError; - const isEmpty = (summary?.entities ?? 0) === 0; - - return ( -
- {/* Left Col: Search & Summary */} -
- - -
-
-
{summary?.entities.toLocaleString() || 0}
-
Nodes
-
-
-
{summary?.relationships.toLocaleString() || 0}
-
Edges
-
-
-
{summary?.code_links.toLocaleString() || 0}
-
Code Links
-
-
-
-
- - - -
- - setQ(e.target.value)} - /> -
-
- -
- {searchLoading ? ( -
Searching...
- ) : searchResults?.length === 0 ? ( -
No entities found.
- ) : ( - searchResults?.map(entity => ( - - )) - )} -
-
-
-
- - {/* Right Col: Viz */} -
-
- {selectedId !== null && ( - - )} - {selectedId !== null && ( - - )} - {typeCounts.map(([t, count]) => ( - - ))} - {typeCounts.length > 0 && predicates.length > 0 && ( - - )} - {predicates.map(p => ( - - ))} -
-
- {isEmpty ? ( -
- -

No knowledge graph yet

-

- Run Build Concepts to extract entities and relationships from your stored memories, - then explore them here. -

-
- ) : graph && !isLoading ? ( - <> - - {focusedNode && ( - setFocusedNode(null)} - onExpand={() => handleExpand(focusedNode)} - onRecenter={() => setSelectedId(focusedNode.id)} - isExpanding={expandingId === focusedNode.id} - /> - )} - - ) : loadError ? ( -
- -

{selectedId === null ? 'Could not load the knowledge graph.' : 'Could not load this neighborhood.'}

-
- ) : ( -
- Loading graph... -
- )} -
-
-
- ); -} - -function DuplicatesTab() { - const { data, isLoading } = useConceptDuplicates(); - - return ( -
- - - Duplicate Candidates - Review potential concept duplicates based on similarity. (Read-only) - - - - - - Entity A - Entity B - Similarity - - - - {isLoading ? ( - Loading duplicates... - ) : data?.length === 0 ? ( - No duplicate candidates found. - ) : ( - data?.map((dup, i) => ( - - -
{dup.entity_a.name}
- {dup.entity_a.type} -
- -
{dup.entity_b.name}
- {dup.entity_b.type} -
- - {(dup.similarity * 100).toFixed(1)}% - -
- )) - )} -
-
-
-
-
- ); -} +import { useState } from 'react'; +import { Button, Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/core'; +import { Layers } from 'lucide-react'; +import { ExplorerTab } from '@/components/knowledge/ExplorerTab'; +import { BuildConceptsDialog, DuplicatesTab } from '@/components/knowledge/BuildAndDuplicates'; export function KnowledgePage() { const [buildOpen, setBuildOpen] = useState(false);