claude-cortex 1.10.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/package-lock.json +273 -4
- package/dashboard/package.json +1 -0
- package/dashboard/src/app/page.tsx +65 -32
- package/dashboard/src/components/graph/KnowledgeGraph.tsx +230 -0
- package/dashboard/src/components/insights/ActivityHeatmap.tsx +131 -0
- package/dashboard/src/components/insights/InsightsView.tsx +46 -0
- package/dashboard/src/components/insights/KnowledgeMapPanel.tsx +80 -0
- package/dashboard/src/components/insights/QualityPanel.tsx +116 -0
- package/dashboard/src/components/memories/MemoriesView.tsx +150 -0
- package/dashboard/src/components/memories/MemoryCard.tsx +103 -0
- package/dashboard/src/components/nav/NavRail.tsx +53 -0
- package/dashboard/src/hooks/useMemories.ts +73 -0
- package/dashboard/src/lib/store.ts +3 -3
- package/dist/api/visualization-server.d.ts.map +1 -1
- package/dist/api/visualization-server.js +65 -0
- package/dist/api/visualization-server.js.map +1 -1
- package/package.json +1 -1
- package/scripts/pre-compact-hook.mjs +0 -17
- package/dashboard/README.md +0 -36
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import dynamic from 'next/dynamic';
|
|
5
|
+
import type { Memory, MemoryLink } from '@/types/memory';
|
|
6
|
+
import { getCategoryColor } from '@/lib/category-colors';
|
|
7
|
+
|
|
8
|
+
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false });
|
|
9
|
+
|
|
10
|
+
interface GraphNode {
|
|
11
|
+
id: number;
|
|
12
|
+
name: string;
|
|
13
|
+
category: Memory['category'];
|
|
14
|
+
type: Memory['type'];
|
|
15
|
+
salience: number;
|
|
16
|
+
decayedScore: number;
|
|
17
|
+
val: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface GraphLink {
|
|
21
|
+
source: number;
|
|
22
|
+
target: number;
|
|
23
|
+
strength: number;
|
|
24
|
+
relationship: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GraphData {
|
|
28
|
+
nodes: GraphNode[];
|
|
29
|
+
links: GraphLink[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface KnowledgeGraphProps {
|
|
33
|
+
memories: Memory[];
|
|
34
|
+
links: MemoryLink[];
|
|
35
|
+
selectedMemory: Memory | null;
|
|
36
|
+
onSelectMemory: (m: Memory | null) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function KnowledgeGraph({
|
|
40
|
+
memories,
|
|
41
|
+
links,
|
|
42
|
+
selectedMemory,
|
|
43
|
+
onSelectMemory,
|
|
44
|
+
}: KnowledgeGraphProps) {
|
|
45
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
47
|
+
const graphRef = useRef<any>(null);
|
|
48
|
+
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const el = containerRef.current;
|
|
52
|
+
if (!el) return;
|
|
53
|
+
|
|
54
|
+
const update = () => {
|
|
55
|
+
setDimensions({ width: el.clientWidth, height: el.clientHeight });
|
|
56
|
+
};
|
|
57
|
+
update();
|
|
58
|
+
|
|
59
|
+
const observer = new ResizeObserver(update);
|
|
60
|
+
observer.observe(el);
|
|
61
|
+
return () => observer.disconnect();
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const graphData: GraphData = useMemo(() => {
|
|
65
|
+
const nodeIds = new Set(memories.map((m) => m.id));
|
|
66
|
+
return {
|
|
67
|
+
nodes: memories.map((m) => ({
|
|
68
|
+
id: m.id,
|
|
69
|
+
name: m.title,
|
|
70
|
+
category: m.category,
|
|
71
|
+
type: m.type,
|
|
72
|
+
salience: m.salience,
|
|
73
|
+
decayedScore: m.decayedScore ?? m.salience,
|
|
74
|
+
val: m.salience * 10,
|
|
75
|
+
})),
|
|
76
|
+
links: links
|
|
77
|
+
.filter((l) => nodeIds.has(l.source_id) && nodeIds.has(l.target_id))
|
|
78
|
+
.map((l) => ({
|
|
79
|
+
source: l.source_id,
|
|
80
|
+
target: l.target_id,
|
|
81
|
+
strength: l.strength,
|
|
82
|
+
relationship: l.relationship,
|
|
83
|
+
})),
|
|
84
|
+
};
|
|
85
|
+
}, [memories, links]);
|
|
86
|
+
|
|
87
|
+
const nodeCanvasObject = useCallback(
|
|
88
|
+
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
|
89
|
+
const x = (node as unknown as { x: number }).x;
|
|
90
|
+
const y = (node as unknown as { y: number }).y;
|
|
91
|
+
if (x == null || y == null) return;
|
|
92
|
+
|
|
93
|
+
const radius = Math.max(3, node.salience * 8);
|
|
94
|
+
const color = getCategoryColor(node.category);
|
|
95
|
+
const opacity = Math.max(0.2, node.decayedScore);
|
|
96
|
+
const isSelected = selectedMemory?.id === node.id;
|
|
97
|
+
|
|
98
|
+
ctx.beginPath();
|
|
99
|
+
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
|
100
|
+
ctx.fillStyle = color + Math.round(opacity * 255).toString(16).padStart(2, '0');
|
|
101
|
+
ctx.fill();
|
|
102
|
+
|
|
103
|
+
// Border style based on type
|
|
104
|
+
ctx.lineWidth = isSelected ? 2 : 1;
|
|
105
|
+
ctx.strokeStyle = isSelected ? '#ffffff' : color;
|
|
106
|
+
if (node.type === 'short_term') {
|
|
107
|
+
ctx.setLineDash([3, 3]);
|
|
108
|
+
} else if (node.type === 'episodic') {
|
|
109
|
+
ctx.setLineDash([1, 2]);
|
|
110
|
+
} else {
|
|
111
|
+
ctx.setLineDash([]);
|
|
112
|
+
}
|
|
113
|
+
ctx.stroke();
|
|
114
|
+
ctx.setLineDash([]);
|
|
115
|
+
|
|
116
|
+
// Label when zoomed in enough
|
|
117
|
+
if (globalScale > 3) {
|
|
118
|
+
const maxChars = 40;
|
|
119
|
+
const label = node.name.length > maxChars ? node.name.slice(0, maxChars) + '…' : node.name;
|
|
120
|
+
const fontSize = Math.max(10, 12 / globalScale);
|
|
121
|
+
ctx.font = `${fontSize}px Sans-Serif`;
|
|
122
|
+
ctx.textAlign = 'center';
|
|
123
|
+
ctx.textBaseline = 'top';
|
|
124
|
+
|
|
125
|
+
const textWidth = ctx.measureText(label).width;
|
|
126
|
+
const textY = y + radius + 3;
|
|
127
|
+
const padding = 2;
|
|
128
|
+
|
|
129
|
+
// Dark background pill behind text
|
|
130
|
+
ctx.fillStyle = 'rgba(2, 6, 23, 0.85)';
|
|
131
|
+
ctx.beginPath();
|
|
132
|
+
ctx.roundRect(
|
|
133
|
+
x - textWidth / 2 - padding,
|
|
134
|
+
textY - padding,
|
|
135
|
+
textWidth + padding * 2,
|
|
136
|
+
fontSize + padding * 2,
|
|
137
|
+
3,
|
|
138
|
+
);
|
|
139
|
+
ctx.fill();
|
|
140
|
+
|
|
141
|
+
// White text with full opacity
|
|
142
|
+
ctx.fillStyle = '#e2e8f0';
|
|
143
|
+
ctx.fillText(label, x, textY);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[selectedMemory],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const linkCanvasObject = useCallback(
|
|
150
|
+
(link: GraphLink, ctx: CanvasRenderingContext2D) => {
|
|
151
|
+
const source = link.source as unknown as { x: number; y: number };
|
|
152
|
+
const target = link.target as unknown as { x: number; y: number };
|
|
153
|
+
if (!source?.x || !target?.x) return;
|
|
154
|
+
|
|
155
|
+
ctx.beginPath();
|
|
156
|
+
ctx.moveTo(source.x, source.y);
|
|
157
|
+
ctx.lineTo(target.x, target.y);
|
|
158
|
+
ctx.strokeStyle = '#334155';
|
|
159
|
+
ctx.lineWidth = link.strength * 3;
|
|
160
|
+
ctx.stroke();
|
|
161
|
+
},
|
|
162
|
+
[],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const lastZoomRef = useRef(1);
|
|
166
|
+
const handleZoom = useCallback(
|
|
167
|
+
({ k }: { k: number }) => {
|
|
168
|
+
const fg = graphRef.current;
|
|
169
|
+
if (!fg) return;
|
|
170
|
+
const prev = lastZoomRef.current;
|
|
171
|
+
if (Math.abs(k - prev) < 0.2) return;
|
|
172
|
+
lastZoomRef.current = k;
|
|
173
|
+
|
|
174
|
+
const charge = fg.d3Force('charge');
|
|
175
|
+
if (!charge) return;
|
|
176
|
+
|
|
177
|
+
if (k > 3) {
|
|
178
|
+
// Zoomed in past label threshold — spread nodes for readability
|
|
179
|
+
charge.strength(-50 * k);
|
|
180
|
+
fg.d3ReheatSimulation();
|
|
181
|
+
} else {
|
|
182
|
+
// Zoomed out — restore default compact layout
|
|
183
|
+
charge.strength(-30);
|
|
184
|
+
fg.d3ReheatSimulation();
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
[],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const handleNodeClick = useCallback(
|
|
191
|
+
(node: GraphNode) => {
|
|
192
|
+
const memory = memories.find((m) => m.id === node.id) ?? null;
|
|
193
|
+
onSelectMemory(memory);
|
|
194
|
+
},
|
|
195
|
+
[memories, onSelectMemory],
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const nodeLabel = useCallback(
|
|
199
|
+
(node: GraphNode) =>
|
|
200
|
+
`${node.name}\nCategory: ${node.category}\nSalience: ${node.salience.toFixed(2)}`,
|
|
201
|
+
[],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div ref={containerRef} style={{ width: '100%', height: '100%' }}>
|
|
206
|
+
{dimensions.width > 0 && (
|
|
207
|
+
<ForceGraph2D
|
|
208
|
+
ref={graphRef as never}
|
|
209
|
+
graphData={graphData}
|
|
210
|
+
width={dimensions.width}
|
|
211
|
+
height={dimensions.height}
|
|
212
|
+
backgroundColor="rgba(0,0,0,0)"
|
|
213
|
+
nodeCanvasObject={nodeCanvasObject as never}
|
|
214
|
+
nodeLabel={nodeLabel as never}
|
|
215
|
+
onNodeClick={handleNodeClick as never}
|
|
216
|
+
onZoom={handleZoom as never}
|
|
217
|
+
linkCanvasObject={linkCanvasObject as never}
|
|
218
|
+
linkDirectionalParticles={2}
|
|
219
|
+
linkDirectionalParticleWidth={2}
|
|
220
|
+
linkDirectionalParticleSpeed={0.005}
|
|
221
|
+
linkDirectionalParticleColor={() => '#22d3ee'}
|
|
222
|
+
d3AlphaDecay={0.02}
|
|
223
|
+
d3VelocityDecay={0.3}
|
|
224
|
+
warmupTicks={100}
|
|
225
|
+
cooldownTicks={200}
|
|
226
|
+
/>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface ActivityDay {
|
|
6
|
+
date: string;
|
|
7
|
+
count: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ActivityHeatmapProps {
|
|
11
|
+
activity: ActivityDay[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const CELL_SIZE = 12;
|
|
15
|
+
const CELL_GAP = 2;
|
|
16
|
+
const CELL_RADIUS = 2;
|
|
17
|
+
const WEEKS = 52;
|
|
18
|
+
const DAYS = 7;
|
|
19
|
+
const LEFT_LABEL_WIDTH = 30;
|
|
20
|
+
const TOP_LABEL_HEIGHT = 16;
|
|
21
|
+
|
|
22
|
+
const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
|
|
23
|
+
const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
24
|
+
|
|
25
|
+
function getColor(count: number, max: number): string {
|
|
26
|
+
if (count === 0) return '#0f172a'; // slate-900 (darker empty)
|
|
27
|
+
const ratio = count / Math.max(max, 1);
|
|
28
|
+
if (ratio < 0.25) return '#155e75'; // cyan-800
|
|
29
|
+
if (ratio < 0.50) return '#0891b2'; // cyan-600
|
|
30
|
+
if (ratio < 0.75) return '#06b6d4'; // cyan-500
|
|
31
|
+
return '#22d3ee'; // cyan-400
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ActivityHeatmap({ activity }: ActivityHeatmapProps) {
|
|
35
|
+
const { grid, monthPositions, maxCount } = useMemo(() => {
|
|
36
|
+
// Build lookup map
|
|
37
|
+
const lookup = new Map<string, number>();
|
|
38
|
+
for (const d of activity) {
|
|
39
|
+
lookup.set(d.date, d.count);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Today at start of day
|
|
43
|
+
const today = new Date();
|
|
44
|
+
today.setHours(0, 0, 0, 0);
|
|
45
|
+
|
|
46
|
+
// Find the Saturday of the current week (end of last column)
|
|
47
|
+
const todayDay = today.getDay(); // 0=Sun
|
|
48
|
+
const endDate = new Date(today);
|
|
49
|
+
endDate.setDate(today.getDate() + (6 - todayDay));
|
|
50
|
+
|
|
51
|
+
// Start date is 52 weeks before endDate's Sunday
|
|
52
|
+
const startDate = new Date(endDate);
|
|
53
|
+
startDate.setDate(endDate.getDate() - (WEEKS * 7 - 1));
|
|
54
|
+
|
|
55
|
+
const cells: Array<{ week: number; day: number; date: string; count: number }> = [];
|
|
56
|
+
const months: Array<{ label: string; week: number }> = [];
|
|
57
|
+
let lastMonth = -1;
|
|
58
|
+
|
|
59
|
+
const cursor = new Date(startDate);
|
|
60
|
+
for (let w = 0; w < WEEKS; w++) {
|
|
61
|
+
for (let d = 0; d < DAYS; d++) {
|
|
62
|
+
const dateStr = cursor.toISOString().slice(0, 10);
|
|
63
|
+
const count = lookup.get(dateStr) || 0;
|
|
64
|
+
cells.push({ week: w, day: d, date: dateStr, count });
|
|
65
|
+
|
|
66
|
+
const month = cursor.getMonth();
|
|
67
|
+
if (month !== lastMonth && d === 0) {
|
|
68
|
+
months.push({ label: MONTH_LABELS[month], week: w });
|
|
69
|
+
lastMonth = month;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const max = Math.max(...cells.map(c => c.count), 1);
|
|
77
|
+
return { grid: cells, monthPositions: months, maxCount: max };
|
|
78
|
+
}, [activity]);
|
|
79
|
+
|
|
80
|
+
const svgWidth = LEFT_LABEL_WIDTH + WEEKS * (CELL_SIZE + CELL_GAP);
|
|
81
|
+
const svgHeight = TOP_LABEL_HEIGHT + DAYS * (CELL_SIZE + CELL_GAP);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="bg-slate-900/50 rounded-lg p-4 overflow-x-auto">
|
|
85
|
+
<svg width={svgWidth} height={svgHeight} className="text-slate-400">
|
|
86
|
+
{/* Month labels */}
|
|
87
|
+
{monthPositions.map((m, i) => (
|
|
88
|
+
<text
|
|
89
|
+
key={i}
|
|
90
|
+
x={LEFT_LABEL_WIDTH + m.week * (CELL_SIZE + CELL_GAP)}
|
|
91
|
+
y={12}
|
|
92
|
+
fontSize={10}
|
|
93
|
+
fill="currentColor"
|
|
94
|
+
>
|
|
95
|
+
{m.label}
|
|
96
|
+
</text>
|
|
97
|
+
))}
|
|
98
|
+
|
|
99
|
+
{/* Day labels */}
|
|
100
|
+
{DAY_LABELS.map((label, i) =>
|
|
101
|
+
label ? (
|
|
102
|
+
<text
|
|
103
|
+
key={i}
|
|
104
|
+
x={0}
|
|
105
|
+
y={TOP_LABEL_HEIGHT + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 1}
|
|
106
|
+
fontSize={10}
|
|
107
|
+
fill="currentColor"
|
|
108
|
+
>
|
|
109
|
+
{label}
|
|
110
|
+
</text>
|
|
111
|
+
) : null
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{/* Cells */}
|
|
115
|
+
{grid.map((cell, i) => (
|
|
116
|
+
<rect
|
|
117
|
+
key={i}
|
|
118
|
+
x={LEFT_LABEL_WIDTH + cell.week * (CELL_SIZE + CELL_GAP)}
|
|
119
|
+
y={TOP_LABEL_HEIGHT + cell.day * (CELL_SIZE + CELL_GAP)}
|
|
120
|
+
width={CELL_SIZE}
|
|
121
|
+
height={CELL_SIZE}
|
|
122
|
+
rx={CELL_RADIUS}
|
|
123
|
+
fill={getColor(cell.count, maxCount)}
|
|
124
|
+
>
|
|
125
|
+
<title>{`${cell.date}: ${cell.count} memories`}</title>
|
|
126
|
+
</rect>
|
|
127
|
+
))}
|
|
128
|
+
</svg>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useActivity } from '@/hooks/useMemories';
|
|
4
|
+
import { useDashboardStore } from '@/lib/store';
|
|
5
|
+
import { ActivityHeatmap } from './ActivityHeatmap';
|
|
6
|
+
import { KnowledgeMapPanel } from './KnowledgeMapPanel';
|
|
7
|
+
import { QualityPanel } from './QualityPanel';
|
|
8
|
+
import type { MemoryStats } from '@/types/memory';
|
|
9
|
+
|
|
10
|
+
interface InsightsViewProps {
|
|
11
|
+
selectedProject?: string;
|
|
12
|
+
stats?: MemoryStats;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function InsightsView({ selectedProject, stats }: InsightsViewProps) {
|
|
16
|
+
const { data: activityData } = useActivity(selectedProject);
|
|
17
|
+
const { setViewMode, setCategoryFilter } = useDashboardStore();
|
|
18
|
+
|
|
19
|
+
const handleNavigate = (filter: { category?: string }) => {
|
|
20
|
+
if (filter.category) {
|
|
21
|
+
setCategoryFilter(filter.category);
|
|
22
|
+
}
|
|
23
|
+
setViewMode('memories');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="h-full overflow-y-auto p-6 space-y-8">
|
|
28
|
+
<section>
|
|
29
|
+
<h2 className="text-lg font-semibold text-white mb-4">Activity</h2>
|
|
30
|
+
<ActivityHeatmap activity={activityData?.activity ?? []} />
|
|
31
|
+
</section>
|
|
32
|
+
|
|
33
|
+
{stats && (
|
|
34
|
+
<section>
|
|
35
|
+
<h2 className="text-lg font-semibold text-white mb-4">Knowledge Coverage</h2>
|
|
36
|
+
<KnowledgeMapPanel stats={stats} onNavigate={handleNavigate} />
|
|
37
|
+
</section>
|
|
38
|
+
)}
|
|
39
|
+
|
|
40
|
+
<section>
|
|
41
|
+
<h2 className="text-lg font-semibold text-white mb-4">Memory Quality</h2>
|
|
42
|
+
<QualityPanel project={selectedProject} />
|
|
43
|
+
</section>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
|
5
|
+
import { getCategoryColor } from '@/lib/category-colors';
|
|
6
|
+
import type { MemoryStats, MemoryCategory } from '@/types/memory';
|
|
7
|
+
|
|
8
|
+
interface KnowledgeMapPanelProps {
|
|
9
|
+
stats: MemoryStats;
|
|
10
|
+
onNavigate: (filter: { category?: string }) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function KnowledgeMapPanel({ stats, onNavigate }: KnowledgeMapPanelProps) {
|
|
14
|
+
const data = useMemo(() => {
|
|
15
|
+
return Object.entries(stats.byCategory)
|
|
16
|
+
.map(([category, count]) => ({
|
|
17
|
+
category,
|
|
18
|
+
count,
|
|
19
|
+
color: getCategoryColor(category as MemoryCategory),
|
|
20
|
+
thin: count < 3,
|
|
21
|
+
}))
|
|
22
|
+
.sort((a, b) => b.count - a.count);
|
|
23
|
+
}, [stats.byCategory]);
|
|
24
|
+
|
|
25
|
+
if (data.length === 0) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="bg-slate-900/50 rounded-lg p-4 text-slate-400 text-sm">
|
|
28
|
+
No category data available.
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="bg-slate-900/50 rounded-lg p-4">
|
|
35
|
+
<ResponsiveContainer width="100%" height={data.length * 36 + 20}>
|
|
36
|
+
<BarChart data={data} layout="vertical" margin={{ left: 10, right: 30 }}>
|
|
37
|
+
<XAxis type="number" hide />
|
|
38
|
+
<YAxis
|
|
39
|
+
type="category"
|
|
40
|
+
dataKey="category"
|
|
41
|
+
width={100}
|
|
42
|
+
tick={{ fill: '#94a3b8', fontSize: 12 }}
|
|
43
|
+
axisLine={false}
|
|
44
|
+
tickLine={false}
|
|
45
|
+
/>
|
|
46
|
+
<Tooltip
|
|
47
|
+
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 8 }}
|
|
48
|
+
labelStyle={{ color: '#e2e8f0' }}
|
|
49
|
+
itemStyle={{ color: '#e2e8f0' }}
|
|
50
|
+
/>
|
|
51
|
+
<Bar
|
|
52
|
+
dataKey="count"
|
|
53
|
+
radius={[0, 4, 4, 0]}
|
|
54
|
+
cursor="pointer"
|
|
55
|
+
onClick={(_data, index) => {
|
|
56
|
+
if (index !== undefined && data[index]) {
|
|
57
|
+
onNavigate({ category: data[index].category });
|
|
58
|
+
}
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{data.map((entry, index) => (
|
|
62
|
+
<Cell key={index} fill={entry.color} />
|
|
63
|
+
))}
|
|
64
|
+
</Bar>
|
|
65
|
+
</BarChart>
|
|
66
|
+
</ResponsiveContainer>
|
|
67
|
+
|
|
68
|
+
{/* Thin coverage warnings */}
|
|
69
|
+
{data.some(d => d.thin) && (
|
|
70
|
+
<div className="mt-2 flex flex-wrap gap-2">
|
|
71
|
+
{data.filter(d => d.thin).map(d => (
|
|
72
|
+
<span key={d.category} className="text-xs text-amber-400 bg-amber-400/10 px-2 py-0.5 rounded">
|
|
73
|
+
⚠ {d.category} ({d.count})
|
|
74
|
+
</span>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Eye, Clock, Copy, AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react';
|
|
5
|
+
import { useQuality, useContradictions } from '@/hooks/useMemories';
|
|
6
|
+
|
|
7
|
+
interface QualityPanelProps {
|
|
8
|
+
project?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function Section({
|
|
12
|
+
icon: Icon,
|
|
13
|
+
title,
|
|
14
|
+
count,
|
|
15
|
+
children,
|
|
16
|
+
}: {
|
|
17
|
+
icon: React.ComponentType<{ className?: string; size?: number }>;
|
|
18
|
+
title: string;
|
|
19
|
+
count: number;
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
}) {
|
|
22
|
+
const [expanded, setExpanded] = useState(false);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="border border-slate-800 rounded-lg overflow-hidden">
|
|
26
|
+
<button
|
|
27
|
+
onClick={() => setExpanded(!expanded)}
|
|
28
|
+
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-slate-800/50 transition-colors"
|
|
29
|
+
>
|
|
30
|
+
{expanded ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
|
31
|
+
<Icon size={14} className="text-slate-400" />
|
|
32
|
+
<span className="text-sm text-slate-200 flex-1 text-left">{title}</span>
|
|
33
|
+
<span className="text-xs px-1.5 py-0.5 rounded bg-slate-700 text-slate-300">{count}</span>
|
|
34
|
+
</button>
|
|
35
|
+
{expanded && <div className="px-3 pb-3 space-y-1">{children}</div>}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function QualityPanel({ project }: QualityPanelProps) {
|
|
41
|
+
const { data: quality } = useQuality(project);
|
|
42
|
+
const { data: contradictionsData } = useContradictions(project);
|
|
43
|
+
|
|
44
|
+
const neverAccessed = quality?.neverAccessed;
|
|
45
|
+
const stale = quality?.stale;
|
|
46
|
+
const duplicates = quality?.duplicates;
|
|
47
|
+
const contradictions = contradictionsData?.contradictions ?? [];
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="space-y-2">
|
|
51
|
+
<Section icon={Eye} title="Never Accessed" count={neverAccessed?.count ?? 0}>
|
|
52
|
+
{neverAccessed?.items?.length ? (
|
|
53
|
+
neverAccessed.items.map((item, i) => (
|
|
54
|
+
<div key={i} className="text-xs text-slate-400 py-1 border-b border-slate-800/50 last:border-0">
|
|
55
|
+
<span className="text-slate-300">{String(item.title || 'Untitled')}</span>
|
|
56
|
+
{item.created_at ? (
|
|
57
|
+
<span className="ml-2 text-slate-500">{String(item.created_at).slice(0, 10)}</span>
|
|
58
|
+
) : null}
|
|
59
|
+
</div>
|
|
60
|
+
))
|
|
61
|
+
) : (
|
|
62
|
+
<div className="text-xs text-slate-500">None found</div>
|
|
63
|
+
)}
|
|
64
|
+
</Section>
|
|
65
|
+
|
|
66
|
+
<Section icon={Clock} title="Stale Memories" count={stale?.count ?? 0}>
|
|
67
|
+
{stale?.items?.length ? (
|
|
68
|
+
stale.items.map((item, i) => {
|
|
69
|
+
const score = Number(item.decayed_score ?? 0);
|
|
70
|
+
const color = score < 0.3 ? 'text-red-400' : score < 0.5 ? 'text-amber-400' : 'text-slate-400';
|
|
71
|
+
return (
|
|
72
|
+
<div key={i} className="text-xs text-slate-400 py-1 border-b border-slate-800/50 last:border-0 flex items-center gap-2">
|
|
73
|
+
<span className={`${color} font-mono`}>{score.toFixed(2)}</span>
|
|
74
|
+
<span className="text-slate-300">{String(item.title || 'Untitled')}</span>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
})
|
|
78
|
+
) : (
|
|
79
|
+
<div className="text-xs text-slate-500">None found</div>
|
|
80
|
+
)}
|
|
81
|
+
</Section>
|
|
82
|
+
|
|
83
|
+
<Section icon={Copy} title="Duplicates" count={duplicates?.count ?? 0}>
|
|
84
|
+
{duplicates?.items?.length ? (
|
|
85
|
+
duplicates.items.map((item, i) => (
|
|
86
|
+
<div key={i} className="text-xs text-slate-400 py-1 border-b border-slate-800/50 last:border-0">
|
|
87
|
+
<span className="text-slate-300">{String(item.title_a || 'Untitled')}</span>
|
|
88
|
+
<span className="mx-1 text-slate-600">↔</span>
|
|
89
|
+
<span className="text-slate-300">{String(item.title_b || 'Untitled')}</span>
|
|
90
|
+
</div>
|
|
91
|
+
))
|
|
92
|
+
) : (
|
|
93
|
+
<div className="text-xs text-slate-500">None found</div>
|
|
94
|
+
)}
|
|
95
|
+
</Section>
|
|
96
|
+
|
|
97
|
+
<Section icon={AlertTriangle} title="Contradictions" count={contradictions.length}>
|
|
98
|
+
{contradictions.length ? (
|
|
99
|
+
contradictions.map((c, i) => (
|
|
100
|
+
<div key={i} className="text-xs py-1 border-b border-slate-800/50 last:border-0">
|
|
101
|
+
<div className="flex items-center gap-2 text-slate-400">
|
|
102
|
+
<span className="font-mono text-amber-400">{c.score.toFixed(2)}</span>
|
|
103
|
+
<span className="text-slate-300">{c.memoryATitle}</span>
|
|
104
|
+
<span className="text-slate-600">↔</span>
|
|
105
|
+
<span className="text-slate-300">{c.memoryBTitle}</span>
|
|
106
|
+
</div>
|
|
107
|
+
{c.reason && <div className="text-slate-500 mt-0.5 ml-10">{c.reason}</div>}
|
|
108
|
+
</div>
|
|
109
|
+
))
|
|
110
|
+
) : (
|
|
111
|
+
<div className="text-xs text-slate-500">None found</div>
|
|
112
|
+
)}
|
|
113
|
+
</Section>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|