memhouse 0.0.1 → 0.3.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/LICENSE +22 -0
- package/README.md +72 -3
- package/bin/memhouse.js +559 -0
- package/cache.js +850 -0
- package/ch-ingest.js +329 -0
- package/ch-schema.js +152 -0
- package/clickhouse.js +87 -0
- package/editors/adapter-errors.js +52 -0
- package/editors/antigravity.js +1048 -0
- package/editors/base.js +321 -0
- package/editors/claude.js +386 -0
- package/editors/codebuff.js +338 -0
- package/editors/codex.js +537 -0
- package/editors/commandcode.js +166 -0
- package/editors/copilot-jetbrains.js +207 -0
- package/editors/copilot.js +260 -0
- package/editors/cursor-agent.js +202 -0
- package/editors/cursor.js +498 -0
- package/editors/gemini.js +231 -0
- package/editors/goose.js +386 -0
- package/editors/gsd.js +366 -0
- package/editors/index.js +274 -0
- package/editors/kiro.js +315 -0
- package/editors/opencode.js +432 -0
- package/editors/vscode.js +413 -0
- package/editors/windsurf.js +634 -0
- package/editors/zed.js +229 -0
- package/index.js +424 -2
- package/mcp-server.js +294 -0
- package/mem-house/DESIGN.md +119 -0
- package/mem-house/delivery/AGENT-INSTALL.md +87 -0
- package/mem-house/delivery/PROMPT.md +42 -0
- package/mem-house/delivery/install.sh +181 -0
- package/mem-house/delivery/kernel-install.md +83 -0
- package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
- package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
- package/mem-house/rls.sql +17 -0
- package/mem-house/schema.sql +145 -0
- package/mem-house/server/queries.js +878 -0
- package/mem-house/server/server.js +228 -0
- package/mem-house/shipper/ship.js +461 -0
- package/misc/syntax-check.js +38 -0
- package/package.json +59 -7
- package/pricing.js +88 -0
- package/pricing.json +992 -0
- package/public/assets/index-CCPh7a2Z.js +75 -0
- package/public/assets/index-DV6ONi_F.css +2 -0
- package/public/index.html +16 -0
- package/relay-client.js +306 -0
- package/relay-server.js +577 -0
- package/server.js +860 -0
- package/share-image.js +367 -0
- package/ui/eslint.config.js +29 -0
- package/ui/index.html +15 -0
- package/ui/package-lock.json +4229 -0
- package/ui/package.json +38 -0
- package/ui/src/App.jsx +364 -0
- package/ui/src/components/ActivityHeatmap.jsx +206 -0
- package/ui/src/components/AiAuditCard.jsx +254 -0
- package/ui/src/components/AnimatedLoader.jsx +14 -0
- package/ui/src/components/AnimatedLogo.jsx +96 -0
- package/ui/src/components/ChatSidebar.jsx +226 -0
- package/ui/src/components/DateRangePicker.jsx +104 -0
- package/ui/src/components/EditorDot.jsx +11 -0
- package/ui/src/components/EditorIcon.jsx +117 -0
- package/ui/src/components/KpiCard.jsx +9 -0
- package/ui/src/components/LiveFeed.jsx +128 -0
- package/ui/src/components/LoginScreen.jsx +81 -0
- package/ui/src/components/MessageRenderer.jsx +148 -0
- package/ui/src/components/PageHeader.jsx +11 -0
- package/ui/src/components/SectionTitle.jsx +3 -0
- package/ui/src/components/ShareModal.jsx +305 -0
- package/ui/src/components/TokenTimeline.jsx +258 -0
- package/ui/src/hooks/useLive.jsx +15 -0
- package/ui/src/index.css +83 -0
- package/ui/src/lib/api.js +343 -0
- package/ui/src/lib/constants.js +88 -0
- package/ui/src/lib/theme.jsx +23 -0
- package/ui/src/main.jsx +16 -0
- package/ui/src/pages/Artifacts.jsx +575 -0
- package/ui/src/pages/Compare.jsx +232 -0
- package/ui/src/pages/CostAnalysis.jsx +354 -0
- package/ui/src/pages/Dashboard.jsx +529 -0
- package/ui/src/pages/DeepAnalysis.jsx +495 -0
- package/ui/src/pages/GSD.jsx +726 -0
- package/ui/src/pages/MCPs.jsx +744 -0
- package/ui/src/pages/ProjectDetail.jsx +325 -0
- package/ui/src/pages/Projects.jsx +249 -0
- package/ui/src/pages/RelayDashboard.jsx +590 -0
- package/ui/src/pages/RelayUserDetail.jsx +466 -0
- package/ui/src/pages/Sessions.jsx +535 -0
- package/ui/src/pages/Settings.jsx +241 -0
- package/ui/src/pages/SqlViewer.jsx +370 -0
- package/ui/src/pages/Subscriptions.jsx +411 -0
- package/ui/vite.config.js +18 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
2
|
+
import { useSearchParams, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { ArrowLeft, Search, FolderOpen, Calendar, MessageSquare, Wrench, Cpu, Zap, AlertTriangle, ShieldCheck } from 'lucide-react'
|
|
4
|
+
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
|
|
5
|
+
import { Doughnut, Bar } from 'react-chartjs-2'
|
|
6
|
+
import { fetchProjects, fetchChats, fetchCosts } from '../lib/api'
|
|
7
|
+
import { editorColor, editorLabel, formatNumber, formatDate, formatCost } from '../lib/constants'
|
|
8
|
+
import { useTheme } from '../lib/theme'
|
|
9
|
+
import KpiCard from '../components/KpiCard'
|
|
10
|
+
import EditorIcon from '../components/EditorIcon'
|
|
11
|
+
import SectionTitle from '../components/SectionTitle'
|
|
12
|
+
import ChatSidebar from '../components/ChatSidebar'
|
|
13
|
+
import AnimatedLoader from '../components/AnimatedLoader'
|
|
14
|
+
import AiAuditCard from '../components/AiAuditCard'
|
|
15
|
+
|
|
16
|
+
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
|
|
17
|
+
|
|
18
|
+
const MONO = 'JetBrains Mono, monospace'
|
|
19
|
+
const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399']
|
|
20
|
+
const TOOL_COLORS = ['#10b981', '#34d399', '#6ee7b7', '#a7f3d0', '#d1fae5', '#ecfdf5', '#b8f0d8', '#7ce0b8', '#4ade80', '#22c55e']
|
|
21
|
+
|
|
22
|
+
export default function ProjectDetail() {
|
|
23
|
+
const [searchParams] = useSearchParams()
|
|
24
|
+
const navigate = useNavigate()
|
|
25
|
+
const folder = searchParams.get('folder')
|
|
26
|
+
const { dark } = useTheme()
|
|
27
|
+
const txtColor = dark ? '#888' : '#555'
|
|
28
|
+
const txtDim = dark ? '#555' : '#999'
|
|
29
|
+
const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
|
|
30
|
+
|
|
31
|
+
const [project, setProject] = useState(null)
|
|
32
|
+
const [chats, setChats] = useState([])
|
|
33
|
+
const [loading, setLoading] = useState(true)
|
|
34
|
+
const [chatSearch, setChatSearch] = useState('')
|
|
35
|
+
const [selectedChatId, setSelectedChatId] = useState(null)
|
|
36
|
+
const [costs, setCosts] = useState(null)
|
|
37
|
+
const [enabledEditors, setEnabledEditors] = useState(null)
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!folder) return
|
|
41
|
+
setLoading(true)
|
|
42
|
+
Promise.all([
|
|
43
|
+
fetchProjects(),
|
|
44
|
+
fetchChats({ folder, limit: 1000 }),
|
|
45
|
+
fetchCosts({ folder }),
|
|
46
|
+
]).then(([projects, chatData, costData]) => {
|
|
47
|
+
const match = projects.find(p => p.folder === folder)
|
|
48
|
+
setProject(match || null)
|
|
49
|
+
setChats(chatData.chats || [])
|
|
50
|
+
setCosts(costData)
|
|
51
|
+
if (match) setEnabledEditors(new Set(Object.keys(match.editors)))
|
|
52
|
+
setLoading(false)
|
|
53
|
+
})
|
|
54
|
+
}, [folder])
|
|
55
|
+
|
|
56
|
+
const editorFilteredChats = useMemo(() => {
|
|
57
|
+
if (!enabledEditors) return chats
|
|
58
|
+
return chats.filter(c => enabledEditors.has(c.source))
|
|
59
|
+
}, [chats, enabledEditors])
|
|
60
|
+
|
|
61
|
+
const filteredChats = useMemo(() => {
|
|
62
|
+
if (!chatSearch) return editorFilteredChats
|
|
63
|
+
const q = chatSearch.toLowerCase()
|
|
64
|
+
return editorFilteredChats.filter(c =>
|
|
65
|
+
(c.name && c.name.toLowerCase().includes(q)) ||
|
|
66
|
+
(c.topModel && c.topModel.toLowerCase().includes(q)) ||
|
|
67
|
+
(c.source && c.source.toLowerCase().includes(q))
|
|
68
|
+
)
|
|
69
|
+
}, [editorFilteredChats, chatSearch])
|
|
70
|
+
|
|
71
|
+
const toggleEditor = (editorId) => {
|
|
72
|
+
setEnabledEditors(prev => {
|
|
73
|
+
const next = new Set(prev)
|
|
74
|
+
if (next.has(editorId)) next.delete(editorId)
|
|
75
|
+
else next.add(editorId)
|
|
76
|
+
return next
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!folder) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>no project specified</div>
|
|
81
|
+
if (loading) return <AnimatedLoader label="Loading project..." />
|
|
82
|
+
if (!project) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>project not found</div>
|
|
83
|
+
|
|
84
|
+
const editorEntries = Object.entries(project.editors).sort((a, b) => b[1] - a[1])
|
|
85
|
+
const allEnabled = !enabledEditors || enabledEditors.size === editorEntries.length
|
|
86
|
+
|
|
87
|
+
// Derive stats from editor-filtered chats
|
|
88
|
+
const fSessionCount = editorFilteredChats.length
|
|
89
|
+
const fEditorCounts = {}
|
|
90
|
+
const fModelCounts = {}
|
|
91
|
+
for (const c of editorFilteredChats) {
|
|
92
|
+
fEditorCounts[c.source] = (fEditorCounts[c.source] || 0) + 1
|
|
93
|
+
if (c.topModel) fModelCounts[c.topModel] = (fModelCounts[c.topModel] || 0) + 1
|
|
94
|
+
}
|
|
95
|
+
const fTopModels = Object.entries(fModelCounts).sort((a, b) => b[1] - a[1]).slice(0, 10)
|
|
96
|
+
const fMaxEditorCount = Math.max(...Object.values(fEditorCounts), 1)
|
|
97
|
+
|
|
98
|
+
// Use project-level stats when all editors enabled, otherwise show filtered session count
|
|
99
|
+
const totalTok = project.totalInputTokens + project.totalOutputTokens
|
|
100
|
+
const outputRatio = project.totalInputTokens > 0 ? (project.totalOutputTokens / project.totalInputTokens).toFixed(1) : '0'
|
|
101
|
+
const displaySessions = allEnabled ? project.totalSessions : fSessionCount
|
|
102
|
+
const avgMsgs = allEnabled && project.totalSessions > 0 ? (project.totalMessages / project.totalSessions).toFixed(1) : 0
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="fade-in space-y-3">
|
|
106
|
+
{/* Header card */}
|
|
107
|
+
<div className="card p-4">
|
|
108
|
+
<div className="flex items-start gap-3">
|
|
109
|
+
<button
|
|
110
|
+
onClick={() => navigate('/projects')}
|
|
111
|
+
className="flex items-center gap-1 text-[12px] transition mt-0.5 flex-shrink-0"
|
|
112
|
+
style={{ color: 'var(--c-text3)' }}
|
|
113
|
+
>
|
|
114
|
+
<ArrowLeft size={12} />
|
|
115
|
+
</button>
|
|
116
|
+
<FolderOpen size={18} className="flex-shrink-0 mt-0.5" style={{ color: 'var(--c-accent)' }} />
|
|
117
|
+
<div className="flex-1 min-w-0">
|
|
118
|
+
<h1 className="text-sm font-bold truncate" style={{ color: 'var(--c-white)' }}>{project.name}</h1>
|
|
119
|
+
<div className="text-[11px] truncate" style={{ color: 'var(--c-text3)' }}>{project.folder}</div>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
122
|
+
{editorEntries.map(([e]) => (
|
|
123
|
+
<EditorIcon key={e} source={e} size={14} />
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="flex items-center gap-4 mt-3 pt-3 text-[11px]" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
128
|
+
<div className="flex items-center gap-1" style={{ color: 'var(--c-text3)' }}>
|
|
129
|
+
<Calendar size={9} />
|
|
130
|
+
<span>{formatDate(project.firstSeen)}</span>
|
|
131
|
+
<span>→</span>
|
|
132
|
+
<span>{formatDate(project.lastSeen)}</span>
|
|
133
|
+
</div>
|
|
134
|
+
<div className="flex items-center gap-1.5 ml-auto">
|
|
135
|
+
{editorEntries.map(([e, c]) => (
|
|
136
|
+
<span key={e} className="inline-flex items-center gap-0.5">
|
|
137
|
+
<span className="w-1.5 h-1.5 rounded-full" style={{ background: editorColor(e) }} />
|
|
138
|
+
<span style={{ color: 'var(--c-text3)' }}>{editorLabel(e)}</span>
|
|
139
|
+
<span className="font-bold" style={{ color: 'var(--c-text2)' }}>{c}</span>
|
|
140
|
+
</span>
|
|
141
|
+
))}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{/* KPIs */}
|
|
147
|
+
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
|
|
148
|
+
<KpiCard label="sessions" value={displaySessions} sub={!allEnabled ? 'filtered' : ''} />
|
|
149
|
+
<KpiCard label="messages" value={formatNumber(project.totalMessages)} sub={`${avgMsgs} avg/session`} />
|
|
150
|
+
<KpiCard label="tool calls" value={formatNumber(project.totalToolCalls)} sub={<span className="flex items-center gap-0.5"><Wrench size={8} /> invocations</span>} />
|
|
151
|
+
<KpiCard label="tokens" value={formatNumber(totalTok)} sub={`${outputRatio}\u00d7 out/in`} />
|
|
152
|
+
{project.totalCacheRead > 0 && (
|
|
153
|
+
<KpiCard label="cache read" value={formatNumber(project.totalCacheRead)} sub={`write: ${formatNumber(project.totalCacheWrite)}`} />
|
|
154
|
+
)}
|
|
155
|
+
<KpiCard label="you wrote" value={formatNumber(project.totalUserChars)} sub={`AI: ${formatNumber(project.totalAssistantChars)}`} />
|
|
156
|
+
<KpiCard label="est. cost" value={costs && costs.totalCost > 0 ? formatCost(costs.totalCost) : '\u2014'} sub={costs && costs.byModel.length > 0 ? `${costs.byModel.length} model${costs.byModel.length !== 1 ? 's' : ''}` : undefined} />
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Charts row */}
|
|
160
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
|
161
|
+
{/* Editors bar with checkboxes */}
|
|
162
|
+
<div className="card p-3">
|
|
163
|
+
<SectionTitle>editors</SectionTitle>
|
|
164
|
+
<div className="space-y-1.5 mt-1">
|
|
165
|
+
{editorEntries.map(([e, c]) => {
|
|
166
|
+
const checked = enabledEditors ? enabledEditors.has(e) : true
|
|
167
|
+
const count = fEditorCounts[e] || 0
|
|
168
|
+
return (
|
|
169
|
+
<div key={e} className="flex items-center gap-2">
|
|
170
|
+
<input
|
|
171
|
+
type="checkbox"
|
|
172
|
+
checked={checked}
|
|
173
|
+
onChange={() => toggleEditor(e)}
|
|
174
|
+
className="accent-[var(--c-accent)] w-3 h-3 flex-shrink-0 cursor-pointer"
|
|
175
|
+
/>
|
|
176
|
+
<EditorIcon source={e} size={11} />
|
|
177
|
+
<span className="text-[11px] truncate flex-1 cursor-pointer select-none" onClick={() => toggleEditor(e)} style={{ color: checked ? 'var(--c-text2)' : 'var(--c-text3)', opacity: checked ? 1 : 0.4 }}>{editorLabel(e)}</span>
|
|
178
|
+
<div className="w-16 h-3 rounded-sm overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
|
|
179
|
+
<div className="h-full rounded-sm transition-all" style={{ width: `${(count / fMaxEditorCount * 100).toFixed(0)}%`, background: checked ? editorColor(e) : 'var(--c-text3)', opacity: checked ? 1 : 0.2 }} />
|
|
180
|
+
</div>
|
|
181
|
+
<span className="text-[11px] w-6 text-right font-bold" style={{ color: checked ? 'var(--c-white)' : 'var(--c-text3)', opacity: checked ? 1 : 0.4 }}>{count}</span>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
})}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{/* Models doughnut */}
|
|
189
|
+
<div className="card p-3">
|
|
190
|
+
<SectionTitle>models</SectionTitle>
|
|
191
|
+
{fTopModels.length > 0 ? (
|
|
192
|
+
<div style={{ height: 160 }}>
|
|
193
|
+
<Doughnut
|
|
194
|
+
data={{
|
|
195
|
+
labels: fTopModels.map(m => m[0]),
|
|
196
|
+
datasets: [{ data: fTopModels.map(m => m[1]), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
|
|
197
|
+
}}
|
|
198
|
+
options={{
|
|
199
|
+
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
|
200
|
+
plugins: {
|
|
201
|
+
legend: { position: 'right', labels: { color: txtColor, font: { size: 8, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
|
|
202
|
+
tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
203
|
+
},
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no model data</div>}
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{/* Top tools horizontal bar */}
|
|
211
|
+
<div className="card p-3">
|
|
212
|
+
<SectionTitle>top tools <span style={{ color: 'var(--c-text3)' }}>({formatNumber(project.totalToolCalls)})</span></SectionTitle>
|
|
213
|
+
{project.topTools.length > 0 ? (
|
|
214
|
+
<div style={{ height: 160 }}>
|
|
215
|
+
<Bar
|
|
216
|
+
data={{
|
|
217
|
+
labels: project.topTools.map(t => t.name),
|
|
218
|
+
datasets: [{
|
|
219
|
+
data: project.topTools.map(t => t.count),
|
|
220
|
+
backgroundColor: TOOL_COLORS.slice(0, project.topTools.length),
|
|
221
|
+
borderRadius: 2,
|
|
222
|
+
}],
|
|
223
|
+
}}
|
|
224
|
+
options={{
|
|
225
|
+
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
|
226
|
+
scales: {
|
|
227
|
+
x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } } },
|
|
228
|
+
y: { grid: { display: false }, ticks: { color: txtColor, font: { size: 8, family: MONO } } },
|
|
229
|
+
},
|
|
230
|
+
plugins: { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } },
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no tool data</div>}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
{/* AI Readiness Audit */}
|
|
239
|
+
{console.log('Rendering AiAuditCard, folder:', folder)}
|
|
240
|
+
<AiAuditCard folder={folder} />
|
|
241
|
+
|
|
242
|
+
{/* Sessions */}
|
|
243
|
+
<div>
|
|
244
|
+
<div className="flex items-center gap-3 mb-2">
|
|
245
|
+
<SectionTitle>sessions <span style={{ color: 'var(--c-text3)' }}>({filteredChats.length}{!allEnabled ? ` of ${chats.length}` : ''})</span></SectionTitle>
|
|
246
|
+
<div className="relative max-w-xs flex-1">
|
|
247
|
+
<Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
248
|
+
<input
|
|
249
|
+
type="text"
|
|
250
|
+
placeholder="filter sessions..."
|
|
251
|
+
value={chatSearch}
|
|
252
|
+
onChange={e => setChatSearch(e.target.value)}
|
|
253
|
+
className="w-full pl-7 pr-3 py-1 text-[12px] outline-none rounded-sm"
|
|
254
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div className="card overflow-hidden">
|
|
260
|
+
<table className="w-full text-[12px]">
|
|
261
|
+
<thead>
|
|
262
|
+
<tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
|
|
263
|
+
<th className="text-left py-2 px-3 font-medium">editor</th>
|
|
264
|
+
<th className="text-left py-2 px-3 font-medium">name</th>
|
|
265
|
+
<th className="text-left py-2 px-3 font-medium">mode</th>
|
|
266
|
+
<th className="text-left py-2 px-3 font-medium">model</th>
|
|
267
|
+
<th className="text-left py-2 px-3 font-medium">context</th>
|
|
268
|
+
<th className="text-right py-2 px-3 font-medium">est. cost</th>
|
|
269
|
+
<th className="text-left py-2 px-3 font-medium">updated</th>
|
|
270
|
+
</tr>
|
|
271
|
+
</thead>
|
|
272
|
+
<tbody>
|
|
273
|
+
{filteredChats.map(c => (
|
|
274
|
+
<tr
|
|
275
|
+
key={c.id}
|
|
276
|
+
className="cursor-pointer transition"
|
|
277
|
+
style={{ borderBottom: '1px solid var(--c-border)' }}
|
|
278
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
|
|
279
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
280
|
+
onClick={() => setSelectedChatId(c.id)}
|
|
281
|
+
>
|
|
282
|
+
<td className="py-2 px-3">
|
|
283
|
+
<span className="inline-flex items-center gap-1.5">
|
|
284
|
+
<EditorIcon source={c.source} size={12} />
|
|
285
|
+
<span style={{ color: 'var(--c-text2)' }}>{editorLabel(c.source)}</span>
|
|
286
|
+
</span>
|
|
287
|
+
</td>
|
|
288
|
+
<td className="py-2 px-3 font-medium truncate max-w-[280px]" style={{ color: 'var(--c-white)' }}>
|
|
289
|
+
{c.name || <span style={{ color: 'var(--c-text3)' }}>Untitled</span>}
|
|
290
|
+
{c.encrypted && <span className="ml-1.5 text-[10px] text-yellow-500/60">locked</span>}
|
|
291
|
+
</td>
|
|
292
|
+
<td className="py-2 px-3" style={{ color: 'var(--c-text2)' }}>{c.mode || ''}</td>
|
|
293
|
+
<td className="py-2 px-3 font-mono truncate max-w-[150px]" style={{ color: 'var(--c-text2)' }} title={c.topModel || ''}>{c.topModel || ''}</td>
|
|
294
|
+
<td className="py-2 px-3">
|
|
295
|
+
{c.bubbleCount >= 500 ? (
|
|
296
|
+
<span className="inline-flex items-center gap-0.5 font-bold" style={{ color: '#ef4444' }}>
|
|
297
|
+
<AlertTriangle size={9} />{c.bubbleCount} msgs
|
|
298
|
+
</span>
|
|
299
|
+
) : c.bubbleCount >= 100 ? (
|
|
300
|
+
<span className="inline-flex items-center gap-0.5 font-bold" style={{ color: '#f59e0b' }}>
|
|
301
|
+
<AlertTriangle size={9} />{c.bubbleCount} msgs
|
|
302
|
+
</span>
|
|
303
|
+
) : (
|
|
304
|
+
<span style={{ color: 'var(--c-text3)' }}>{c.bubbleCount || 0} msgs</span>
|
|
305
|
+
)}
|
|
306
|
+
</td>
|
|
307
|
+
<td className="py-2 px-3 font-mono text-right" style={{ color: c.cost > 0 ? 'var(--c-text2)' : 'var(--c-text3)' }}>
|
|
308
|
+
{c.cost > 0 ? formatCost(c.cost) : ''}
|
|
309
|
+
</td>
|
|
310
|
+
<td className="py-2 px-3 whitespace-nowrap" style={{ color: 'var(--c-text3)' }}>{formatDate(c.lastUpdatedAt || c.createdAt)}</td>
|
|
311
|
+
</tr>
|
|
312
|
+
))}
|
|
313
|
+
</tbody>
|
|
314
|
+
</table>
|
|
315
|
+
{filteredChats.length === 0 && (
|
|
316
|
+
<div className="text-center py-8 text-sm" style={{ color: 'var(--c-text3)' }}>no sessions found</div>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
{/* Chat sidebar */}
|
|
322
|
+
<ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
|
|
323
|
+
</div>
|
|
324
|
+
)
|
|
325
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
|
|
3
|
+
import { Doughnut, Bar } from 'react-chartjs-2'
|
|
4
|
+
import { Search, MessageSquare, Wrench, Cpu, FolderOpen, Calendar } from 'lucide-react'
|
|
5
|
+
import { useNavigate } from 'react-router-dom'
|
|
6
|
+
import { fetchProjects, fetchCosts } from '../lib/api'
|
|
7
|
+
import { editorColor, editorLabel, formatNumber, formatCost, formatDate, dateRangeToApiParams } from '../lib/constants'
|
|
8
|
+
import { useTheme } from '../lib/theme'
|
|
9
|
+
import KpiCard from '../components/KpiCard'
|
|
10
|
+
import EditorIcon from '../components/EditorIcon'
|
|
11
|
+
import DateRangePicker from '../components/DateRangePicker'
|
|
12
|
+
import SectionTitle from '../components/SectionTitle'
|
|
13
|
+
import AnimatedLoader from '../components/AnimatedLoader'
|
|
14
|
+
import PageHeader from '../components/PageHeader'
|
|
15
|
+
|
|
16
|
+
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
|
|
17
|
+
|
|
18
|
+
const MONO = 'JetBrains Mono, monospace'
|
|
19
|
+
const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399']
|
|
20
|
+
|
|
21
|
+
export default function Projects({ overview }) {
|
|
22
|
+
const { dark } = useTheme()
|
|
23
|
+
const txtColor = dark ? '#888' : '#555'
|
|
24
|
+
const txtDim = dark ? '#555' : '#999'
|
|
25
|
+
const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
|
|
26
|
+
const [projects, setProjects] = useState(null)
|
|
27
|
+
const [search, setSearch] = useState('')
|
|
28
|
+
const [editorFilter, setEditorFilter] = useState('')
|
|
29
|
+
const [dateRange, setDateRange] = useState(null)
|
|
30
|
+
const [costs, setCosts] = useState(null)
|
|
31
|
+
const navigate = useNavigate()
|
|
32
|
+
const editors = overview?.editors || []
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const dateParams = dateRangeToApiParams(dateRange)
|
|
36
|
+
Promise.all([
|
|
37
|
+
fetchProjects(dateParams),
|
|
38
|
+
fetchCosts(dateParams),
|
|
39
|
+
]).then(([p, c]) => { setProjects(p); setCosts(c) })
|
|
40
|
+
}, [dateRange])
|
|
41
|
+
|
|
42
|
+
if (!projects) return <AnimatedLoader label="Loading projects..." />
|
|
43
|
+
|
|
44
|
+
const filtered = projects.filter(p => {
|
|
45
|
+
if (editorFilter && !p.editors[editorFilter]) return false
|
|
46
|
+
if (search && !p.folder.toLowerCase().includes(search.toLowerCase()) && !p.name.toLowerCase().includes(search.toLowerCase())) return false
|
|
47
|
+
return true
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const totalSessions = projects.reduce((s, p) => s + p.totalSessions, 0)
|
|
51
|
+
const totalMessages = projects.reduce((s, p) => s + p.totalMessages, 0)
|
|
52
|
+
const totalTokens = projects.reduce((s, p) => s + p.totalInputTokens + p.totalOutputTokens, 0)
|
|
53
|
+
const maxSessions = Math.max(...projects.map(p => p.totalSessions), 1)
|
|
54
|
+
|
|
55
|
+
// Aggregate for charts
|
|
56
|
+
const globalModels = {}
|
|
57
|
+
const globalEditors = {}
|
|
58
|
+
for (const p of projects) {
|
|
59
|
+
for (const m of p.topModels) globalModels[m.name] = (globalModels[m.name] || 0) + m.count
|
|
60
|
+
for (const [e, c] of Object.entries(p.editors)) globalEditors[e] = (globalEditors[e] || 0) + c
|
|
61
|
+
}
|
|
62
|
+
const topGlobalModels = Object.entries(globalModels).sort((a, b) => b[1] - a[1]).slice(0, 8)
|
|
63
|
+
const topGlobalEditors = Object.entries(globalEditors).sort((a, b) => b[1] - a[1])
|
|
64
|
+
const top10 = projects.slice(0, 10)
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="fade-in space-y-3">
|
|
68
|
+
<PageHeader icon={FolderOpen} title="Projects" />
|
|
69
|
+
|
|
70
|
+
{/* KPIs */}
|
|
71
|
+
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
|
|
72
|
+
<KpiCard label="projects" value={projects.length} />
|
|
73
|
+
<KpiCard label="sessions" value={formatNumber(totalSessions)} onClick={() => navigate('/sessions')} />
|
|
74
|
+
<KpiCard label="messages" value={formatNumber(totalMessages)} />
|
|
75
|
+
<KpiCard label="tokens" value={formatNumber(totalTokens)} />
|
|
76
|
+
<KpiCard label="est. cost" value={costs && costs.totalCost > 0 ? formatCost(costs.totalCost) : '\u2014'} />
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Charts row */}
|
|
80
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
|
81
|
+
<div className="card p-3">
|
|
82
|
+
<SectionTitle>top projects <span style={{ color: 'var(--c-text3)' }}>by sessions</span></SectionTitle>
|
|
83
|
+
<div style={{ height: 160 }}>
|
|
84
|
+
<Bar
|
|
85
|
+
data={{
|
|
86
|
+
labels: top10.map(p => p.name),
|
|
87
|
+
datasets: [{
|
|
88
|
+
data: top10.map(p => p.totalSessions),
|
|
89
|
+
backgroundColor: '#6366f1',
|
|
90
|
+
borderRadius: 3,
|
|
91
|
+
}],
|
|
92
|
+
}}
|
|
93
|
+
options={{
|
|
94
|
+
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
|
95
|
+
scales: {
|
|
96
|
+
x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } } },
|
|
97
|
+
y: { grid: { display: false }, ticks: { color: txtColor, font: { size: 8, family: MONO }, callback: (v, i) => top10[i]?.name?.length > 16 ? top10[i].name.slice(0, 15) + '…' : top10[i]?.name } },
|
|
98
|
+
},
|
|
99
|
+
plugins: { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } },
|
|
100
|
+
}}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="card p-3">
|
|
105
|
+
<SectionTitle>editors</SectionTitle>
|
|
106
|
+
<div style={{ height: 160 }}>
|
|
107
|
+
<Doughnut
|
|
108
|
+
data={{
|
|
109
|
+
labels: topGlobalEditors.map(e => editorLabel(e[0])),
|
|
110
|
+
datasets: [{ data: topGlobalEditors.map(e => e[1]), backgroundColor: topGlobalEditors.map(e => editorColor(e[0])), borderWidth: 0 }],
|
|
111
|
+
}}
|
|
112
|
+
options={{
|
|
113
|
+
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
|
114
|
+
plugins: {
|
|
115
|
+
legend: { position: 'right', labels: { color: txtColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
|
|
116
|
+
tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
117
|
+
},
|
|
118
|
+
}}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="card p-3">
|
|
123
|
+
<SectionTitle>models</SectionTitle>
|
|
124
|
+
<div style={{ height: 160 }}>
|
|
125
|
+
{topGlobalModels.length > 0 ? (
|
|
126
|
+
<Doughnut
|
|
127
|
+
data={{
|
|
128
|
+
labels: topGlobalModels.map(m => m[0]),
|
|
129
|
+
datasets: [{ data: topGlobalModels.map(m => m[1]), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
|
|
130
|
+
}}
|
|
131
|
+
options={{
|
|
132
|
+
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
|
133
|
+
plugins: {
|
|
134
|
+
legend: { position: 'right', labels: { color: txtColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
|
|
135
|
+
tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
136
|
+
},
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
) : <div className="text-xs text-center py-12" style={{ color: 'var(--c-text3)' }}>no model data</div>}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* Filters */}
|
|
145
|
+
<div className="flex items-center gap-2">
|
|
146
|
+
<select
|
|
147
|
+
value={editorFilter}
|
|
148
|
+
onChange={e => setEditorFilter(e.target.value)}
|
|
149
|
+
className="px-2 py-1 text-[12px] outline-none"
|
|
150
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
151
|
+
>
|
|
152
|
+
<option value="">All Editors</option>
|
|
153
|
+
{editors.map(e => (
|
|
154
|
+
<option key={e.id} value={e.id}>{editorLabel(e.id)}</option>
|
|
155
|
+
))}
|
|
156
|
+
</select>
|
|
157
|
+
<div className="relative flex-1 max-w-sm">
|
|
158
|
+
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
159
|
+
<input
|
|
160
|
+
type="text"
|
|
161
|
+
placeholder="Search projects..."
|
|
162
|
+
value={search}
|
|
163
|
+
onChange={e => setSearch(e.target.value)}
|
|
164
|
+
className="w-full pl-7 pr-3 py-1 text-[12px] outline-none"
|
|
165
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
<div className="ml-auto"><DateRangePicker value={dateRange} onChange={setDateRange} /></div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Project cards */}
|
|
172
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
173
|
+
{filtered.map(p => {
|
|
174
|
+
const editorEntries = Object.entries(p.editors).sort((a, b) => b[1] - a[1])
|
|
175
|
+
const totalTok = p.totalInputTokens + p.totalOutputTokens
|
|
176
|
+
const topModel = p.topModels[0]
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
key={p.folder}
|
|
181
|
+
className="card p-3 cursor-pointer transition hover:opacity-90 flex flex-col gap-2.5"
|
|
182
|
+
onClick={() => navigate(`/projects/detail?folder=${encodeURIComponent(p.folder)}`)}
|
|
183
|
+
>
|
|
184
|
+
{/* Header: name + editors */}
|
|
185
|
+
<div className="flex items-start gap-2 min-w-0">
|
|
186
|
+
<FolderOpen size={14} className="flex-shrink-0 mt-0.5" style={{ color: 'var(--c-accent)' }} />
|
|
187
|
+
<div className="flex-1 min-w-0">
|
|
188
|
+
<div className="text-[12px] font-bold truncate" style={{ color: 'var(--c-white)' }}>{p.name}</div>
|
|
189
|
+
<div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{p.folder}</div>
|
|
190
|
+
</div>
|
|
191
|
+
<div className="flex items-center gap-1 flex-shrink-0">
|
|
192
|
+
{editorEntries.slice(0, 4).map(([e]) => (
|
|
193
|
+
<EditorIcon key={e} source={e} size={12} />
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{/* Activity bar */}
|
|
199
|
+
<div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
|
|
200
|
+
<div className="h-full rounded-full" style={{ width: `${(p.totalSessions / maxSessions * 100).toFixed(1)}%`, background: 'var(--c-accent)' }} />
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* Stats grid */}
|
|
204
|
+
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
|
205
|
+
<div className="flex items-center gap-1">
|
|
206
|
+
<MessageSquare size={9} style={{ color: 'var(--c-text3)' }} />
|
|
207
|
+
<span style={{ color: 'var(--c-text2)' }}>{p.totalSessions} sessions</span>
|
|
208
|
+
<span className="ml-auto font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(p.totalMessages)} msgs</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div className="flex items-center gap-1">
|
|
211
|
+
<Wrench size={9} style={{ color: 'var(--c-text3)' }} />
|
|
212
|
+
<span style={{ color: 'var(--c-text2)' }}>tools</span>
|
|
213
|
+
<span className="ml-auto font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(p.totalToolCalls)}</span>
|
|
214
|
+
</div>
|
|
215
|
+
<div className="flex items-center gap-1">
|
|
216
|
+
<Cpu size={9} style={{ color: 'var(--c-text3)' }} />
|
|
217
|
+
<span className="truncate" style={{ color: 'var(--c-text2)' }}>{topModel ? topModel.name : '—'}</span>
|
|
218
|
+
</div>
|
|
219
|
+
<div className="flex items-center gap-1">
|
|
220
|
+
<span style={{ color: 'var(--c-text3)' }}>tok</span>
|
|
221
|
+
<span className="ml-auto font-bold" style={{ color: totalTok > 0 ? 'var(--c-white)' : 'var(--c-text3)' }}>{totalTok > 0 ? formatNumber(totalTok) : '—'}</span>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Footer: editors breakdown + date */}
|
|
226
|
+
<div className="flex items-center gap-2 pt-1 text-[10px]" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
227
|
+
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
|
228
|
+
{editorEntries.map(([e, c]) => (
|
|
229
|
+
<span key={e} className="inline-flex items-center gap-0.5 truncate">
|
|
230
|
+
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: editorColor(e) }} />
|
|
231
|
+
<span style={{ color: 'var(--c-text3)' }}>{editorLabel(e)}</span>
|
|
232
|
+
<span className="font-bold" style={{ color: 'var(--c-text2)' }}>{c}</span>
|
|
233
|
+
</span>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
<div className="flex items-center gap-1 flex-shrink-0" style={{ color: 'var(--c-text3)' }}>
|
|
237
|
+
<Calendar size={8} />
|
|
238
|
+
<span>{formatDate(p.lastSeen)}</span>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
)
|
|
243
|
+
})}
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{filtered.length === 0 && <div className="text-center py-12 text-sm" style={{ color: 'var(--c-text3)' }}>no projects found</div>}
|
|
247
|
+
</div>
|
|
248
|
+
)
|
|
249
|
+
}
|