mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(memory): redesign memory workspace with graph, insights, heatmap (#185)
* updated memory files * updated memory * Refactor MemoryHeatmap component to enhance functionality and improve performance - Updated the MemoryHeatmap component to track document/relation timestamps over the last 8 months instead of 52 weeks. - Improved date handling by aligning the start date to the nearest Sunday and counting timestamps within the display range. - Enhanced grid generation logic to dynamically calculate the number of weeks displayed based on the available data. - Adjusted SVG dimensions to ensure proper scaling and responsiveness. - Updated UI text to reflect the new time frame for event tracking. These changes improve the accuracy and usability of the MemoryHeatmap, providing a clearer view of user activity over time. * feat(memory): redesign memory workspace with graph, insights, heatmap Break MemoryWorkspace into focused sub-components (MemoryStatsBar, MemoryGraphMap, MemoryInsights, MemoryHeatmap). Fix lint errors in MemoryGraphMap (window.rAF, unused ref). Change heatmap to fixed 8-month range with responsive SVG width. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: format MemoryGraphMap with prettier Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tests): update MemoryWorkspace tests for refactored components Align test assertions with the new sub-component structure (MemoryStatsBar, MemoryGraphMap, MemoryInsights, MemoryHeatmap). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0dd4d1f60b
commit
cb3f2d351c
@@ -0,0 +1,372 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { GraphRelation } from '../../utils/tauriCommands';
|
||||
|
||||
interface MemoryGraphMapProps {
|
||||
relations: GraphRelation[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
namespace: string | null;
|
||||
connectionCount: number;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
predicate: string;
|
||||
}
|
||||
|
||||
const NAMESPACE_COLORS = [
|
||||
'#4A83DD', // ocean blue
|
||||
'#4DC46F', // sage green
|
||||
'#E8A838', // amber
|
||||
'#9B8AFB', // lavender
|
||||
'#F56565', // coral
|
||||
'#7DD3FC', // sky
|
||||
'#FDA4AF', // rose
|
||||
'#6EE7B7', // mint
|
||||
];
|
||||
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 500;
|
||||
const MAX_NODES = 100;
|
||||
const MAX_EDGES = 200;
|
||||
|
||||
function truncate(s: string, max = 15): string {
|
||||
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
||||
}
|
||||
|
||||
function buildGraph(relations: GraphRelation[]): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
||||
// Cap edges by evidence count descending
|
||||
const sorted = [...relations].sort((a, b) => b.evidenceCount - a.evidenceCount);
|
||||
const cappedRelations = sorted.slice(0, MAX_EDGES);
|
||||
|
||||
// Collect unique entity ids
|
||||
const entitySet = new Map<string, { namespace: string | null; count: number }>();
|
||||
|
||||
for (const r of cappedRelations) {
|
||||
const subKey = r.subject.toLowerCase();
|
||||
const objKey = r.object.toLowerCase();
|
||||
|
||||
const existing = entitySet.get(subKey);
|
||||
entitySet.set(subKey, { namespace: r.namespace, count: (existing?.count ?? 0) + 1 });
|
||||
|
||||
const existingObj = entitySet.get(objKey);
|
||||
entitySet.set(objKey, {
|
||||
namespace: existingObj?.namespace ?? r.namespace,
|
||||
count: (existingObj?.count ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by connection count, cap at MAX_NODES
|
||||
const sortedEntities = [...entitySet.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||
const cappedEntities = sortedEntities.slice(0, MAX_NODES);
|
||||
const allowedIds = new Set(cappedEntities.map(([id]) => id));
|
||||
|
||||
const nodes: GraphNode[] = cappedEntities.map(([id, info]) => ({
|
||||
id,
|
||||
label: id,
|
||||
namespace: info.namespace,
|
||||
connectionCount: info.count,
|
||||
x: 80 + Math.random() * (WIDTH - 160),
|
||||
y: 80 + Math.random() * (HEIGHT - 160),
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
}));
|
||||
|
||||
const edges: GraphEdge[] = cappedRelations
|
||||
.filter(r => allowedIds.has(r.subject.toLowerCase()) && allowedIds.has(r.object.toLowerCase()))
|
||||
.map(r => ({
|
||||
source: r.subject.toLowerCase(),
|
||||
target: r.object.toLowerCase(),
|
||||
predicate: r.predicate,
|
||||
}));
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function runSimulation(nodes: GraphNode[], edges: GraphEdge[], iterations = 150): GraphNode[] {
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, { ...n }]));
|
||||
const nodeList = [...nodeMap.values()];
|
||||
|
||||
const REPULSION = 3500;
|
||||
const ATTRACTION = 0.04;
|
||||
const CENTER_FORCE = 0.012;
|
||||
const DAMPING = 0.75;
|
||||
const cx = WIDTH / 2;
|
||||
const cy = HEIGHT / 2;
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
for (let i = 0; i < nodeList.length; i++) {
|
||||
for (let j = i + 1; j < nodeList.length; j++) {
|
||||
const a = nodeList[i];
|
||||
const b = nodeList[j];
|
||||
const dx = b.x - a.x || 0.01;
|
||||
const dy = b.y - a.y || 0.01;
|
||||
const dist2 = dx * dx + dy * dy;
|
||||
const force = REPULSION / (dist2 + 1);
|
||||
const fx = (dx / Math.sqrt(dist2 + 1)) * force;
|
||||
const fy = (dy / Math.sqrt(dist2 + 1)) * force;
|
||||
a.vx -= fx;
|
||||
a.vy -= fy;
|
||||
b.vx += fx;
|
||||
b.vy += fy;
|
||||
}
|
||||
}
|
||||
for (const edge of edges) {
|
||||
const a = nodeMap.get(edge.source);
|
||||
const b = nodeMap.get(edge.target);
|
||||
if (!a || !b) continue;
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const delta = dist - 120;
|
||||
const fx = (dx / dist) * delta * ATTRACTION;
|
||||
const fy = (dy / dist) * delta * ATTRACTION;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
for (const n of nodeList) {
|
||||
n.vx += (cx - n.x) * CENTER_FORCE;
|
||||
n.vy += (cy - n.y) * CENTER_FORCE;
|
||||
n.vx *= DAMPING;
|
||||
n.vy *= DAMPING;
|
||||
n.x = Math.max(40, Math.min(WIDTH - 40, n.x + n.vx));
|
||||
n.y = Math.max(40, Math.min(HEIGHT - 40, n.y + n.vy));
|
||||
}
|
||||
}
|
||||
|
||||
return nodeList;
|
||||
}
|
||||
|
||||
export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) {
|
||||
const [nodes, setNodes] = useState<GraphNode[]>([]);
|
||||
const [edges, setEdges] = useState<GraphEdge[]>([]);
|
||||
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
const [namespacePalette, setNamespacePalette] = useState<Map<string, string>>(new Map());
|
||||
// Build graph data from relations (synchronous, deterministic)
|
||||
const { initialNodes, initialEdges, palette } = useMemo(() => {
|
||||
if (relations.length === 0) {
|
||||
return {
|
||||
initialNodes: [] as GraphNode[],
|
||||
initialEdges: [] as GraphEdge[],
|
||||
palette: new Map<string, string>(),
|
||||
};
|
||||
}
|
||||
const { nodes: rawNodes, edges: rawEdges } = buildGraph(relations);
|
||||
const namespaces = [...new Set(rawNodes.map(n => n.namespace ?? '__none__'))];
|
||||
const p = new Map<string, string>();
|
||||
namespaces.forEach((ns, i) => {
|
||||
p.set(ns, NAMESPACE_COLORS[i % NAMESPACE_COLORS.length]);
|
||||
});
|
||||
const simulated = runSimulation(rawNodes, rawEdges);
|
||||
return { initialNodes: simulated, initialEdges: rawEdges, palette: p };
|
||||
}, [relations]);
|
||||
|
||||
// Sync memo results into state (needed for interactive selection/hover)
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes);
|
||||
setEdges(initialEdges);
|
||||
setNamespacePalette(palette);
|
||||
}, [initialNodes, initialEdges, palette]);
|
||||
|
||||
const getNodeColor = useCallback(
|
||||
(node: GraphNode): string => {
|
||||
const ns = node.namespace ?? '__none__';
|
||||
return namespacePalette.get(ns) ?? NAMESPACE_COLORS[0];
|
||||
},
|
||||
[namespacePalette]
|
||||
);
|
||||
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||
|
||||
const centerNodeId =
|
||||
nodes.find(n => n.id === 'user' || n.id === 'self' || n.id === 'you')?.id ??
|
||||
(nodes.length > 0 ? nodes[0].id : null);
|
||||
|
||||
// Connected node ids for selected highlight
|
||||
const connectedIds = selectedNode
|
||||
? new Set(
|
||||
edges
|
||||
.filter(e => e.source === selectedNode || e.target === selectedNode)
|
||||
.flatMap(e => [e.source, e.target])
|
||||
)
|
||||
: null;
|
||||
|
||||
const namespaceEntries = [...namespacePalette.entries()].filter(([ns]) => ns !== '__none__');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-sm font-semibold text-white mb-3">Memory Graph</p>
|
||||
<div className="flex items-center justify-center" style={{ minHeight: 320 }}>
|
||||
<div className="flex gap-2 items-center text-stone-400 text-sm">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary-500 border-t-transparent animate-spin" />
|
||||
Loading graph…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-sm font-semibold text-white mb-3">Memory Graph</p>
|
||||
<div className="flex items-center justify-center" style={{ minHeight: 320 }}>
|
||||
<p className="text-stone-400 text-sm">No memory graph data yet</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxConn = Math.max(...nodes.map(n => n.connectionCount), 1);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-sm font-semibold text-white mb-3">Memory Graph</p>
|
||||
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg border border-white/5"
|
||||
style={{ minHeight: 320 }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
|
||||
width="100%"
|
||||
style={{ display: 'block', background: 'rgba(0,0,0,0.25)' }}
|
||||
onClick={() => setSelectedNode(null)}>
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill="rgba(255,255,255,0.18)" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Edges */}
|
||||
{edges.map((edge, i) => {
|
||||
const src = nodeMap.get(edge.source);
|
||||
const tgt = nodeMap.get(edge.target);
|
||||
if (!src || !tgt) return null;
|
||||
|
||||
const isHighlighted =
|
||||
selectedNode === null || edge.source === selectedNode || edge.target === selectedNode;
|
||||
|
||||
const midX = (src.x + tgt.x) / 2;
|
||||
const midY = (src.y + tgt.y) / 2;
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={src.x}
|
||||
y1={src.y}
|
||||
x2={tgt.x}
|
||||
y2={tgt.y}
|
||||
stroke={isHighlighted ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.05)'}
|
||||
strokeWidth={isHighlighted ? 1.5 : 1}
|
||||
markerEnd="url(#arrowhead)"
|
||||
style={{ cursor: 'pointer', transition: 'stroke 0.15s' }}
|
||||
onMouseEnter={() => setHoveredEdge(i)}
|
||||
onMouseLeave={() => setHoveredEdge(null)}
|
||||
/>
|
||||
{/* Edge label */}
|
||||
<text
|
||||
x={midX}
|
||||
y={midY - 4}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fill={
|
||||
hoveredEdge === i
|
||||
? 'rgba(255,255,255,0.85)'
|
||||
: isHighlighted
|
||||
? 'rgba(255,255,255,0.3)'
|
||||
: 'rgba(255,255,255,0.08)'
|
||||
}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none', transition: 'fill 0.15s' }}>
|
||||
{truncate(edge.predicate, 18)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nodes */}
|
||||
{nodes.map(node => {
|
||||
const r = 8 + (node.connectionCount / maxConn) * 18;
|
||||
const color = getNodeColor(node);
|
||||
const isCenter = node.id === centerNodeId;
|
||||
const isSelected = selectedNode === node.id;
|
||||
const isDimmed = selectedNode !== null && !connectedIds?.has(node.id);
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${node.x},${node.y})`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setSelectedNode(selectedNode === node.id ? null : node.id);
|
||||
}}>
|
||||
{(isCenter || isSelected) && (
|
||||
<circle r={r + 5} fill="none" stroke={color} strokeWidth={2} opacity={0.4} />
|
||||
)}
|
||||
<circle
|
||||
r={r}
|
||||
fill={color}
|
||||
opacity={isDimmed ? 0.15 : isSelected ? 1 : 0.82}
|
||||
stroke={isSelected ? 'white' : 'rgba(255,255,255,0.18)'}
|
||||
strokeWidth={isSelected ? 2 : 1}
|
||||
style={{ transition: 'opacity 0.15s' }}
|
||||
/>
|
||||
<text
|
||||
y={r + 11}
|
||||
textAnchor="middle"
|
||||
fontSize={isCenter ? 11 : 9}
|
||||
fontWeight={isCenter ? 600 : 400}
|
||||
fill={isDimmed ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.85)'}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none', transition: 'fill 0.15s' }}>
|
||||
{isCenter && node.id !== 'you' ? 'You' : truncate(node.label)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
{namespaceEntries.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{namespaceEntries.map(([ns, color]) => (
|
||||
<div key={ns} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-xs text-stone-400 truncate max-w-[120px]">{ns}</span>
|
||||
</div>
|
||||
))}
|
||||
{namespacePalette.has('__none__') && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: namespacePalette.get('__none__') }}
|
||||
/>
|
||||
<span className="text-xs text-stone-400">uncategorized</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs text-stone-500">
|
||||
{nodes.length} entities · {edges.length} relations · click a node to highlight connections
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user