context-mode 1.0.80 → 1.0.82
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/build/cli.js +57 -0
- package/build/server.js +94 -1
- package/cli.bundle.mjs +106 -99
- package/insight/components.json +25 -0
- package/insight/index.html +13 -0
- package/insight/package.json +54 -0
- package/insight/server.mjs +624 -0
- package/insight/src/components/analytics.tsx +112 -0
- package/insight/src/components/ui/badge.tsx +52 -0
- package/insight/src/components/ui/button.tsx +58 -0
- package/insight/src/components/ui/card.tsx +103 -0
- package/insight/src/components/ui/chart.tsx +371 -0
- package/insight/src/components/ui/collapsible.tsx +19 -0
- package/insight/src/components/ui/input.tsx +20 -0
- package/insight/src/components/ui/progress.tsx +83 -0
- package/insight/src/components/ui/scroll-area.tsx +55 -0
- package/insight/src/components/ui/separator.tsx +23 -0
- package/insight/src/components/ui/table.tsx +114 -0
- package/insight/src/components/ui/tabs.tsx +82 -0
- package/insight/src/components/ui/tooltip.tsx +64 -0
- package/insight/src/lib/api.ts +71 -0
- package/insight/src/lib/utils.ts +6 -0
- package/insight/src/main.tsx +22 -0
- package/insight/src/routeTree.gen.ts +189 -0
- package/insight/src/router.tsx +19 -0
- package/insight/src/routes/__root.tsx +55 -0
- package/insight/src/routes/enterprise.tsx +316 -0
- package/insight/src/routes/index.tsx +914 -0
- package/insight/src/routes/knowledge.tsx +221 -0
- package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +137 -0
- package/insight/src/routes/search.tsx +97 -0
- package/insight/src/routes/sessions.tsx +179 -0
- package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +181 -0
- package/insight/src/styles.css +104 -0
- package/insight/tsconfig.json +29 -0
- package/insight/vite.config.ts +19 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/server.bundle.mjs +76 -72
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { api, type ContentDB } from "@/lib/api";
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
|
+
import { Badge } from "@/components/ui/badge";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip";
|
|
8
|
+
import { timeAgo, dateGroup, parseSourceLabel } from "@/components/analytics";
|
|
9
|
+
import { Database, Layers, Clock, Trash2, ChevronRight } from "lucide-react";
|
|
10
|
+
|
|
11
|
+
export const Route = createFileRoute("/knowledge")({ component: KnowledgeBase });
|
|
12
|
+
|
|
13
|
+
interface GroupedSources {
|
|
14
|
+
label: string;
|
|
15
|
+
sources: { source: ContentDB["sources"][0]; dbHash: string }[];
|
|
16
|
+
totalChunks: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function KnowledgeBase() {
|
|
20
|
+
const [dbs, setDbs] = useState<ContentDB[]>([]);
|
|
21
|
+
const [loading, setLoading] = useState(true);
|
|
22
|
+
|
|
23
|
+
useEffect(() => { api.content().then(d => { setDbs(d); setLoading(false); }); }, []);
|
|
24
|
+
|
|
25
|
+
const handleDelete = async (e: React.MouseEvent, dbHash: string, sourceId: number) => {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
e.stopPropagation();
|
|
28
|
+
if (!confirm("Delete this source and all its chunks?")) return;
|
|
29
|
+
await api.deleteSource(dbHash, sourceId);
|
|
30
|
+
api.content().then(setDbs);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (loading) return <p className="text-muted-foreground animate-pulse">Loading knowledge base...</p>;
|
|
34
|
+
|
|
35
|
+
const nonEmpty = dbs.filter(db => db.sourceCount > 0);
|
|
36
|
+
|
|
37
|
+
// Compute KPI values
|
|
38
|
+
const totalSources = nonEmpty.reduce((a, db) => a + db.sourceCount, 0);
|
|
39
|
+
const totalChunks = nonEmpty.reduce((a, db) => a + db.chunkCount, 0);
|
|
40
|
+
|
|
41
|
+
// Find freshest indexed_at across all sources
|
|
42
|
+
let freshestDate: string | null = null;
|
|
43
|
+
for (const db of nonEmpty) {
|
|
44
|
+
for (const s of db.sources) {
|
|
45
|
+
if (s.indexedAt && (!freshestDate || s.indexedAt > freshestDate)) {
|
|
46
|
+
freshestDate = s.indexedAt;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Flatten all sources with their dbHash, then group by date
|
|
52
|
+
const allSources: { source: ContentDB["sources"][0]; dbHash: string }[] = [];
|
|
53
|
+
for (const db of nonEmpty) {
|
|
54
|
+
for (const s of db.sources) {
|
|
55
|
+
allSources.push({ source: s, dbHash: db.hash });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Sort by indexedAt descending
|
|
59
|
+
allSources.sort((a, b) => (b.source.indexedAt || "").localeCompare(a.source.indexedAt || ""));
|
|
60
|
+
|
|
61
|
+
// Group by date
|
|
62
|
+
const groupOrder = ["Today", "Yesterday", "This Week", "Older"];
|
|
63
|
+
const groupMap = new Map<string, GroupedSources>();
|
|
64
|
+
for (const g of groupOrder) groupMap.set(g, { label: g, sources: [], totalChunks: 0 });
|
|
65
|
+
|
|
66
|
+
for (const item of allSources) {
|
|
67
|
+
const g = dateGroup(item.source.indexedAt);
|
|
68
|
+
const group = groupMap.get(g)!;
|
|
69
|
+
group.sources.push(item);
|
|
70
|
+
group.totalChunks += item.source.chunks;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const groups = groupOrder.map(g => groupMap.get(g)!).filter(g => g.sources.length > 0);
|
|
74
|
+
|
|
75
|
+
if (nonEmpty.length === 0) {
|
|
76
|
+
return (
|
|
77
|
+
<div className="space-y-6">
|
|
78
|
+
<div>
|
|
79
|
+
<h2 className="text-2xl font-semibold">Knowledge Base</h2>
|
|
80
|
+
<p className="text-sm text-muted-foreground mt-1">No indexed content yet</p>
|
|
81
|
+
</div>
|
|
82
|
+
<Card>
|
|
83
|
+
<CardContent className="py-12 text-center text-muted-foreground">
|
|
84
|
+
Use <code className="text-xs bg-secondary px-1.5 py-0.5 rounded">ctx_index</code> or <code className="text-xs bg-secondary px-1.5 py-0.5 rounded">ctx_fetch_and_index</code> to add content.
|
|
85
|
+
</CardContent>
|
|
86
|
+
</Card>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="space-y-6">
|
|
93
|
+
<div>
|
|
94
|
+
<h2 className="text-2xl font-semibold">Knowledge Base</h2>
|
|
95
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
96
|
+
{nonEmpty.length} database{nonEmpty.length > 1 ? "s" : ""} with indexed content
|
|
97
|
+
</p>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{/* KPI Strip */}
|
|
101
|
+
<div className="grid grid-cols-3 gap-3">
|
|
102
|
+
<Card>
|
|
103
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
104
|
+
<CardTitle className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Sources</CardTitle>
|
|
105
|
+
<Database className="h-4 w-4 text-blue-500" />
|
|
106
|
+
</CardHeader>
|
|
107
|
+
<CardContent>
|
|
108
|
+
<div className="text-2xl font-bold tabular-nums">{totalSources}</div>
|
|
109
|
+
<p className="text-[11px] text-muted-foreground mt-0.5">across {nonEmpty.length} db{nonEmpty.length > 1 ? "s" : ""}</p>
|
|
110
|
+
</CardContent>
|
|
111
|
+
</Card>
|
|
112
|
+
<Card>
|
|
113
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
114
|
+
<CardTitle className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Chunks</CardTitle>
|
|
115
|
+
<Layers className="h-4 w-4 text-purple-500" />
|
|
116
|
+
</CardHeader>
|
|
117
|
+
<CardContent>
|
|
118
|
+
<div className="text-2xl font-bold tabular-nums">{totalChunks}</div>
|
|
119
|
+
<p className="text-[11px] text-muted-foreground mt-0.5">{totalSources > 0 ? `~${Math.round(totalChunks / totalSources)} per source` : "no sources"}</p>
|
|
120
|
+
</CardContent>
|
|
121
|
+
</Card>
|
|
122
|
+
<Card>
|
|
123
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
124
|
+
<CardTitle className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Freshness</CardTitle>
|
|
125
|
+
<Clock className="h-4 w-4 text-emerald-500" />
|
|
126
|
+
</CardHeader>
|
|
127
|
+
<CardContent>
|
|
128
|
+
<div className="text-2xl font-bold tabular-nums">{timeAgo(freshestDate)}</div>
|
|
129
|
+
<p className="text-[11px] text-muted-foreground mt-0.5">last indexed</p>
|
|
130
|
+
</CardContent>
|
|
131
|
+
</Card>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{/* Source groups by date */}
|
|
135
|
+
{groups.map(group => (
|
|
136
|
+
<div key={group.label}>
|
|
137
|
+
<div className="flex items-center gap-3 mb-3">
|
|
138
|
+
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</h3>
|
|
139
|
+
<Badge variant="secondary" className="text-[10px]">{group.sources.length} source{group.sources.length > 1 ? "s" : ""}</Badge>
|
|
140
|
+
<Badge variant="outline" className="text-[10px]">{group.totalChunks} chunks</Badge>
|
|
141
|
+
</div>
|
|
142
|
+
<div className="grid gap-2">
|
|
143
|
+
{group.sources.map(({ source: s, dbHash }) => {
|
|
144
|
+
const labels = parseSourceLabel(s.label);
|
|
145
|
+
return (
|
|
146
|
+
<Link
|
|
147
|
+
key={`${dbHash}-${s.id}`}
|
|
148
|
+
to="/knowledge/$dbHash/$sourceId"
|
|
149
|
+
params={{ dbHash, sourceId: String(s.id) }}
|
|
150
|
+
className="block group"
|
|
151
|
+
>
|
|
152
|
+
<Card className="transition-colors hover:border-primary/30">
|
|
153
|
+
<CardContent className="py-3 px-4">
|
|
154
|
+
<div className="flex items-center gap-3">
|
|
155
|
+
{/* Source labels as badges */}
|
|
156
|
+
<div className="flex-1 min-w-0">
|
|
157
|
+
<div className="flex flex-wrap gap-1.5 mb-1">
|
|
158
|
+
{labels.map((lbl, i) => (
|
|
159
|
+
<Badge key={i} variant="secondary" className="text-[10px] font-mono max-w-[200px] truncate">
|
|
160
|
+
{lbl}
|
|
161
|
+
</Badge>
|
|
162
|
+
))}
|
|
163
|
+
{parseSourceLabel(s.label).length < s.label.split(",").length && (
|
|
164
|
+
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
|
165
|
+
+{s.label.split(",").length - 3} more
|
|
166
|
+
</Badge>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
<TooltipProvider>
|
|
170
|
+
<Tooltip>
|
|
171
|
+
<TooltipTrigger>
|
|
172
|
+
<span className="text-[10px] text-muted-foreground font-mono block truncate max-w-[400px] text-left">
|
|
173
|
+
{s.label}
|
|
174
|
+
</span>
|
|
175
|
+
</TooltipTrigger>
|
|
176
|
+
<TooltipContent side="bottom" className="max-w-lg">
|
|
177
|
+
<span className="font-mono text-xs break-all">{s.label}</span>
|
|
178
|
+
</TooltipContent>
|
|
179
|
+
</Tooltip>
|
|
180
|
+
</TooltipProvider>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{/* Chunk counts */}
|
|
184
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
185
|
+
<Badge variant="secondary" className="text-[10px] tabular-nums">
|
|
186
|
+
{s.chunks} chunk{s.chunks !== 1 ? "s" : ""}
|
|
187
|
+
</Badge>
|
|
188
|
+
{s.codeChunks > 0 && (
|
|
189
|
+
<Badge variant="outline" className="text-[10px] tabular-nums text-cyan-500 border-cyan-500/30">
|
|
190
|
+
{s.codeChunks} code
|
|
191
|
+
</Badge>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{/* Time ago */}
|
|
196
|
+
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0 min-w-[50px] text-right">
|
|
197
|
+
{timeAgo(s.indexedAt)}
|
|
198
|
+
</span>
|
|
199
|
+
|
|
200
|
+
{/* Delete button */}
|
|
201
|
+
<Button
|
|
202
|
+
variant="ghost" size="icon"
|
|
203
|
+
className="h-7 w-7 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
|
204
|
+
onClick={(e) => handleDelete(e, dbHash, s.id)}
|
|
205
|
+
>
|
|
206
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
207
|
+
</Button>
|
|
208
|
+
|
|
209
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0" />
|
|
210
|
+
</div>
|
|
211
|
+
</CardContent>
|
|
212
|
+
</Card>
|
|
213
|
+
</Link>
|
|
214
|
+
);
|
|
215
|
+
})}
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
))}
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { api, type Chunk } from "@/lib/api";
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
|
+
import { Badge } from "@/components/ui/badge";
|
|
6
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
7
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
8
|
+
import { RatioBar, COLORS } from "@/components/analytics";
|
|
9
|
+
import { ArrowLeft, ChevronDown, Layers, Code, FileText } from "lucide-react";
|
|
10
|
+
|
|
11
|
+
export const Route = createFileRoute("/knowledge_/$dbHash/$sourceId")({ component: ChunkView });
|
|
12
|
+
|
|
13
|
+
function ChunkView() {
|
|
14
|
+
const { dbHash, sourceId } = Route.useParams();
|
|
15
|
+
const [chunks, setChunks] = useState<Chunk[]>([]);
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
api.chunks(dbHash, Number(sourceId)).then(c => { setChunks(c); setLoading(false); });
|
|
20
|
+
}, [dbHash, sourceId]);
|
|
21
|
+
|
|
22
|
+
if (loading) return <p className="text-muted-foreground animate-pulse">Loading chunks...</p>;
|
|
23
|
+
|
|
24
|
+
const sourceLabel = chunks[0]?.label || dbHash;
|
|
25
|
+
|
|
26
|
+
// Compute stats
|
|
27
|
+
const totalChunks = chunks.length;
|
|
28
|
+
const codeChunks = chunks.filter(c => c.content_type === "code").length;
|
|
29
|
+
const textChunks = totalChunks - codeChunks;
|
|
30
|
+
|
|
31
|
+
// Content type breakdown
|
|
32
|
+
const typeCounts: Record<string, number> = {};
|
|
33
|
+
chunks.forEach(c => { typeCounts[c.content_type] = (typeCounts[c.content_type] || 0) + 1; });
|
|
34
|
+
const typeEntries = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]);
|
|
35
|
+
|
|
36
|
+
// Average content length
|
|
37
|
+
const avgLen = totalChunks > 0
|
|
38
|
+
? Math.round(chunks.reduce((a, c) => a + (c.content?.length || 0), 0) / totalChunks)
|
|
39
|
+
: 0;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="space-y-6">
|
|
43
|
+
<Link to="/knowledge" className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
|
44
|
+
<ArrowLeft className="h-4 w-4" /> Back to Knowledge Base
|
|
45
|
+
</Link>
|
|
46
|
+
|
|
47
|
+
<div>
|
|
48
|
+
<div className="flex items-center gap-3 mb-2">
|
|
49
|
+
<h2 className="text-2xl font-semibold">Source Detail</h2>
|
|
50
|
+
<Badge variant="outline">{dbHash.slice(0, 8)}</Badge>
|
|
51
|
+
</div>
|
|
52
|
+
<p className="text-sm text-muted-foreground font-mono break-all">{sourceLabel}</p>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{/* KPI Strip */}
|
|
56
|
+
<div className="grid grid-cols-3 gap-3">
|
|
57
|
+
<Card>
|
|
58
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
59
|
+
<CardTitle className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Chunks</CardTitle>
|
|
60
|
+
<Layers className="h-4 w-4 text-blue-500" />
|
|
61
|
+
</CardHeader>
|
|
62
|
+
<CardContent>
|
|
63
|
+
<div className="text-2xl font-bold tabular-nums">{totalChunks}</div>
|
|
64
|
+
<p className="text-[11px] text-muted-foreground mt-0.5">~{avgLen} chars avg</p>
|
|
65
|
+
</CardContent>
|
|
66
|
+
</Card>
|
|
67
|
+
<Card>
|
|
68
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
69
|
+
<CardTitle className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Code</CardTitle>
|
|
70
|
+
<Code className="h-4 w-4 text-cyan-500" />
|
|
71
|
+
</CardHeader>
|
|
72
|
+
<CardContent>
|
|
73
|
+
<div className="text-2xl font-bold tabular-nums">{codeChunks}</div>
|
|
74
|
+
<p className="text-[11px] text-muted-foreground mt-0.5">{totalChunks > 0 ? `${Math.round(100 * codeChunks / totalChunks)}% of total` : "none"}</p>
|
|
75
|
+
</CardContent>
|
|
76
|
+
</Card>
|
|
77
|
+
<Card>
|
|
78
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
79
|
+
<CardTitle className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Text</CardTitle>
|
|
80
|
+
<FileText className="h-4 w-4 text-purple-500" />
|
|
81
|
+
</CardHeader>
|
|
82
|
+
<CardContent>
|
|
83
|
+
<div className="text-2xl font-bold tabular-nums">{textChunks}</div>
|
|
84
|
+
<p className="text-[11px] text-muted-foreground mt-0.5">{totalChunks > 0 ? `${Math.round(100 * textChunks / totalChunks)}% of total` : "none"}</p>
|
|
85
|
+
</CardContent>
|
|
86
|
+
</Card>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Content type ratio bar */}
|
|
90
|
+
{typeEntries.length > 1 && (
|
|
91
|
+
<Card>
|
|
92
|
+
<CardHeader>
|
|
93
|
+
<CardTitle className="text-sm">Content Types</CardTitle>
|
|
94
|
+
</CardHeader>
|
|
95
|
+
<CardContent>
|
|
96
|
+
<RatioBar items={typeEntries.map(([type, count], i) => ({
|
|
97
|
+
label: type, value: count, color: type === "code" ? "#06b6d4" : COLORS[i % COLORS.length],
|
|
98
|
+
}))} />
|
|
99
|
+
</CardContent>
|
|
100
|
+
</Card>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{/* Chunk list */}
|
|
104
|
+
<div className="space-y-2">
|
|
105
|
+
{chunks.map((chunk, i) => (
|
|
106
|
+
<Collapsible key={i}>
|
|
107
|
+
<Card>
|
|
108
|
+
<CollapsibleTrigger className="w-full">
|
|
109
|
+
<CardHeader className="flex flex-row items-center justify-between py-3 cursor-pointer hover:bg-accent/50 transition-colors">
|
|
110
|
+
<span className="text-sm font-medium text-left flex-1 truncate">{chunk.title || "(untitled)"}</span>
|
|
111
|
+
<div className="flex items-center gap-2">
|
|
112
|
+
<Badge variant={chunk.content_type === "code" ? "default" : "secondary"} className="text-[10px]">
|
|
113
|
+
{chunk.content_type}
|
|
114
|
+
</Badge>
|
|
115
|
+
<Badge variant="outline" className="text-[10px] tabular-nums text-muted-foreground">
|
|
116
|
+
{chunk.content?.length || 0} chars
|
|
117
|
+
</Badge>
|
|
118
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
119
|
+
</div>
|
|
120
|
+
</CardHeader>
|
|
121
|
+
</CollapsibleTrigger>
|
|
122
|
+
<CollapsibleContent>
|
|
123
|
+
<CardContent className="pt-0">
|
|
124
|
+
<div className="max-h-80 overflow-y-auto rounded-md border border-border/50 bg-background/50 p-3">
|
|
125
|
+
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-words font-mono leading-relaxed">
|
|
126
|
+
{chunk.content}
|
|
127
|
+
</pre>
|
|
128
|
+
</div>
|
|
129
|
+
</CardContent>
|
|
130
|
+
</CollapsibleContent>
|
|
131
|
+
</Card>
|
|
132
|
+
</Collapsible>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { api, type Chunk } from "@/lib/api";
|
|
4
|
+
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
5
|
+
import { Badge } from "@/components/ui/badge";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
9
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
10
|
+
import { Search as SearchIcon, ChevronDown } from "lucide-react";
|
|
11
|
+
|
|
12
|
+
export const Route = createFileRoute("/search")({ component: SearchPage });
|
|
13
|
+
|
|
14
|
+
function esc(s: string) {
|
|
15
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function SearchPage() {
|
|
19
|
+
const [query, setQuery] = useState("");
|
|
20
|
+
const [results, setResults] = useState<Chunk[] | null>(null);
|
|
21
|
+
const [loading, setLoading] = useState(false);
|
|
22
|
+
|
|
23
|
+
const doSearch = async () => {
|
|
24
|
+
if (!query.trim()) return;
|
|
25
|
+
setLoading(true);
|
|
26
|
+
try {
|
|
27
|
+
setResults(await api.search(query.trim()));
|
|
28
|
+
} finally { setLoading(false); }
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div>
|
|
33
|
+
<h2 className="text-2xl font-semibold mb-6">Search All Memory</h2>
|
|
34
|
+
|
|
35
|
+
<div className="flex gap-2 mb-6">
|
|
36
|
+
<Input
|
|
37
|
+
value={query}
|
|
38
|
+
onChange={e => setQuery(e.target.value)}
|
|
39
|
+
onKeyDown={e => e.key === "Enter" && doSearch()}
|
|
40
|
+
placeholder='Search across all knowledge bases (full words or partial text)...'
|
|
41
|
+
className="font-mono text-sm"
|
|
42
|
+
/>
|
|
43
|
+
<Button onClick={doSearch} disabled={loading} className="gap-2">
|
|
44
|
+
<SearchIcon className="h-4 w-4" />
|
|
45
|
+
Search
|
|
46
|
+
</Button>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{loading && <p className="text-sm text-muted-foreground">Searching...</p>}
|
|
50
|
+
|
|
51
|
+
{results && results.length === 0 && (
|
|
52
|
+
<Card>
|
|
53
|
+
<CardContent className="py-12 text-center">
|
|
54
|
+
<p className="text-muted-foreground">No results for “{query}”</p>
|
|
55
|
+
<p className="text-xs text-muted-foreground mt-1">Try different terms, FTS5 syntax (word1 OR word2), or paste partial text</p>
|
|
56
|
+
</CardContent>
|
|
57
|
+
</Card>
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
{results && results.length > 0 && (
|
|
61
|
+
<div className="space-y-2">
|
|
62
|
+
<p className="text-sm text-muted-foreground mb-4">{results.length} results</p>
|
|
63
|
+
{results.map((r, i) => (
|
|
64
|
+
<Collapsible key={i} defaultOpen={i < 3}>
|
|
65
|
+
<Card>
|
|
66
|
+
<CollapsibleTrigger className="w-full">
|
|
67
|
+
<CardHeader className="flex flex-row items-center justify-between py-3 cursor-pointer hover:bg-accent/50 transition-colors">
|
|
68
|
+
<span className="text-sm font-medium text-left flex-1 truncate">{r.title || "(untitled)"}</span>
|
|
69
|
+
<div className="flex items-center gap-2">
|
|
70
|
+
{r.dbHash && <Badge variant="outline" className="text-[10px]">{r.dbHash.slice(0, 8)}</Badge>}
|
|
71
|
+
<Badge variant={r.content_type === "code" ? "default" : "secondary"} className="text-[10px]">{r.content_type}</Badge>
|
|
72
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
73
|
+
</div>
|
|
74
|
+
</CardHeader>
|
|
75
|
+
</CollapsibleTrigger>
|
|
76
|
+
<CollapsibleContent>
|
|
77
|
+
<CardContent className="pt-0">
|
|
78
|
+
<ScrollArea className="max-h-64">
|
|
79
|
+
<pre
|
|
80
|
+
className="text-xs text-muted-foreground whitespace-pre-wrap break-words font-mono leading-relaxed"
|
|
81
|
+
dangerouslySetInnerHTML={{
|
|
82
|
+
__html: (r.highlighted || esc(r.content))
|
|
83
|
+
.replace(/«/g, '<mark class="bg-amber-500/20 text-foreground rounded px-0.5">')
|
|
84
|
+
.replace(/»/g, "</mark>"),
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
</ScrollArea>
|
|
88
|
+
</CardContent>
|
|
89
|
+
</CollapsibleContent>
|
|
90
|
+
</Card>
|
|
91
|
+
</Collapsible>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { api, type SessionDB, type SessionMeta } from "@/lib/api";
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
|
+
import { Badge } from "@/components/ui/badge";
|
|
6
|
+
import { timeAgo, formatDuration } from "@/components/analytics";
|
|
7
|
+
import { Zap, Clock, Activity, ChevronRight, AlertTriangle } from "lucide-react";
|
|
8
|
+
|
|
9
|
+
export const Route = createFileRoute("/sessions")({ component: Sessions });
|
|
10
|
+
|
|
11
|
+
function Sessions() {
|
|
12
|
+
const [dbs, setDbs] = useState<SessionDB[]>([]);
|
|
13
|
+
const [loading, setLoading] = useState(true);
|
|
14
|
+
|
|
15
|
+
useEffect(() => { api.sessions().then(d => { setDbs(d); setLoading(false); }); }, []);
|
|
16
|
+
|
|
17
|
+
if (loading) return <p className="text-muted-foreground animate-pulse">Loading sessions...</p>;
|
|
18
|
+
|
|
19
|
+
const nonEmpty = dbs.filter(db => db.sessions.length > 0);
|
|
20
|
+
|
|
21
|
+
if (nonEmpty.length === 0) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="space-y-6">
|
|
24
|
+
<div>
|
|
25
|
+
<h2 className="text-2xl font-semibold">Sessions</h2>
|
|
26
|
+
<p className="text-sm text-muted-foreground mt-1">No sessions with events found</p>
|
|
27
|
+
</div>
|
|
28
|
+
<Card>
|
|
29
|
+
<CardContent className="py-12 text-center text-muted-foreground">
|
|
30
|
+
Sessions appear here once your AI coding tools start generating events.
|
|
31
|
+
</CardContent>
|
|
32
|
+
</Card>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Flatten all sessions
|
|
38
|
+
const allSessions: { session: SessionMeta; dbHash: string }[] = [];
|
|
39
|
+
for (const db of nonEmpty) {
|
|
40
|
+
for (const s of db.sessions) {
|
|
41
|
+
allSessions.push({ session: s, dbHash: db.hash });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Compute KPIs
|
|
46
|
+
const totalSessions = allSessions.length;
|
|
47
|
+
const totalEvents = allSessions.reduce((a, s) => a + s.session.eventCount, 0);
|
|
48
|
+
|
|
49
|
+
// Compute average duration from startedAt to lastEventAt
|
|
50
|
+
let totalDurationMin = 0;
|
|
51
|
+
let durCount = 0;
|
|
52
|
+
for (const { session: s } of allSessions) {
|
|
53
|
+
if (s.startedAt && s.lastEventAt) {
|
|
54
|
+
const start = new Date(s.startedAt).getTime();
|
|
55
|
+
const end = new Date(s.lastEventAt).getTime();
|
|
56
|
+
if (!isNaN(start) && !isNaN(end) && end > start) {
|
|
57
|
+
totalDurationMin += (end - start) / 60000;
|
|
58
|
+
durCount++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const avgDuration = durCount > 0 ? Math.round(totalDurationMin / durCount) : 0;
|
|
63
|
+
|
|
64
|
+
// Sort by startedAt descending
|
|
65
|
+
allSessions.sort((a, b) => (b.session.startedAt || "").localeCompare(a.session.startedAt || ""));
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="space-y-6">
|
|
69
|
+
<div>
|
|
70
|
+
<h2 className="text-2xl font-semibold">Sessions</h2>
|
|
71
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
72
|
+
All recorded AI coding sessions
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* KPI Strip */}
|
|
77
|
+
<div className="grid grid-cols-3 gap-3">
|
|
78
|
+
<Card>
|
|
79
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
80
|
+
<CardTitle className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Sessions</CardTitle>
|
|
81
|
+
<Zap className="h-4 w-4 text-blue-500" />
|
|
82
|
+
</CardHeader>
|
|
83
|
+
<CardContent>
|
|
84
|
+
<div className="text-2xl font-bold tabular-nums">{totalSessions}</div>
|
|
85
|
+
<p className="text-[11px] text-muted-foreground mt-0.5">across {nonEmpty.length} db{nonEmpty.length > 1 ? "s" : ""}</p>
|
|
86
|
+
</CardContent>
|
|
87
|
+
</Card>
|
|
88
|
+
<Card>
|
|
89
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
90
|
+
<CardTitle className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Avg Duration</CardTitle>
|
|
91
|
+
<Clock className="h-4 w-4 text-purple-500" />
|
|
92
|
+
</CardHeader>
|
|
93
|
+
<CardContent>
|
|
94
|
+
<div className="text-2xl font-bold tabular-nums">{formatDuration(avgDuration)}</div>
|
|
95
|
+
<p className="text-[11px] text-muted-foreground mt-0.5">per session</p>
|
|
96
|
+
</CardContent>
|
|
97
|
+
</Card>
|
|
98
|
+
<Card>
|
|
99
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
100
|
+
<CardTitle className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Events</CardTitle>
|
|
101
|
+
<Activity className="h-4 w-4 text-emerald-500" />
|
|
102
|
+
</CardHeader>
|
|
103
|
+
<CardContent>
|
|
104
|
+
<div className="text-2xl font-bold tabular-nums">{totalEvents}</div>
|
|
105
|
+
<p className="text-[11px] text-muted-foreground mt-0.5">{totalSessions > 0 ? `~${Math.round(totalEvents / totalSessions)} per session` : "none"}</p>
|
|
106
|
+
</CardContent>
|
|
107
|
+
</Card>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Session cards */}
|
|
111
|
+
<div className="grid gap-2">
|
|
112
|
+
{allSessions.map(({ session: s, dbHash }) => {
|
|
113
|
+
// Compute duration
|
|
114
|
+
let durationMin = 0;
|
|
115
|
+
if (s.startedAt && s.lastEventAt) {
|
|
116
|
+
const start = new Date(s.startedAt).getTime();
|
|
117
|
+
const end = new Date(s.lastEventAt).getTime();
|
|
118
|
+
if (!isNaN(start) && !isNaN(end) && end > start) {
|
|
119
|
+
durationMin = (end - start) / 60000;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Project name: last 2 path segments
|
|
124
|
+
const projectName = s.projectDir
|
|
125
|
+
? s.projectDir.split("/").filter(Boolean).slice(-2).join("/")
|
|
126
|
+
: "Unknown";
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Link
|
|
130
|
+
key={`${dbHash}-${s.id}`}
|
|
131
|
+
to="/sessions/$dbHash/$sessionId"
|
|
132
|
+
params={{ dbHash, sessionId: s.id }}
|
|
133
|
+
className="block group"
|
|
134
|
+
>
|
|
135
|
+
<Card className="transition-colors hover:border-primary/30">
|
|
136
|
+
<CardContent className="py-3 px-4">
|
|
137
|
+
<div className="flex items-center gap-3">
|
|
138
|
+
{/* Project name */}
|
|
139
|
+
<div className="flex-1 min-w-0">
|
|
140
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
141
|
+
<span className="text-sm font-medium truncate">{projectName}</span>
|
|
142
|
+
{s.compactCount > 0 && (
|
|
143
|
+
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/30 shrink-0">
|
|
144
|
+
<AlertTriangle className="h-2.5 w-2.5 mr-0.5" />
|
|
145
|
+
{s.compactCount} compact{s.compactCount > 1 ? "s" : ""}
|
|
146
|
+
</Badge>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
<span className="text-[10px] text-muted-foreground font-mono">{s.id.slice(0, 16)}</span>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{/* Duration badge */}
|
|
153
|
+
{durationMin > 0 && (
|
|
154
|
+
<Badge variant="secondary" className="text-[10px] tabular-nums shrink-0">
|
|
155
|
+
{formatDuration(durationMin)}
|
|
156
|
+
</Badge>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{/* Event count */}
|
|
160
|
+
<Badge variant="secondary" className="text-[10px] tabular-nums shrink-0">
|
|
161
|
+
{s.eventCount} event{s.eventCount !== 1 ? "s" : ""}
|
|
162
|
+
</Badge>
|
|
163
|
+
|
|
164
|
+
{/* Started time */}
|
|
165
|
+
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0 min-w-[50px] text-right">
|
|
166
|
+
{timeAgo(s.startedAt)}
|
|
167
|
+
</span>
|
|
168
|
+
|
|
169
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0" />
|
|
170
|
+
</div>
|
|
171
|
+
</CardContent>
|
|
172
|
+
</Card>
|
|
173
|
+
</Link>
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|