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/relay-server.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const Database = require('better-sqlite3');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
const CACHE_DIR = path.join(os.homedir(), '.agentlytics');
|
|
9
|
+
const RELAY_DB_PATH = path.join(CACHE_DIR, 'relay.db');
|
|
10
|
+
|
|
11
|
+
let db = null;
|
|
12
|
+
|
|
13
|
+
// ============================================================
|
|
14
|
+
// Schema
|
|
15
|
+
// ============================================================
|
|
16
|
+
|
|
17
|
+
function initRelayDb() {
|
|
18
|
+
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
19
|
+
db = new Database(RELAY_DB_PATH);
|
|
20
|
+
db.pragma('journal_mode = WAL');
|
|
21
|
+
db.pragma('synchronous = NORMAL');
|
|
22
|
+
|
|
23
|
+
db.exec(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
25
|
+
username TEXT PRIMARY KEY,
|
|
26
|
+
last_seen INTEGER,
|
|
27
|
+
projects TEXT DEFAULT '[]'
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS relay_chats (
|
|
31
|
+
id TEXT NOT NULL,
|
|
32
|
+
username TEXT NOT NULL,
|
|
33
|
+
source TEXT,
|
|
34
|
+
name TEXT,
|
|
35
|
+
mode TEXT,
|
|
36
|
+
folder TEXT,
|
|
37
|
+
created_at INTEGER,
|
|
38
|
+
last_updated_at INTEGER,
|
|
39
|
+
bubble_count INTEGER DEFAULT 0,
|
|
40
|
+
PRIMARY KEY (id, username)
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS relay_messages (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
chat_id TEXT NOT NULL,
|
|
46
|
+
username TEXT NOT NULL,
|
|
47
|
+
seq INTEGER,
|
|
48
|
+
role TEXT,
|
|
49
|
+
content TEXT,
|
|
50
|
+
model TEXT
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS relay_chat_stats (
|
|
54
|
+
chat_id TEXT NOT NULL,
|
|
55
|
+
username TEXT NOT NULL,
|
|
56
|
+
total_messages INTEGER DEFAULT 0,
|
|
57
|
+
user_messages INTEGER DEFAULT 0,
|
|
58
|
+
assistant_messages INTEGER DEFAULT 0,
|
|
59
|
+
tool_calls TEXT DEFAULT '[]',
|
|
60
|
+
models TEXT DEFAULT '[]',
|
|
61
|
+
total_input_tokens INTEGER DEFAULT 0,
|
|
62
|
+
total_output_tokens INTEGER DEFAULT 0,
|
|
63
|
+
PRIMARY KEY (chat_id, username)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_relay_chats_username ON relay_chats(username);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_relay_chats_folder ON relay_chats(folder);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_relay_messages_chat ON relay_messages(chat_id, username);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_relay_messages_content ON relay_messages(content);
|
|
70
|
+
`);
|
|
71
|
+
|
|
72
|
+
return db;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getRelayDb() {
|
|
76
|
+
return db;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================
|
|
80
|
+
// Express app
|
|
81
|
+
// ============================================================
|
|
82
|
+
|
|
83
|
+
function createRelayApp() {
|
|
84
|
+
const app = express();
|
|
85
|
+
app.use(express.json({ limit: '50mb' }));
|
|
86
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
87
|
+
|
|
88
|
+
// ── Password-based auth ──
|
|
89
|
+
const RELAY_PASSWORD = process.env.RELAY_PASSWORD || null;
|
|
90
|
+
const AUTH_TOKEN = RELAY_PASSWORD
|
|
91
|
+
? crypto.createHmac('sha256', 'agentlytics-relay').update(RELAY_PASSWORD).digest('hex')
|
|
92
|
+
: null;
|
|
93
|
+
|
|
94
|
+
function requireAuth(req, res, next) {
|
|
95
|
+
if (!AUTH_TOKEN) return next();
|
|
96
|
+
const header = req.headers.authorization || '';
|
|
97
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
|
98
|
+
if (token === AUTH_TOKEN) return next();
|
|
99
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Login endpoint ──
|
|
103
|
+
app.post('/api/login', (req, res) => {
|
|
104
|
+
if (!AUTH_TOKEN) return res.json({ token: null });
|
|
105
|
+
const { password } = req.body || {};
|
|
106
|
+
if (!password) return res.status(400).json({ error: 'Password required' });
|
|
107
|
+
const attempt = crypto.createHmac('sha256', 'agentlytics-relay').update(password).digest('hex');
|
|
108
|
+
if (attempt === AUTH_TOKEN) return res.json({ token: AUTH_TOKEN });
|
|
109
|
+
res.status(401).json({ error: 'Invalid password' });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── Mode detection for UI ──
|
|
113
|
+
app.get('/api/mode', (req, res) => {
|
|
114
|
+
res.json({ mode: 'relay', auth: !!AUTH_TOKEN });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── Config for UI ──
|
|
118
|
+
app.get('/relay/config', requireAuth, (req, res) => {
|
|
119
|
+
res.json({ relayPassword: RELAY_PASSWORD || '' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── Team stats (aggregate across all users) ──
|
|
123
|
+
app.get('/relay/team-stats', requireAuth, (req, res) => {
|
|
124
|
+
try {
|
|
125
|
+
const users = db.prepare('SELECT username, last_seen, projects FROM users ORDER BY last_seen DESC').all();
|
|
126
|
+
const totalUsers = users.length;
|
|
127
|
+
|
|
128
|
+
const chatStats = db.prepare(`
|
|
129
|
+
SELECT COUNT(*) as totalSessions,
|
|
130
|
+
COUNT(DISTINCT rc.username) as activeUsers,
|
|
131
|
+
COUNT(DISTINCT rc.folder) as totalProjects
|
|
132
|
+
FROM relay_chats rc
|
|
133
|
+
`).get();
|
|
134
|
+
|
|
135
|
+
const editorBreakdown = db.prepare(`
|
|
136
|
+
SELECT source, COUNT(*) as count, COUNT(DISTINCT username) as users
|
|
137
|
+
FROM relay_chats WHERE source IS NOT NULL
|
|
138
|
+
GROUP BY source ORDER BY count DESC
|
|
139
|
+
`).all();
|
|
140
|
+
|
|
141
|
+
const perUser = db.prepare(`
|
|
142
|
+
SELECT rc.username,
|
|
143
|
+
COUNT(*) as sessions,
|
|
144
|
+
COUNT(DISTINCT rc.source) as editors,
|
|
145
|
+
COUNT(DISTINCT rc.folder) as projects,
|
|
146
|
+
MAX(rc.last_updated_at) as lastActive,
|
|
147
|
+
COALESCE(SUM(rcs.total_messages), 0) as totalMessages,
|
|
148
|
+
COALESCE(SUM(rcs.total_input_tokens), 0) as totalInputTokens,
|
|
149
|
+
COALESCE(SUM(rcs.total_output_tokens), 0) as totalOutputTokens
|
|
150
|
+
FROM relay_chats rc
|
|
151
|
+
LEFT JOIN relay_chat_stats rcs ON rc.id = rcs.chat_id AND rc.username = rcs.username
|
|
152
|
+
GROUP BY rc.username
|
|
153
|
+
ORDER BY sessions DESC
|
|
154
|
+
`).all();
|
|
155
|
+
|
|
156
|
+
const perUserEditors = db.prepare(`
|
|
157
|
+
SELECT username, source, COUNT(*) as count
|
|
158
|
+
FROM relay_chats WHERE source IS NOT NULL
|
|
159
|
+
GROUP BY username, source
|
|
160
|
+
`).all();
|
|
161
|
+
const userEditorMap = {};
|
|
162
|
+
for (const r of perUserEditors) {
|
|
163
|
+
if (!userEditorMap[r.username]) userEditorMap[r.username] = {};
|
|
164
|
+
userEditorMap[r.username][r.source] = r.count;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const perUserModels = db.prepare(`
|
|
168
|
+
SELECT rcs.username, rcs.models
|
|
169
|
+
FROM relay_chat_stats rcs
|
|
170
|
+
`).all();
|
|
171
|
+
const userModelMap = {};
|
|
172
|
+
for (const r of perUserModels) {
|
|
173
|
+
if (!userModelMap[r.username]) userModelMap[r.username] = {};
|
|
174
|
+
try {
|
|
175
|
+
for (const m of JSON.parse(r.models || '[]')) {
|
|
176
|
+
userModelMap[r.username][m] = (userModelMap[r.username][m] || 0) + 1;
|
|
177
|
+
}
|
|
178
|
+
} catch {}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const totalTokens = db.prepare(`
|
|
182
|
+
SELECT COALESCE(SUM(total_messages), 0) as messages,
|
|
183
|
+
COALESCE(SUM(total_input_tokens), 0) as inputTokens,
|
|
184
|
+
COALESCE(SUM(total_output_tokens), 0) as outputTokens
|
|
185
|
+
FROM relay_chat_stats
|
|
186
|
+
`).get();
|
|
187
|
+
|
|
188
|
+
const modelBreakdown = db.prepare('SELECT models FROM relay_chat_stats').all();
|
|
189
|
+
const modelFreq = {};
|
|
190
|
+
for (const r of modelBreakdown) {
|
|
191
|
+
try { for (const m of JSON.parse(r.models || '[]')) modelFreq[m] = (modelFreq[m] || 0) + 1; } catch {}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
res.json({
|
|
195
|
+
totalUsers,
|
|
196
|
+
totalSessions: chatStats.totalSessions,
|
|
197
|
+
activeUsers: chatStats.activeUsers,
|
|
198
|
+
totalProjects: chatStats.totalProjects,
|
|
199
|
+
totalMessages: totalTokens.messages,
|
|
200
|
+
totalInputTokens: totalTokens.inputTokens,
|
|
201
|
+
totalOutputTokens: totalTokens.outputTokens,
|
|
202
|
+
editors: editorBreakdown.map(e => ({ source: e.source, count: e.count, users: e.users })),
|
|
203
|
+
topModels: Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 15).map(([name, count]) => ({ name, count })),
|
|
204
|
+
users: perUser.map(u => ({
|
|
205
|
+
username: u.username,
|
|
206
|
+
sessions: u.sessions,
|
|
207
|
+
editors: userEditorMap[u.username] || {},
|
|
208
|
+
projects: u.projects,
|
|
209
|
+
lastActive: u.lastActive,
|
|
210
|
+
totalMessages: u.totalMessages,
|
|
211
|
+
totalInputTokens: u.totalInputTokens,
|
|
212
|
+
totalOutputTokens: u.totalOutputTokens,
|
|
213
|
+
topModels: Object.entries(userModelMap[u.username] || {}).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([name, count]) => ({ name, count })),
|
|
214
|
+
sharedProjects: JSON.parse((users.find(x => x.username === u.username) || {}).projects || '[]'),
|
|
215
|
+
})),
|
|
216
|
+
});
|
|
217
|
+
} catch (err) {
|
|
218
|
+
res.status(500).json({ error: err.message });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── Health check ──
|
|
223
|
+
app.get('/relay/health', requireAuth, (req, res) => {
|
|
224
|
+
res.json({ ok: true, users: db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── List connected users ──
|
|
228
|
+
app.get('/relay/users', requireAuth, (req, res) => {
|
|
229
|
+
try {
|
|
230
|
+
const users = db.prepare('SELECT username, last_seen, projects FROM users ORDER BY last_seen DESC').all();
|
|
231
|
+
res.json(users.map(u => ({
|
|
232
|
+
username: u.username,
|
|
233
|
+
lastSeen: u.last_seen,
|
|
234
|
+
projects: JSON.parse(u.projects || '[]'),
|
|
235
|
+
})));
|
|
236
|
+
} catch (err) {
|
|
237
|
+
res.status(500).json({ error: err.message });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── Sync endpoint — receives data from join clients ──
|
|
242
|
+
app.post('/relay/sync', requireAuth, (req, res) => {
|
|
243
|
+
try {
|
|
244
|
+
const { username, projects, chats, messages, stats } = req.body;
|
|
245
|
+
if (!username) return res.status(400).json({ error: 'username required' });
|
|
246
|
+
|
|
247
|
+
// Upsert user
|
|
248
|
+
db.prepare(`
|
|
249
|
+
INSERT INTO users (username, last_seen, projects)
|
|
250
|
+
VALUES (?, ?, ?)
|
|
251
|
+
ON CONFLICT(username) DO UPDATE SET last_seen = excluded.last_seen, projects = excluded.projects
|
|
252
|
+
`).run(username, Date.now(), JSON.stringify(projects || []));
|
|
253
|
+
|
|
254
|
+
let syncedChats = 0;
|
|
255
|
+
let syncedMessages = 0;
|
|
256
|
+
let syncedStats = 0;
|
|
257
|
+
|
|
258
|
+
// Upsert chats
|
|
259
|
+
if (chats && chats.length > 0) {
|
|
260
|
+
const insChat = db.prepare(`
|
|
261
|
+
INSERT INTO relay_chats (id, username, source, name, mode, folder, created_at, last_updated_at, bubble_count)
|
|
262
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
263
|
+
ON CONFLICT(id, username) DO UPDATE SET
|
|
264
|
+
name = excluded.name, mode = excluded.mode, folder = excluded.folder,
|
|
265
|
+
last_updated_at = excluded.last_updated_at, bubble_count = excluded.bubble_count
|
|
266
|
+
`);
|
|
267
|
+
const insertChats = db.transaction((chatList) => {
|
|
268
|
+
for (const c of chatList) {
|
|
269
|
+
insChat.run(c.id, username, c.source, c.name, c.mode, c.folder, c.created_at, c.last_updated_at, c.bubble_count || 0);
|
|
270
|
+
syncedChats++;
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
insertChats(chats);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Upsert messages (delete + reinsert per chat)
|
|
277
|
+
if (messages && messages.length > 0) {
|
|
278
|
+
const chatIds = [...new Set(messages.map(m => m.chat_id))];
|
|
279
|
+
const delMsgs = db.prepare('DELETE FROM relay_messages WHERE chat_id = ? AND username = ?');
|
|
280
|
+
const insMsg = db.prepare('INSERT INTO relay_messages (chat_id, username, seq, role, content, model) VALUES (?, ?, ?, ?, ?, ?)');
|
|
281
|
+
const insertMessages = db.transaction((msgList) => {
|
|
282
|
+
for (const cid of chatIds) {
|
|
283
|
+
delMsgs.run(cid, username);
|
|
284
|
+
}
|
|
285
|
+
for (const m of msgList) {
|
|
286
|
+
insMsg.run(m.chat_id, username, m.seq, m.role, m.content, m.model);
|
|
287
|
+
syncedMessages++;
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
insertMessages(messages);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Upsert stats
|
|
294
|
+
if (stats && stats.length > 0) {
|
|
295
|
+
const insStat = db.prepare(`
|
|
296
|
+
INSERT INTO relay_chat_stats (chat_id, username, total_messages, user_messages, assistant_messages, tool_calls, models, total_input_tokens, total_output_tokens)
|
|
297
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
298
|
+
ON CONFLICT(chat_id, username) DO UPDATE SET
|
|
299
|
+
total_messages = excluded.total_messages, user_messages = excluded.user_messages,
|
|
300
|
+
assistant_messages = excluded.assistant_messages, tool_calls = excluded.tool_calls,
|
|
301
|
+
models = excluded.models, total_input_tokens = excluded.total_input_tokens,
|
|
302
|
+
total_output_tokens = excluded.total_output_tokens
|
|
303
|
+
`);
|
|
304
|
+
const insertStats = db.transaction((statList) => {
|
|
305
|
+
for (const s of statList) {
|
|
306
|
+
insStat.run(s.chat_id, username, s.total_messages, s.user_messages, s.assistant_messages,
|
|
307
|
+
JSON.stringify(s.tool_calls || []), JSON.stringify(s.models || []),
|
|
308
|
+
s.total_input_tokens, s.total_output_tokens);
|
|
309
|
+
syncedStats++;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
insertStats(stats);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
res.json({ ok: true, synced: { chats: syncedChats, messages: syncedMessages, stats: syncedStats } });
|
|
316
|
+
} catch (err) {
|
|
317
|
+
res.status(500).json({ error: err.message });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ── Search messages across all users ──
|
|
322
|
+
app.get('/relay/search', requireAuth, (req, res) => {
|
|
323
|
+
try {
|
|
324
|
+
const { q, username, folder, limit } = req.query;
|
|
325
|
+
if (!q) return res.status(400).json({ error: 'q (query) required' });
|
|
326
|
+
|
|
327
|
+
let sql = `
|
|
328
|
+
SELECT rm.chat_id, rm.username, rm.role, rm.content, rm.model, rm.seq,
|
|
329
|
+
rc.name as chat_name, rc.source, rc.folder
|
|
330
|
+
FROM relay_messages rm
|
|
331
|
+
JOIN relay_chats rc ON rm.chat_id = rc.id AND rm.username = rc.username
|
|
332
|
+
WHERE rm.content LIKE ?`;
|
|
333
|
+
const params = [`%${q}%`];
|
|
334
|
+
|
|
335
|
+
if (username) { sql += ' AND rm.username = ?'; params.push(username); }
|
|
336
|
+
if (folder) { sql += ' AND rc.folder LIKE ?'; params.push(`%${folder}%`); }
|
|
337
|
+
sql += ' ORDER BY rc.last_updated_at DESC LIMIT ?';
|
|
338
|
+
params.push(parseInt(limit) || 50);
|
|
339
|
+
|
|
340
|
+
const rows = db.prepare(sql).all(...params);
|
|
341
|
+
res.json(rows.map(r => ({
|
|
342
|
+
chatId: r.chat_id,
|
|
343
|
+
username: r.username,
|
|
344
|
+
role: r.role,
|
|
345
|
+
content: r.content.length > 500 ? r.content.substring(0, 500) + '...' : r.content,
|
|
346
|
+
model: r.model,
|
|
347
|
+
seq: r.seq,
|
|
348
|
+
chatName: r.chat_name,
|
|
349
|
+
source: r.source,
|
|
350
|
+
folder: r.folder,
|
|
351
|
+
})));
|
|
352
|
+
} catch (err) {
|
|
353
|
+
res.status(500).json({ error: err.message });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ── Get user activity ──
|
|
358
|
+
app.get('/relay/activity/:username', requireAuth, (req, res) => {
|
|
359
|
+
try {
|
|
360
|
+
const { username } = req.params;
|
|
361
|
+
const { folder, limit } = req.query;
|
|
362
|
+
|
|
363
|
+
let sql = `
|
|
364
|
+
SELECT rc.*, rcs.total_messages, rcs.models, rcs.tool_calls,
|
|
365
|
+
rcs.total_input_tokens, rcs.total_output_tokens
|
|
366
|
+
FROM relay_chats rc
|
|
367
|
+
LEFT JOIN relay_chat_stats rcs ON rc.id = rcs.chat_id AND rc.username = rcs.username
|
|
368
|
+
WHERE rc.username = ?`;
|
|
369
|
+
const params = [username];
|
|
370
|
+
|
|
371
|
+
if (folder) { sql += ' AND rc.folder LIKE ?'; params.push(`%${folder}%`); }
|
|
372
|
+
sql += ' ORDER BY rc.last_updated_at DESC LIMIT ?';
|
|
373
|
+
params.push(parseInt(limit) || 50);
|
|
374
|
+
|
|
375
|
+
const rows = db.prepare(sql).all(...params);
|
|
376
|
+
res.json(rows.map(r => ({
|
|
377
|
+
id: r.id,
|
|
378
|
+
username: r.username,
|
|
379
|
+
source: r.source,
|
|
380
|
+
name: r.name,
|
|
381
|
+
mode: r.mode,
|
|
382
|
+
folder: r.folder,
|
|
383
|
+
createdAt: r.created_at,
|
|
384
|
+
lastUpdatedAt: r.last_updated_at,
|
|
385
|
+
totalMessages: r.total_messages,
|
|
386
|
+
models: r.models ? JSON.parse(r.models) : [],
|
|
387
|
+
toolCalls: r.tool_calls ? JSON.parse(r.tool_calls) : [],
|
|
388
|
+
totalInputTokens: r.total_input_tokens,
|
|
389
|
+
totalOutputTokens: r.total_output_tokens,
|
|
390
|
+
})));
|
|
391
|
+
} catch (err) {
|
|
392
|
+
res.status(500).json({ error: err.message });
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ── Get session detail ──
|
|
397
|
+
app.get('/relay/session/:chatId', requireAuth, (req, res) => {
|
|
398
|
+
try {
|
|
399
|
+
const { chatId } = req.params;
|
|
400
|
+
const { username } = req.query;
|
|
401
|
+
|
|
402
|
+
let chatSql = 'SELECT * FROM relay_chats WHERE id = ?';
|
|
403
|
+
const chatParams = [chatId];
|
|
404
|
+
if (username) { chatSql += ' AND username = ?'; chatParams.push(username); }
|
|
405
|
+
chatSql += ' LIMIT 1';
|
|
406
|
+
|
|
407
|
+
const chat = db.prepare(chatSql).get(...chatParams);
|
|
408
|
+
if (!chat) return res.status(404).json({ error: 'Session not found' });
|
|
409
|
+
|
|
410
|
+
const messages = db.prepare(
|
|
411
|
+
'SELECT seq, role, content, model FROM relay_messages WHERE chat_id = ? AND username = ? ORDER BY seq'
|
|
412
|
+
).all(chat.id, chat.username);
|
|
413
|
+
|
|
414
|
+
const stats = db.prepare(
|
|
415
|
+
'SELECT * FROM relay_chat_stats WHERE chat_id = ? AND username = ?'
|
|
416
|
+
).get(chat.id, chat.username);
|
|
417
|
+
|
|
418
|
+
res.json({
|
|
419
|
+
id: chat.id,
|
|
420
|
+
username: chat.username,
|
|
421
|
+
source: chat.source,
|
|
422
|
+
name: chat.name,
|
|
423
|
+
mode: chat.mode,
|
|
424
|
+
folder: chat.folder,
|
|
425
|
+
createdAt: chat.created_at,
|
|
426
|
+
lastUpdatedAt: chat.last_updated_at,
|
|
427
|
+
messages,
|
|
428
|
+
stats: stats ? {
|
|
429
|
+
totalMessages: stats.total_messages,
|
|
430
|
+
models: JSON.parse(stats.models || '[]'),
|
|
431
|
+
toolCalls: JSON.parse(stats.tool_calls || '[]'),
|
|
432
|
+
totalInputTokens: stats.total_input_tokens,
|
|
433
|
+
totalOutputTokens: stats.total_output_tokens,
|
|
434
|
+
} : null,
|
|
435
|
+
});
|
|
436
|
+
} catch (err) {
|
|
437
|
+
res.status(500).json({ error: err.message });
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// ── Live feed — recent activity timeline ──
|
|
442
|
+
app.get('/relay/feed', requireAuth, (req, res) => {
|
|
443
|
+
try {
|
|
444
|
+
const limit = parseInt(req.query.limit) || 60;
|
|
445
|
+
const since = parseInt(req.query.since) || 0;
|
|
446
|
+
|
|
447
|
+
let sql = `
|
|
448
|
+
SELECT rc.id, rc.username, rc.source, rc.name, rc.mode, rc.folder,
|
|
449
|
+
rc.last_updated_at, rc.created_at,
|
|
450
|
+
rcs.total_messages, rcs.models, rcs.total_input_tokens, rcs.total_output_tokens
|
|
451
|
+
FROM relay_chats rc
|
|
452
|
+
LEFT JOIN relay_chat_stats rcs ON rc.id = rcs.chat_id AND rc.username = rcs.username
|
|
453
|
+
WHERE rc.last_updated_at > ?
|
|
454
|
+
ORDER BY rc.last_updated_at DESC
|
|
455
|
+
LIMIT ?
|
|
456
|
+
`;
|
|
457
|
+
const rows = db.prepare(sql).all(since, limit);
|
|
458
|
+
|
|
459
|
+
res.json(rows.map(r => ({
|
|
460
|
+
id: r.id,
|
|
461
|
+
username: r.username,
|
|
462
|
+
source: r.source,
|
|
463
|
+
name: r.name,
|
|
464
|
+
mode: r.mode,
|
|
465
|
+
folder: r.folder,
|
|
466
|
+
lastUpdatedAt: r.last_updated_at,
|
|
467
|
+
createdAt: r.created_at,
|
|
468
|
+
totalMessages: r.total_messages || 0,
|
|
469
|
+
models: r.models ? JSON.parse(r.models) : [],
|
|
470
|
+
totalInputTokens: r.total_input_tokens || 0,
|
|
471
|
+
totalOutputTokens: r.total_output_tokens || 0,
|
|
472
|
+
})));
|
|
473
|
+
} catch (err) {
|
|
474
|
+
res.status(500).json({ error: err.message });
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// ── Merge users ──────────────────────────────────────────────
|
|
479
|
+
app.post('/relay/merge-users', requireAuth, (req, res) => {
|
|
480
|
+
try {
|
|
481
|
+
const { from, to } = req.body;
|
|
482
|
+
if (!from || !to) return res.status(400).json({ error: 'Both "from" and "to" usernames are required.' });
|
|
483
|
+
if (from === to) return res.status(400).json({ error: '"from" and "to" cannot be the same.' });
|
|
484
|
+
|
|
485
|
+
const result = db.transaction(() => {
|
|
486
|
+
// 1. Find conflicting chat IDs (exist for both users)
|
|
487
|
+
const conflicts = db.prepare(`
|
|
488
|
+
SELECT a.id FROM relay_chats a
|
|
489
|
+
INNER JOIN relay_chats b ON a.id = b.id
|
|
490
|
+
WHERE a.username = ? AND b.username = ?
|
|
491
|
+
`).all(from, to).map(r => r.id);
|
|
492
|
+
|
|
493
|
+
// 2. For conflicting chats: keep the "to" user's row, delete the "from" row
|
|
494
|
+
if (conflicts.length > 0) {
|
|
495
|
+
const placeholders = conflicts.map(() => '?').join(',');
|
|
496
|
+
|
|
497
|
+
// Delete conflicting messages from "from" user
|
|
498
|
+
db.prepare(`DELETE FROM relay_messages WHERE username = ? AND chat_id IN (${placeholders})`).run(from, ...conflicts);
|
|
499
|
+
|
|
500
|
+
// Delete conflicting chat stats from "from" user
|
|
501
|
+
db.prepare(`DELETE FROM relay_chat_stats WHERE username = ? AND chat_id IN (${placeholders})`).run(from, ...conflicts);
|
|
502
|
+
|
|
503
|
+
// Delete conflicting chats from "from" user
|
|
504
|
+
db.prepare(`DELETE FROM relay_chats WHERE username = ? AND id IN (${placeholders})`).run(from, ...conflicts);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 3. Move remaining non-conflicting data from "from" → "to"
|
|
508
|
+
const movedChats = db.prepare(`UPDATE relay_chats SET username = ? WHERE username = ?`).run(to, from).changes;
|
|
509
|
+
const movedMessages = db.prepare(`UPDATE relay_messages SET username = ? WHERE username = ?`).run(to, from).changes;
|
|
510
|
+
const movedStats = db.prepare(`UPDATE relay_chat_stats SET username = ? WHERE username = ?`).run(to, from).changes;
|
|
511
|
+
|
|
512
|
+
// 4. Merge user record: update projects, remove old user
|
|
513
|
+
const fromUser = db.prepare(`SELECT projects FROM users WHERE username = ?`).get(from);
|
|
514
|
+
const toUser = db.prepare(`SELECT projects FROM users WHERE username = ?`).get(to);
|
|
515
|
+
|
|
516
|
+
if (fromUser && toUser) {
|
|
517
|
+
const fromProjects = JSON.parse(fromUser.projects || '[]');
|
|
518
|
+
const toProjects = JSON.parse(toUser.projects || '[]');
|
|
519
|
+
const merged = [...new Set([...toProjects, ...fromProjects])];
|
|
520
|
+
db.prepare(`UPDATE users SET projects = ? WHERE username = ?`).run(JSON.stringify(merged), to);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
db.prepare(`DELETE FROM users WHERE username = ?`).run(from);
|
|
524
|
+
|
|
525
|
+
return { movedChats, movedMessages, movedStats, conflicts: conflicts.length };
|
|
526
|
+
})();
|
|
527
|
+
|
|
528
|
+
res.json({
|
|
529
|
+
ok: true,
|
|
530
|
+
merged: { from, to },
|
|
531
|
+
moved: { chats: result.movedChats, messages: result.movedMessages, stats: result.movedStats },
|
|
532
|
+
duplicatesSkipped: result.conflicts,
|
|
533
|
+
});
|
|
534
|
+
} catch (err) {
|
|
535
|
+
res.status(500).json({ error: err.message });
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// SPA fallback
|
|
540
|
+
app.get('*', (req, res) => {
|
|
541
|
+
const index = path.join(__dirname, 'public', 'index.html');
|
|
542
|
+
if (fs.existsSync(index)) {
|
|
543
|
+
res.sendFile(index);
|
|
544
|
+
} else {
|
|
545
|
+
res.status(404).send('UI not built. Run: cd ui && npm install && npm run build');
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
return app;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
module.exports = { initRelayDb, getRelayDb, createRelayApp };
|
package/server.js
CHANGED
|
@@ -19,6 +19,10 @@ function parseDateOpts(query) {
|
|
|
19
19
|
return opts;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
app.get('/api/mode', (req, res) => {
|
|
23
|
+
res.json({ mode: 'local' });
|
|
24
|
+
});
|
|
25
|
+
|
|
22
26
|
app.get('/api/overview', (req, res) => {
|
|
23
27
|
try {
|
|
24
28
|
const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query) };
|