agentlytics 0.0.7 → 0.0.10
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/README.md +8 -2
- package/cache.js +67 -24
- package/editors/codex.js +453 -0
- package/editors/commandcode.js +159 -0
- package/editors/index.js +3 -1
- package/index.js +1 -1
- package/package.json +3 -2
- package/server.js +60 -4
- package/ui/src/App.jsx +40 -3
- package/ui/src/components/ActivityHeatmap.jsx +11 -7
- package/ui/src/components/ChatSidebar.jsx +313 -0
- package/ui/src/components/DateRangePicker.jsx +104 -0
- package/ui/src/index.css +6 -0
- package/ui/src/lib/api.js +17 -3
- package/ui/src/lib/constants.js +16 -0
- package/ui/src/pages/ChatDetail.jsx +13 -3
- package/ui/src/pages/Dashboard.jsx +14 -14
- package/ui/src/pages/DeepAnalysis.jsx +6 -3
- package/ui/src/pages/ProjectDetail.jsx +236 -0
- package/ui/src/pages/Projects.jsx +50 -121
- package/ui/src/pages/Sessions.jsx +58 -46
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import { X, User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown, Download, Send, Search } from 'lucide-react'
|
|
3
|
+
import ReactMarkdown from 'react-markdown'
|
|
4
|
+
import remarkGfm from 'remark-gfm'
|
|
5
|
+
import { fetchChat, BASE } from '../lib/api'
|
|
6
|
+
import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
|
|
7
|
+
|
|
8
|
+
const ROLE_CONFIG = {
|
|
9
|
+
user: { icon: User, label: 'User', borderColor: 'rgba(34,197,94,0.2)', bg: 'rgba(34,197,94,0.05)' },
|
|
10
|
+
assistant: { icon: Bot, label: 'Assistant', borderColor: 'rgba(99,102,241,0.2)', bg: 'rgba(99,102,241,0.05)' },
|
|
11
|
+
system: { icon: Settings, label: 'System', borderColor: 'rgba(107,114,128,0.2)', bg: 'rgba(107,114,128,0.05)' },
|
|
12
|
+
tool: { icon: Wrench, label: 'Tool', borderColor: 'rgba(234,179,8,0.2)', bg: 'rgba(234,179,8,0.05)' },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseContent(content) {
|
|
16
|
+
const segments = []
|
|
17
|
+
const regex = /\[tool-call: ([^\]]+)\]|\[tool-result: ([^\]]+)\]\s*(.*?)(?=\n\[tool-|$)/gs
|
|
18
|
+
let lastIdx = 0
|
|
19
|
+
let match
|
|
20
|
+
while ((match = regex.exec(content)) !== null) {
|
|
21
|
+
if (match.index > lastIdx) {
|
|
22
|
+
const text = content.slice(lastIdx, match.index).trim()
|
|
23
|
+
if (text) segments.push({ type: 'text', value: text })
|
|
24
|
+
}
|
|
25
|
+
if (match[1]) {
|
|
26
|
+
segments.push({ type: 'tool-call', name: match[1].replace(/\(.*\)$/, '').trim(), args: match[1] })
|
|
27
|
+
} else if (match[2]) {
|
|
28
|
+
segments.push({ type: 'tool-result', name: match[2].trim(), preview: (match[3] || '').trim() })
|
|
29
|
+
}
|
|
30
|
+
lastIdx = match.index + match[0].length
|
|
31
|
+
}
|
|
32
|
+
if (lastIdx < content.length) {
|
|
33
|
+
const text = content.slice(lastIdx).trim()
|
|
34
|
+
if (text) segments.push({ type: 'text', value: text })
|
|
35
|
+
}
|
|
36
|
+
return segments.length > 0 ? segments : [{ type: 'text', value: content }]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function summarizeToolArgs(name, args) {
|
|
40
|
+
if (!args || typeof args !== 'object') return ''
|
|
41
|
+
if (args.file_path || args.TargetFile) return args.file_path || args.TargetFile
|
|
42
|
+
if (args.CommandLine || args.command) return args.CommandLine || args.command
|
|
43
|
+
if (args.Query || args.query) return `${args.Query || args.query}${args.SearchPath ? ` in ${args.SearchPath}` : ''}`
|
|
44
|
+
if (args.Url || args.url) return args.Url || args.url
|
|
45
|
+
const vals = Object.values(args).filter(v => typeof v === 'string' && v.length > 0 && v.length < 120)
|
|
46
|
+
return vals.length > 0 ? vals[0] : ''
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ToolArgsDiff({ args }) {
|
|
50
|
+
const old = args.old_string || args.old_text || args.oldText || args.search || null
|
|
51
|
+
const nw = args.new_string || args.new_text || args.newText || args.replace || null
|
|
52
|
+
if (old == null && nw == null) return null
|
|
53
|
+
const maxLines = 8
|
|
54
|
+
const oldLines = (old || '').split('\n').slice(0, maxLines)
|
|
55
|
+
const newLines = (nw || '').split('\n').slice(0, maxLines)
|
|
56
|
+
return (
|
|
57
|
+
<div className="mt-1 text-[9px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
|
|
58
|
+
{(args.file_path || args.TargetFile) && (
|
|
59
|
+
<div className="px-2 py-0.5" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text)' }}>{args.file_path || args.TargetFile}</div>
|
|
60
|
+
)}
|
|
61
|
+
{old && oldLines.map((line, i) => (
|
|
62
|
+
<div key={'o' + i} className="px-2" style={{ background: 'rgba(248,113,113,0.1)', color: '#f87171' }}>
|
|
63
|
+
<span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>- </span>{line}
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
{old && oldLines.length < (old || '').split('\n').length && (
|
|
67
|
+
<div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(old || '').split('\n').length - maxLines} more lines</div>
|
|
68
|
+
)}
|
|
69
|
+
{nw && newLines.map((line, i) => (
|
|
70
|
+
<div key={'n' + i} className="px-2" style={{ background: 'rgba(52,211,153,0.1)', color: '#34d399' }}>
|
|
71
|
+
<span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>+ </span>{line}
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
{nw && newLines.length < (nw || '').split('\n').length && (
|
|
75
|
+
<div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(nw || '').split('\n').length - maxLines} more lines</div>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ToolArgsDetail({ args }) {
|
|
82
|
+
if (!args || Object.keys(args).length === 0) return null
|
|
83
|
+
const hasDiff = args.old_string || args.new_string || args.old_text || args.new_text || args.search || args.replace
|
|
84
|
+
if (hasDiff) return <ToolArgsDiff args={args} />
|
|
85
|
+
const file = args.file_path || args.TargetFile || args.filePath || args.path || null
|
|
86
|
+
const cmd = args.CommandLine || args.command || null
|
|
87
|
+
const query = args.Query || args.query || args.search_term || null
|
|
88
|
+
const url = args.Url || args.url || null
|
|
89
|
+
return (
|
|
90
|
+
<div className="mt-1 text-[9px] font-mono overflow-x-auto" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
91
|
+
{file && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>file: {file}</div>}
|
|
92
|
+
{cmd && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>cmd: {cmd}</div>}
|
|
93
|
+
{query && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>query: {query}</div>}
|
|
94
|
+
{url && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>url: {url}</div>}
|
|
95
|
+
{!file && !cmd && !query && !url && (
|
|
96
|
+
<pre className="px-2 py-1 whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{JSON.stringify(args, null, 2)}</pre>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ToolCallBlock({ name, args, detail }) {
|
|
103
|
+
const [open, setOpen] = useState(false)
|
|
104
|
+
const hasDetail = detail && Object.keys(detail).length > 0
|
|
105
|
+
return (
|
|
106
|
+
<div className="my-1 px-2 py-1 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
107
|
+
<div className="flex items-center gap-1.5 cursor-pointer" onClick={() => hasDetail && setOpen(!open)}>
|
|
108
|
+
{hasDetail
|
|
109
|
+
? (open ? <ChevronDown size={9} style={{ color: '#a78bfa' }} /> : <ChevronRight size={9} style={{ color: '#a78bfa' }} />)
|
|
110
|
+
: <Play size={9} style={{ color: '#a78bfa' }} />
|
|
111
|
+
}
|
|
112
|
+
<span className="font-bold" style={{ color: 'var(--c-white)' }}>{name}</span>
|
|
113
|
+
{args !== name && !hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{args}</span>}
|
|
114
|
+
{hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{summarizeToolArgs(name, detail)}</span>}
|
|
115
|
+
</div>
|
|
116
|
+
{open && hasDetail && <ToolArgsDetail args={detail} />}
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ToolResultBlock({ name, preview }) {
|
|
122
|
+
const [open, setOpen] = useState(false)
|
|
123
|
+
const isNoisy = preview.length > 120 || preview.startsWith('{') || preview.includes('contentId')
|
|
124
|
+
const short = isNoisy ? `${name} completed` : preview.substring(0, 120)
|
|
125
|
+
return (
|
|
126
|
+
<div className="my-1 px-2 py-1 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
127
|
+
<div className="flex items-center gap-1.5 cursor-pointer" onClick={() => preview && setOpen(!open)}>
|
|
128
|
+
<CheckCircle size={9} style={{ color: '#34d399' }} />
|
|
129
|
+
<span className="truncate" style={{ color: 'var(--c-text)' }}>{short}</span>
|
|
130
|
+
{isNoisy && preview && <span style={{ color: 'var(--c-text3)' }}>{open ? '[-]' : '[+]'}</span>}
|
|
131
|
+
</div>
|
|
132
|
+
{open && <pre className="mt-1 text-[9px] overflow-x-auto whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{preview}</pre>}
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function MessageContent({ content, toolCallDetails }) {
|
|
138
|
+
const segments = parseContent(content)
|
|
139
|
+
let toolIdx = 0
|
|
140
|
+
return segments.map((seg, i) => {
|
|
141
|
+
if (seg.type === 'tool-call') {
|
|
142
|
+
const detail = toolCallDetails ? toolCallDetails.find(tc => tc.name === seg.name && toolCallDetails.indexOf(tc) >= toolIdx) : null
|
|
143
|
+
if (detail) toolIdx = toolCallDetails.indexOf(detail) + 1
|
|
144
|
+
return <ToolCallBlock key={i} name={seg.name} args={seg.args} detail={detail?.args} />
|
|
145
|
+
}
|
|
146
|
+
if (seg.type === 'tool-result') return <ToolResultBlock key={i} name={seg.name} preview={seg.preview} />
|
|
147
|
+
return <div key={i} className="md-body text-[12px] leading-relaxed"><ReactMarkdown remarkPlugins={[remarkGfm]}>{seg.value}</ReactMarkdown></div>
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default function ChatSidebar({ chatId, onClose }) {
|
|
152
|
+
const [chat, setChat] = useState(null)
|
|
153
|
+
const [loading, setLoading] = useState(true)
|
|
154
|
+
const [msgFilter, setMsgFilter] = useState('')
|
|
155
|
+
const scrollRef = useRef(null)
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!chatId) return
|
|
159
|
+
setLoading(true)
|
|
160
|
+
setChat(null)
|
|
161
|
+
fetchChat(chatId).then(data => {
|
|
162
|
+
setChat(data)
|
|
163
|
+
setLoading(false)
|
|
164
|
+
})
|
|
165
|
+
}, [chatId])
|
|
166
|
+
|
|
167
|
+
// Scroll to bottom when chat loads
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!loading && chat && scrollRef.current) {
|
|
170
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
171
|
+
}
|
|
172
|
+
}, [loading, chat])
|
|
173
|
+
|
|
174
|
+
// Reset filter when chat changes
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
setMsgFilter('')
|
|
177
|
+
}, [chatId])
|
|
178
|
+
|
|
179
|
+
if (!chatId) return null
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<>
|
|
183
|
+
{/* Backdrop */}
|
|
184
|
+
<div
|
|
185
|
+
className="fixed inset-0 z-40 transition-opacity"
|
|
186
|
+
style={{ background: 'rgba(0,0,0,0.3)' }}
|
|
187
|
+
onClick={onClose}
|
|
188
|
+
/>
|
|
189
|
+
|
|
190
|
+
{/* Sidebar */}
|
|
191
|
+
<div
|
|
192
|
+
className="fixed top-0 right-0 bottom-0 z-50 flex flex-col shadow-2xl sidebar-slide-in"
|
|
193
|
+
style={{
|
|
194
|
+
width: 'min(580px, 90vw)',
|
|
195
|
+
background: 'var(--c-bg)',
|
|
196
|
+
borderLeft: '1px solid var(--c-border)',
|
|
197
|
+
}}
|
|
198
|
+
>
|
|
199
|
+
{/* Header */}
|
|
200
|
+
<div className="flex items-center gap-3 px-4 py-3 shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
201
|
+
<button onClick={onClose} className="p-1 rounded transition hover:bg-[var(--c-bg3)]" style={{ color: 'var(--c-text2)' }}>
|
|
202
|
+
<X size={14} />
|
|
203
|
+
</button>
|
|
204
|
+
{chat && (
|
|
205
|
+
<div className="flex-1 min-w-0">
|
|
206
|
+
<div className="text-sm font-medium truncate" style={{ color: 'var(--c-white)' }}>
|
|
207
|
+
{chat.name || '(untitled)'}
|
|
208
|
+
</div>
|
|
209
|
+
<div className="flex items-center gap-2 text-[10px]" style={{ color: 'var(--c-text2)' }}>
|
|
210
|
+
<span className="inline-flex items-center gap-1">
|
|
211
|
+
<span className="w-1.5 h-1.5 rounded-full" style={{ background: editorColor(chat.source) }} />
|
|
212
|
+
{editorLabel(chat.source)}
|
|
213
|
+
</span>
|
|
214
|
+
{chat.mode && <span>· {chat.mode}</span>}
|
|
215
|
+
<span>{formatDateTime(chat.createdAt)}</span>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
{chat && (
|
|
220
|
+
<a
|
|
221
|
+
href={`${BASE}/api/chats/${chat.id}/markdown`}
|
|
222
|
+
download
|
|
223
|
+
className="flex items-center gap-1 px-2 py-1 text-[10px] transition shrink-0"
|
|
224
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
225
|
+
>
|
|
226
|
+
<Download size={11} /> .md
|
|
227
|
+
</a>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{/* Stats row */}
|
|
232
|
+
{chat?.stats && (
|
|
233
|
+
<div className="flex items-center gap-3 px-4 py-2 text-[10px] shrink-0" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text2)' }}>
|
|
234
|
+
<span>{chat.stats.totalMessages} msgs</span>
|
|
235
|
+
<span>{chat.stats.toolCalls.length} tools</span>
|
|
236
|
+
{chat.stats.totalInputTokens > 0 && <span>{formatNumber(chat.stats.totalInputTokens)} in</span>}
|
|
237
|
+
{chat.stats.totalOutputTokens > 0 && <span>{formatNumber(chat.stats.totalOutputTokens)} out</span>}
|
|
238
|
+
{chat.stats.models.length > 0 && (
|
|
239
|
+
<span className="ml-auto font-mono truncate" style={{ color: 'var(--c-accent)', opacity: 0.7 }}>
|
|
240
|
+
{[...new Set(chat.stats.models)].join(', ')}
|
|
241
|
+
</span>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{/* Search bar */}
|
|
247
|
+
{chat && chat.messages.length > 0 && (
|
|
248
|
+
<div className="shrink-0 px-4 py-2" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
249
|
+
<div className="relative">
|
|
250
|
+
<Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
251
|
+
<input
|
|
252
|
+
type="text"
|
|
253
|
+
placeholder="Filter messages..."
|
|
254
|
+
value={msgFilter}
|
|
255
|
+
onChange={e => setMsgFilter(e.target.value)}
|
|
256
|
+
className="w-full pl-7 pr-3 py-1 text-[11px] outline-none"
|
|
257
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{/* Messages */}
|
|
264
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin px-4 py-3 space-y-2">
|
|
265
|
+
{loading && (
|
|
266
|
+
<div className="text-[11px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>Loading conversation...</div>
|
|
267
|
+
)}
|
|
268
|
+
{!loading && chat && chat.messages.length === 0 && (
|
|
269
|
+
<div className="text-[11px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>
|
|
270
|
+
{chat.encrypted ? '🔒 This conversation is encrypted.' : 'No messages found.'}
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
{!loading && chat && chat.messages
|
|
274
|
+
.filter(msg => !msgFilter || msg.content.toLowerCase().includes(msgFilter.toLowerCase()))
|
|
275
|
+
.map((msg, i) => {
|
|
276
|
+
const cfg = ROLE_CONFIG[msg.role] || ROLE_CONFIG.system
|
|
277
|
+
const Icon = cfg.icon
|
|
278
|
+
return (
|
|
279
|
+
<div key={i} className="rounded-r px-3 py-2" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
|
|
280
|
+
<div className="flex items-center gap-1.5 text-[10px] mb-1" style={{ color: 'var(--c-text2)' }}>
|
|
281
|
+
<Icon size={11} />
|
|
282
|
+
<span className="font-medium">{cfg.label}</span>
|
|
283
|
+
{msg.model && <span className="font-mono" style={{ color: 'var(--c-accent)', opacity: 0.6 }}>· {msg.model}</span>}
|
|
284
|
+
</div>
|
|
285
|
+
<div className="text-[12px]" style={{ color: 'var(--c-text)' }}>
|
|
286
|
+
<MessageContent content={msg.content} toolCallDetails={chat.toolCallDetails} />
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
)
|
|
290
|
+
})}
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
{/* Fake disabled chat input */}
|
|
294
|
+
<div className="shrink-0 px-4 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
295
|
+
<div
|
|
296
|
+
className="flex items-center gap-2 px-3 py-2.5 rounded-lg"
|
|
297
|
+
style={{
|
|
298
|
+
background: 'var(--c-bg3)',
|
|
299
|
+
border: '1px solid var(--c-border)',
|
|
300
|
+
opacity: 0.5,
|
|
301
|
+
cursor: 'not-allowed',
|
|
302
|
+
}}
|
|
303
|
+
>
|
|
304
|
+
<span className="flex-1 text-[12px]" style={{ color: 'var(--c-text3)' }}>
|
|
305
|
+
Message is read-only...
|
|
306
|
+
</span>
|
|
307
|
+
<Send size={13} style={{ color: 'var(--c-text3)' }} />
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
</>
|
|
312
|
+
)
|
|
313
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Calendar, X } from 'lucide-react'
|
|
2
|
+
import { useTheme } from '../lib/theme'
|
|
3
|
+
|
|
4
|
+
const PRESETS = [
|
|
5
|
+
{ label: '7d', days: 7 },
|
|
6
|
+
{ label: '30d', days: 30 },
|
|
7
|
+
{ label: '90d', days: 90 },
|
|
8
|
+
{ label: '1y', days: 365 },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
// value: { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' } | null
|
|
12
|
+
// onChange: called with same shape, or null to clear
|
|
13
|
+
export default function DateRangePicker({ value, onChange }) {
|
|
14
|
+
const { dark } = useTheme()
|
|
15
|
+
const today = new Date().toISOString().split('T')[0]
|
|
16
|
+
const active = !!value
|
|
17
|
+
|
|
18
|
+
function applyPreset(days) {
|
|
19
|
+
const to = new Date()
|
|
20
|
+
const from = new Date(to.getTime() - days * 86400000)
|
|
21
|
+
onChange({
|
|
22
|
+
from: from.toISOString().split('T')[0],
|
|
23
|
+
to: to.toISOString().split('T')[0],
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setFrom(from) {
|
|
28
|
+
onChange({ from, to: value?.to || today })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setTo(to) {
|
|
32
|
+
onChange({ from: value?.from || today, to })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
37
|
+
<Calendar size={12} style={{ color: active ? 'var(--c-accent)' : 'var(--c-text3)', flexShrink: 0 }} />
|
|
38
|
+
|
|
39
|
+
{/* Preset buttons */}
|
|
40
|
+
{PRESETS.map(p => {
|
|
41
|
+
const isActive = active && (() => {
|
|
42
|
+
const diff = Math.round((Date.now() - new Date(value.from).getTime()) / 86400000)
|
|
43
|
+
return diff >= p.days - 1 && diff <= p.days + 1
|
|
44
|
+
})()
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
key={p.label}
|
|
48
|
+
onClick={() => applyPreset(p.days)}
|
|
49
|
+
className="px-2 py-0.5 text-[10px] transition"
|
|
50
|
+
style={{
|
|
51
|
+
border: isActive ? '1px solid var(--c-accent)' : '1px solid var(--c-border)',
|
|
52
|
+
color: isActive ? 'var(--c-accent)' : 'var(--c-text2)',
|
|
53
|
+
background: isActive ? 'rgba(99,102,241,0.1)' : 'transparent',
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
{p.label}
|
|
57
|
+
</button>
|
|
58
|
+
)
|
|
59
|
+
})}
|
|
60
|
+
|
|
61
|
+
{/* Custom date inputs */}
|
|
62
|
+
<input
|
|
63
|
+
type="date"
|
|
64
|
+
value={value?.from || ''}
|
|
65
|
+
max={value?.to || today}
|
|
66
|
+
onChange={e => setFrom(e.target.value)}
|
|
67
|
+
className="px-1.5 py-0.5 text-[10px] outline-none cursor-pointer"
|
|
68
|
+
style={{
|
|
69
|
+
background: 'var(--c-bg3)',
|
|
70
|
+
color: 'var(--c-text)',
|
|
71
|
+
border: '1px solid var(--c-border)',
|
|
72
|
+
colorScheme: dark ? 'dark' : 'light',
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
<span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>—</span>
|
|
76
|
+
<input
|
|
77
|
+
type="date"
|
|
78
|
+
value={value?.to || ''}
|
|
79
|
+
min={value?.from || ''}
|
|
80
|
+
max={today}
|
|
81
|
+
onChange={e => setTo(e.target.value)}
|
|
82
|
+
className="px-1.5 py-0.5 text-[10px] outline-none cursor-pointer"
|
|
83
|
+
style={{
|
|
84
|
+
background: 'var(--c-bg3)',
|
|
85
|
+
color: 'var(--c-text)',
|
|
86
|
+
border: '1px solid var(--c-border)',
|
|
87
|
+
colorScheme: dark ? 'dark' : 'light',
|
|
88
|
+
}}
|
|
89
|
+
/>
|
|
90
|
+
|
|
91
|
+
{/* Clear button */}
|
|
92
|
+
{active && (
|
|
93
|
+
<button
|
|
94
|
+
onClick={() => onChange(null)}
|
|
95
|
+
className="flex items-center gap-0.5 px-2 py-0.5 text-[10px] transition"
|
|
96
|
+
style={{ border: '1px solid var(--c-accent)', color: 'var(--c-accent)' }}
|
|
97
|
+
>
|
|
98
|
+
<X size={9} /> clear
|
|
99
|
+
</button>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
package/ui/src/index.css
CHANGED
|
@@ -60,6 +60,12 @@ body {
|
|
|
60
60
|
.fade-in { animation: fadeIn 0.2s ease-out; }
|
|
61
61
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
|
62
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
|
+
|
|
63
69
|
/* Markdown */
|
|
64
70
|
.md-body h1,.md-body h2,.md-body h3 { font-weight:600; margin:8px 0 4px; color:var(--c-white); }
|
|
65
71
|
.md-body h1 { font-size:1.15em; } .md-body h2 { font-size:1.05em; } .md-body h3 { font-size:1em; }
|
package/ui/src/lib/api.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
const BASE = '';
|
|
1
|
+
export const BASE = '';
|
|
2
|
+
|
|
3
|
+
// Append optional dateFrom/dateTo (ms timestamps) to URLSearchParams
|
|
4
|
+
function appendDateParams(q, params) {
|
|
5
|
+
if (params.dateFrom) q.set('dateFrom', params.dateFrom);
|
|
6
|
+
if (params.dateTo) q.set('dateTo', params.dateTo);
|
|
7
|
+
}
|
|
2
8
|
|
|
3
9
|
export async function fetchOverview(params = {}) {
|
|
4
10
|
const q = new URLSearchParams();
|
|
5
11
|
if (params.editor) q.set('editor', params.editor);
|
|
12
|
+
appendDateParams(q, params);
|
|
6
13
|
const qs = q.toString();
|
|
7
14
|
const res = await fetch(`${BASE}/api/overview${qs ? '?' + qs : ''}`);
|
|
8
15
|
return res.json();
|
|
@@ -15,6 +22,7 @@ export async function fetchChats(params = {}) {
|
|
|
15
22
|
if (params.limit) q.set('limit', params.limit);
|
|
16
23
|
if (params.offset) q.set('offset', params.offset);
|
|
17
24
|
if (params.named === false) q.set('named', 'false');
|
|
25
|
+
appendDateParams(q, params);
|
|
18
26
|
const res = await fetch(`${BASE}/api/chats?${q}`);
|
|
19
27
|
return res.json();
|
|
20
28
|
}
|
|
@@ -24,14 +32,18 @@ export async function fetchChat(id) {
|
|
|
24
32
|
return res.json();
|
|
25
33
|
}
|
|
26
34
|
|
|
27
|
-
export async function fetchProjects() {
|
|
28
|
-
const
|
|
35
|
+
export async function fetchProjects(params = {}) {
|
|
36
|
+
const q = new URLSearchParams();
|
|
37
|
+
appendDateParams(q, params);
|
|
38
|
+
const qs = q.toString();
|
|
39
|
+
const res = await fetch(`${BASE}/api/projects${qs ? '?' + qs : ''}`);
|
|
29
40
|
return res.json();
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
export async function fetchDailyActivity(params = {}) {
|
|
33
44
|
const q = new URLSearchParams();
|
|
34
45
|
if (params.editor) q.set('editor', params.editor);
|
|
46
|
+
appendDateParams(q, params);
|
|
35
47
|
const qs = q.toString();
|
|
36
48
|
const res = await fetch(`${BASE}/api/daily-activity${qs ? '?' + qs : ''}`);
|
|
37
49
|
return res.json();
|
|
@@ -42,6 +54,7 @@ export async function fetchDeepAnalytics(params = {}) {
|
|
|
42
54
|
if (params.editor) q.set('editor', params.editor);
|
|
43
55
|
if (params.folder) q.set('folder', params.folder);
|
|
44
56
|
if (params.limit) q.set('limit', params.limit);
|
|
57
|
+
appendDateParams(q, params);
|
|
45
58
|
const res = await fetch(`${BASE}/api/deep-analytics?${q}`);
|
|
46
59
|
return res.json();
|
|
47
60
|
}
|
|
@@ -64,6 +77,7 @@ export function refetchAgents(onProgress) {
|
|
|
64
77
|
export async function fetchDashboardStats(params = {}) {
|
|
65
78
|
const q = new URLSearchParams();
|
|
66
79
|
if (params.editor) q.set('editor', params.editor);
|
|
80
|
+
appendDateParams(q, params);
|
|
67
81
|
const qs = q.toString();
|
|
68
82
|
const res = await fetch(`${BASE}/api/dashboard-stats${qs ? '?' + qs : ''}`);
|
|
69
83
|
return res.json();
|
package/ui/src/lib/constants.js
CHANGED
|
@@ -9,9 +9,11 @@ export const EDITOR_COLORS = {
|
|
|
9
9
|
'vscode-insiders': '#60a5fa',
|
|
10
10
|
'zed': '#10b981',
|
|
11
11
|
'opencode': '#ec4899',
|
|
12
|
+
'codex': '#0f766e',
|
|
12
13
|
'gemini-cli': '#4285f4',
|
|
13
14
|
'copilot-cli': '#8957e5',
|
|
14
15
|
'cursor-agent': '#f59e0b',
|
|
16
|
+
'commandcode': '#e11d48',
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
export const EDITOR_LABELS = {
|
|
@@ -25,9 +27,11 @@ export const EDITOR_LABELS = {
|
|
|
25
27
|
'vscode-insiders': 'VS Code Insiders',
|
|
26
28
|
'zed': 'Zed',
|
|
27
29
|
'opencode': 'OpenCode',
|
|
30
|
+
'codex': 'Codex',
|
|
28
31
|
'gemini-cli': 'Gemini CLI',
|
|
29
32
|
'copilot-cli': 'Copilot CLI',
|
|
30
33
|
'cursor-agent': 'Cursor Agent',
|
|
34
|
+
'commandcode': 'Command Code',
|
|
31
35
|
};
|
|
32
36
|
|
|
33
37
|
export function editorColor(src) {
|
|
@@ -50,6 +54,18 @@ export function formatDate(ts) {
|
|
|
50
54
|
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Convert { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' } date range to API ms timestamps.
|
|
59
|
+
* Returns {} if range is null/incomplete.
|
|
60
|
+
*/
|
|
61
|
+
export function dateRangeToApiParams(range) {
|
|
62
|
+
if (!range?.from || !range?.to) return {};
|
|
63
|
+
return {
|
|
64
|
+
dateFrom: new Date(range.from).getTime(),
|
|
65
|
+
dateTo: new Date(range.to + 'T23:59:59').getTime(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
53
69
|
export function formatDateTime(ts) {
|
|
54
70
|
if (!ts) return '';
|
|
55
71
|
return new Date(ts).toLocaleString();
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
|
-
import { ArrowLeft, User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown } from 'lucide-react'
|
|
3
|
+
import { ArrowLeft, User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown, Download } from 'lucide-react'
|
|
4
4
|
import ReactMarkdown from 'react-markdown'
|
|
5
5
|
import remarkGfm from 'remark-gfm'
|
|
6
|
-
import { fetchChat } from '../lib/api'
|
|
6
|
+
import { fetchChat, BASE } from '../lib/api'
|
|
7
7
|
import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
|
|
8
8
|
import KpiCard from '../components/KpiCard'
|
|
9
9
|
|
|
@@ -184,7 +184,7 @@ export default function ChatDetail() {
|
|
|
184
184
|
{/* Header */}
|
|
185
185
|
<div className="card p-5 mb-4">
|
|
186
186
|
<div className="flex items-start justify-between">
|
|
187
|
-
<div>
|
|
187
|
+
<div className="flex-1 min-w-0">
|
|
188
188
|
<h2 className="text-xl font-semibold mb-1" style={{ color: 'var(--c-white)' }}>{chat.name || '(untitled)'}</h2>
|
|
189
189
|
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--c-text2)' }}>
|
|
190
190
|
<span className="inline-flex items-center gap-1.5">
|
|
@@ -200,6 +200,16 @@ export default function ChatDetail() {
|
|
|
200
200
|
<span className="ml-3 font-mono" style={{ color: 'var(--c-text3)' }}>{chat.id}</span>
|
|
201
201
|
</div>
|
|
202
202
|
</div>
|
|
203
|
+
<a
|
|
204
|
+
href={`${BASE}/api/chats/${chat.id}/markdown`}
|
|
205
|
+
download
|
|
206
|
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition shrink-0"
|
|
207
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
208
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg2)'}
|
|
209
|
+
onMouseLeave={e => e.currentTarget.style.background = 'var(--c-bg3)'}
|
|
210
|
+
>
|
|
211
|
+
<Download size={13} /> Export .md
|
|
212
|
+
</a>
|
|
203
213
|
</div>
|
|
204
214
|
</div>
|
|
205
215
|
|
|
@@ -5,8 +5,9 @@ import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearSca
|
|
|
5
5
|
import { Doughnut, Bar, Line } from 'react-chartjs-2'
|
|
6
6
|
import KpiCard from '../components/KpiCard'
|
|
7
7
|
import ActivityHeatmap from '../components/ActivityHeatmap'
|
|
8
|
+
import DateRangePicker from '../components/DateRangePicker'
|
|
9
|
+
import { editorColor, editorLabel, formatNumber, dateRangeToApiParams } from '../lib/constants'
|
|
8
10
|
import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage } from '../lib/api'
|
|
9
|
-
import { editorColor, editorLabel, formatNumber } from '../lib/constants'
|
|
10
11
|
import { useTheme } from '../lib/theme'
|
|
11
12
|
|
|
12
13
|
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler)
|
|
@@ -29,6 +30,7 @@ export default function Dashboard({ overview }) {
|
|
|
29
30
|
const [filteredData, setFilteredData] = useState(null)
|
|
30
31
|
const [stats, setStats] = useState(null)
|
|
31
32
|
const [selectedEditor, setSelectedEditor] = useState(null)
|
|
33
|
+
const [dateRange, setDateRange] = useState(null)
|
|
32
34
|
const { dark } = useTheme()
|
|
33
35
|
const [sharing, setSharing] = useState(false)
|
|
34
36
|
const txtColor = dark ? '#888' : '#555'
|
|
@@ -50,27 +52,23 @@ export default function Dashboard({ overview }) {
|
|
|
50
52
|
const noLegend = { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } }
|
|
51
53
|
|
|
52
54
|
useEffect(() => {
|
|
53
|
-
|
|
54
|
-
fetchDashboardStats().then(setStats)
|
|
55
|
-
}, [])
|
|
56
|
-
|
|
57
|
-
useEffect(() => {
|
|
55
|
+
const dateParams = dateRangeToApiParams(dateRange)
|
|
58
56
|
if (!selectedEditor) {
|
|
59
57
|
setFilteredData(null)
|
|
60
|
-
fetchDailyActivity().then(setDailyData)
|
|
61
|
-
fetchDashboardStats().then(setStats)
|
|
58
|
+
fetchDailyActivity(dateParams).then(setDailyData)
|
|
59
|
+
fetchDashboardStats(dateParams).then(setStats)
|
|
62
60
|
return
|
|
63
61
|
}
|
|
64
62
|
Promise.all([
|
|
65
|
-
fetchOverviewApi({ editor: selectedEditor }),
|
|
66
|
-
fetchDailyActivity({ editor: selectedEditor }),
|
|
67
|
-
fetchDashboardStats({ editor: selectedEditor }),
|
|
63
|
+
fetchOverviewApi({ editor: selectedEditor, ...dateParams }),
|
|
64
|
+
fetchDailyActivity({ editor: selectedEditor, ...dateParams }),
|
|
65
|
+
fetchDashboardStats({ editor: selectedEditor, ...dateParams }),
|
|
68
66
|
]).then(([ov, daily, st]) => {
|
|
69
67
|
setFilteredData(ov)
|
|
70
68
|
setDailyData(daily)
|
|
71
69
|
setStats(st)
|
|
72
70
|
})
|
|
73
|
-
}, [selectedEditor])
|
|
71
|
+
}, [selectedEditor, dateRange])
|
|
74
72
|
|
|
75
73
|
if (!overview) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading...</div>
|
|
76
74
|
|
|
@@ -209,8 +207,9 @@ export default function Dashboard({ overview }) {
|
|
|
209
207
|
|
|
210
208
|
return (
|
|
211
209
|
<div className="fade-in space-y-3">
|
|
212
|
-
{/*
|
|
213
|
-
<div className="flex justify-end">
|
|
210
|
+
{/* Top bar */}
|
|
211
|
+
<div className="flex items-center justify-end gap-3">
|
|
212
|
+
<DateRangePicker value={dateRange} onChange={setDateRange} />
|
|
214
213
|
<button
|
|
215
214
|
onClick={handleShare}
|
|
216
215
|
disabled={sharing}
|
|
@@ -261,6 +260,7 @@ export default function Dashboard({ overview }) {
|
|
|
261
260
|
)}
|
|
262
261
|
</div>
|
|
263
262
|
|
|
263
|
+
|
|
264
264
|
{/* KPIs row 1: Core stats */}
|
|
265
265
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-2">
|
|
266
266
|
<KpiCard label="total sessions" value={formatNumber(d.totalChats)} sub={sel ? editorLabel(sel.id) : `${allEditors.length} editors`} />
|