mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface MemoryHeatmapProps {
|
||||
/** Array of document/relation timestamps (unix epoch seconds). */
|
||||
timestamps: number[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const MONTHS = 8;
|
||||
const DAYS_PER_WEEK = 7;
|
||||
const CELL_GAP = 2;
|
||||
const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
|
||||
|
||||
const INTENSITY_COLORS = [
|
||||
'rgba(255,255,255,0.04)', // 0 events
|
||||
'rgba(74,131,221,0.25)', // 1
|
||||
'rgba(74,131,221,0.45)', // 2-3
|
||||
'rgba(74,131,221,0.65)', // 4-6
|
||||
'rgba(74,131,221,0.85)', // 7+
|
||||
];
|
||||
|
||||
function getIntensity(count: number): number {
|
||||
if (count === 0) return 0;
|
||||
if (count === 1) return 1;
|
||||
if (count <= 3) return 2;
|
||||
if (count <= 6) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
function dateToKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) {
|
||||
const [hoveredCell, setHoveredCell] = useState<{
|
||||
date: Date;
|
||||
count: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const { grid, monthLabels, totalEvents, maxDailyCount, totalWeeks } = useMemo(() => {
|
||||
// The window: 6 months ago through today
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const rangeStart = new Date(today);
|
||||
rangeStart.setMonth(rangeStart.getMonth() - MONTHS);
|
||||
rangeStart.setDate(1); // start of that month
|
||||
|
||||
// Align to the Sunday of rangeStart's week
|
||||
const startDate = new Date(rangeStart);
|
||||
startDate.setDate(startDate.getDate() - startDate.getDay());
|
||||
|
||||
// Count timestamps that fall anywhere (not limited to the 6-month window)
|
||||
// — this means ingesting old data still lights up that old date.
|
||||
const countMap = new Map<string, number>();
|
||||
let total = 0;
|
||||
let maxCount = 0;
|
||||
|
||||
for (const ts of timestamps) {
|
||||
const date = new Date(ts > 9999999999 ? ts : ts * 1000);
|
||||
const key = dateToKey(date);
|
||||
const prev = countMap.get(key) ?? 0;
|
||||
const next = prev + 1;
|
||||
countMap.set(key, next);
|
||||
// Only count towards total/max if inside our display range
|
||||
if (date >= startDate && date <= today) {
|
||||
total++;
|
||||
if (next > maxCount) maxCount = next;
|
||||
}
|
||||
}
|
||||
|
||||
// Build grid
|
||||
const cells: { date: Date; count: number; weekIdx: number; dayIdx: number }[] = [];
|
||||
const months: { label: string; weekIdx: number }[] = [];
|
||||
let lastMonth = -1;
|
||||
let weekIdx = 0;
|
||||
|
||||
const cursor = new Date(startDate);
|
||||
while (cursor <= today) {
|
||||
const d = cursor.getDay(); // 0=Sun ... 6=Sat
|
||||
|
||||
if (d === 0 && cells.length > 0) weekIdx++;
|
||||
|
||||
const cellDate = new Date(cursor);
|
||||
const key = dateToKey(cellDate);
|
||||
cells.push({ date: cellDate, count: countMap.get(key) ?? 0, weekIdx, dayIdx: d });
|
||||
|
||||
// Track month labels (on the first Sunday-row cell of each new month)
|
||||
if (cellDate.getMonth() !== lastMonth && d === 0) {
|
||||
lastMonth = cellDate.getMonth();
|
||||
months.push({ label: cellDate.toLocaleDateString('en-US', { month: 'short' }), weekIdx });
|
||||
}
|
||||
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
grid: cells,
|
||||
monthLabels: months,
|
||||
totalEvents: total,
|
||||
maxDailyCount: maxCount,
|
||||
totalWeeks: weekIdx + 1,
|
||||
};
|
||||
}, [timestamps]);
|
||||
|
||||
// Dynamic cell size: fill available width (parent is ~100%).
|
||||
// We use a viewBox + 100% width so SVG scales to fit container.
|
||||
const DAY_LABEL_WIDTH = 28;
|
||||
const cellSize = 11;
|
||||
const svgWidth = DAY_LABEL_WIDTH + totalWeeks * (cellSize + CELL_GAP) + CELL_GAP;
|
||||
const svgHeight = DAYS_PER_WEEK * (cellSize + CELL_GAP) + 22;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Ingestion Activity</h3>
|
||||
<div className="h-28 rounded-lg bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Ingestion Activity</h3>
|
||||
<p className="text-xs text-stone-500 mt-0.5">
|
||||
{totalEvents} event{totalEvents !== 1 ? 's' : ''} over the last {MONTHS} months
|
||||
{maxDailyCount > 0 && <> · peak: {maxDailyCount}/day</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-stone-500">
|
||||
<span>Less</span>
|
||||
{INTENSITY_COLORS.map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[10px] h-[10px] rounded-[2px]"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
className="block">
|
||||
{/* Day labels */}
|
||||
{DAY_LABELS.map((label, i) =>
|
||||
label ? (
|
||||
<text
|
||||
key={i}
|
||||
x={0}
|
||||
y={22 + i * (cellSize + CELL_GAP) + cellSize * 0.75}
|
||||
fontSize={9}
|
||||
fill="rgba(255,255,255,0.3)"
|
||||
style={{ userSelect: 'none' }}>
|
||||
{label}
|
||||
</text>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{/* Month labels */}
|
||||
{monthLabels.map((m, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={DAY_LABEL_WIDTH + m.weekIdx * (cellSize + CELL_GAP)}
|
||||
y={12}
|
||||
fontSize={9}
|
||||
fill="rgba(255,255,255,0.3)"
|
||||
style={{ userSelect: 'none' }}>
|
||||
{m.label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Cells */}
|
||||
{grid.map((cell, i) => {
|
||||
const x = DAY_LABEL_WIDTH + cell.weekIdx * (cellSize + CELL_GAP);
|
||||
const y = 18 + cell.dayIdx * (cellSize + CELL_GAP);
|
||||
const intensity = getIntensity(cell.count);
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={x}
|
||||
y={y}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={INTENSITY_COLORS[intensity]}
|
||||
stroke={
|
||||
hoveredCell?.date.getTime() === cell.date.getTime()
|
||||
? 'rgba(255,255,255,0.4)'
|
||||
: 'transparent'
|
||||
}
|
||||
strokeWidth={1}
|
||||
style={{ cursor: 'pointer', transition: 'fill 0.1s' }}
|
||||
onMouseEnter={e => {
|
||||
const rect = (e.target as SVGRectElement).getBoundingClientRect();
|
||||
setHoveredCell({
|
||||
date: cell.date,
|
||||
count: cell.count,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setHoveredCell(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredCell && (
|
||||
<div
|
||||
className="fixed z-50 px-2 py-1 rounded-md bg-stone-800 border border-white/10 text-[11px] text-white shadow-lg pointer-events-none"
|
||||
style={{ left: hoveredCell.x, top: hoveredCell.y - 32, transform: 'translateX(-50%)' }}>
|
||||
<span className="font-medium">
|
||||
{hoveredCell.count} event{hoveredCell.count !== 1 ? 's' : ''}
|
||||
</span>{' '}
|
||||
<span className="text-stone-400">on {formatDate(hoveredCell.date)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { GraphRelation } from '../../utils/tauriCommands';
|
||||
|
||||
interface MemoryInsightsProps {
|
||||
relations: GraphRelation[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorizes graph relations into insight types based on their predicates.
|
||||
* This gives the user a structured view of what the system has learned.
|
||||
*/
|
||||
type InsightCategory = 'facts' | 'preferences' | 'relationships' | 'skills' | 'opinions' | 'other';
|
||||
|
||||
interface InsightGroup {
|
||||
category: InsightCategory;
|
||||
label: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
items: InsightItem[];
|
||||
}
|
||||
|
||||
interface InsightItem {
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
evidenceCount: number;
|
||||
namespace: string | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const PREDICATE_CATEGORIES: Record<string, InsightCategory> = {
|
||||
// Facts
|
||||
is: 'facts',
|
||||
'is a': 'facts',
|
||||
was: 'facts',
|
||||
has: 'facts',
|
||||
contains: 'facts',
|
||||
located_in: 'facts',
|
||||
created_by: 'facts',
|
||||
founded: 'facts',
|
||||
built: 'facts',
|
||||
// Preferences
|
||||
prefers: 'preferences',
|
||||
likes: 'preferences',
|
||||
dislikes: 'preferences',
|
||||
wants: 'preferences',
|
||||
uses: 'preferences',
|
||||
favors: 'preferences',
|
||||
avoids: 'preferences',
|
||||
// Relationships
|
||||
knows: 'relationships',
|
||||
works_with: 'relationships',
|
||||
reports_to: 'relationships',
|
||||
manages: 'relationships',
|
||||
collaborates_with: 'relationships',
|
||||
member_of: 'relationships',
|
||||
part_of: 'relationships',
|
||||
belongs_to: 'relationships',
|
||||
// Skills
|
||||
skilled_in: 'skills',
|
||||
experienced_with: 'skills',
|
||||
certified_in: 'skills',
|
||||
specializes_in: 'skills',
|
||||
proficient_in: 'skills',
|
||||
// Opinions
|
||||
thinks: 'opinions',
|
||||
believes: 'opinions',
|
||||
considers: 'opinions',
|
||||
feels: 'opinions',
|
||||
views: 'opinions',
|
||||
};
|
||||
|
||||
function categorize(predicate: string): InsightCategory {
|
||||
const normalized = predicate.toLowerCase().replace(/[_-]/g, ' ').trim();
|
||||
if (PREDICATE_CATEGORIES[normalized]) return PREDICATE_CATEGORIES[normalized];
|
||||
|
||||
// Fuzzy match: check if predicate contains known keywords
|
||||
for (const [key, category] of Object.entries(PREDICATE_CATEGORIES)) {
|
||||
if (normalized.includes(key) || key.includes(normalized)) return category;
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
const CATEGORY_CONFIG: Record<
|
||||
InsightCategory,
|
||||
{ label: string; icon: string; color: string; bgColor: string; borderColor: string }
|
||||
> = {
|
||||
facts: {
|
||||
label: 'Known Facts',
|
||||
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
color: 'text-emerald-300',
|
||||
bgColor: 'bg-emerald-500/10',
|
||||
borderColor: 'border-emerald-500/20',
|
||||
},
|
||||
preferences: {
|
||||
label: 'Preferences',
|
||||
icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z',
|
||||
color: 'text-rose-300',
|
||||
bgColor: 'bg-rose-500/10',
|
||||
borderColor: 'border-rose-500/20',
|
||||
},
|
||||
relationships: {
|
||||
label: 'Relationships',
|
||||
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
color: 'text-primary-300',
|
||||
bgColor: 'bg-primary-500/10',
|
||||
borderColor: 'border-primary-500/20',
|
||||
},
|
||||
skills: {
|
||||
label: 'Skills & Expertise',
|
||||
icon: 'M13 10V3L4 14h7v7l9-11h-7z',
|
||||
color: 'text-amber-300',
|
||||
bgColor: 'bg-amber-500/10',
|
||||
borderColor: 'border-amber-500/20',
|
||||
},
|
||||
opinions: {
|
||||
label: 'Opinions & Beliefs',
|
||||
icon: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
|
||||
color: 'text-lavender-300',
|
||||
bgColor: 'bg-lavender-500/10',
|
||||
borderColor: 'border-lavender-500/20',
|
||||
},
|
||||
other: {
|
||||
label: 'Other Insights',
|
||||
icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
|
||||
color: 'text-stone-300',
|
||||
bgColor: 'bg-stone-500/10',
|
||||
borderColor: 'border-stone-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
|
||||
const [expandedCategory, setExpandedCategory] = useState<InsightCategory | null>(null);
|
||||
|
||||
const groups = useMemo<InsightGroup[]>(() => {
|
||||
const buckets = new Map<InsightCategory, InsightItem[]>();
|
||||
|
||||
for (const rel of relations) {
|
||||
const category = categorize(rel.predicate);
|
||||
const items = buckets.get(category) ?? [];
|
||||
items.push({
|
||||
subject: rel.subject,
|
||||
predicate: rel.predicate,
|
||||
object: rel.object,
|
||||
evidenceCount: rel.evidenceCount,
|
||||
namespace: rel.namespace,
|
||||
updatedAt: rel.updatedAt,
|
||||
});
|
||||
buckets.set(category, items);
|
||||
}
|
||||
|
||||
// Sort items within each bucket by evidence count descending
|
||||
for (const items of buckets.values()) {
|
||||
items.sort((a, b) => b.evidenceCount - a.evidenceCount);
|
||||
}
|
||||
|
||||
const categoryOrder: InsightCategory[] = [
|
||||
'facts',
|
||||
'preferences',
|
||||
'relationships',
|
||||
'skills',
|
||||
'opinions',
|
||||
'other',
|
||||
];
|
||||
|
||||
return categoryOrder
|
||||
.filter(cat => (buckets.get(cat)?.length ?? 0) > 0)
|
||||
.map(cat => ({ category: cat, ...CATEGORY_CONFIG[cat], items: buckets.get(cat)! }));
|
||||
}, [relations]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Intelligent Insights</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-28 rounded-lg bg-white/5 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
|
||||
<h3 className="text-sm font-semibold text-white mb-2">Intelligent Insights</h3>
|
||||
<p className="text-sm text-stone-400">
|
||||
No insights yet. Ingest documents to extract facts, preferences, and relationships.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Intelligent Insights</h3>
|
||||
<p className="text-xs text-stone-500 mt-0.5">
|
||||
Extracted knowledge organized by type — {relations.length} total relations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{groups.map(group => {
|
||||
const isExpanded = expandedCategory === group.category;
|
||||
const displayItems = isExpanded ? group.items.slice(0, 20) : group.items.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.category}
|
||||
className={`rounded-lg border ${group.borderColor} ${group.bgColor} p-3 transition-all ${
|
||||
isExpanded ? 'col-span-2 lg:col-span-3' : ''
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => setExpandedCategory(isExpanded ? null : group.category)}
|
||||
className="flex items-center gap-2 w-full text-left mb-2">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-md ${group.bgColor} flex items-center justify-center flex-shrink-0`}>
|
||||
<svg
|
||||
className={`w-4 h-4 ${group.color}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d={group.icon}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-xs font-medium ${group.color}`}>{group.label}</div>
|
||||
<div className="text-[10px] text-stone-500">{group.items.length} items</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-stone-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className={`space-y-1.5 ${isExpanded ? 'max-h-80 overflow-y-auto pr-1' : ''}`}>
|
||||
{displayItems.map((item, idx) => (
|
||||
<div
|
||||
key={`${item.subject}-${item.predicate}-${item.object}-${idx}`}
|
||||
className="flex items-start gap-1.5 text-[11px] leading-relaxed">
|
||||
<span
|
||||
className="text-white/80 font-medium shrink-0 max-w-[30%] truncate"
|
||||
title={item.subject}>
|
||||
{item.subject}
|
||||
</span>
|
||||
<span className="text-stone-500 shrink-0 italic">{item.predicate}</span>
|
||||
<span className="text-white/60 truncate" title={item.object}>
|
||||
{item.object}
|
||||
</span>
|
||||
{item.evidenceCount > 1 && (
|
||||
<span className="ml-auto text-[9px] text-stone-600 shrink-0 tabular-nums">
|
||||
x{item.evidenceCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!isExpanded && group.items.length > 3 && (
|
||||
<div className="text-[10px] text-stone-500 pt-0.5">
|
||||
+{group.items.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
interface MemoryStatsBarProps {
|
||||
totalDocs: number;
|
||||
totalFiles: number;
|
||||
totalNamespaces: number;
|
||||
totalRelations: number;
|
||||
totalSessions: number | null;
|
||||
totalTokens: number | null;
|
||||
/** Estimated storage in bytes (sum of document content lengths). */
|
||||
estimatedStorageBytes: number;
|
||||
/** Unix-epoch seconds of the oldest document. */
|
||||
oldestDocTimestamp: number | null;
|
||||
/** Unix-epoch seconds of the newest document. */
|
||||
newestDocTimestamp: number | null;
|
||||
docsToday: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const value = bytes / Math.pow(1024, i);
|
||||
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatTimeAgo(epochSeconds: number): string {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = now - epochSeconds;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
|
||||
if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo ago`;
|
||||
return `${(diff / 31536000).toFixed(1)}y ago`;
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat().format(value);
|
||||
}
|
||||
|
||||
export function MemoryStatsBar(props: MemoryStatsBarProps) {
|
||||
const {
|
||||
totalDocs,
|
||||
totalFiles,
|
||||
totalNamespaces,
|
||||
totalRelations,
|
||||
totalSessions,
|
||||
totalTokens,
|
||||
estimatedStorageBytes,
|
||||
oldestDocTimestamp,
|
||||
newestDocTimestamp,
|
||||
docsToday,
|
||||
loading,
|
||||
} = props;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Storage',
|
||||
value: estimatedStorageBytes > 0 ? formatBytes(estimatedStorageBytes) : '--',
|
||||
sub: totalFiles > 0 ? `${formatNumber(totalFiles)} files` : undefined,
|
||||
color: 'text-primary-300',
|
||||
},
|
||||
{
|
||||
label: 'Documents',
|
||||
value: formatNumber(totalDocs),
|
||||
sub: docsToday > 0 ? `+${docsToday} today` : undefined,
|
||||
color: 'text-emerald-300',
|
||||
},
|
||||
{
|
||||
label: 'Namespaces',
|
||||
value: formatNumber(totalNamespaces),
|
||||
sub: undefined,
|
||||
color: 'text-amber-300',
|
||||
},
|
||||
{
|
||||
label: 'Relations',
|
||||
value: formatNumber(totalRelations),
|
||||
sub: undefined,
|
||||
color: 'text-lavender-300',
|
||||
},
|
||||
{
|
||||
label: 'First Memory',
|
||||
value: oldestDocTimestamp ? formatTimeAgo(oldestDocTimestamp) : '--',
|
||||
sub: newestDocTimestamp ? `Latest: ${formatTimeAgo(newestDocTimestamp)}` : undefined,
|
||||
color: 'text-sky-300',
|
||||
},
|
||||
{
|
||||
label: 'Sessions',
|
||||
value: totalSessions !== null ? formatNumber(totalSessions) : '--',
|
||||
sub: totalTokens !== null ? `${formatNumber(totalTokens)} tokens` : undefined,
|
||||
color: 'text-rose-300',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{stats.map(stat => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="rounded-xl border border-white/10 bg-black/20 p-3 transition-colors hover:bg-black/30">
|
||||
<div className="text-[11px] uppercase tracking-wide text-stone-500 mb-1">
|
||||
{stat.label}
|
||||
</div>
|
||||
<div className={`text-xl font-semibold ${stat.color}`}>
|
||||
{loading ? <div className="h-7 w-16 rounded bg-white/5 animate-pulse" /> : stat.value}
|
||||
</div>
|
||||
{stat.sub && <div className="text-[11px] text-stone-500 mt-0.5">{stat.sub}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,81 +15,69 @@ import {
|
||||
memoryQueryNamespace,
|
||||
memoryRecallNamespace,
|
||||
} from '../../utils/tauriCommands';
|
||||
import { MemoryGraphMap } from './MemoryGraphMap';
|
||||
import { MemoryHeatmap } from './MemoryHeatmap';
|
||||
import { MemoryInsights } from './MemoryInsights';
|
||||
import { MemoryStatsBar } from './MemoryStatsBar';
|
||||
|
||||
type MemoryDoc = { documentId: string; namespace: string; title?: string; raw: unknown };
|
||||
|
||||
type MemoryNode = { label: string; value?: string };
|
||||
|
||||
interface MemoryWorkspaceProps {
|
||||
onToast: (toast: Omit<ToastNotification, 'id'>) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function pickString(record: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findDocumentRows(payload: unknown): unknown[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) return payload;
|
||||
const root = asRecord(payload);
|
||||
if (!root) return [];
|
||||
|
||||
const candidates = ['documents', 'items', 'results'];
|
||||
for (const key of candidates) {
|
||||
for (const key of ['documents', 'items', 'results']) {
|
||||
const value = root[key];
|
||||
if (Array.isArray(value)) return value;
|
||||
}
|
||||
|
||||
const data = asRecord(root.data);
|
||||
if (!data) return [];
|
||||
|
||||
for (const key of candidates) {
|
||||
for (const key of ['documents', 'items', 'results']) {
|
||||
const value = data[key];
|
||||
if (Array.isArray(value)) return value;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeMemoryDocuments(payload: unknown): MemoryDoc[] {
|
||||
const rows = findDocumentRows(payload);
|
||||
|
||||
return rows
|
||||
return findDocumentRows(payload)
|
||||
.map(row => {
|
||||
const record = asRecord(row);
|
||||
if (!record) return null;
|
||||
|
||||
const documentId = pickString(record, ['documentId', 'document_id', 'id']);
|
||||
const namespace = pickString(record, ['namespace']);
|
||||
const title = pickString(record, ['title', 'name']);
|
||||
|
||||
if (!documentId || !namespace) return null;
|
||||
|
||||
return { documentId, namespace, title, raw: row } as MemoryDoc;
|
||||
})
|
||||
.filter((doc): doc is MemoryDoc => Boolean(doc));
|
||||
}
|
||||
|
||||
function parseTimestamp(raw: unknown): Date | null {
|
||||
function extractTimestamp(raw: unknown): number | null {
|
||||
const record = asRecord(raw);
|
||||
if (!record) return null;
|
||||
|
||||
const maybeDateKeys = [
|
||||
for (const key of [
|
||||
'createdAt',
|
||||
'created_at',
|
||||
'updatedAt',
|
||||
@@ -97,26 +85,27 @@ function parseTimestamp(raw: unknown): Date | null {
|
||||
'timestamp',
|
||||
'insertedAt',
|
||||
'inserted_at',
|
||||
];
|
||||
|
||||
for (const key of maybeDateKeys) {
|
||||
]) {
|
||||
const value = record[key];
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
const asMs = value > 9999999999 ? value : value * 1000;
|
||||
const date = new Date(asMs);
|
||||
if (!Number.isNaN(date.getTime())) return date;
|
||||
return value > 9999999999 ? value / 1000 : value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const date = new Date(value);
|
||||
if (!Number.isNaN(date.getTime())) return date;
|
||||
if (!Number.isNaN(date.getTime())) return date.getTime() / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function estimateContentSize(raw: unknown): number {
|
||||
const record = asRecord(raw);
|
||||
if (!record) return 0;
|
||||
const content = record.content;
|
||||
if (typeof content === 'string') return new TextEncoder().encode(content).length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isSameLocalDay(left: Date, right: Date): boolean {
|
||||
return (
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
@@ -125,18 +114,12 @@ function isSameLocalDay(left: Date, right: Date): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat().format(value);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
const {
|
||||
sessions,
|
||||
memoryFiles,
|
||||
entities,
|
||||
isLoading: statsLoading,
|
||||
refetch: refetchStats,
|
||||
} = useIntelligenceStats();
|
||||
const { sessions, isLoading: statsLoading, refetch: refetchStats } = useIntelligenceStats();
|
||||
|
||||
const [memoryDocs, setMemoryDocs] = useState<MemoryDoc[]>([]);
|
||||
const [memoryNamespaces, setMemoryNamespaces] = useState<string[]>([]);
|
||||
@@ -144,6 +127,11 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
const [memoryWorkspaceLoading, setMemoryWorkspaceLoading] = useState(false);
|
||||
const [memoryWorkspaceError, setMemoryWorkspaceError] = useState<string | null>(null);
|
||||
|
||||
const [graphRelations, setGraphRelations] = useState<GraphRelation[]>([]);
|
||||
const [graphRelationsLoading, setGraphRelationsLoading] = useState(false);
|
||||
|
||||
// Manage memory section (collapsible)
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const [selectedNamespace, setSelectedNamespace] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState('memory.md');
|
||||
const [selectedFileContent, setSelectedFileContent] = useState('');
|
||||
@@ -160,12 +148,12 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
const [memoryNote, setMemoryNote] = useState('');
|
||||
const [memoryNoteSaving, setMemoryNoteSaving] = useState(false);
|
||||
|
||||
const [graphRelations, setGraphRelations] = useState<GraphRelation[]>([]);
|
||||
const [graphRelationsLoading, setGraphRelationsLoading] = useState(false);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const loadWorkspace = useCallback(async () => {
|
||||
if (!isTauri()) return;
|
||||
|
||||
setMemoryWorkspaceLoading(true);
|
||||
setMemoryWorkspaceError(null);
|
||||
|
||||
@@ -176,7 +164,6 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
aiListMemoryFiles('memory'),
|
||||
]);
|
||||
|
||||
// Load graph relations from local store
|
||||
setGraphRelationsLoading(true);
|
||||
try {
|
||||
const relations = await memoryGraphQuery();
|
||||
@@ -197,7 +184,6 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
if (!selectedNamespace && namespacesPayload.length > 0) {
|
||||
setSelectedNamespace(namespacesPayload[0]);
|
||||
}
|
||||
|
||||
if (!combinedFiles.includes(selectedFile)) {
|
||||
setSelectedFile(combinedFiles[0] || 'memory.md');
|
||||
}
|
||||
@@ -213,10 +199,8 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
|
||||
const loadSelectedFile = useCallback(async () => {
|
||||
if (!isTauri() || !selectedFile) return;
|
||||
|
||||
setSelectedFileLoading(true);
|
||||
setSelectedFileError(null);
|
||||
|
||||
try {
|
||||
const content = await aiReadMemoryFile(selectedFile);
|
||||
setSelectedFileContent(content);
|
||||
@@ -231,18 +215,20 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
useEffect(() => {
|
||||
loadWorkspace();
|
||||
}, [loadWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSelectedFile();
|
||||
}, [loadSelectedFile]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Management handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleDeleteMemoryDoc = useCallback(
|
||||
async (doc: MemoryDoc) => {
|
||||
const confirmed = window.confirm(
|
||||
`Delete document "${doc.documentId}" from namespace "${doc.namespace}"?`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await memoryDeleteDocument(doc.documentId, doc.namespace);
|
||||
await loadWorkspace();
|
||||
@@ -261,10 +247,8 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
|
||||
const handleQueryNamespace = useCallback(async () => {
|
||||
if (!selectedNamespace || !queryInput.trim()) return;
|
||||
|
||||
setQueryLoading(true);
|
||||
setMemoryActionError(null);
|
||||
|
||||
try {
|
||||
const response = await memoryQueryNamespace(selectedNamespace, queryInput.trim(), 10);
|
||||
setQueryResult(response);
|
||||
@@ -278,10 +262,8 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
|
||||
const handleRecallNamespace = useCallback(async () => {
|
||||
if (!selectedNamespace) return;
|
||||
|
||||
setRecallLoading(true);
|
||||
setMemoryActionError(null);
|
||||
|
||||
try {
|
||||
const response = await memoryRecallNamespace(selectedNamespace, 10);
|
||||
setRecallResult(response ?? '');
|
||||
@@ -295,10 +277,8 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
|
||||
const handleSaveMemoryNote = useCallback(async () => {
|
||||
if (!memoryNote.trim()) return;
|
||||
|
||||
setMemoryNoteSaving(true);
|
||||
setMemoryActionError(null);
|
||||
|
||||
try {
|
||||
let existing = '';
|
||||
try {
|
||||
@@ -306,17 +286,14 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
} catch {
|
||||
existing = '';
|
||||
}
|
||||
|
||||
const timestamp = new Date().toLocaleString();
|
||||
const noteBlock = `\n\n## Manual note (${timestamp})\n${memoryNote.trim()}\n`;
|
||||
const nextContent = existing ? `${existing}${noteBlock}` : `# Memory\n${noteBlock}`;
|
||||
|
||||
await aiWriteMemoryFile('memory.md', nextContent);
|
||||
setMemoryNote('');
|
||||
await loadWorkspace();
|
||||
await loadSelectedFile();
|
||||
await refetchStats();
|
||||
|
||||
onToast({
|
||||
type: 'success',
|
||||
title: 'Memory Updated',
|
||||
@@ -329,397 +306,265 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
}
|
||||
}, [loadSelectedFile, loadWorkspace, memoryNote, onToast, refetchStats]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const today = new Date();
|
||||
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
today.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
const memoryFilesToday = memoryFilesList.filter(filePath => filePath.includes(todayKey)).length;
|
||||
const memoryDocsToday = memoryDocs.filter(doc => {
|
||||
const timestamp = parseTimestamp(doc.raw);
|
||||
return timestamp ? isSameLocalDay(timestamp, today) : false;
|
||||
const docsToday = memoryDocs.filter(doc => {
|
||||
const ts = extractTimestamp(doc.raw);
|
||||
return ts ? isSameLocalDay(new Date(ts * 1000), today) : false;
|
||||
}).length;
|
||||
|
||||
const topNamespaces = useMemo(() => {
|
||||
const counter = new Map<string, number>();
|
||||
const estimatedStorageBytes = useMemo(
|
||||
() => memoryDocs.reduce((sum, doc) => sum + estimateContentSize(doc.raw), 0),
|
||||
[memoryDocs]
|
||||
);
|
||||
|
||||
for (const doc of memoryDocs) {
|
||||
counter.set(doc.namespace, (counter.get(doc.namespace) || 0) + 1);
|
||||
}
|
||||
const docTimestamps = useMemo(
|
||||
() => memoryDocs.map(doc => extractTimestamp(doc.raw)).filter((t): t is number => t !== null),
|
||||
[memoryDocs]
|
||||
);
|
||||
|
||||
return Array.from(counter.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([namespace, count]) => ({ namespace, count }));
|
||||
}, [memoryDocs]);
|
||||
const oldestTimestamp = docTimestamps.length > 0 ? Math.min(...docTimestamps) : null;
|
||||
const newestTimestamp = docTimestamps.length > 0 ? Math.max(...docTimestamps) : null;
|
||||
|
||||
const graphNodes = useMemo<MemoryNode[]>(() => {
|
||||
const namespaceNodes = topNamespaces.map(item => ({
|
||||
label: item.namespace,
|
||||
value: `${item.count} docs`,
|
||||
}));
|
||||
|
||||
// Build entity nodes from local graph store relations
|
||||
const entityCounts = new Map<string, number>();
|
||||
// Combine doc timestamps + graph relation updated_at for heatmap
|
||||
const heatmapTimestamps = useMemo(() => {
|
||||
const timestamps = [...docTimestamps];
|
||||
for (const rel of graphRelations) {
|
||||
const subjectType =
|
||||
(rel.attrs?.entity_types as Record<string, string> | undefined)?.subject ?? 'entity';
|
||||
const objectType =
|
||||
(rel.attrs?.entity_types as Record<string, string> | undefined)?.object ?? 'entity';
|
||||
entityCounts.set(subjectType, (entityCounts.get(subjectType) ?? 0) + 1);
|
||||
entityCounts.set(objectType, (entityCounts.get(objectType) ?? 0) + 1);
|
||||
if (rel.updatedAt) timestamps.push(rel.updatedAt);
|
||||
}
|
||||
return timestamps;
|
||||
}, [docTimestamps, graphRelations]);
|
||||
|
||||
let entityNodes: MemoryNode[];
|
||||
if (entityCounts.size > 0) {
|
||||
entityNodes = Array.from(entityCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([type, count]) => ({ label: type, value: `${count} entities` }));
|
||||
} else {
|
||||
// Fallback to backend API entity counts
|
||||
entityNodes = Object.entries(entities || {})
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([type, count]) => ({ label: type, value: `${count} entities` }));
|
||||
}
|
||||
|
||||
return [...namespaceNodes, ...entityNodes].slice(0, 6);
|
||||
}, [entities, graphRelations, topNamespaces]);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<section className="glass rounded-2xl p-5 border border-white/10 animate-fade-up">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Memory Workspace</h2>
|
||||
<p className="text-sm text-stone-400">
|
||||
Browse memory files, inspect graph relationships, and manage stored context.
|
||||
</p>
|
||||
<section className="space-y-4 animate-fade-up">
|
||||
{/* Header */}
|
||||
<div className="glass rounded-2xl p-5 border border-white/10">
|
||||
<div className="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Memory</h2>
|
||||
<p className="text-sm text-stone-400">
|
||||
Your AI's knowledge graph, extracted insights, and ingestion activity.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
void Promise.all([loadWorkspace(), refetchStats()]);
|
||||
}}
|
||||
disabled={memoryWorkspaceLoading || statsLoading}
|
||||
className="px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-stone-300 disabled:opacity-40 transition-colors">
|
||||
{memoryWorkspaceLoading ? 'Loading...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<MemoryStatsBar
|
||||
totalDocs={memoryDocs.length}
|
||||
totalFiles={memoryFilesList.length}
|
||||
totalNamespaces={memoryNamespaces.length}
|
||||
totalRelations={graphRelations.length}
|
||||
totalSessions={sessions?.total ?? null}
|
||||
totalTokens={sessions?.totalTokens ?? null}
|
||||
estimatedStorageBytes={estimatedStorageBytes}
|
||||
oldestDocTimestamp={oldestTimestamp}
|
||||
newestDocTimestamp={newestTimestamp}
|
||||
docsToday={docsToday}
|
||||
loading={memoryWorkspaceLoading || statsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Knowledge Graph */}
|
||||
<MemoryGraphMap relations={graphRelations} loading={graphRelationsLoading} />
|
||||
|
||||
{/* Intelligent Insights */}
|
||||
<MemoryInsights relations={graphRelations} loading={graphRelationsLoading} />
|
||||
|
||||
{/* Activity Heatmap */}
|
||||
<MemoryHeatmap timestamps={heatmapTimestamps} loading={memoryWorkspaceLoading} />
|
||||
|
||||
{/* Collapsible: Files & Management */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/20">
|
||||
<button
|
||||
onClick={() => {
|
||||
void Promise.all([loadWorkspace(), refetchStats()]);
|
||||
}}
|
||||
disabled={memoryWorkspaceLoading || statsLoading}
|
||||
className="px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-stone-300 disabled:opacity-40">
|
||||
Refresh Memory
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] uppercase tracking-wide text-stone-500">Files (All-Time)</div>
|
||||
<div className="text-xl text-white font-semibold mt-1">
|
||||
{memoryFiles !== null ? formatNumber(memoryFiles) : '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] uppercase tracking-wide text-stone-500">Files (Today)</div>
|
||||
<div className="text-xl text-white font-semibold mt-1">
|
||||
{formatNumber(memoryFilesToday)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] uppercase tracking-wide text-stone-500">Docs (All-Time)</div>
|
||||
<div className="text-xl text-white font-semibold mt-1">
|
||||
{formatNumber(memoryDocs.length)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] uppercase tracking-wide text-stone-500">Docs (Today)</div>
|
||||
<div className="text-xl text-white font-semibold mt-1">
|
||||
{formatNumber(memoryDocsToday)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Sample Memory Graph</h3>
|
||||
<div className="relative h-64 rounded-lg bg-stone-950/70 overflow-hidden border border-white/5">
|
||||
onClick={() => setManageOpen(!manageOpen)}
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-white/5 transition-colors rounded-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none">
|
||||
{graphNodes.map((_, index) => {
|
||||
const points = [
|
||||
[50, 12],
|
||||
[80, 24],
|
||||
[88, 52],
|
||||
[76, 80],
|
||||
[24, 80],
|
||||
[12, 50],
|
||||
] as const;
|
||||
const [x, y] = points[index] || [50, 90];
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`line-${index}`}
|
||||
x1="50"
|
||||
y1="50"
|
||||
x2={x}
|
||||
y2={y}
|
||||
stroke="rgba(74, 131, 221, 0.45)"
|
||||
strokeWidth="0.6"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
className={`w-4 h-4 text-stone-400 transition-transform ${manageOpen ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h3 className="text-sm font-semibold text-white">Files & Management</h3>
|
||||
<span className="text-xs text-stone-500">
|
||||
{memoryFilesList.length} files · {memoryNamespaces.length} namespaces ·{' '}
|
||||
{memoryDocs.length} docs
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 px-2.5 py-1.5 rounded-full bg-primary-500/20 border border-primary-400/40 text-[11px] text-primary-200 whitespace-nowrap">
|
||||
You (Core Memory)
|
||||
</div>
|
||||
|
||||
{graphNodes.map((node, index) => {
|
||||
const positions = [
|
||||
'left-1/2 top-3 -translate-x-1/2',
|
||||
'right-3 top-9',
|
||||
'right-2 top-1/2 -translate-y-1/2',
|
||||
'right-8 bottom-3',
|
||||
'left-8 bottom-3',
|
||||
'left-2 top-1/2 -translate-y-1/2',
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`node-${node.label}`}
|
||||
className={`absolute ${positions[index] || positions[0]} max-w-28 px-2 py-1 rounded-md bg-white/5 border border-white/10`}>
|
||||
<div className="text-[11px] text-white truncate">{node.label}</div>
|
||||
{node.value && (
|
||||
<div className="text-[10px] text-stone-400 truncate">{node.value}</div>
|
||||
{manageOpen && (
|
||||
<div className="px-4 pb-4 space-y-4 animate-fade-up">
|
||||
{/* File browser */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-stone-400 mb-2">Memory Files</h4>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-3">
|
||||
<div className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-52 overflow-y-auto">
|
||||
{memoryFilesList.length === 0 ? (
|
||||
<div className="text-xs text-stone-500 p-2">No files found.</div>
|
||||
) : (
|
||||
memoryFilesList.map(filePath => (
|
||||
<button
|
||||
key={filePath}
|
||||
onClick={() => setSelectedFile(filePath)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded text-xs mb-1 border transition-colors ${
|
||||
selectedFile === filePath
|
||||
? 'border-primary-400/40 bg-primary-500/20 text-primary-200'
|
||||
: 'border-transparent hover:border-white/10 hover:bg-white/5 text-stone-300'
|
||||
}`}>
|
||||
{filePath}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/10 bg-stone-950/50 p-3 h-52 overflow-auto">
|
||||
{selectedFileLoading ? (
|
||||
<div className="text-xs text-stone-400">Loading file...</div>
|
||||
) : selectedFileError ? (
|
||||
<div className="text-xs text-coral-300">{selectedFileError}</div>
|
||||
) : (
|
||||
<pre className="text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
|
||||
{selectedFileContent || 'Empty file'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{graphNodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-stone-500">
|
||||
No graph nodes yet. Refresh memory to load.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-stone-400">
|
||||
Nodes show top namespaces and entity buckets connected to your core profile.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs text-stone-400">
|
||||
Graph relations{' '}
|
||||
{graphRelations.length > 0 && (
|
||||
<span className="text-stone-500">({graphRelations.length})</span>
|
||||
{/* Namespace query & management */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-stone-400 mb-2">Manage Namespace</h4>
|
||||
<select
|
||||
value={selectedNamespace}
|
||||
onChange={e => setSelectedNamespace(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-3 focus:outline-none focus:border-primary-500/50">
|
||||
{memoryNamespaces.length === 0 ? (
|
||||
<option value="">No namespaces</option>
|
||||
) : (
|
||||
memoryNamespaces.map(ns => (
|
||||
<option key={ns} value={ns}>
|
||||
{ns}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
|
||||
<label className="block text-xs text-stone-400 mb-1">Query</label>
|
||||
<textarea
|
||||
value={queryInput}
|
||||
onChange={e => setQueryInput(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-2 focus:outline-none focus:border-primary-500/50"
|
||||
placeholder="Search this namespace..."
|
||||
/>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => void handleQueryNamespace()}
|
||||
disabled={!selectedNamespace || !queryInput.trim() || queryLoading}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-stone-200 disabled:opacity-40">
|
||||
{queryLoading ? 'Querying...' : 'Run Query'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleRecallNamespace()}
|
||||
disabled={!selectedNamespace || recallLoading}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-stone-200 disabled:opacity-40">
|
||||
{recallLoading ? 'Recalling...' : 'Run Recall'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs text-stone-400 mb-1">Add Note</label>
|
||||
<textarea
|
||||
value={memoryNote}
|
||||
onChange={e => setMemoryNote(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-2 focus:outline-none focus:border-primary-500/50"
|
||||
placeholder="Store a durable user fact, preference, or decision"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void handleSaveMemoryNote()}
|
||||
disabled={!memoryNote.trim() || memoryNoteSaving}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-primary-500/40 bg-primary-500/20 hover:bg-primary-500/30 text-primary-200 disabled:opacity-40">
|
||||
{memoryNoteSaving ? 'Saving...' : 'Save Note'}
|
||||
</button>
|
||||
{memoryActionError && (
|
||||
<div className="mt-2 text-xs text-coral-300 border border-coral-500/30 bg-coral-500/10 rounded p-2">
|
||||
{memoryActionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{graphRelationsLoading && (
|
||||
<div className="text-[10px] text-stone-500">Loading...</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 max-h-36 overflow-y-auto pr-1">
|
||||
{graphRelations.length === 0 ? (
|
||||
<div className="text-[11px] text-stone-500">
|
||||
{graphRelationsLoading
|
||||
? 'Loading graph...'
|
||||
: 'No relations yet. Ingest documents to populate the graph.'}
|
||||
</div>
|
||||
) : (
|
||||
graphRelations.slice(0, 20).map((rel, idx) => (
|
||||
<div
|
||||
key={`${rel.subject}-${rel.predicate}-${rel.object}-${idx}`}
|
||||
className="flex items-center gap-1.5 rounded border border-white/5 bg-stone-950/50 px-2 py-1 text-[11px]">
|
||||
<span className="text-primary-300 truncate max-w-20" title={rel.subject}>
|
||||
{rel.subject}
|
||||
</span>
|
||||
<span className="text-stone-500 shrink-0">→</span>
|
||||
<span className="text-amber-300/80 truncate max-w-20" title={rel.predicate}>
|
||||
{rel.predicate}
|
||||
</span>
|
||||
<span className="text-stone-500 shrink-0">→</span>
|
||||
<span className="text-emerald-300/80 truncate max-w-20" title={rel.object}>
|
||||
{rel.object}
|
||||
</span>
|
||||
{rel.evidenceCount > 1 && (
|
||||
<span className="ml-auto text-[10px] text-stone-500 shrink-0">
|
||||
x{rel.evidenceCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="xl:col-span-2">
|
||||
<h4 className="text-xs font-medium text-stone-400 mb-2">Namespace Responses</h4>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-[11px] text-stone-500 mb-1">Query response</div>
|
||||
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
|
||||
{queryResult || 'No query result yet.'}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4 xl:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Memory Files</h3>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-3">
|
||||
<div className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-64 overflow-y-auto">
|
||||
{memoryFilesList.length === 0 ? (
|
||||
<div className="text-xs text-stone-500 p-2">No files found.</div>
|
||||
) : (
|
||||
memoryFilesList.map(filePath => (
|
||||
<button
|
||||
key={filePath}
|
||||
onClick={() => setSelectedFile(filePath)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded text-xs mb-1 border transition-colors ${
|
||||
selectedFile === filePath
|
||||
? 'border-primary-400/40 bg-primary-500/20 text-primary-200'
|
||||
: 'border-transparent hover:border-white/10 hover:bg-white/5 text-stone-300'
|
||||
}`}>
|
||||
{filePath}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-stone-950/50 p-3 h-64 overflow-auto">
|
||||
{selectedFileLoading ? (
|
||||
<div className="text-xs text-stone-400">Loading file...</div>
|
||||
) : selectedFileError ? (
|
||||
<div className="text-xs text-coral-300">{selectedFileError}</div>
|
||||
) : (
|
||||
<pre className="text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
|
||||
{selectedFileContent || 'Empty file'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mt-4">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Manage Memory</h3>
|
||||
<label className="block text-xs text-stone-400 mb-2">Namespace</label>
|
||||
<select
|
||||
value={selectedNamespace}
|
||||
onChange={event => setSelectedNamespace(event.target.value)}
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-3 focus:outline-none focus:border-primary-500/50">
|
||||
{memoryNamespaces.length === 0 ? (
|
||||
<option value="">No namespaces</option>
|
||||
) : (
|
||||
memoryNamespaces.map(namespace => (
|
||||
<option key={namespace} value={namespace}>
|
||||
{namespace}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
|
||||
<label className="block text-xs text-stone-400 mb-2">Query</label>
|
||||
<textarea
|
||||
value={queryInput}
|
||||
onChange={event => setQueryInput(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-3 focus:outline-none focus:border-primary-500/50"
|
||||
placeholder="Search this namespace..."
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => void handleQueryNamespace()}
|
||||
disabled={!selectedNamespace || !queryInput.trim() || queryLoading}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-stone-200 disabled:opacity-40">
|
||||
{queryLoading ? 'Querying...' : 'Run Query'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleRecallNamespace()}
|
||||
disabled={!selectedNamespace || recallLoading}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-stone-200 disabled:opacity-40">
|
||||
{recallLoading ? 'Recalling...' : 'Run Recall'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs text-stone-400 mb-2">Add Note to memory.md</label>
|
||||
<textarea
|
||||
value={memoryNote}
|
||||
onChange={event => setMemoryNote(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-3 focus:outline-none focus:border-primary-500/50"
|
||||
placeholder="Store a durable user fact, preference, or decision"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void handleSaveMemoryNote()}
|
||||
disabled={!memoryNote.trim() || memoryNoteSaving}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-primary-500/40 bg-primary-500/20 hover:bg-primary-500/30 text-primary-200 disabled:opacity-40">
|
||||
{memoryNoteSaving ? 'Saving...' : 'Save Note'}
|
||||
</button>
|
||||
{memoryActionError && (
|
||||
<div className="mt-3 text-xs text-coral-300 border border-coral-500/30 bg-coral-500/10 rounded p-2">
|
||||
{memoryActionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4 xl:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Namespace Responses</h3>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-xs text-stone-400 mb-1">Query response</div>
|
||||
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-36 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
|
||||
{queryResult || 'No query result yet.'}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-stone-400 mb-1">Recall response</div>
|
||||
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-36 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
|
||||
{recallResult || 'No recall result yet.'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-stone-400 mb-2">Top namespaces by document volume</div>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{topNamespaces.length === 0 ? (
|
||||
<div className="text-xs text-stone-500">No namespace data yet.</div>
|
||||
) : (
|
||||
topNamespaces.map(item => (
|
||||
<div
|
||||
key={item.namespace}
|
||||
className="px-2 py-1 rounded-full border border-white/10 bg-white/5 text-xs text-stone-300">
|
||||
{item.namespace}: {item.count}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-stone-400 mb-2">Recent documents</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto pr-1">
|
||||
{memoryDocs.slice(0, 8).map(doc => (
|
||||
<div
|
||||
key={`${doc.namespace}:${doc.documentId}`}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-stone-950/50 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-white truncate">{doc.title || doc.documentId}</div>
|
||||
<div className="text-[11px] text-stone-400 truncate">
|
||||
{doc.namespace} · {doc.documentId}
|
||||
<div>
|
||||
<div className="text-[11px] text-stone-500 mb-1">Recall response</div>
|
||||
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
|
||||
{recallResult || 'No recall result yet.'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleDeleteMemoryDoc(doc)}
|
||||
className="text-[11px] px-2 py-1 rounded border border-coral-500/30 text-coral-300 hover:bg-coral-500/10">
|
||||
Delete
|
||||
</button>
|
||||
|
||||
<div className="text-[11px] text-stone-500 mb-2">Recent documents</div>
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto pr-1">
|
||||
{memoryDocs.slice(0, 8).map(doc => (
|
||||
<div
|
||||
key={`${doc.namespace}:${doc.documentId}`}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-stone-950/50 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-white truncate">
|
||||
{doc.title || doc.documentId}
|
||||
</div>
|
||||
<div className="text-[11px] text-stone-400 truncate">
|
||||
{doc.namespace} · {doc.documentId}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleDeleteMemoryDoc(doc)}
|
||||
className="text-[11px] px-2 py-1 rounded border border-coral-500/30 text-coral-300 hover:bg-coral-500/10 shrink-0">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{memoryDocs.length === 0 && (
|
||||
<div className="text-xs text-stone-500">No documents available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{memoryDocs.length === 0 && (
|
||||
<div className="text-xs text-stone-500">No documents available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{(memoryWorkspaceError || (!isTauri() && !memoryWorkspaceLoading)) && (
|
||||
<div className="mt-4 text-xs text-amber-300 border border-amber-500/30 bg-amber-500/10 rounded p-2">
|
||||
<div className="text-xs text-amber-300 border border-amber-500/30 bg-amber-500/10 rounded-lg p-3">
|
||||
{memoryWorkspaceError ||
|
||||
'Memory workspace requires the desktop Tauri runtime to load real data.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-[11px] text-stone-500">
|
||||
Sessions: {sessions ? formatNumber(sessions.total) : '--'} · Token volume:{' '}
|
||||
{sessions ? formatNumber(sessions.totalTokens) : '--'}
|
||||
{graphRelations.length > 0 && <> · Relations: {formatNumber(graphRelations.length)}</>}
|
||||
{entities && <> · Entity buckets: {formatNumber(Object.keys(entities).length)}</>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@ vi.mock('../../../utils/tauriCommands', () => ({
|
||||
describe('MemoryWorkspace', () => {
|
||||
const onToast = vi.fn();
|
||||
|
||||
it('renders the Memory Workspace heading', async () => {
|
||||
it('renders the Memory heading', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
expect(screen.getByText('Memory Workspace')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays graph relations after loading', async () => {
|
||||
@@ -93,30 +93,20 @@ describe('MemoryWorkspace', () => {
|
||||
expect(screen.queryByText('x1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows relations count in footer stats', async () => {
|
||||
it('shows Relations stat in the stats bar', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
// The stats bar has a "Relations" label
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Relations: 2/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Relations')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays Graph relations header with count', async () => {
|
||||
it('renders the Memory Graph section', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('(2)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('builds entity nodes from graph relation attrs', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
// Entity nodes are built from entity_types in attrs
|
||||
// person appears 2x (Alice and Bob as subject), document appears 2x (Paper A as object)
|
||||
await waitFor(() => {
|
||||
// Entity nodes should include counts from relation entity_types
|
||||
expect(screen.getByText('2 entities')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Graph')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -132,9 +122,7 @@ describe('MemoryWorkspace – no graph relations', () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('No relations yet. Ingest documents to populate the graph.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('No memory graph data yet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user