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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Lock } from 'lucide-react'
|
|
3
|
+
import { login } from '../lib/api'
|
|
4
|
+
|
|
5
|
+
export default function LoginScreen({ onSuccess }) {
|
|
6
|
+
const [password, setPassword] = useState('')
|
|
7
|
+
const [error, setError] = useState(null)
|
|
8
|
+
const [loading, setLoading] = useState(false)
|
|
9
|
+
|
|
10
|
+
const handleSubmit = async (e) => {
|
|
11
|
+
e.preventDefault()
|
|
12
|
+
setError(null)
|
|
13
|
+
setLoading(true)
|
|
14
|
+
try {
|
|
15
|
+
await login(password)
|
|
16
|
+
onSuccess()
|
|
17
|
+
} catch (err) {
|
|
18
|
+
setError(err.message || 'Invalid password')
|
|
19
|
+
}
|
|
20
|
+
setLoading(false)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--c-bg)' }}>
|
|
25
|
+
<div className="w-full max-w-xs">
|
|
26
|
+
<div className="card p-6">
|
|
27
|
+
<div className="flex flex-col items-center mb-5">
|
|
28
|
+
<div
|
|
29
|
+
className="w-10 h-10 flex items-center justify-center rounded-full mb-3"
|
|
30
|
+
style={{ background: 'rgba(99,102,241,0.12)' }}
|
|
31
|
+
>
|
|
32
|
+
<Lock size={18} style={{ color: '#818cf8' }} />
|
|
33
|
+
</div>
|
|
34
|
+
<div className="text-sm font-bold" style={{ color: 'var(--c-white)' }}>
|
|
35
|
+
npx agentlytics
|
|
36
|
+
<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' }}>
|
|
37
|
+
relay
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div className="text-[10px] mt-1" style={{ color: 'var(--c-text3)' }}>
|
|
41
|
+
This relay is password-protected
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<form onSubmit={handleSubmit} className="space-y-3">
|
|
46
|
+
<input
|
|
47
|
+
type="password"
|
|
48
|
+
value={password}
|
|
49
|
+
onChange={e => setPassword(e.target.value)}
|
|
50
|
+
placeholder="Password"
|
|
51
|
+
autoFocus
|
|
52
|
+
className="w-full px-3 py-2 text-[12px] outline-none rounded"
|
|
53
|
+
style={{
|
|
54
|
+
background: 'var(--c-bg3)',
|
|
55
|
+
color: 'var(--c-white)',
|
|
56
|
+
border: error ? '1px solid #f87171' : '1px solid var(--c-border)',
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
{error && (
|
|
60
|
+
<div className="text-[10px]" style={{ color: '#f87171' }}>{error}</div>
|
|
61
|
+
)}
|
|
62
|
+
<button
|
|
63
|
+
type="submit"
|
|
64
|
+
disabled={loading || !password}
|
|
65
|
+
className="w-full py-2 text-[11px] font-medium rounded transition"
|
|
66
|
+
style={{
|
|
67
|
+
background: '#6366f1',
|
|
68
|
+
color: '#fff',
|
|
69
|
+
opacity: loading || !password ? 0.5 : 1,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{loading ? 'Signing in...' : 'Sign in'}
|
|
73
|
+
</button>
|
|
74
|
+
</form>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown } from 'lucide-react'
|
|
3
|
+
import ReactMarkdown from 'react-markdown'
|
|
4
|
+
import remarkGfm from 'remark-gfm'
|
|
5
|
+
|
|
6
|
+
export const ROLE_CONFIG = {
|
|
7
|
+
user: { icon: User, label: 'User', borderColor: 'rgba(34,197,94,0.2)', bg: 'rgba(34,197,94,0.05)' },
|
|
8
|
+
assistant: { icon: Bot, label: 'Assistant', borderColor: 'rgba(99,102,241,0.2)', bg: 'rgba(99,102,241,0.05)' },
|
|
9
|
+
system: { icon: Settings, label: 'System', borderColor: 'rgba(107,114,128,0.2)', bg: 'rgba(107,114,128,0.05)' },
|
|
10
|
+
tool: { icon: Wrench, label: 'Tool', borderColor: 'rgba(234,179,8,0.2)', bg: 'rgba(234,179,8,0.05)' },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseContent(content) {
|
|
14
|
+
const segments = []
|
|
15
|
+
const regex = /\[tool-call: ([^\]]+)\]|\[tool-result: ([^\]]+)\]\s*(.*?)(?=\n\[tool-|$)/gs
|
|
16
|
+
let lastIdx = 0
|
|
17
|
+
let match
|
|
18
|
+
while ((match = regex.exec(content)) !== null) {
|
|
19
|
+
if (match.index > lastIdx) {
|
|
20
|
+
const text = content.slice(lastIdx, match.index).trim()
|
|
21
|
+
if (text) segments.push({ type: 'text', value: text })
|
|
22
|
+
}
|
|
23
|
+
if (match[1]) {
|
|
24
|
+
segments.push({ type: 'tool-call', name: match[1].replace(/\(.*\)$/, '').trim(), args: match[1] })
|
|
25
|
+
} else if (match[2]) {
|
|
26
|
+
segments.push({ type: 'tool-result', name: match[2].trim(), preview: (match[3] || '').trim() })
|
|
27
|
+
}
|
|
28
|
+
lastIdx = match.index + match[0].length
|
|
29
|
+
}
|
|
30
|
+
if (lastIdx < content.length) {
|
|
31
|
+
const text = content.slice(lastIdx).trim()
|
|
32
|
+
if (text) segments.push({ type: 'text', value: text })
|
|
33
|
+
}
|
|
34
|
+
return segments.length > 0 ? segments : [{ type: 'text', value: content }]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function summarizeToolArgs(name, args) {
|
|
38
|
+
if (!args || typeof args !== 'object') return ''
|
|
39
|
+
if (args.file_path || args.TargetFile) return args.file_path || args.TargetFile
|
|
40
|
+
if (args.CommandLine || args.command) return args.CommandLine || args.command
|
|
41
|
+
if (args.Query || args.query) return `${args.Query || args.query}${args.SearchPath ? ` in ${args.SearchPath}` : ''}`
|
|
42
|
+
if (args.Url || args.url) return args.Url || args.url
|
|
43
|
+
const vals = Object.values(args).filter(v => typeof v === 'string' && v.length > 0 && v.length < 120)
|
|
44
|
+
return vals.length > 0 ? vals[0] : ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ToolArgsDiff({ args }) {
|
|
48
|
+
const old = args.old_string || args.old_text || args.oldText || args.search || null
|
|
49
|
+
const nw = args.new_string || args.new_text || args.newText || args.replace || null
|
|
50
|
+
if (old == null && nw == null) return null
|
|
51
|
+
const maxLines = 12
|
|
52
|
+
const oldLines = (old || '').split('\n').slice(0, maxLines)
|
|
53
|
+
const newLines = (nw || '').split('\n').slice(0, maxLines)
|
|
54
|
+
return (
|
|
55
|
+
<div className="mt-1.5 text-[9px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
|
|
56
|
+
{(args.file_path || args.TargetFile) && (
|
|
57
|
+
<div className="px-2 py-0.5" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text)' }}>{args.file_path || args.TargetFile}</div>
|
|
58
|
+
)}
|
|
59
|
+
{old && oldLines.map((line, i) => (
|
|
60
|
+
<div key={'o' + i} className="px-2" style={{ background: 'rgba(248,113,113,0.1)', color: '#f87171' }}>
|
|
61
|
+
<span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>- </span>{line}
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
{old && oldLines.length < (old || '').split('\n').length && (
|
|
65
|
+
<div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(old || '').split('\n').length - maxLines} more lines</div>
|
|
66
|
+
)}
|
|
67
|
+
{nw && newLines.map((line, i) => (
|
|
68
|
+
<div key={'n' + i} className="px-2" style={{ background: 'rgba(52,211,153,0.1)', color: '#34d399' }}>
|
|
69
|
+
<span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>+ </span>{line}
|
|
70
|
+
</div>
|
|
71
|
+
))}
|
|
72
|
+
{nw && newLines.length < (nw || '').split('\n').length && (
|
|
73
|
+
<div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(nw || '').split('\n').length - maxLines} more lines</div>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ToolArgsDetail({ args }) {
|
|
80
|
+
if (!args || Object.keys(args).length === 0) return null
|
|
81
|
+
const hasDiff = args.old_string || args.new_string || args.old_text || args.new_text || args.search || args.replace
|
|
82
|
+
if (hasDiff) return <ToolArgsDiff args={args} />
|
|
83
|
+
const file = args.file_path || args.TargetFile || args.filePath || args.path || null
|
|
84
|
+
const cmd = args.CommandLine || args.command || null
|
|
85
|
+
const query = args.Query || args.query || args.search_term || null
|
|
86
|
+
const url = args.Url || args.url || null
|
|
87
|
+
return (
|
|
88
|
+
<div className="mt-1.5 text-[9px] font-mono overflow-x-auto" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
89
|
+
{file && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>file: {file}</div>}
|
|
90
|
+
{cmd && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>cmd: {cmd}</div>}
|
|
91
|
+
{query && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>query: {query}</div>}
|
|
92
|
+
{url && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>url: {url}</div>}
|
|
93
|
+
{!file && !cmd && !query && !url && (
|
|
94
|
+
<pre className="px-2 py-1 whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{JSON.stringify(args, null, 2)}</pre>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function ToolCallBlock({ name, args, detail }) {
|
|
101
|
+
const [open, setOpen] = useState(false)
|
|
102
|
+
const hasDetail = detail && Object.keys(detail).length > 0
|
|
103
|
+
return (
|
|
104
|
+
<div className="my-1 px-2.5 py-1.5 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
105
|
+
<div className="flex items-center gap-2 cursor-pointer" onClick={() => hasDetail && setOpen(!open)}>
|
|
106
|
+
{hasDetail
|
|
107
|
+
? (open ? <ChevronDown size={10} style={{ color: '#a78bfa' }} /> : <ChevronRight size={10} style={{ color: '#a78bfa' }} />)
|
|
108
|
+
: <Play size={10} style={{ color: '#a78bfa' }} />
|
|
109
|
+
}
|
|
110
|
+
<span className="font-bold" style={{ color: 'var(--c-white)' }}>{name}</span>
|
|
111
|
+
{args !== name && !hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{args}</span>}
|
|
112
|
+
{hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{summarizeToolArgs(name, detail)}</span>}
|
|
113
|
+
</div>
|
|
114
|
+
{open && hasDetail && <ToolArgsDetail args={detail} />}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function ToolResultBlock({ name, preview }) {
|
|
120
|
+
const [open, setOpen] = useState(false)
|
|
121
|
+
const isNoisy = preview.length > 120 || preview.startsWith('{') || preview.includes('contentId')
|
|
122
|
+
const short = isNoisy ? `${name} completed` : preview.substring(0, 120)
|
|
123
|
+
return (
|
|
124
|
+
<div className="my-1 px-2.5 py-1.5 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
125
|
+
<div className="flex items-center gap-2 cursor-pointer" onClick={() => preview && setOpen(!open)}>
|
|
126
|
+
<CheckCircle size={10} style={{ color: '#34d399' }} />
|
|
127
|
+
<span className="truncate" style={{ color: 'var(--c-text)' }}>{short}</span>
|
|
128
|
+
{isNoisy && preview && <span style={{ color: 'var(--c-text3)' }}>{open ? '[-]' : '[+]'}</span>}
|
|
129
|
+
</div>
|
|
130
|
+
{open && <pre className="mt-1 text-[9px] overflow-x-auto whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{preview}</pre>}
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default function MessageContent({ content, toolCallDetails }) {
|
|
136
|
+
const segments = parseContent(content)
|
|
137
|
+
let toolIdx = 0
|
|
138
|
+
return segments.map((seg, i) => {
|
|
139
|
+
if (seg.type === 'tool-call') {
|
|
140
|
+
const detail = toolCallDetails ? toolCallDetails.find(tc => tc.name === seg.name && toolCallDetails.indexOf(tc) >= toolIdx) : null
|
|
141
|
+
if (detail) toolIdx = toolCallDetails.indexOf(detail) + 1
|
|
142
|
+
return <ToolCallBlock key={i} name={seg.name} args={seg.args} detail={detail?.args} />
|
|
143
|
+
}
|
|
144
|
+
if (seg.type === 'tool-result') return <ToolResultBlock key={i} name={seg.name} preview={seg.preview} />
|
|
145
|
+
return <div key={i} className="md-body"><ReactMarkdown remarkPlugins={[remarkGfm]}>{seg.value}</ReactMarkdown></div>
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Renders a single message bubble with role icon, model tag, and content.
|
|
151
|
+
*/
|
|
152
|
+
export function MessageBubble({ msg, toolCallDetails }) {
|
|
153
|
+
const cfg = ROLE_CONFIG[msg.role] || ROLE_CONFIG.system
|
|
154
|
+
const Icon = cfg.icon
|
|
155
|
+
return (
|
|
156
|
+
<div className="rounded-r-lg px-4 py-3" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
|
|
157
|
+
<div className="flex items-center gap-2 text-xs mb-2" style={{ color: 'var(--c-text2)' }}>
|
|
158
|
+
<Icon size={13} />
|
|
159
|
+
<span className="font-medium">{cfg.label}</span>
|
|
160
|
+
{msg.model && <span className="font-mono" style={{ color: 'var(--c-accent)', opacity: 0.6 }}>ยท {msg.model}</span>}
|
|
161
|
+
</div>
|
|
162
|
+
<div className="text-sm" style={{ color: 'var(--c-text)' }}>
|
|
163
|
+
<MessageContent content={msg.content} toolCallDetails={toolCallDetails} />
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Cpu } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
export default function ModelBreakdown({ models }) {
|
|
4
|
+
const max = models[0]?.[1] || 1
|
|
5
|
+
return (
|
|
6
|
+
<div className="space-y-1.5">
|
|
7
|
+
{models.map(([name, count]) => {
|
|
8
|
+
const pct = (count / max * 100)
|
|
9
|
+
return (
|
|
10
|
+
<div key={name} className="flex items-center gap-2">
|
|
11
|
+
<Cpu size={10} style={{ color: '#818cf8' }} />
|
|
12
|
+
<span className="text-[10px] truncate w-40" style={{ color: 'var(--c-text)' }}>{name}</span>
|
|
13
|
+
<div className="flex-1 h-2 relative" style={{ background: 'var(--c-card)' }}>
|
|
14
|
+
<div className="h-full" style={{ width: `${pct}%`, background: '#6366f1', opacity: 0.5 }} />
|
|
15
|
+
</div>
|
|
16
|
+
<span className="text-[10px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
})}
|
|
20
|
+
{models.length === 0 && <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>No model data</div>}
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
package/ui/src/lib/api.js
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
export const BASE = '';
|
|
2
2
|
|
|
3
|
+
// โโ Auth helpers โโ
|
|
4
|
+
const AUTH_KEY = 'agentlytics_relay_token';
|
|
5
|
+
|
|
6
|
+
export function getAuthToken() {
|
|
7
|
+
return localStorage.getItem(AUTH_KEY);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function setAuthToken(token) {
|
|
11
|
+
if (token) localStorage.setItem(AUTH_KEY, token);
|
|
12
|
+
else localStorage.removeItem(AUTH_KEY);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let onAuthFailure = null;
|
|
16
|
+
export function setOnAuthFailure(fn) { onAuthFailure = fn; }
|
|
17
|
+
|
|
18
|
+
async function authFetch(url, opts = {}) {
|
|
19
|
+
const token = getAuthToken();
|
|
20
|
+
if (token) {
|
|
21
|
+
opts.headers = { ...opts.headers, Authorization: `Bearer ${token}` };
|
|
22
|
+
}
|
|
23
|
+
const res = await fetch(url, opts);
|
|
24
|
+
if (res.status === 401) {
|
|
25
|
+
setAuthToken(null);
|
|
26
|
+
if (onAuthFailure) onAuthFailure();
|
|
27
|
+
throw new Error('Unauthorized');
|
|
28
|
+
}
|
|
29
|
+
return res;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function login(password) {
|
|
33
|
+
const res = await fetch(`${BASE}/api/login`, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ password }),
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const data = await res.json().catch(() => ({}));
|
|
40
|
+
throw new Error(data.error || 'Login failed');
|
|
41
|
+
}
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
if (data.token) setAuthToken(data.token);
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
|
|
3
47
|
// Append optional dateFrom/dateTo (ms timestamps) to URLSearchParams
|
|
4
48
|
function appendDateParams(q, params) {
|
|
5
49
|
if (params.dateFrom) q.set('dateFrom', params.dateFrom);
|
|
@@ -109,3 +153,74 @@ export async function fetchToolCalls(name, opts = {}) {
|
|
|
109
153
|
const res = await fetch(`${BASE}/api/tool-calls?${q}`);
|
|
110
154
|
return res.json();
|
|
111
155
|
}
|
|
156
|
+
|
|
157
|
+
// โโ Relay API โโ
|
|
158
|
+
|
|
159
|
+
export async function fetchMode() {
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(`${BASE}/api/mode`);
|
|
162
|
+
if (!res.ok) return { mode: 'local' };
|
|
163
|
+
return res.json();
|
|
164
|
+
} catch {
|
|
165
|
+
return { mode: 'local' };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function fetchRelayTeamStats() {
|
|
170
|
+
const res = await authFetch(`${BASE}/relay/team-stats`);
|
|
171
|
+
return res.json();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function fetchRelayUsers() {
|
|
175
|
+
const res = await authFetch(`${BASE}/relay/users`);
|
|
176
|
+
return res.json();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function fetchRelayUserActivity(username, opts = {}) {
|
|
180
|
+
const q = new URLSearchParams();
|
|
181
|
+
if (opts.folder) q.set('folder', opts.folder);
|
|
182
|
+
if (opts.limit) q.set('limit', opts.limit);
|
|
183
|
+
const qs = q.toString();
|
|
184
|
+
const res = await authFetch(`${BASE}/relay/activity/${encodeURIComponent(username)}${qs ? '?' + qs : ''}`);
|
|
185
|
+
return res.json();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function fetchRelaySearch(query, opts = {}) {
|
|
189
|
+
const q = new URLSearchParams({ q: query });
|
|
190
|
+
if (opts.username) q.set('username', opts.username);
|
|
191
|
+
if (opts.folder) q.set('folder', opts.folder);
|
|
192
|
+
if (opts.limit) q.set('limit', opts.limit);
|
|
193
|
+
const res = await authFetch(`${BASE}/relay/search?${q}`);
|
|
194
|
+
return res.json();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function fetchRelayFeed(opts = {}) {
|
|
198
|
+
const q = new URLSearchParams();
|
|
199
|
+
if (opts.limit) q.set('limit', opts.limit);
|
|
200
|
+
if (opts.since) q.set('since', opts.since);
|
|
201
|
+
const qs = q.toString();
|
|
202
|
+
const res = await authFetch(`${BASE}/relay/feed${qs ? '?' + qs : ''}`);
|
|
203
|
+
return res.json();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function fetchRelayConfig() {
|
|
207
|
+
const res = await authFetch(`${BASE}/relay/config`);
|
|
208
|
+
return res.json();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function mergeRelayUsers(from, to) {
|
|
212
|
+
const res = await authFetch(`${BASE}/relay/merge-users`, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: { 'Content-Type': 'application/json' },
|
|
215
|
+
body: JSON.stringify({ from, to }),
|
|
216
|
+
});
|
|
217
|
+
return res.json();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function fetchRelaySession(chatId, username) {
|
|
221
|
+
const q = new URLSearchParams();
|
|
222
|
+
if (username) q.set('username', username);
|
|
223
|
+
const qs = q.toString();
|
|
224
|
+
const res = await authFetch(`${BASE}/relay/session/${encodeURIComponent(chatId)}${qs ? '?' + qs : ''}`);
|
|
225
|
+
return res.json();
|
|
226
|
+
}
|
|
@@ -1,156 +1,10 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
|
-
import { ArrowLeft,
|
|
4
|
-
import ReactMarkdown from 'react-markdown'
|
|
5
|
-
import remarkGfm from 'remark-gfm'
|
|
3
|
+
import { ArrowLeft, Download } from 'lucide-react'
|
|
6
4
|
import { fetchChat, BASE } from '../lib/api'
|
|
7
5
|
import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
|
|
8
6
|
import KpiCard from '../components/KpiCard'
|
|
9
|
-
|
|
10
|
-
const ROLE_CONFIG = {
|
|
11
|
-
user: { icon: User, label: 'User', borderColor: 'rgba(34,197,94,0.2)', bg: 'rgba(34,197,94,0.05)' },
|
|
12
|
-
assistant: { icon: Bot, label: 'Assistant', borderColor: 'rgba(99,102,241,0.2)', bg: 'rgba(99,102,241,0.05)' },
|
|
13
|
-
system: { icon: Settings, label: 'System', borderColor: 'rgba(107,114,128,0.2)', bg: 'rgba(107,114,128,0.05)' },
|
|
14
|
-
tool: { icon: Wrench, label: 'Tool', borderColor: 'rgba(234,179,8,0.2)', bg: 'rgba(234,179,8,0.05)' },
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// Parse message content into segments: text, tool-call, tool-result
|
|
18
|
-
function parseContent(content) {
|
|
19
|
-
const segments = []
|
|
20
|
-
const regex = /\[tool-call: ([^\]]+)\]|\[tool-result: ([^\]]+)\]\s*(.*?)(?=\n\[tool-|$)/gs
|
|
21
|
-
let lastIdx = 0
|
|
22
|
-
let match
|
|
23
|
-
while ((match = regex.exec(content)) !== null) {
|
|
24
|
-
if (match.index > lastIdx) {
|
|
25
|
-
const text = content.slice(lastIdx, match.index).trim()
|
|
26
|
-
if (text) segments.push({ type: 'text', value: text })
|
|
27
|
-
}
|
|
28
|
-
if (match[1]) {
|
|
29
|
-
segments.push({ type: 'tool-call', name: match[1].replace(/\(.*\)$/, '').trim(), args: match[1] })
|
|
30
|
-
} else if (match[2]) {
|
|
31
|
-
segments.push({ type: 'tool-result', name: match[2].trim(), preview: (match[3] || '').trim() })
|
|
32
|
-
}
|
|
33
|
-
lastIdx = match.index + match[0].length
|
|
34
|
-
}
|
|
35
|
-
if (lastIdx < content.length) {
|
|
36
|
-
const text = content.slice(lastIdx).trim()
|
|
37
|
-
if (text) segments.push({ type: 'text', value: text })
|
|
38
|
-
}
|
|
39
|
-
return segments.length > 0 ? segments : [{ type: 'text', value: content }]
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function ToolArgsDiff({ args }) {
|
|
43
|
-
const old = args.old_string || args.old_text || args.oldText || args.search || null
|
|
44
|
-
const nw = args.new_string || args.new_text || args.newText || args.replace || null
|
|
45
|
-
if (old == null && nw == null) return null
|
|
46
|
-
const maxLines = 12
|
|
47
|
-
const oldLines = (old || '').split('\n').slice(0, maxLines)
|
|
48
|
-
const newLines = (nw || '').split('\n').slice(0, maxLines)
|
|
49
|
-
return (
|
|
50
|
-
<div className="mt-1.5 text-[9px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
|
|
51
|
-
{(args.file_path || args.TargetFile) && (
|
|
52
|
-
<div className="px-2 py-0.5" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text)' }}>{args.file_path || args.TargetFile}</div>
|
|
53
|
-
)}
|
|
54
|
-
{old && oldLines.map((line, i) => (
|
|
55
|
-
<div key={'o' + i} className="px-2" style={{ background: 'rgba(248,113,113,0.1)', color: '#f87171' }}>
|
|
56
|
-
<span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>- </span>{line}
|
|
57
|
-
</div>
|
|
58
|
-
))}
|
|
59
|
-
{old && oldLines.length < (old || '').split('\n').length && (
|
|
60
|
-
<div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(old || '').split('\n').length - maxLines} more lines</div>
|
|
61
|
-
)}
|
|
62
|
-
{nw && newLines.map((line, i) => (
|
|
63
|
-
<div key={'n' + i} className="px-2" style={{ background: 'rgba(52,211,153,0.1)', color: '#34d399' }}>
|
|
64
|
-
<span style={{ color: 'var(--c-text3)', userSelect: 'none' }}>+ </span>{line}
|
|
65
|
-
</div>
|
|
66
|
-
))}
|
|
67
|
-
{nw && newLines.length < (nw || '').split('\n').length && (
|
|
68
|
-
<div className="px-2" style={{ color: 'var(--c-text3)' }}> ... {(nw || '').split('\n').length - maxLines} more lines</div>
|
|
69
|
-
)}
|
|
70
|
-
</div>
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function ToolArgsDetail({ args }) {
|
|
75
|
-
if (!args || Object.keys(args).length === 0) return null
|
|
76
|
-
const hasDiff = args.old_string || args.new_string || args.old_text || args.new_text || args.search || args.replace
|
|
77
|
-
if (hasDiff) return <ToolArgsDiff args={args} />
|
|
78
|
-
// Summarize key args
|
|
79
|
-
const file = args.file_path || args.TargetFile || args.filePath || args.path || null
|
|
80
|
-
const cmd = args.CommandLine || args.command || null
|
|
81
|
-
const query = args.Query || args.query || args.search_term || null
|
|
82
|
-
const url = args.Url || args.url || null
|
|
83
|
-
return (
|
|
84
|
-
<div className="mt-1.5 text-[9px] font-mono overflow-x-auto" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
85
|
-
{file && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>file: {file}</div>}
|
|
86
|
-
{cmd && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>cmd: {cmd}</div>}
|
|
87
|
-
{query && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>query: {query}</div>}
|
|
88
|
-
{url && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>url: {url}</div>}
|
|
89
|
-
{!file && !cmd && !query && !url && (
|
|
90
|
-
<pre className="px-2 py-1 whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{JSON.stringify(args, null, 2)}</pre>
|
|
91
|
-
)}
|
|
92
|
-
</div>
|
|
93
|
-
)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function ToolCallBlock({ name, args, detail }) {
|
|
97
|
-
const [open, setOpen] = useState(false)
|
|
98
|
-
const hasDetail = detail && Object.keys(detail).length > 0
|
|
99
|
-
return (
|
|
100
|
-
<div className="my-1 px-2.5 py-1.5 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
101
|
-
<div className="flex items-center gap-2 cursor-pointer" onClick={() => hasDetail && setOpen(!open)}>
|
|
102
|
-
{hasDetail
|
|
103
|
-
? (open ? <ChevronDown size={10} style={{ color: '#a78bfa' }} /> : <ChevronRight size={10} style={{ color: '#a78bfa' }} />)
|
|
104
|
-
: <Play size={10} style={{ color: '#a78bfa' }} />
|
|
105
|
-
}
|
|
106
|
-
<span className="font-bold" style={{ color: 'var(--c-white)' }}>{name}</span>
|
|
107
|
-
{args !== name && !hasDetail && <span className="truncate" style={{ color: 'var(--c-text2)' }}>{args}</span>}
|
|
108
|
-
{hasDetail && <span style={{ color: 'var(--c-text2)' }}>{summarizeToolArgs(name, detail)}</span>}
|
|
109
|
-
</div>
|
|
110
|
-
{open && hasDetail && <ToolArgsDetail args={detail} />}
|
|
111
|
-
</div>
|
|
112
|
-
)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function summarizeToolArgs(name, args) {
|
|
116
|
-
if (!args || typeof args !== 'object') return ''
|
|
117
|
-
if (args.file_path || args.TargetFile) return args.file_path || args.TargetFile
|
|
118
|
-
if (args.CommandLine || args.command) return args.CommandLine || args.command
|
|
119
|
-
if (args.Query || args.query) return `${args.Query || args.query}${args.SearchPath ? ` in ${args.SearchPath}` : ''}`
|
|
120
|
-
if (args.Url || args.url) return args.Url || args.url
|
|
121
|
-
const vals = Object.values(args).filter(v => typeof v === 'string' && v.length > 0 && v.length < 120)
|
|
122
|
-
return vals.length > 0 ? vals[0] : ''
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function ToolResultBlock({ name, preview }) {
|
|
126
|
-
const [open, setOpen] = useState(false)
|
|
127
|
-
const isNoisy = preview.length > 120 || preview.startsWith('{') || preview.includes('contentId')
|
|
128
|
-
const short = isNoisy ? `${name} completed` : preview.substring(0, 120)
|
|
129
|
-
return (
|
|
130
|
-
<div className="my-1 px-2.5 py-1.5 text-[10px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
131
|
-
<div className="flex items-center gap-2 cursor-pointer" onClick={() => preview && setOpen(!open)}>
|
|
132
|
-
<CheckCircle size={10} style={{ color: '#34d399' }} />
|
|
133
|
-
<span style={{ color: 'var(--c-text)' }}>{short}</span>
|
|
134
|
-
{isNoisy && preview && <span style={{ color: 'var(--c-text3)' }}>{open ? '[-]' : '[+]'}</span>}
|
|
135
|
-
</div>
|
|
136
|
-
{open && <pre className="mt-1 text-[9px] overflow-x-auto whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{preview}</pre>}
|
|
137
|
-
</div>
|
|
138
|
-
)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function MessageContent({ content, toolCallDetails }) {
|
|
142
|
-
const segments = parseContent(content)
|
|
143
|
-
let toolIdx = 0
|
|
144
|
-
return segments.map((seg, i) => {
|
|
145
|
-
if (seg.type === 'tool-call') {
|
|
146
|
-
const detail = toolCallDetails ? toolCallDetails.find(tc => tc.name === seg.name && toolCallDetails.indexOf(tc) >= toolIdx) : null
|
|
147
|
-
if (detail) toolIdx = toolCallDetails.indexOf(detail) + 1
|
|
148
|
-
return <ToolCallBlock key={i} name={seg.name} args={seg.args} detail={detail?.args} />
|
|
149
|
-
}
|
|
150
|
-
if (seg.type === 'tool-result') return <ToolResultBlock key={i} name={seg.name} preview={seg.preview} />
|
|
151
|
-
return <div key={i} className="md-body"><ReactMarkdown remarkPlugins={[remarkGfm]}>{seg.value}</ReactMarkdown></div>
|
|
152
|
-
})
|
|
153
|
-
}
|
|
7
|
+
import { MessageBubble } from '../components/MessageRenderer'
|
|
154
8
|
|
|
155
9
|
export default function ChatDetail() {
|
|
156
10
|
const { id } = useParams()
|
|
@@ -241,22 +95,9 @@ export default function ChatDetail() {
|
|
|
241
95
|
{chat.encrypted ? '๐ This conversation is encrypted.' : 'No messages found.'}
|
|
242
96
|
</div>
|
|
243
97
|
)}
|
|
244
|
-
{chat.messages.map((msg, i) =>
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
return (
|
|
248
|
-
<div key={i} className="rounded-r-lg px-4 py-3" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
|
|
249
|
-
<div className="flex items-center gap-2 text-xs mb-2" style={{ color: 'var(--c-text2)' }}>
|
|
250
|
-
<Icon size={13} />
|
|
251
|
-
<span className="font-medium">{cfg.label}</span>
|
|
252
|
-
{msg.model && <span className="font-mono" style={{ color: 'var(--c-accent)', opacity: 0.6 }}>ยท {msg.model}</span>}
|
|
253
|
-
</div>
|
|
254
|
-
<div className="text-sm" style={{ color: 'var(--c-text)' }}>
|
|
255
|
-
<MessageContent content={msg.content} toolCallDetails={chat.toolCallDetails} />
|
|
256
|
-
</div>
|
|
257
|
-
</div>
|
|
258
|
-
)
|
|
259
|
-
})}
|
|
98
|
+
{chat.messages.map((msg, i) => (
|
|
99
|
+
<MessageBubble key={i} msg={msg} toolCallDetails={chat.toolCallDetails} />
|
|
100
|
+
))}
|
|
260
101
|
</div>
|
|
261
102
|
</div>
|
|
262
103
|
)
|
|
@@ -9,6 +9,7 @@ import DateRangePicker from '../components/DateRangePicker'
|
|
|
9
9
|
import { editorColor, editorLabel, formatNumber, dateRangeToApiParams } from '../lib/constants'
|
|
10
10
|
import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage } from '../lib/api'
|
|
11
11
|
import { useTheme } from '../lib/theme'
|
|
12
|
+
import SectionTitle from '../components/SectionTitle'
|
|
12
13
|
|
|
13
14
|
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler)
|
|
14
15
|
|
|
@@ -20,10 +21,6 @@ const MODE_COLORS = {
|
|
|
20
21
|
copilot: '#f59e0b', thread: '#ec4899', opencode: '#f43f5e', claude: '#f97316',
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
function SectionTitle({ children }) {
|
|
24
|
-
return <h3 className="text-[10px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>{children}</h3>
|
|
25
|
-
}
|
|
26
|
-
|
|
27
24
|
export default function Dashboard({ overview }) {
|
|
28
25
|
const navigate = useNavigate()
|
|
29
26
|
const [dailyData, setDailyData] = useState(null)
|