memhouse 0.0.1 → 0.3.0
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 +59 -3
- package/bin/memhouse.js +487 -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/antigravity.js +1019 -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 +438 -0
- package/editors/gemini.js +231 -0
- package/editors/goose.js +358 -0
- package/editors/gsd.js +366 -0
- package/editors/index.js +179 -0
- package/editors/kiro.js +315 -0
- package/editors/opencode.js +374 -0
- package/editors/vscode.js +413 -0
- package/editors/windsurf.js +634 -0
- package/editors/zed.js +213 -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 +75 -0
- package/mem-house/delivery/PROMPT.md +42 -0
- package/mem-house/delivery/install.sh +181 -0
- package/mem-house/delivery/kernel-install.md +76 -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 +402 -0
- package/misc/syntax-check.js +38 -0
- package/package.json +56 -7
- package/pricing.js +88 -0
- package/pricing.json +992 -0
- package/public/assets/index-BsgjOSTv.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 +344 -0
- package/ui/src/pages/Subscriptions.jsx +411 -0
- package/ui/vite.config.js +18 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { X, Download, Share2, BarChart3, DollarSign, Clock, Cpu, Braces, User, Sun, Moon, FolderOpen } from 'lucide-react'
|
|
3
|
+
import { fetchShareImage, fetchProjects } from '../lib/api'
|
|
4
|
+
|
|
5
|
+
const TOGGLE_ITEMS = [
|
|
6
|
+
{ key: 'showEditors', label: 'Editors', icon: BarChart3 },
|
|
7
|
+
{ key: 'showCosts', label: 'Est. Costs', icon: DollarSign },
|
|
8
|
+
{ key: 'showHours', label: 'Peak Hours', icon: Clock },
|
|
9
|
+
{ key: 'showModels', label: 'Top Models', icon: Cpu },
|
|
10
|
+
{ key: 'showTokens', label: 'Token Footer', icon: Braces },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export default function ShareModal({ open, onClose }) {
|
|
14
|
+
const [opts, setOpts] = useState({
|
|
15
|
+
showEditors: true,
|
|
16
|
+
showCosts: true,
|
|
17
|
+
showHours: true,
|
|
18
|
+
showModels: true,
|
|
19
|
+
showTokens: true,
|
|
20
|
+
username: '',
|
|
21
|
+
theme: 'dark',
|
|
22
|
+
})
|
|
23
|
+
const [svg, setSvg] = useState('')
|
|
24
|
+
const [loading, setLoading] = useState(false)
|
|
25
|
+
const [downloading, setDownloading] = useState(false)
|
|
26
|
+
const [projects, setProjects] = useState([])
|
|
27
|
+
const debounceRef = useRef(null)
|
|
28
|
+
const backdropRef = useRef(null)
|
|
29
|
+
|
|
30
|
+
const loadPreview = useCallback(async (currentOpts) => {
|
|
31
|
+
setLoading(true)
|
|
32
|
+
try {
|
|
33
|
+
const result = await fetchShareImage(currentOpts)
|
|
34
|
+
if (result && !result.startsWith('{')) setSvg(result)
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error('Preview failed:', e)
|
|
37
|
+
}
|
|
38
|
+
setLoading(false)
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!open) return
|
|
43
|
+
loadPreview(opts)
|
|
44
|
+
fetchProjects().then(p => setProjects(p || [])).catch(() => {})
|
|
45
|
+
}, [open])
|
|
46
|
+
|
|
47
|
+
const updateOpt = (key, value) => {
|
|
48
|
+
const next = { ...opts, [key]: value }
|
|
49
|
+
setOpts(next)
|
|
50
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
51
|
+
debounceRef.current = setTimeout(() => loadPreview(next), 300)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleDownloadPng = async () => {
|
|
55
|
+
if (!svg) return
|
|
56
|
+
setDownloading(true)
|
|
57
|
+
try {
|
|
58
|
+
const scale = 2
|
|
59
|
+
const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' })
|
|
60
|
+
const url = URL.createObjectURL(svgBlob)
|
|
61
|
+
const img = new Image()
|
|
62
|
+
img.width = 1200 * scale
|
|
63
|
+
img.height = 675 * scale
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
img.onload = resolve
|
|
66
|
+
img.onerror = reject
|
|
67
|
+
img.src = url
|
|
68
|
+
})
|
|
69
|
+
const canvas = document.createElement('canvas')
|
|
70
|
+
canvas.width = 1200 * scale
|
|
71
|
+
canvas.height = 675 * scale
|
|
72
|
+
const ctx = canvas.getContext('2d')
|
|
73
|
+
ctx.drawImage(img, 0, 0, 1200 * scale, 675 * scale)
|
|
74
|
+
URL.revokeObjectURL(url)
|
|
75
|
+
canvas.toBlob((blob) => {
|
|
76
|
+
if (!blob) return
|
|
77
|
+
const a = document.createElement('a')
|
|
78
|
+
a.href = URL.createObjectURL(blob)
|
|
79
|
+
a.download = 'agentlytics.png'
|
|
80
|
+
a.click()
|
|
81
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000)
|
|
82
|
+
}, 'image/png')
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.error('PNG conversion failed:', e)
|
|
85
|
+
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
|
86
|
+
const a = document.createElement('a')
|
|
87
|
+
a.href = URL.createObjectURL(blob)
|
|
88
|
+
a.download = 'agentlytics.svg'
|
|
89
|
+
a.click()
|
|
90
|
+
URL.revokeObjectURL(a.href)
|
|
91
|
+
}
|
|
92
|
+
setDownloading(false)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const handleDownloadSvg = () => {
|
|
96
|
+
if (!svg) return
|
|
97
|
+
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
|
98
|
+
const a = document.createElement('a')
|
|
99
|
+
a.href = URL.createObjectURL(blob)
|
|
100
|
+
a.download = 'agentlytics.svg'
|
|
101
|
+
a.click()
|
|
102
|
+
URL.revokeObjectURL(a.href)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const handleShareTwitter = async () => {
|
|
106
|
+
await handleDownloadPng()
|
|
107
|
+
const text = encodeURIComponent("Here's my agentic coding stats using agentlytics.io\n\nrun `npx agentlytics` to get yours ✨\n\ngithub.com/f/agentlytics")
|
|
108
|
+
window.open(`https://x.com/intent/post?text=${text}`, '_blank')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!open) return null
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
ref={backdropRef}
|
|
116
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
117
|
+
style={{ background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(8px)' }}
|
|
118
|
+
onClick={(e) => e.target === backdropRef.current && onClose()}
|
|
119
|
+
>
|
|
120
|
+
<div
|
|
121
|
+
className="w-full relative flex flex-col"
|
|
122
|
+
style={{
|
|
123
|
+
maxWidth: 960,
|
|
124
|
+
maxHeight: '90vh',
|
|
125
|
+
background: 'var(--c-bg)',
|
|
126
|
+
border: '1px solid var(--c-border)',
|
|
127
|
+
borderRadius: 12,
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{/* Header */}
|
|
131
|
+
<div className="flex items-center justify-between px-5 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
132
|
+
<div className="flex items-center gap-2">
|
|
133
|
+
<Share2 size={14} style={{ color: 'var(--c-accent)' }} />
|
|
134
|
+
<span className="text-[13px] font-semibold" style={{ color: 'var(--c-white)' }}>Share Stats</span>
|
|
135
|
+
</div>
|
|
136
|
+
<button onClick={onClose} className="p-1 rounded hover:opacity-70 transition" style={{ color: 'var(--c-text2)' }}>
|
|
137
|
+
<X size={16} />
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Body */}
|
|
142
|
+
<div className="flex-1 overflow-auto p-5" style={{ minHeight: 0 }}>
|
|
143
|
+
<div className="flex gap-5" style={{ flexDirection: 'row' }}>
|
|
144
|
+
|
|
145
|
+
{/* Sidebar: toggles */}
|
|
146
|
+
<div className="flex-shrink-0" style={{ width: 200 }}>
|
|
147
|
+
<div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
148
|
+
Customize
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div className="mb-4">
|
|
152
|
+
<div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
153
|
+
Project
|
|
154
|
+
</div>
|
|
155
|
+
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md" style={{ border: '1px solid var(--c-border)', background: 'var(--c-bg)' }}>
|
|
156
|
+
<FolderOpen size={11} style={{ color: 'var(--c-text3)' }} />
|
|
157
|
+
<select
|
|
158
|
+
value={opts.folder || ''}
|
|
159
|
+
onChange={(e) => updateOpt('folder', e.target.value || undefined)}
|
|
160
|
+
className="bg-transparent outline-none text-[12px] w-full cursor-pointer"
|
|
161
|
+
style={{ color: 'var(--c-text)', appearance: 'none' }}
|
|
162
|
+
>
|
|
163
|
+
<option value="">All Projects</option>
|
|
164
|
+
{projects.map(p => (
|
|
165
|
+
<option key={p.folder} value={p.folder}>
|
|
166
|
+
{p.name} ({p.totalSessions})
|
|
167
|
+
</option>
|
|
168
|
+
))}
|
|
169
|
+
</select>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div className="space-y-1.5">
|
|
174
|
+
{TOGGLE_ITEMS.map(({ key, label, icon: Icon }) => {
|
|
175
|
+
const active = opts[key]
|
|
176
|
+
return (
|
|
177
|
+
<button
|
|
178
|
+
key={key}
|
|
179
|
+
onClick={() => updateOpt(key, !active)}
|
|
180
|
+
className="flex items-center gap-2 w-full px-2.5 py-2 rounded-md text-[12px] transition"
|
|
181
|
+
style={{
|
|
182
|
+
background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
|
|
183
|
+
border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
|
|
184
|
+
color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
|
|
185
|
+
opacity: active ? 1 : 0.6,
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<Icon size={12} />
|
|
189
|
+
{label}
|
|
190
|
+
</button>
|
|
191
|
+
)
|
|
192
|
+
})}
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div className="mt-4">
|
|
196
|
+
<div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
197
|
+
Theme
|
|
198
|
+
</div>
|
|
199
|
+
<div className="flex gap-1.5">
|
|
200
|
+
{[{ key: 'dark', icon: Moon, label: 'Dark' }, { key: 'light', icon: Sun, label: 'Light' }].map(({ key, icon: Icon, label }) => {
|
|
201
|
+
const active = opts.theme === key
|
|
202
|
+
return (
|
|
203
|
+
<button
|
|
204
|
+
key={key}
|
|
205
|
+
onClick={() => updateOpt('theme', key)}
|
|
206
|
+
className="flex items-center gap-1.5 flex-1 justify-center px-2 py-1.5 rounded-md text-[11px] transition"
|
|
207
|
+
style={{
|
|
208
|
+
background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
|
|
209
|
+
border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
|
|
210
|
+
color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
|
|
211
|
+
opacity: active ? 1 : 0.6,
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
<Icon size={11} />
|
|
215
|
+
{label}
|
|
216
|
+
</button>
|
|
217
|
+
)
|
|
218
|
+
})}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<div className="mt-4">
|
|
223
|
+
<div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
224
|
+
Username
|
|
225
|
+
</div>
|
|
226
|
+
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md" style={{ border: '1px solid var(--c-border)', background: 'var(--c-bg)' }}>
|
|
227
|
+
<User size={11} style={{ color: 'var(--c-text3)' }} />
|
|
228
|
+
<input
|
|
229
|
+
type="text"
|
|
230
|
+
placeholder="optional"
|
|
231
|
+
value={opts.username}
|
|
232
|
+
onChange={(e) => updateOpt('username', e.target.value)}
|
|
233
|
+
className="bg-transparent outline-none text-[12px] w-full"
|
|
234
|
+
style={{ color: 'var(--c-text)' }}
|
|
235
|
+
/>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
{/* Preview */}
|
|
241
|
+
<div className="flex-1 min-w-0">
|
|
242
|
+
<div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
243
|
+
Preview
|
|
244
|
+
</div>
|
|
245
|
+
<div
|
|
246
|
+
className="rounded-lg overflow-hidden relative"
|
|
247
|
+
style={{
|
|
248
|
+
border: '1px solid var(--c-border)',
|
|
249
|
+
background: '#09090f',
|
|
250
|
+
minHeight: 200,
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
{loading && (
|
|
254
|
+
<div className="absolute inset-0 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.5)', zIndex: 2 }}>
|
|
255
|
+
<span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Generating...</span>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
{svg && (
|
|
259
|
+
<div
|
|
260
|
+
className="w-full [&>svg]:w-full [&>svg]:h-auto [&>svg]:block"
|
|
261
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
262
|
+
/>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{/* Footer: actions */}
|
|
270
|
+
<div className="flex items-center justify-between px-5 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
271
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
|
|
272
|
+
Tip: Toggle sections to customize your share card
|
|
273
|
+
</span>
|
|
274
|
+
<div className="flex items-center gap-2">
|
|
275
|
+
<button
|
|
276
|
+
onClick={handleDownloadSvg}
|
|
277
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
|
|
278
|
+
style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
|
|
279
|
+
>
|
|
280
|
+
<Download size={12} />
|
|
281
|
+
SVG
|
|
282
|
+
</button>
|
|
283
|
+
<button
|
|
284
|
+
onClick={handleDownloadPng}
|
|
285
|
+
disabled={downloading}
|
|
286
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
|
|
287
|
+
style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)', opacity: downloading ? 0.5 : 1 }}
|
|
288
|
+
>
|
|
289
|
+
<Download size={12} />
|
|
290
|
+
{downloading ? 'Converting...' : 'PNG'}
|
|
291
|
+
</button>
|
|
292
|
+
<button
|
|
293
|
+
onClick={handleShareTwitter}
|
|
294
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
|
|
295
|
+
style={{ background: '#6366f1', color: '#fff' }}
|
|
296
|
+
>
|
|
297
|
+
<Share2 size={12} />
|
|
298
|
+
Share on X
|
|
299
|
+
</button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useMemo } from 'react'
|
|
2
|
+
import { Activity, ChevronDown, ChevronUp, Clock } from 'lucide-react'
|
|
3
|
+
import { formatNumber } from '../lib/constants'
|
|
4
|
+
|
|
5
|
+
const CHART_H = 56
|
|
6
|
+
const POINT_STEP = 52
|
|
7
|
+
const PAD_X = 20
|
|
8
|
+
const PAD_Y = 6
|
|
9
|
+
const BUCKET_MINUTES = 5
|
|
10
|
+
const BUCKET_MSG_COUNT = 8
|
|
11
|
+
|
|
12
|
+
const AVG_TOKEN_SPEED = 80
|
|
13
|
+
|
|
14
|
+
function formatDuration(ms) {
|
|
15
|
+
if (ms < 60000) return `${Math.round(ms / 1000)}s`
|
|
16
|
+
const mins = Math.floor(ms / 60000)
|
|
17
|
+
if (mins < 60) return `${mins}m`
|
|
18
|
+
const hrs = Math.floor(mins / 60)
|
|
19
|
+
const rem = mins % 60
|
|
20
|
+
return rem > 0 ? `${hrs}h ${rem}m` : `${hrs}h`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function lerp(a, b, t) {
|
|
24
|
+
return a + (b - a) * Math.max(0, Math.min(1, t))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function tokenColor(ratio) {
|
|
28
|
+
const r = Math.round(lerp(52, 239, ratio))
|
|
29
|
+
const g = Math.round(lerp(211, 68, ratio))
|
|
30
|
+
const b = Math.round(lerp(153, 68, ratio))
|
|
31
|
+
return `rgb(${r},${g},${b})`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildTimeBuckets(messages, createdAt, lastUpdatedAt) {
|
|
35
|
+
const start = new Date(createdAt).getTime()
|
|
36
|
+
const end = new Date(lastUpdatedAt).getTime()
|
|
37
|
+
const duration = Math.max(end - start, 1)
|
|
38
|
+
const bucketMs = BUCKET_MINUTES * 60 * 1000
|
|
39
|
+
const bucketCount = Math.max(1, Math.ceil(duration / bucketMs))
|
|
40
|
+
|
|
41
|
+
const buckets = Array.from({ length: bucketCount }, (_, i) => ({
|
|
42
|
+
label: `${i * BUCKET_MINUTES}m`,
|
|
43
|
+
tokens: 0,
|
|
44
|
+
messageIndices: [],
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
messages.forEach((msg, idx) => {
|
|
48
|
+
const totalTokens = (msg.inputTokens || 0) + (msg.outputTokens || 0)
|
|
49
|
+
const msgTime = start + (idx / Math.max(messages.length - 1, 1)) * duration
|
|
50
|
+
const bucketIdx = Math.min(Math.floor((msgTime - start) / bucketMs), bucketCount - 1)
|
|
51
|
+
buckets[bucketIdx].tokens += totalTokens
|
|
52
|
+
buckets[bucketIdx].messageIndices.push(idx)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return buckets
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildSeqBuckets(messages) {
|
|
59
|
+
const bucketCount = Math.max(1, Math.ceil(messages.length / BUCKET_MSG_COUNT))
|
|
60
|
+
|
|
61
|
+
const buckets = Array.from({ length: bucketCount }, () => ({
|
|
62
|
+
label: '',
|
|
63
|
+
tokens: 0,
|
|
64
|
+
messageIndices: [],
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
messages.forEach((msg, idx) => {
|
|
68
|
+
const chars = typeof msg.content === 'string' ? msg.content.length : 0
|
|
69
|
+
const totalTokens = Math.round(chars / 4)
|
|
70
|
+
const bucketIdx = Math.min(Math.floor(idx / BUCKET_MSG_COUNT), bucketCount - 1)
|
|
71
|
+
buckets[bucketIdx].tokens += totalTokens
|
|
72
|
+
buckets[bucketIdx].messageIndices.push(idx)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return buckets
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default function TokenTimeline({ messages, createdAt, lastUpdatedAt, onScrollToMessage }) {
|
|
79
|
+
const [open, setOpen] = useState(true)
|
|
80
|
+
const [hoveredIdx, setHoveredIdx] = useState(null)
|
|
81
|
+
const scrollContainerRef = useRef(null)
|
|
82
|
+
|
|
83
|
+
const hasRealTokens = useMemo(() => {
|
|
84
|
+
if (!messages) return false
|
|
85
|
+
return messages.some(m => (m.inputTokens || 0) + (m.outputTokens || 0) > 0)
|
|
86
|
+
}, [messages])
|
|
87
|
+
|
|
88
|
+
const buckets = useMemo(() => {
|
|
89
|
+
if (!messages || messages.length === 0) return []
|
|
90
|
+
if (hasRealTokens) return buildTimeBuckets(messages, createdAt, lastUpdatedAt)
|
|
91
|
+
return buildSeqBuckets(messages)
|
|
92
|
+
}, [messages, createdAt, lastUpdatedAt, hasRealTokens])
|
|
93
|
+
|
|
94
|
+
const maxTokens = useMemo(() => Math.max(...buckets.map(b => b.tokens), 1), [buckets])
|
|
95
|
+
|
|
96
|
+
const totalTokens = useMemo(() => buckets.reduce((s, b) => s + b.tokens, 0), [buckets])
|
|
97
|
+
|
|
98
|
+
const durationLabel = useMemo(() => {
|
|
99
|
+
if (hasRealTokens && createdAt && lastUpdatedAt) {
|
|
100
|
+
const ms = new Date(lastUpdatedAt).getTime() - new Date(createdAt).getTime()
|
|
101
|
+
if (ms > 0) return formatDuration(ms)
|
|
102
|
+
}
|
|
103
|
+
if (totalTokens > 0) {
|
|
104
|
+
const estSeconds = totalTokens / AVG_TOKEN_SPEED
|
|
105
|
+
return '~' + formatDuration(estSeconds * 1000)
|
|
106
|
+
}
|
|
107
|
+
return null
|
|
108
|
+
}, [hasRealTokens, createdAt, lastUpdatedAt, totalTokens])
|
|
109
|
+
|
|
110
|
+
const handlePointClick = useCallback((bucket) => {
|
|
111
|
+
if (bucket.messageIndices.length > 0 && onScrollToMessage) {
|
|
112
|
+
onScrollToMessage(bucket.messageIndices[0])
|
|
113
|
+
}
|
|
114
|
+
}, [onScrollToMessage])
|
|
115
|
+
|
|
116
|
+
if (!messages || messages.length === 0 || totalTokens === 0) return null
|
|
117
|
+
|
|
118
|
+
const svgW = PAD_X * 2 + Math.max((buckets.length - 1) * POINT_STEP, 0)
|
|
119
|
+
const svgH = CHART_H + PAD_Y * 2 + (hasRealTokens ? 16 : 4)
|
|
120
|
+
|
|
121
|
+
const points = buckets.map((b, i) => {
|
|
122
|
+
const x = PAD_X + i * POINT_STEP
|
|
123
|
+
const ratio = b.tokens / maxTokens
|
|
124
|
+
const y = PAD_Y + CHART_H - ratio * CHART_H
|
|
125
|
+
return { x, y, ratio, bucket: b }
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
|
129
|
+
|
|
130
|
+
const areaPath = points.length > 0
|
|
131
|
+
? `${linePath} L ${points[points.length - 1].x} ${PAD_Y + CHART_H} L ${points[0].x} ${PAD_Y + CHART_H} Z`
|
|
132
|
+
: ''
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
136
|
+
{/* Toggle button */}
|
|
137
|
+
<button
|
|
138
|
+
onClick={() => setOpen(o => !o)}
|
|
139
|
+
className="flex items-center gap-2 w-full px-4 py-1.5 text-[11px] transition hover:bg-[var(--c-bg3)]"
|
|
140
|
+
style={{ color: 'var(--c-text2)' }}
|
|
141
|
+
>
|
|
142
|
+
<Activity size={12} style={{ color: 'var(--c-accent)' }} />
|
|
143
|
+
<span className="font-medium">Token Timeline</span>
|
|
144
|
+
<span style={{ color: 'var(--c-text3)' }}>
|
|
145
|
+
({formatNumber(totalTokens)} tokens{!hasRealTokens ? ', est.' : ''})
|
|
146
|
+
</span>
|
|
147
|
+
{durationLabel && (
|
|
148
|
+
<span className="inline-flex items-center gap-1" style={{ color: 'var(--c-text3)' }}>
|
|
149
|
+
<Clock size={10} /> {durationLabel}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
<span className="ml-auto">
|
|
153
|
+
{open ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
154
|
+
</span>
|
|
155
|
+
</button>
|
|
156
|
+
|
|
157
|
+
{/* Collapsible chart area */}
|
|
158
|
+
{open && (
|
|
159
|
+
<div className="px-2 pb-2 pt-0">
|
|
160
|
+
<div
|
|
161
|
+
ref={scrollContainerRef}
|
|
162
|
+
className="overflow-x-auto scrollbar-thin"
|
|
163
|
+
style={{ scrollBehavior: 'smooth' }}
|
|
164
|
+
>
|
|
165
|
+
<svg
|
|
166
|
+
width={svgW}
|
|
167
|
+
height={svgH}
|
|
168
|
+
style={{ display: 'block', minWidth: svgW }}
|
|
169
|
+
>
|
|
170
|
+
<defs>
|
|
171
|
+
<linearGradient id="tokenAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
|
172
|
+
<stop offset="0%" stopColor="var(--c-accent)" stopOpacity="0.15" />
|
|
173
|
+
<stop offset="100%" stopColor="var(--c-accent)" stopOpacity="0.02" />
|
|
174
|
+
</linearGradient>
|
|
175
|
+
</defs>
|
|
176
|
+
|
|
177
|
+
{/* Baseline */}
|
|
178
|
+
<line
|
|
179
|
+
x1={PAD_X} y1={PAD_Y + CHART_H}
|
|
180
|
+
x2={PAD_X + (buckets.length - 1) * POINT_STEP} y2={PAD_Y + CHART_H}
|
|
181
|
+
stroke="var(--c-border)" strokeWidth="1"
|
|
182
|
+
/>
|
|
183
|
+
|
|
184
|
+
{/* Area fill */}
|
|
185
|
+
{areaPath && <path d={areaPath} fill="url(#tokenAreaGrad)" />}
|
|
186
|
+
|
|
187
|
+
{/* Line */}
|
|
188
|
+
{points.length > 1 && (
|
|
189
|
+
<path d={linePath} fill="none" stroke="var(--c-accent)" strokeWidth="1.5" strokeLinejoin="round" />
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{/* Data points + labels */}
|
|
193
|
+
{points.map((p, i) => {
|
|
194
|
+
const isHovered = hoveredIdx === i
|
|
195
|
+
const color = tokenColor(p.ratio)
|
|
196
|
+
return (
|
|
197
|
+
<g
|
|
198
|
+
key={i}
|
|
199
|
+
style={{ cursor: p.bucket.tokens > 0 ? 'pointer' : 'default' }}
|
|
200
|
+
onMouseEnter={() => setHoveredIdx(i)}
|
|
201
|
+
onMouseLeave={() => setHoveredIdx(null)}
|
|
202
|
+
onClick={() => handlePointClick(p.bucket)}
|
|
203
|
+
>
|
|
204
|
+
<circle cx={p.x} cy={p.y} r={12} fill="transparent" />
|
|
205
|
+
|
|
206
|
+
<circle
|
|
207
|
+
cx={p.x} cy={p.y}
|
|
208
|
+
r={isHovered ? 5 : 3}
|
|
209
|
+
fill={p.bucket.tokens > 0 ? color : 'var(--c-border)'}
|
|
210
|
+
stroke={isHovered ? 'var(--c-white)' : 'none'}
|
|
211
|
+
strokeWidth={1.5}
|
|
212
|
+
style={{ transition: 'r 0.15s' }}
|
|
213
|
+
/>
|
|
214
|
+
|
|
215
|
+
{isHovered && (
|
|
216
|
+
<line
|
|
217
|
+
x1={p.x} y1={p.y} x2={p.x} y2={PAD_Y + CHART_H}
|
|
218
|
+
stroke="var(--c-text3)" strokeWidth="0.5" strokeDasharray="2,2"
|
|
219
|
+
/>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{isHovered && p.bucket.tokens > 0 && (
|
|
223
|
+
<g>
|
|
224
|
+
<rect
|
|
225
|
+
x={p.x - 32} y={p.y - 22}
|
|
226
|
+
width={64} height={16} rx={3}
|
|
227
|
+
fill="var(--c-bg)" stroke="var(--c-border)" strokeWidth="0.5"
|
|
228
|
+
/>
|
|
229
|
+
<text
|
|
230
|
+
x={p.x} y={p.y - 11}
|
|
231
|
+
textAnchor="middle" fontSize="9" fontFamily="monospace"
|
|
232
|
+
fill="var(--c-white)"
|
|
233
|
+
>
|
|
234
|
+
{formatNumber(p.bucket.tokens)} tok
|
|
235
|
+
</text>
|
|
236
|
+
</g>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
{/* X label — only for time-based buckets */}
|
|
240
|
+
{hasRealTokens && p.bucket.label && (
|
|
241
|
+
<text
|
|
242
|
+
x={p.x} y={PAD_Y + CHART_H + 12}
|
|
243
|
+
textAnchor="middle" fontSize="9" fontFamily="monospace"
|
|
244
|
+
fill="var(--c-text3)"
|
|
245
|
+
>
|
|
246
|
+
{p.bucket.label}
|
|
247
|
+
</text>
|
|
248
|
+
)}
|
|
249
|
+
</g>
|
|
250
|
+
)
|
|
251
|
+
})}
|
|
252
|
+
</svg>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
const KEY = 'agentlytics-live'
|
|
4
|
+
|
|
5
|
+
export function useLive() {
|
|
6
|
+
const [live, setLive] = useState(() => localStorage.getItem(KEY) === 'true')
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
localStorage.setItem(KEY, live)
|
|
10
|
+
}, [live])
|
|
11
|
+
|
|
12
|
+
const toggle = useCallback(() => setLive(l => !l), [])
|
|
13
|
+
|
|
14
|
+
return { live, toggle }
|
|
15
|
+
}
|
package/ui/src/index.css
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@theme {
|
|
4
|
+
--color-bg-900: #0e0e0e;
|
|
5
|
+
--color-bg-800: #161616;
|
|
6
|
+
--color-bg-700: #1e1e1e;
|
|
7
|
+
--color-bg-600: #272727;
|
|
8
|
+
--color-accent: #6366f1;
|
|
9
|
+
--color-accent-light: #818cf8;
|
|
10
|
+
--color-accent-dim: #4f46e5;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
:root {
|
|
14
|
+
--c-bg: #0e0e0e; --c-bg2: #161616; --c-bg3: #1e1e1e; --c-bg4: #272727;
|
|
15
|
+
--c-text: #a0a0a0; --c-text2: #666; --c-text3: #444;
|
|
16
|
+
--c-white: #e0e0e0; --c-border: rgba(255,255,255,0.06);
|
|
17
|
+
--c-card: rgba(255,255,255,0.025); --c-card-hover: rgba(99,102,241,0.25);
|
|
18
|
+
--c-header: rgba(14,14,14,0.85);
|
|
19
|
+
--c-code-bg: rgba(255,255,255,0.05); --c-pre-bg: rgba(0,0,0,0.4);
|
|
20
|
+
--c-md-strong: #d0d0d0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.light {
|
|
24
|
+
--c-bg: #f5f5f5; --c-bg2: #ebebeb; --c-bg3: #e0e0e0; --c-bg4: #d6d6d6;
|
|
25
|
+
--c-text: #444; --c-text2: #888; --c-text3: #bbb;
|
|
26
|
+
--c-white: #111; --c-border: rgba(0,0,0,0.08);
|
|
27
|
+
--c-card: rgba(0,0,0,0.025); --c-card-hover: rgba(99,102,241,0.2);
|
|
28
|
+
--c-header: rgba(245,245,245,0.9);
|
|
29
|
+
--c-code-bg: rgba(0,0,0,0.05); --c-pre-bg: rgba(0,0,0,0.04);
|
|
30
|
+
--c-md-strong: #111;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
body {
|
|
34
|
+
margin: 0;
|
|
35
|
+
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
|
36
|
+
background: var(--c-bg);
|
|
37
|
+
color: var(--c-text);
|
|
38
|
+
min-height: 100vh;
|
|
39
|
+
font-size: 13px;
|
|
40
|
+
line-height: 1.5;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.card {
|
|
44
|
+
background: var(--c-card);
|
|
45
|
+
border: 1px solid var(--c-border);
|
|
46
|
+
border-radius: 0;
|
|
47
|
+
transition: border-color 0.15s;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
* { border-radius: 0 !important; }
|
|
51
|
+
.card:hover { border-color: var(--c-card-hover); }
|
|
52
|
+
|
|
53
|
+
.scrollbar-thin { scrollbar-width: thin; scrollbar-color: transparent transparent; }
|
|
54
|
+
.scrollbar-thin:hover { scrollbar-color: var(--c-border) transparent; }
|
|
55
|
+
.scrollbar-thin::-webkit-scrollbar { width: 3px; }
|
|
56
|
+
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
|
|
57
|
+
.scrollbar-thin::-webkit-scrollbar-thumb { background: transparent; }
|
|
58
|
+
.scrollbar-thin:hover::-webkit-scrollbar-thumb { background: var(--c-border); }
|
|
59
|
+
|
|
60
|
+
.fade-in { animation: fadeIn 0.2s ease-out; }
|
|
61
|
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
|
62
|
+
|
|
63
|
+
.sidebar-slide-in { animation: slideInRight 0.2s ease-out; }
|
|
64
|
+
@keyframes slideInRight { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
|
65
|
+
|
|
66
|
+
.pulse-dot { animation: pulseDot 1.5s ease-in-out infinite; }
|
|
67
|
+
@keyframes pulseDot { 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34,197,94,0.5); } 50% { opacity: 0.6; box-shadow: 0 0 0 4px rgba(34,197,94,0); } }
|
|
68
|
+
|
|
69
|
+
/* Markdown */
|
|
70
|
+
.md-body h1,.md-body h2,.md-body h3 { font-weight:600; margin:8px 0 4px; color:var(--c-white); }
|
|
71
|
+
.md-body h1 { font-size:1.15em; } .md-body h2 { font-size:1.05em; } .md-body h3 { font-size:1em; }
|
|
72
|
+
.md-body p { margin:4px 0; line-height:1.6; }
|
|
73
|
+
.md-body ul,.md-body ol { padding-left:1.3em; margin:3px 0; }
|
|
74
|
+
.md-body li { margin:1px 0; }
|
|
75
|
+
.md-body code { background:var(--c-code-bg); padding:1px 4px; border-radius:3px; font-size:0.9em; }
|
|
76
|
+
.md-body pre { background:var(--c-pre-bg); padding:10px; border-radius:6px; overflow-x:auto; margin:6px 0; border:1px solid var(--c-border); }
|
|
77
|
+
.md-body pre code { background:none; padding:0; }
|
|
78
|
+
.md-body strong { color:var(--c-md-strong); }
|
|
79
|
+
.md-body a { color:#818cf8; text-decoration:underline; }
|
|
80
|
+
.md-body blockquote { border-left:2px solid rgba(99,102,241,0.3); padding-left:10px; color:var(--c-text2); margin:6px 0; }
|
|
81
|
+
.md-body table { border-collapse:collapse; margin:6px 0; }
|
|
82
|
+
.md-body th,.md-body td { border:1px solid var(--c-border); padding:4px 8px; font-size:0.9em; }
|
|
83
|
+
.md-body th { background:var(--c-code-bg); }
|