agentlytics 0.0.10 → 0.1.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/README.md +84 -1
- package/cache.js +12 -7
- package/editors/index.js +7 -1
- package/editors/windsurf.js +199 -47
- package/index.js +125 -3
- package/mcp-server.js +279 -0
- package/package.json +6 -1
- package/relay-client.js +307 -0
- package/relay-server.js +552 -0
- package/server.js +4 -0
- package/ui/src/App.jsx +154 -45
- package/ui/src/components/ChatSidebar.jsx +27 -155
- package/ui/src/components/EditorBreakdown.jsx +22 -0
- package/ui/src/components/LiveFeed.jsx +138 -0
- package/ui/src/components/LoginScreen.jsx +79 -0
- package/ui/src/components/MessageRenderer.jsx +167 -0
- package/ui/src/components/ModelBreakdown.jsx +23 -0
- package/ui/src/components/SectionTitle.jsx +3 -0
- package/ui/src/lib/api.js +115 -0
- package/ui/src/pages/ChatDetail.jsx +5 -164
- package/ui/src/pages/Dashboard.jsx +1 -4
- package/ui/src/pages/RelayDashboard.jsx +380 -0
- package/ui/src/pages/RelaySessionDetail.jsx +32 -0
- package/ui/src/pages/RelayUserDetail.jsx +204 -0
- package/ui/src/pages/Sessions.jsx +14 -1
- package/ui/vite.config.js +2 -1
package/ui/src/App.jsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
2
|
import { Routes, Route, NavLink } from 'react-router-dom'
|
|
3
|
-
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database } from 'lucide-react'
|
|
4
|
-
import { fetchOverview, refetchAgents } from './lib/api'
|
|
3
|
+
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Radio, Plug, Copy, Check } from 'lucide-react'
|
|
4
|
+
import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
|
|
5
5
|
import { useTheme } from './lib/theme'
|
|
6
|
+
import LoginScreen from './components/LoginScreen'
|
|
6
7
|
import Dashboard from './pages/Dashboard'
|
|
7
8
|
import Sessions from './pages/Sessions'
|
|
8
9
|
import DeepAnalysis from './pages/DeepAnalysis'
|
|
@@ -11,13 +12,38 @@ import ChatDetail from './pages/ChatDetail'
|
|
|
11
12
|
import Projects from './pages/Projects'
|
|
12
13
|
import ProjectDetail from './pages/ProjectDetail'
|
|
13
14
|
import SqlViewer from './pages/SqlViewer'
|
|
15
|
+
import RelayDashboard from './pages/RelayDashboard'
|
|
16
|
+
import RelayUserDetail from './pages/RelayUserDetail'
|
|
14
17
|
|
|
15
18
|
export default function App() {
|
|
16
19
|
const [overview, setOverview] = useState(null)
|
|
17
20
|
const [refetchState, setRefetchState] = useState(null) // null | { scanned, total }
|
|
18
21
|
const [live, setLive] = useState(false)
|
|
22
|
+
const [mode, setMode] = useState(null) // 'local' | 'relay'
|
|
23
|
+
const [needsAuth, setNeedsAuth] = useState(false)
|
|
24
|
+
const [authed, setAuthed] = useState(!!getAuthToken())
|
|
19
25
|
const liveRef = useRef(null)
|
|
20
26
|
const { dark, toggle } = useTheme()
|
|
27
|
+
const [mcpOpen, setMcpOpen] = useState(false)
|
|
28
|
+
const [mcpCopied, setMcpCopied] = useState(false)
|
|
29
|
+
const [relayPassword, setRelayPassword] = useState('')
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
setOnAuthFailure(() => setAuthed(false))
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
fetchMode().then(data => {
|
|
37
|
+
setMode(data.mode || 'local')
|
|
38
|
+
setNeedsAuth(!!data.auth)
|
|
39
|
+
})
|
|
40
|
+
}, [])
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (mode === 'relay' && authed) {
|
|
44
|
+
fetchRelayConfig().then(c => setRelayPassword(c.relayPassword || '')).catch(() => {})
|
|
45
|
+
}
|
|
46
|
+
}, [mode, authed])
|
|
21
47
|
|
|
22
48
|
const refreshOverview = useCallback(() => {
|
|
23
49
|
fetchOverview().then(setOverview)
|
|
@@ -50,7 +76,12 @@ export default function App() {
|
|
|
50
76
|
setRefetchState(null)
|
|
51
77
|
}
|
|
52
78
|
|
|
53
|
-
const
|
|
79
|
+
const isRelay = mode === 'relay'
|
|
80
|
+
const showLogin = isRelay && needsAuth && !authed
|
|
81
|
+
|
|
82
|
+
const nav = isRelay ? [
|
|
83
|
+
{ to: '/', icon: Users, label: 'Team' },
|
|
84
|
+
] : [
|
|
54
85
|
{ to: '/', icon: Activity, label: 'Dashboard' },
|
|
55
86
|
{ to: '/projects', icon: FolderOpen, label: 'Projects' },
|
|
56
87
|
{ to: '/sessions', icon: MessageSquare, label: 'Sessions' },
|
|
@@ -59,10 +90,16 @@ export default function App() {
|
|
|
59
90
|
{ to: '/sql', icon: Database, label: 'SQL' },
|
|
60
91
|
]
|
|
61
92
|
|
|
93
|
+
if (showLogin) {
|
|
94
|
+
return <LoginScreen onSuccess={() => setAuthed(true)} />
|
|
95
|
+
}
|
|
96
|
+
|
|
62
97
|
return (
|
|
63
98
|
<div className="min-h-screen">
|
|
64
99
|
<header className="border-b px-4 py-1.5 flex items-center gap-3 sticky top-0 z-50 backdrop-blur-xl" style={{ borderColor: 'var(--c-border)', background: 'var(--c-header)' }}>
|
|
65
|
-
<span className="text-xs font-bold tracking-tight" style={{ color: 'var(--c-white)' }}>
|
|
100
|
+
<span className="text-xs font-bold tracking-tight" style={{ color: 'var(--c-white)' }}>
|
|
101
|
+
npx agentlytics{isRelay && <span className="ml-1.5 text-[9px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>relay</span>}
|
|
102
|
+
</span>
|
|
66
103
|
<nav className="flex gap-0.5 ml-2">
|
|
67
104
|
{nav.map(({ to, icon: Icon, label }) => (
|
|
68
105
|
<NavLink
|
|
@@ -81,37 +118,52 @@ export default function App() {
|
|
|
81
118
|
))}
|
|
82
119
|
</nav>
|
|
83
120
|
<div className="ml-auto flex items-center gap-3">
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
{!isRelay && (
|
|
122
|
+
<>
|
|
123
|
+
<button
|
|
124
|
+
onClick={() => setLive(!live)}
|
|
125
|
+
className="flex items-center gap-1.5 px-2 py-0.5 text-[10px] transition"
|
|
126
|
+
style={{
|
|
127
|
+
color: live ? '#22c55e' : 'var(--c-text3)',
|
|
128
|
+
border: live ? '1px solid rgba(34,197,94,0.3)' : '1px solid var(--c-border)',
|
|
129
|
+
background: live ? 'rgba(34,197,94,0.08)' : 'transparent',
|
|
130
|
+
}}
|
|
131
|
+
title={live ? 'Disable live refresh' : 'Enable live refresh (every 60s)'}
|
|
132
|
+
>
|
|
133
|
+
<span
|
|
134
|
+
className={`inline-block w-1.5 h-1.5 rounded-full ${live ? 'pulse-dot' : ''}`}
|
|
135
|
+
style={{ background: live ? '#22c55e' : 'var(--c-text3)' }}
|
|
136
|
+
/>
|
|
137
|
+
Live
|
|
138
|
+
</button>
|
|
139
|
+
<button
|
|
140
|
+
onClick={handleRefetch}
|
|
141
|
+
disabled={!!refetchState}
|
|
142
|
+
className="flex items-center gap-1 px-2 py-0.5 text-[10px] rounded transition hover:bg-[var(--c-card)]"
|
|
143
|
+
style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
|
|
144
|
+
title="Clear cache and rescan all editors"
|
|
145
|
+
>
|
|
146
|
+
<RefreshCw size={10} className={refetchState ? 'animate-spin' : ''} />
|
|
147
|
+
{refetchState
|
|
148
|
+
? `Refetching (${refetchState.scanned}/${refetchState.total})...`
|
|
149
|
+
: 'Refetch'}
|
|
150
|
+
</button>
|
|
151
|
+
<span className="text-[10px]" style={{ color: 'var(--c-text2)' }}>
|
|
152
|
+
{overview ? `${overview.totalChats} sessions` : '...'}
|
|
153
|
+
</span>
|
|
154
|
+
</>
|
|
155
|
+
)}
|
|
156
|
+
{isRelay && (
|
|
157
|
+
<button
|
|
158
|
+
onClick={() => { setMcpOpen(true); setMcpCopied(false) }}
|
|
159
|
+
className="flex items-center gap-1.5 px-2 py-0.5 text-[10px] transition hover:bg-[var(--c-card)]"
|
|
160
|
+
style={{ color: '#818cf8', border: '1px solid var(--c-border)' }}
|
|
161
|
+
title="MCP Connection"
|
|
162
|
+
>
|
|
163
|
+
<Plug size={10} />
|
|
164
|
+
Connect
|
|
165
|
+
</button>
|
|
166
|
+
)}
|
|
115
167
|
<button
|
|
116
168
|
onClick={toggle}
|
|
117
169
|
className="p-1 rounded transition hover:bg-[var(--c-card)]"
|
|
@@ -131,16 +183,26 @@ export default function App() {
|
|
|
131
183
|
)}
|
|
132
184
|
|
|
133
185
|
<main className="p-4 max-w-[1400px] mx-auto">
|
|
134
|
-
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
186
|
+
{mode === null ? (
|
|
187
|
+
<div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading...</div>
|
|
188
|
+
) : isRelay ? (
|
|
189
|
+
<Routes>
|
|
190
|
+
<Route path="/" element={<RelayDashboard />} />
|
|
191
|
+
<Route path="/relay" element={<RelayDashboard />} />
|
|
192
|
+
<Route path="/relay/user/:username" element={<RelayUserDetail />} />
|
|
193
|
+
</Routes>
|
|
194
|
+
) : (
|
|
195
|
+
<Routes>
|
|
196
|
+
<Route path="/" element={<Dashboard overview={overview} />} />
|
|
197
|
+
<Route path="/projects" element={<Projects overview={overview} />} />
|
|
198
|
+
<Route path="/projects/detail" element={<ProjectDetail />} />
|
|
199
|
+
<Route path="/sessions" element={<Sessions overview={overview} />} />
|
|
200
|
+
{/* ChatDetail is now a sidebar in Sessions */}
|
|
201
|
+
<Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
|
|
202
|
+
<Route path="/compare" element={<Compare overview={overview} />} />
|
|
203
|
+
<Route path="/sql" element={<SqlViewer />} />
|
|
204
|
+
</Routes>
|
|
205
|
+
)}
|
|
144
206
|
</main>
|
|
145
207
|
|
|
146
208
|
<footer className="border-t mt-8 px-4 py-3 flex items-center justify-between text-[10px]" style={{ borderColor: 'var(--c-border)', color: 'var(--c-text3)' }}>
|
|
@@ -158,6 +220,53 @@ export default function App() {
|
|
|
158
220
|
built by <a href="https://github.com/f" target="_blank" rel="noopener noreferrer" className="hover:text-[var(--c-text)] transition" style={{ color: 'var(--c-text2)' }}>fkadev</a>
|
|
159
221
|
</span>
|
|
160
222
|
</footer>
|
|
223
|
+
|
|
224
|
+
{/* MCP Config Modal */}
|
|
225
|
+
{mcpOpen && (
|
|
226
|
+
<>
|
|
227
|
+
<div className="fixed inset-0 z-[60]" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={() => setMcpOpen(false)} />
|
|
228
|
+
<div
|
|
229
|
+
className="fixed z-[70] w-[440px] max-w-[90vw] p-5 rounded shadow-2xl"
|
|
230
|
+
style={{ top: '50%', left: '50%', transform: 'translate(-50%, -50%)', background: 'var(--c-bg)', border: '1px solid var(--c-border)' }}
|
|
231
|
+
>
|
|
232
|
+
<div className="flex items-center justify-between mb-4">
|
|
233
|
+
<div className="text-[13px] font-bold" style={{ color: 'var(--c-white)' }}>
|
|
234
|
+
<Plug size={13} className="inline mr-1.5" style={{ color: '#818cf8' }} />
|
|
235
|
+
Connection Config
|
|
236
|
+
</div>
|
|
237
|
+
<button onClick={() => setMcpOpen(false)} className="text-[18px] leading-none px-1 hover:opacity-70 transition" style={{ color: 'var(--c-text3)' }}>×</button>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div className="text-[11px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>MCP Config</div>
|
|
241
|
+
<div className="flex items-center justify-between mb-1">
|
|
242
|
+
<div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>Add to your AI client's MCP settings</div>
|
|
243
|
+
<button
|
|
244
|
+
onClick={() => {
|
|
245
|
+
const json = JSON.stringify({ "mcpServers": { "agentlytics": { "url": `${window.location.origin}/mcp` } } }, null, 2)
|
|
246
|
+
navigator.clipboard.writeText(json)
|
|
247
|
+
setMcpCopied(true)
|
|
248
|
+
setTimeout(() => setMcpCopied(false), 2000)
|
|
249
|
+
}}
|
|
250
|
+
className="flex items-center gap-1 px-1.5 py-0.5 text-[9px] transition hover:bg-[var(--c-bg3)]"
|
|
251
|
+
style={{ border: '1px solid var(--c-border)', color: mcpCopied ? '#22c55e' : 'var(--c-text2)' }}
|
|
252
|
+
>
|
|
253
|
+
{mcpCopied ? <><Check size={9} /> Copied</> : <><Copy size={9} /> Copy</>}
|
|
254
|
+
</button>
|
|
255
|
+
</div>
|
|
256
|
+
<pre
|
|
257
|
+
className="text-[10px] px-3 py-2 overflow-x-auto mb-4"
|
|
258
|
+
style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
|
|
259
|
+
>{`{\n "mcpServers": {\n "agentlytics": {\n "url": "${window.location.origin}/mcp"\n }\n }\n}`}</pre>
|
|
260
|
+
|
|
261
|
+
<div className="text-[11px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>Join Command</div>
|
|
262
|
+
<div className="text-[9px] mb-1" style={{ color: 'var(--c-text3)' }}>Share with your team to start syncing sessions</div>
|
|
263
|
+
<pre
|
|
264
|
+
className="text-[10px] px-3 py-2 overflow-x-auto"
|
|
265
|
+
style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
|
|
266
|
+
>{`cd /path/to/your-project\nRELAY_PASSWORD=${relayPassword || '<pass>'} npx agentlytics --join ${window.location.host}`}</pre>
|
|
267
|
+
</div>
|
|
268
|
+
</>
|
|
269
|
+
)}
|
|
161
270
|
</div>
|
|
162
271
|
)
|
|
163
272
|
}
|
|
@@ -1,170 +1,41 @@
|
|
|
1
1
|
import { useState, useEffect, useRef } from 'react'
|
|
2
|
-
import { X,
|
|
3
|
-
import ReactMarkdown from 'react-markdown'
|
|
4
|
-
import remarkGfm from 'remark-gfm'
|
|
2
|
+
import { X, Download, Send, Search } from 'lucide-react'
|
|
5
3
|
import { fetchChat, BASE } from '../lib/api'
|
|
6
4
|
import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
|
|
5
|
+
import MessageContent, { ROLE_CONFIG } from './MessageRenderer'
|
|
7
6
|
|
|
8
|
-
|
|
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 }) {
|
|
7
|
+
export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, username }) {
|
|
152
8
|
const [chat, setChat] = useState(null)
|
|
153
9
|
const [loading, setLoading] = useState(true)
|
|
154
10
|
const [msgFilter, setMsgFilter] = useState('')
|
|
155
11
|
const scrollRef = useRef(null)
|
|
12
|
+
const msgCountRef = useRef(0)
|
|
156
13
|
|
|
157
14
|
useEffect(() => {
|
|
158
15
|
if (!chatId) return
|
|
159
16
|
setLoading(true)
|
|
160
17
|
setChat(null)
|
|
161
|
-
|
|
18
|
+
msgCountRef.current = 0
|
|
19
|
+
const doFetch = fetchFn || fetchChat
|
|
20
|
+
doFetch(chatId).then(data => {
|
|
162
21
|
setChat(data)
|
|
22
|
+
msgCountRef.current = data?.messages?.length || 0
|
|
163
23
|
setLoading(false)
|
|
164
24
|
})
|
|
165
|
-
}, [chatId])
|
|
166
25
|
|
|
167
|
-
|
|
26
|
+
// Poll for new messages while sidebar is open
|
|
27
|
+
const iv = setInterval(() => {
|
|
28
|
+
doFetch(chatId).then(data => {
|
|
29
|
+
if (data?.messages?.length !== msgCountRef.current) {
|
|
30
|
+
msgCountRef.current = data?.messages?.length || 0
|
|
31
|
+
setChat(data)
|
|
32
|
+
}
|
|
33
|
+
}).catch(() => {})
|
|
34
|
+
}, 10000)
|
|
35
|
+
return () => clearInterval(iv)
|
|
36
|
+
}, [chatId, fetchFn])
|
|
37
|
+
|
|
38
|
+
// Scroll to bottom when chat loads or new messages arrive
|
|
168
39
|
useEffect(() => {
|
|
169
40
|
if (!loading && chat && scrollRef.current) {
|
|
170
41
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
@@ -216,7 +87,7 @@ export default function ChatSidebar({ chatId, onClose }) {
|
|
|
216
87
|
</div>
|
|
217
88
|
</div>
|
|
218
89
|
)}
|
|
219
|
-
{chat && (
|
|
90
|
+
{chat && !fetchFn && (
|
|
220
91
|
<a
|
|
221
92
|
href={`${BASE}/api/chats/${chat.id}/markdown`}
|
|
222
93
|
download
|
|
@@ -226,16 +97,17 @@ export default function ChatSidebar({ chatId, onClose }) {
|
|
|
226
97
|
<Download size={11} /> .md
|
|
227
98
|
</a>
|
|
228
99
|
)}
|
|
100
|
+
{extraHeader}
|
|
229
101
|
</div>
|
|
230
102
|
|
|
231
103
|
{/* Stats row */}
|
|
232
104
|
{chat?.stats && (
|
|
233
105
|
<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
106
|
<span>{chat.stats.totalMessages} msgs</span>
|
|
235
|
-
<span>{chat.stats.toolCalls.length} tools</span>
|
|
107
|
+
{chat.stats.toolCalls?.length > 0 && <span>{chat.stats.toolCalls.length} tools</span>}
|
|
236
108
|
{chat.stats.totalInputTokens > 0 && <span>{formatNumber(chat.stats.totalInputTokens)} in</span>}
|
|
237
109
|
{chat.stats.totalOutputTokens > 0 && <span>{formatNumber(chat.stats.totalOutputTokens)} out</span>}
|
|
238
|
-
{chat.stats.models
|
|
110
|
+
{chat.stats.models?.length > 0 && (
|
|
239
111
|
<span className="ml-auto font-mono truncate" style={{ color: 'var(--c-accent)', opacity: 0.7 }}>
|
|
240
112
|
{[...new Set(chat.stats.models)].join(', ')}
|
|
241
113
|
</span>
|
|
@@ -279,11 +151,11 @@ export default function ChatSidebar({ chatId, onClose }) {
|
|
|
279
151
|
<div key={i} className="rounded-r px-3 py-2" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
|
|
280
152
|
<div className="flex items-center gap-1.5 text-[10px] mb-1" style={{ color: 'var(--c-text2)' }}>
|
|
281
153
|
<Icon size={11} />
|
|
282
|
-
<span className="font-medium">{cfg.label}</span>
|
|
154
|
+
<span className="font-medium">{msg.role === 'user' && (username || chat?.username) ? (username || chat.username) : cfg.label}</span>
|
|
283
155
|
{msg.model && <span className="font-mono" style={{ color: 'var(--c-accent)', opacity: 0.6 }}>· {msg.model}</span>}
|
|
284
156
|
</div>
|
|
285
157
|
<div className="text-[12px]" style={{ color: 'var(--c-text)' }}>
|
|
286
|
-
<MessageContent content={msg.content} toolCallDetails={chat.toolCallDetails} />
|
|
158
|
+
<MessageContent content={msg.content} toolCallDetails={chat.toolCallDetails || []} />
|
|
287
159
|
</div>
|
|
288
160
|
</div>
|
|
289
161
|
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import EditorDot from './EditorDot'
|
|
2
|
+
import { editorColor, editorLabel } from '../lib/constants'
|
|
3
|
+
|
|
4
|
+
export default function EditorBreakdown({ editors, total }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="space-y-1.5">
|
|
7
|
+
{editors.map(([src, count]) => {
|
|
8
|
+
const pct = total > 0 ? (count / total * 100) : 0
|
|
9
|
+
return (
|
|
10
|
+
<div key={src} className="flex items-center gap-2">
|
|
11
|
+
<EditorDot source={src} size={8} />
|
|
12
|
+
<span className="text-[10px] w-24" style={{ color: 'var(--c-text)' }}>{editorLabel(src)}</span>
|
|
13
|
+
<div className="flex-1 h-2 relative" style={{ background: 'var(--c-card)' }}>
|
|
14
|
+
<div className="h-full" style={{ width: `${pct}%`, background: editorColor(src), opacity: 0.7 }} />
|
|
15
|
+
</div>
|
|
16
|
+
<span className="text-[10px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
})}
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import { Radio, MessageSquare, FolderOpen, Cpu } from 'lucide-react'
|
|
3
|
+
import EditorDot from './EditorDot'
|
|
4
|
+
import { editorLabel, formatNumber } from '../lib/constants'
|
|
5
|
+
import { fetchRelayFeed } from '../lib/api'
|
|
6
|
+
|
|
7
|
+
function timeAgo(ts) {
|
|
8
|
+
if (!ts) return ''
|
|
9
|
+
const diff = Date.now() - ts
|
|
10
|
+
if (diff < 60000) return 'just now'
|
|
11
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
|
12
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
|
13
|
+
return `${Math.floor(diff / 86400000)}d ago`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function timeLabel(ts) {
|
|
17
|
+
if (!ts) return ''
|
|
18
|
+
const d = new Date(ts)
|
|
19
|
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function LiveFeed({ onSessionClick }) {
|
|
23
|
+
const [items, setItems] = useState([])
|
|
24
|
+
const scrollRef = useRef(null)
|
|
25
|
+
const prevCountRef = useRef(0)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const load = () => {
|
|
29
|
+
fetchRelayFeed({ limit: 80 })
|
|
30
|
+
.then(data => {
|
|
31
|
+
if (Array.isArray(data)) setItems(data)
|
|
32
|
+
})
|
|
33
|
+
.catch(() => {})
|
|
34
|
+
}
|
|
35
|
+
load()
|
|
36
|
+
const iv = setInterval(load, 10000)
|
|
37
|
+
return () => clearInterval(iv)
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
// Group items by relative time buckets
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
const buckets = []
|
|
43
|
+
let currentBucket = null
|
|
44
|
+
|
|
45
|
+
for (const item of items) {
|
|
46
|
+
const diff = now - item.lastUpdatedAt
|
|
47
|
+
let label
|
|
48
|
+
if (diff < 300000) label = 'Just now'
|
|
49
|
+
else if (diff < 3600000) label = `${Math.floor(diff / 60000)} min ago`
|
|
50
|
+
else if (diff < 86400000) label = `${Math.floor(diff / 3600000)}h ago`
|
|
51
|
+
else label = new Date(item.lastUpdatedAt).toLocaleDateString()
|
|
52
|
+
|
|
53
|
+
if (!currentBucket || currentBucket.label !== label) {
|
|
54
|
+
currentBucket = { label, items: [] }
|
|
55
|
+
buckets.push(currentBucket)
|
|
56
|
+
}
|
|
57
|
+
currentBucket.items.push(item)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex flex-col h-full">
|
|
62
|
+
{/* Header */}
|
|
63
|
+
<div className="flex items-center gap-2 px-3 py-2.5 shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
64
|
+
<Radio size={12} style={{ color: '#22c55e' }} />
|
|
65
|
+
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>Live Feed</span>
|
|
66
|
+
<span className="inline-block w-1.5 h-1.5 rounded-full pulse-dot ml-auto" style={{ background: '#22c55e' }} />
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{/* Feed */}
|
|
70
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin">
|
|
71
|
+
{items.length === 0 && (
|
|
72
|
+
<div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>
|
|
73
|
+
No recent activity
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
{buckets.map((bucket, bi) => (
|
|
78
|
+
<div key={bi}>
|
|
79
|
+
{/* Time separator */}
|
|
80
|
+
<div className="sticky top-0 px-3 py-1.5 text-[9px] font-medium uppercase tracking-wider" style={{ background: 'var(--c-bg)', color: 'var(--c-text3)', borderBottom: '1px solid var(--c-border)' }}>
|
|
81
|
+
{bucket.label}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{bucket.items.map((item) => (
|
|
85
|
+
<div
|
|
86
|
+
key={`${item.id}-${item.username}`}
|
|
87
|
+
className="px-3 py-2.5 cursor-pointer transition hover:bg-[var(--c-card)]"
|
|
88
|
+
style={{ borderBottom: '1px solid var(--c-border)' }}
|
|
89
|
+
onClick={() => onSessionClick && onSessionClick(item.id, item.username)}
|
|
90
|
+
>
|
|
91
|
+
{/* Session name */}
|
|
92
|
+
<div className="text-[11px] font-medium truncate mb-1" style={{ color: 'var(--c-white)' }}>
|
|
93
|
+
{item.name || 'Untitled'}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{/* User + editor row */}
|
|
97
|
+
<div className="flex items-center gap-1.5 mb-1">
|
|
98
|
+
<span
|
|
99
|
+
className="text-[9px] font-medium px-1 py-0.5 shrink-0 truncate max-w-[120px]"
|
|
100
|
+
style={{ background: 'rgba(99,102,241,0.12)', color: '#818cf8' }}
|
|
101
|
+
title={item.username}
|
|
102
|
+
>
|
|
103
|
+
{item.username}
|
|
104
|
+
</span>
|
|
105
|
+
<EditorDot source={item.source} size={6} />
|
|
106
|
+
<span className="text-[9px] truncate" style={{ color: 'var(--c-text2)' }}>{editorLabel(item.source)}</span>
|
|
107
|
+
<span className="text-[9px] ml-auto shrink-0" style={{ color: 'var(--c-text3)' }}>{timeLabel(item.lastUpdatedAt)}</span>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Meta row */}
|
|
111
|
+
<div className="flex items-center gap-2 text-[9px]" style={{ color: 'var(--c-text3)' }}>
|
|
112
|
+
{item.totalMessages > 0 && (
|
|
113
|
+
<span className="flex items-center gap-0.5">
|
|
114
|
+
<MessageSquare size={8} /> {item.totalMessages}
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
{item.folder && (
|
|
118
|
+
<span className="flex items-center gap-0.5 truncate">
|
|
119
|
+
<FolderOpen size={8} /> {item.folder.split('/').pop()}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
{item.mode && (
|
|
123
|
+
<span className="px-1 py-0" style={{ background: 'rgba(168,85,247,0.1)', color: '#a855f7' }}>{item.mode}</span>
|
|
124
|
+
)}
|
|
125
|
+
{item.models?.[0] && (
|
|
126
|
+
<span className="flex items-center gap-0.5 ml-auto truncate" style={{ color: '#818cf8' }}>
|
|
127
|
+
<Cpu size={8} /> {item.models[0]}
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|