agentgate 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -2
- package/package.json +1 -1
- package/src/index.js +64 -2
- package/src/lib/agentNotifier.js +150 -0
- package/src/lib/db.js +185 -8
- package/src/routes/agents.js +185 -0
- package/src/routes/ui.js +663 -14
package/src/routes/ui.js
CHANGED
|
@@ -3,12 +3,15 @@ import {
|
|
|
3
3
|
listAccounts, getSetting, setSetting, deleteSetting,
|
|
4
4
|
setAdminPassword, verifyAdminPassword, hasAdminPassword,
|
|
5
5
|
listQueueEntries, getQueueEntry, updateQueueStatus, clearQueueByStatus, deleteQueueEntry, getPendingQueueCount, getQueueCounts,
|
|
6
|
-
listApiKeys, createApiKey, deleteApiKey,
|
|
7
|
-
listUnnotifiedEntries
|
|
6
|
+
listApiKeys, createApiKey, deleteApiKey, updateAgentWebhook, getApiKeyById,
|
|
7
|
+
listUnnotifiedEntries,
|
|
8
|
+
getMessagingMode, setMessagingMode, listPendingMessages, listAgentMessages,
|
|
9
|
+
approveAgentMessage, rejectAgentMessage, deleteAgentMessage, clearAgentMessagesByStatus, getMessageCounts, getAgentMessage
|
|
8
10
|
} from '../lib/db.js';
|
|
9
11
|
import { connectHsync, disconnectHsync, getHsyncUrl, isHsyncConnected } from '../lib/hsyncManager.js';
|
|
10
12
|
import { executeQueueEntry } from '../lib/queueExecutor.js';
|
|
11
13
|
import { notifyClawdbot, retryNotification } from '../lib/notifier.js';
|
|
14
|
+
import { notifyAgentMessage, notifyMessageRejected } from '../lib/agentNotifier.js';
|
|
12
15
|
import { registerAllRoutes, renderAllCards } from './ui/index.js';
|
|
13
16
|
|
|
14
17
|
const router = Router();
|
|
@@ -124,8 +127,10 @@ router.get('/', (req, res) => {
|
|
|
124
127
|
const hsyncConnected = isHsyncConnected();
|
|
125
128
|
const pendingQueueCount = getPendingQueueCount();
|
|
126
129
|
const notificationsConfig = getSetting('notifications');
|
|
130
|
+
const messagingMode = getMessagingMode();
|
|
131
|
+
const pendingMessagesCount = listPendingMessages().length;
|
|
127
132
|
|
|
128
|
-
res.send(renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig }));
|
|
133
|
+
res.send(renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig, messagingMode, pendingMessagesCount }));
|
|
129
134
|
});
|
|
130
135
|
|
|
131
136
|
// Register all service routes (github, bluesky, reddit, etc.)
|
|
@@ -225,6 +230,120 @@ router.post('/notifications/test', async (req, res) => {
|
|
|
225
230
|
}
|
|
226
231
|
});
|
|
227
232
|
|
|
233
|
+
// Agent Messaging settings
|
|
234
|
+
router.post('/messaging/mode', (req, res) => {
|
|
235
|
+
const { mode } = req.body;
|
|
236
|
+
try {
|
|
237
|
+
setMessagingMode(mode);
|
|
238
|
+
res.redirect('/ui');
|
|
239
|
+
} catch (err) {
|
|
240
|
+
res.status(400).send(err.message);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Agent Messages Queue
|
|
245
|
+
router.get('/messages', (req, res) => {
|
|
246
|
+
const filter = req.query.filter || 'all';
|
|
247
|
+
let messages;
|
|
248
|
+
if (filter === 'all') {
|
|
249
|
+
messages = listAgentMessages();
|
|
250
|
+
} else {
|
|
251
|
+
messages = listAgentMessages(filter);
|
|
252
|
+
}
|
|
253
|
+
const counts = getMessageCounts();
|
|
254
|
+
const mode = getMessagingMode();
|
|
255
|
+
res.send(renderMessagesPage(messages, filter, counts, mode));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
router.post('/messages/:id/approve', async (req, res) => {
|
|
259
|
+
const { id } = req.params;
|
|
260
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
261
|
+
|
|
262
|
+
const msg = getAgentMessage(id);
|
|
263
|
+
if (!msg) {
|
|
264
|
+
return wantsJson
|
|
265
|
+
? res.status(404).json({ error: 'Message not found' })
|
|
266
|
+
: res.status(404).send('Message not found');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (msg.status !== 'pending') {
|
|
270
|
+
return wantsJson
|
|
271
|
+
? res.status(400).json({ error: 'Can only approve pending messages' })
|
|
272
|
+
: res.status(400).send('Can only approve pending messages');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
approveAgentMessage(id);
|
|
276
|
+
const updated = getAgentMessage(id);
|
|
277
|
+
const counts = getMessageCounts();
|
|
278
|
+
|
|
279
|
+
// Try to notify the recipient agent
|
|
280
|
+
notifyAgentMessage(updated).catch(err => {
|
|
281
|
+
console.error('[agentNotifier] Failed to notify agent:', err.message);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
if (wantsJson) {
|
|
285
|
+
return res.json({ success: true, message: updated, counts });
|
|
286
|
+
}
|
|
287
|
+
res.redirect('/ui/messages');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
router.post('/messages/:id/reject', (req, res) => {
|
|
291
|
+
const { id } = req.params;
|
|
292
|
+
const { reason } = req.body;
|
|
293
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
294
|
+
|
|
295
|
+
const msg = getAgentMessage(id);
|
|
296
|
+
if (!msg) {
|
|
297
|
+
return wantsJson
|
|
298
|
+
? res.status(404).json({ error: 'Message not found' })
|
|
299
|
+
: res.status(404).send('Message not found');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (msg.status !== 'pending') {
|
|
303
|
+
return wantsJson
|
|
304
|
+
? res.status(400).json({ error: 'Can only reject pending messages' })
|
|
305
|
+
: res.status(400).send('Can only reject pending messages');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
rejectAgentMessage(id, reason);
|
|
309
|
+
const updated = getAgentMessage(id);
|
|
310
|
+
const counts = getMessageCounts();
|
|
311
|
+
|
|
312
|
+
// Notify sender that their message was rejected
|
|
313
|
+
notifyMessageRejected(updated);
|
|
314
|
+
|
|
315
|
+
if (wantsJson) {
|
|
316
|
+
return res.json({ success: true, message: updated, counts });
|
|
317
|
+
}
|
|
318
|
+
res.redirect('/ui/messages');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
router.post('/messages/:id/delete', (req, res) => {
|
|
322
|
+
const { id } = req.params;
|
|
323
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
324
|
+
|
|
325
|
+
deleteAgentMessage(id);
|
|
326
|
+
const counts = getMessageCounts();
|
|
327
|
+
|
|
328
|
+
if (wantsJson) {
|
|
329
|
+
return res.json({ success: true, counts });
|
|
330
|
+
}
|
|
331
|
+
res.redirect('/ui/messages');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
router.post('/messages/clear', (req, res) => {
|
|
335
|
+
const { status } = req.body;
|
|
336
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
337
|
+
|
|
338
|
+
clearAgentMessagesByStatus(status || 'all');
|
|
339
|
+
const counts = getMessageCounts();
|
|
340
|
+
|
|
341
|
+
if (wantsJson) {
|
|
342
|
+
return res.json({ success: true, counts });
|
|
343
|
+
}
|
|
344
|
+
res.redirect('/ui/messages');
|
|
345
|
+
});
|
|
346
|
+
|
|
228
347
|
// Write Queue Management
|
|
229
348
|
router.get('/queue', (req, res) => {
|
|
230
349
|
const filter = req.query.filter || 'all';
|
|
@@ -422,6 +541,27 @@ router.post('/keys/:id/delete', (req, res) => {
|
|
|
422
541
|
res.redirect('/ui/keys');
|
|
423
542
|
});
|
|
424
543
|
|
|
544
|
+
router.post('/keys/:id/webhook', (req, res) => {
|
|
545
|
+
const { id } = req.params;
|
|
546
|
+
const { webhook_url, webhook_token } = req.body;
|
|
547
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
548
|
+
|
|
549
|
+
const agent = getApiKeyById(id);
|
|
550
|
+
if (!agent) {
|
|
551
|
+
return wantsJson
|
|
552
|
+
? res.status(404).json({ error: 'Agent not found' })
|
|
553
|
+
: res.status(404).send('Agent not found');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
updateAgentWebhook(id, webhook_url, webhook_token);
|
|
557
|
+
const keys = listApiKeys();
|
|
558
|
+
|
|
559
|
+
if (wantsJson) {
|
|
560
|
+
return res.json({ success: true, keys });
|
|
561
|
+
}
|
|
562
|
+
res.redirect('/ui/keys');
|
|
563
|
+
});
|
|
564
|
+
|
|
425
565
|
router.delete('/keys/:id', (req, res) => {
|
|
426
566
|
const { id } = req.params;
|
|
427
567
|
const wantsJson = req.headers.accept?.includes('application/json');
|
|
@@ -437,7 +577,7 @@ router.delete('/keys/:id', (req, res) => {
|
|
|
437
577
|
|
|
438
578
|
// HTML Templates
|
|
439
579
|
|
|
440
|
-
function renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig }) {
|
|
580
|
+
function renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount, notificationsConfig, messagingMode, pendingMessagesCount }) {
|
|
441
581
|
const clawdbotConfig = notificationsConfig?.clawdbot;
|
|
442
582
|
return `<!DOCTYPE html>
|
|
443
583
|
<html>
|
|
@@ -467,6 +607,12 @@ function renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQu
|
|
|
467
607
|
Write Queue
|
|
468
608
|
${pendingQueueCount > 0 ? `<span class="badge">${pendingQueueCount}</span>` : ''}
|
|
469
609
|
</a>
|
|
610
|
+
${messagingMode !== 'off' ? `
|
|
611
|
+
<a href="/ui/messages" class="nav-btn nav-btn-default" style="position: relative;">
|
|
612
|
+
Messages
|
|
613
|
+
${pendingMessagesCount > 0 ? `<span class="badge">${pendingMessagesCount}</span>` : ''}
|
|
614
|
+
</a>
|
|
615
|
+
` : ''}
|
|
470
616
|
<div class="nav-divider"></div>
|
|
471
617
|
<form method="POST" action="/ui/logout" style="margin: 0;">
|
|
472
618
|
<button type="submit" class="nav-btn nav-btn-default" style="color: #f87171;">Logout</button>
|
|
@@ -497,6 +643,33 @@ curl -X POST http://localhost:${PORT}/api/queue/github/personal/submit \\
|
|
|
497
643
|
</div>
|
|
498
644
|
|
|
499
645
|
<h2>Advanced</h2>
|
|
646
|
+
<div class="card">
|
|
647
|
+
<details ${messagingMode !== 'off' ? 'open' : ''}>
|
|
648
|
+
<summary>Agent Messaging ${messagingMode !== 'off' ? `<span class="status configured">${messagingMode}</span>` : ''}</summary>
|
|
649
|
+
<div style="margin-top: 16px;">
|
|
650
|
+
<p class="help">Allow agents to send messages to each other. Messages can require human approval (supervised) or be delivered immediately (open).</p>
|
|
651
|
+
<form method="POST" action="/ui/messaging/mode" style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
|
|
652
|
+
<label style="display: flex; align-items: center; gap: 6px; margin: 0; cursor: pointer;">
|
|
653
|
+
<input type="radio" name="mode" value="off" ${messagingMode === 'off' ? 'checked' : ''}>
|
|
654
|
+
<span>Off</span>
|
|
655
|
+
</label>
|
|
656
|
+
<label style="display: flex; align-items: center; gap: 6px; margin: 0; cursor: pointer;">
|
|
657
|
+
<input type="radio" name="mode" value="supervised" ${messagingMode === 'supervised' ? 'checked' : ''}>
|
|
658
|
+
<span>Supervised</span>
|
|
659
|
+
</label>
|
|
660
|
+
<label style="display: flex; align-items: center; gap: 6px; margin: 0; cursor: pointer;">
|
|
661
|
+
<input type="radio" name="mode" value="open" ${messagingMode === 'open' ? 'checked' : ''}>
|
|
662
|
+
<span>Open</span>
|
|
663
|
+
</label>
|
|
664
|
+
<button type="submit" class="btn-primary btn-sm">Save</button>
|
|
665
|
+
</form>
|
|
666
|
+
${messagingMode === 'supervised' && pendingMessagesCount > 0 ? `
|
|
667
|
+
<p style="margin-top: 12px;"><a href="/ui/messages" style="color: #818cf8;">${pendingMessagesCount} pending message${pendingMessagesCount > 1 ? 's' : ''} awaiting approval →</a></p>
|
|
668
|
+
` : ''}
|
|
669
|
+
</div>
|
|
670
|
+
</details>
|
|
671
|
+
</div>
|
|
672
|
+
|
|
500
673
|
<div class="card">
|
|
501
674
|
<details ${hsyncConfig?.enabled ? 'open' : ''}>
|
|
502
675
|
<summary>hsync (Remote Access) ${hsyncConnected ? '<span class="status configured">Connected</span>' : hsyncConfig?.enabled ? '<span class="status not-configured">Disconnected</span>' : ''}</summary>
|
|
@@ -1259,6 +1432,384 @@ function renderQueuePage(entries, filter, counts, unnotifiedCount = 0) {
|
|
|
1259
1432
|
</html>`;
|
|
1260
1433
|
}
|
|
1261
1434
|
|
|
1435
|
+
function renderMessagesPage(messages, filter, counts, mode) {
|
|
1436
|
+
const escapeHtml = (str) => {
|
|
1437
|
+
if (typeof str !== 'string') str = String(str);
|
|
1438
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
const formatDate = (dateStr) => {
|
|
1442
|
+
if (!dateStr) return '';
|
|
1443
|
+
const d = new Date(dateStr);
|
|
1444
|
+
return d.toLocaleString();
|
|
1445
|
+
};
|
|
1446
|
+
|
|
1447
|
+
const statusBadge = (status) => {
|
|
1448
|
+
const colors = {
|
|
1449
|
+
pending: 'background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3);',
|
|
1450
|
+
delivered: 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);',
|
|
1451
|
+
rejected: 'background: rgba(156, 163, 175, 0.15); color: #9ca3af; border: 1px solid rgba(156, 163, 175, 0.3);'
|
|
1452
|
+
};
|
|
1453
|
+
return `<span class="status" style="${colors[status] || ''}">${status}</span>`;
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
const renderMessage = (msg) => {
|
|
1457
|
+
let actions = '';
|
|
1458
|
+
if (msg.status === 'pending') {
|
|
1459
|
+
actions = `
|
|
1460
|
+
<div class="message-actions">
|
|
1461
|
+
<button type="button" class="btn-primary btn-sm" onclick="approveMessage('${msg.id}')">Approve</button>
|
|
1462
|
+
<input type="text" id="reason-${msg.id}" placeholder="Rejection reason (optional)" class="reject-input" style="width: 200px;">
|
|
1463
|
+
<button type="button" class="btn-danger btn-sm" onclick="rejectMessage('${msg.id}')">Reject</button>
|
|
1464
|
+
</div>
|
|
1465
|
+
`;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
let rejectionSection = '';
|
|
1469
|
+
if (msg.rejection_reason) {
|
|
1470
|
+
rejectionSection = `
|
|
1471
|
+
<div class="rejection-reason">
|
|
1472
|
+
<strong>Rejection reason:</strong> ${escapeHtml(msg.rejection_reason)}
|
|
1473
|
+
</div>
|
|
1474
|
+
`;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
return `
|
|
1478
|
+
<div class="card message-entry" id="message-${msg.id}" data-status="${msg.status}">
|
|
1479
|
+
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
|
|
1480
|
+
<div class="entry-header">
|
|
1481
|
+
<strong>${escapeHtml(msg.from_agent)}</strong> → <strong>${escapeHtml(msg.to_agent)}</strong>
|
|
1482
|
+
<span class="status-badge">${statusBadge(msg.status)}</span>
|
|
1483
|
+
</div>
|
|
1484
|
+
<div style="display: flex; align-items: center; gap: 12px;">
|
|
1485
|
+
<span class="help" style="margin: 0;">${formatDate(msg.created_at)}</span>
|
|
1486
|
+
<button type="button" class="delete-btn" onclick="deleteMessage('${msg.id}')" title="Delete">×</button>
|
|
1487
|
+
</div>
|
|
1488
|
+
</div>
|
|
1489
|
+
|
|
1490
|
+
<div class="message-content">
|
|
1491
|
+
<pre style="white-space: pre-wrap; word-wrap: break-word; margin: 0; background: rgba(0,0,0,0.2); padding: 12px; border-radius: 8px;">${escapeHtml(msg.message)}</pre>
|
|
1492
|
+
</div>
|
|
1493
|
+
|
|
1494
|
+
${rejectionSection}
|
|
1495
|
+
${actions}
|
|
1496
|
+
</div>
|
|
1497
|
+
`;
|
|
1498
|
+
};
|
|
1499
|
+
|
|
1500
|
+
const filters = ['all', 'pending', 'delivered', 'rejected'];
|
|
1501
|
+
const filterLinks = filters.map(f =>
|
|
1502
|
+
`<a href="/ui/messages?filter=${f}" class="filter-link ${filter === f ? 'active' : ''}">${f}${counts[f] > 0 ? ` (${counts[f]})` : ''}</a>`
|
|
1503
|
+
).join('');
|
|
1504
|
+
|
|
1505
|
+
return `<!DOCTYPE html>
|
|
1506
|
+
<html>
|
|
1507
|
+
<head>
|
|
1508
|
+
<title>agentgate - Agent Messages</title>
|
|
1509
|
+
<link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
|
|
1510
|
+
<link rel="stylesheet" href="/public/style.css">
|
|
1511
|
+
<style>
|
|
1512
|
+
.filter-bar { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; align-items: center; }
|
|
1513
|
+
.filter-link {
|
|
1514
|
+
padding: 10px 20px;
|
|
1515
|
+
border-radius: 25px;
|
|
1516
|
+
text-decoration: none;
|
|
1517
|
+
background: rgba(255, 255, 255, 0.05);
|
|
1518
|
+
color: #9ca3af;
|
|
1519
|
+
font-weight: 600;
|
|
1520
|
+
font-size: 13px;
|
|
1521
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
1522
|
+
transition: all 0.3s ease;
|
|
1523
|
+
}
|
|
1524
|
+
.filter-link:hover {
|
|
1525
|
+
background: rgba(255, 255, 255, 0.1);
|
|
1526
|
+
color: #e5e7eb;
|
|
1527
|
+
border-color: rgba(255, 255, 255, 0.2);
|
|
1528
|
+
}
|
|
1529
|
+
.filter-link.active {
|
|
1530
|
+
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
1531
|
+
color: white;
|
|
1532
|
+
border-color: transparent;
|
|
1533
|
+
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
|
|
1534
|
+
}
|
|
1535
|
+
.message-entry { margin-bottom: 20px; }
|
|
1536
|
+
.message-actions {
|
|
1537
|
+
margin-top: 16px;
|
|
1538
|
+
padding-top: 16px;
|
|
1539
|
+
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
1540
|
+
display: flex;
|
|
1541
|
+
align-items: center;
|
|
1542
|
+
gap: 12px;
|
|
1543
|
+
flex-wrap: wrap;
|
|
1544
|
+
}
|
|
1545
|
+
.back-link {
|
|
1546
|
+
color: #818cf8;
|
|
1547
|
+
text-decoration: none;
|
|
1548
|
+
font-weight: 600;
|
|
1549
|
+
transition: color 0.2s ease;
|
|
1550
|
+
}
|
|
1551
|
+
.back-link:hover { color: #ffffff; }
|
|
1552
|
+
.delete-btn {
|
|
1553
|
+
background: rgba(239, 68, 68, 0.1);
|
|
1554
|
+
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
1555
|
+
color: #f87171;
|
|
1556
|
+
font-size: 18px;
|
|
1557
|
+
cursor: pointer;
|
|
1558
|
+
padding: 4px 10px;
|
|
1559
|
+
line-height: 1;
|
|
1560
|
+
font-weight: bold;
|
|
1561
|
+
border-radius: 6px;
|
|
1562
|
+
transition: all 0.2s ease;
|
|
1563
|
+
}
|
|
1564
|
+
.delete-btn:hover {
|
|
1565
|
+
background: rgba(239, 68, 68, 0.2);
|
|
1566
|
+
border-color: rgba(239, 68, 68, 0.4);
|
|
1567
|
+
}
|
|
1568
|
+
.clear-section { margin-left: auto; display: flex; gap: 10px; }
|
|
1569
|
+
.entry-header {
|
|
1570
|
+
display: flex;
|
|
1571
|
+
align-items: center;
|
|
1572
|
+
gap: 12px;
|
|
1573
|
+
}
|
|
1574
|
+
.entry-header strong {
|
|
1575
|
+
color: #f3f4f6;
|
|
1576
|
+
font-size: 16px;
|
|
1577
|
+
}
|
|
1578
|
+
.rejection-reason {
|
|
1579
|
+
margin-top: 16px;
|
|
1580
|
+
padding: 16px;
|
|
1581
|
+
background: rgba(239, 68, 68, 0.1);
|
|
1582
|
+
border-radius: 10px;
|
|
1583
|
+
border-left: 4px solid #f87171;
|
|
1584
|
+
color: #e5e7eb;
|
|
1585
|
+
}
|
|
1586
|
+
.rejection-reason strong { color: #f87171; }
|
|
1587
|
+
.empty-state {
|
|
1588
|
+
text-align: center;
|
|
1589
|
+
padding: 60px 40px;
|
|
1590
|
+
}
|
|
1591
|
+
.empty-state p { color: #6b7280; margin: 0; font-size: 16px; }
|
|
1592
|
+
.reject-input {
|
|
1593
|
+
padding: 10px 14px;
|
|
1594
|
+
margin: 0;
|
|
1595
|
+
font-size: 13px;
|
|
1596
|
+
background: rgba(15, 15, 25, 0.6);
|
|
1597
|
+
border: 2px solid rgba(239, 68, 68, 0.2);
|
|
1598
|
+
border-radius: 8px;
|
|
1599
|
+
color: #f3f4f6;
|
|
1600
|
+
}
|
|
1601
|
+
.reject-input:focus {
|
|
1602
|
+
outline: none;
|
|
1603
|
+
border-color: #f87171;
|
|
1604
|
+
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.15);
|
|
1605
|
+
}
|
|
1606
|
+
.reject-input::placeholder { color: #6b7280; }
|
|
1607
|
+
.mode-badge {
|
|
1608
|
+
display: inline-flex;
|
|
1609
|
+
align-items: center;
|
|
1610
|
+
gap: 6px;
|
|
1611
|
+
padding: 6px 14px;
|
|
1612
|
+
border-radius: 20px;
|
|
1613
|
+
font-size: 12px;
|
|
1614
|
+
font-weight: 600;
|
|
1615
|
+
text-transform: uppercase;
|
|
1616
|
+
background: rgba(99, 102, 241, 0.15);
|
|
1617
|
+
color: #818cf8;
|
|
1618
|
+
border: 1px solid rgba(99, 102, 241, 0.3);
|
|
1619
|
+
}
|
|
1620
|
+
</style>
|
|
1621
|
+
</head>
|
|
1622
|
+
<body>
|
|
1623
|
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
1624
|
+
<h1>Agent Messages</h1>
|
|
1625
|
+
<div style="display: flex; align-items: center; gap: 16px;">
|
|
1626
|
+
<span class="mode-badge">Mode: ${mode}</span>
|
|
1627
|
+
<a href="/ui" class="back-link">← Back to Dashboard</a>
|
|
1628
|
+
</div>
|
|
1629
|
+
</div>
|
|
1630
|
+
<p>Review and approve messages between agents${mode === 'supervised' ? ' (supervised mode)' : ''}.</p>
|
|
1631
|
+
|
|
1632
|
+
<div class="filter-bar" id="filter-bar">
|
|
1633
|
+
${filterLinks}
|
|
1634
|
+
<div class="clear-section">
|
|
1635
|
+
${filter === 'delivered' && counts.delivered > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'delivered\')">Clear Delivered</button>' : ''}
|
|
1636
|
+
${filter === 'rejected' && counts.rejected > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'rejected\')">Clear Rejected</button>' : ''}
|
|
1637
|
+
${filter === 'all' && (counts.delivered > 0 || counts.rejected > 0) ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'all\')">Clear All Non-Pending</button>' : ''}
|
|
1638
|
+
</div>
|
|
1639
|
+
</div>
|
|
1640
|
+
|
|
1641
|
+
<div id="messages-container">
|
|
1642
|
+
${messages.length === 0 ? `
|
|
1643
|
+
<div class="card empty-state">
|
|
1644
|
+
<p>No ${filter === 'all' ? '' : filter + ' '}messages</p>
|
|
1645
|
+
</div>
|
|
1646
|
+
` : messages.map(renderMessage).join('')}
|
|
1647
|
+
</div>
|
|
1648
|
+
|
|
1649
|
+
<script>
|
|
1650
|
+
function escapeHtml(str) {
|
|
1651
|
+
if (typeof str !== 'string') str = JSON.stringify(str, null, 2);
|
|
1652
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
async function approveMessage(id) {
|
|
1656
|
+
const btn = event.target;
|
|
1657
|
+
btn.disabled = true;
|
|
1658
|
+
btn.textContent = 'Approving...';
|
|
1659
|
+
|
|
1660
|
+
try {
|
|
1661
|
+
const res = await fetch('/ui/messages/' + id + '/approve', {
|
|
1662
|
+
method: 'POST',
|
|
1663
|
+
headers: { 'Accept': 'application/json' }
|
|
1664
|
+
});
|
|
1665
|
+
const data = await res.json();
|
|
1666
|
+
|
|
1667
|
+
if (data.success) {
|
|
1668
|
+
const el = document.getElementById('message-' + id);
|
|
1669
|
+
if (el) {
|
|
1670
|
+
el.dataset.status = 'delivered';
|
|
1671
|
+
// Update status badge
|
|
1672
|
+
const badge = el.querySelector('.status-badge');
|
|
1673
|
+
if (badge) {
|
|
1674
|
+
badge.innerHTML = '<span class="status" style="background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);">delivered</span>';
|
|
1675
|
+
}
|
|
1676
|
+
// Remove actions
|
|
1677
|
+
const actions = el.querySelector('.message-actions');
|
|
1678
|
+
if (actions) actions.remove();
|
|
1679
|
+
}
|
|
1680
|
+
} else {
|
|
1681
|
+
alert(data.error || 'Failed to approve');
|
|
1682
|
+
btn.disabled = false;
|
|
1683
|
+
btn.textContent = 'Approve';
|
|
1684
|
+
}
|
|
1685
|
+
} catch (err) {
|
|
1686
|
+
alert('Error: ' + err.message);
|
|
1687
|
+
btn.disabled = false;
|
|
1688
|
+
btn.textContent = 'Approve';
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
async function rejectMessage(id) {
|
|
1693
|
+
const btn = event.target;
|
|
1694
|
+
const reason = document.getElementById('reason-' + id)?.value || '';
|
|
1695
|
+
btn.disabled = true;
|
|
1696
|
+
btn.textContent = 'Rejecting...';
|
|
1697
|
+
|
|
1698
|
+
try {
|
|
1699
|
+
const res = await fetch('/ui/messages/' + id + '/reject', {
|
|
1700
|
+
method: 'POST',
|
|
1701
|
+
headers: {
|
|
1702
|
+
'Accept': 'application/json',
|
|
1703
|
+
'Content-Type': 'application/json'
|
|
1704
|
+
},
|
|
1705
|
+
body: JSON.stringify({ reason })
|
|
1706
|
+
});
|
|
1707
|
+
const data = await res.json();
|
|
1708
|
+
|
|
1709
|
+
if (data.success) {
|
|
1710
|
+
const el = document.getElementById('message-' + id);
|
|
1711
|
+
if (el) {
|
|
1712
|
+
el.dataset.status = 'rejected';
|
|
1713
|
+
// Update status badge
|
|
1714
|
+
const badge = el.querySelector('.status-badge');
|
|
1715
|
+
if (badge) {
|
|
1716
|
+
badge.innerHTML = '<span class="status" style="background: rgba(156, 163, 175, 0.15); color: #9ca3af; border: 1px solid rgba(156, 163, 175, 0.3);">rejected</span>';
|
|
1717
|
+
}
|
|
1718
|
+
// Remove actions and add rejection reason
|
|
1719
|
+
const actions = el.querySelector('.message-actions');
|
|
1720
|
+
if (actions) {
|
|
1721
|
+
actions.outerHTML = '<div class="rejection-reason"><strong>Rejection reason:</strong> ' + escapeHtml(reason || 'No reason provided') + '</div>';
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
} else {
|
|
1725
|
+
alert(data.error || 'Failed to reject');
|
|
1726
|
+
btn.disabled = false;
|
|
1727
|
+
btn.textContent = 'Reject';
|
|
1728
|
+
}
|
|
1729
|
+
} catch (err) {
|
|
1730
|
+
alert('Error: ' + err.message);
|
|
1731
|
+
btn.disabled = false;
|
|
1732
|
+
btn.textContent = 'Reject';
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
async function deleteMessage(id) {
|
|
1737
|
+
if (!confirm('Delete this message?')) return;
|
|
1738
|
+
|
|
1739
|
+
try {
|
|
1740
|
+
const res = await fetch('/ui/messages/' + id + '/delete', {
|
|
1741
|
+
method: 'POST',
|
|
1742
|
+
headers: { 'Accept': 'application/json' }
|
|
1743
|
+
});
|
|
1744
|
+
const data = await res.json();
|
|
1745
|
+
|
|
1746
|
+
if (data.success) {
|
|
1747
|
+
const el = document.getElementById('message-' + id);
|
|
1748
|
+
if (el) el.remove();
|
|
1749
|
+
|
|
1750
|
+
// Show empty message if no messages left
|
|
1751
|
+
const container = document.getElementById('messages-container');
|
|
1752
|
+
if (container.querySelectorAll('.message-entry').length === 0) {
|
|
1753
|
+
container.innerHTML = '<div class="card empty-state"><p>No messages</p></div>';
|
|
1754
|
+
}
|
|
1755
|
+
} else {
|
|
1756
|
+
alert(data.error || 'Failed to delete');
|
|
1757
|
+
}
|
|
1758
|
+
} catch (err) {
|
|
1759
|
+
alert('Error: ' + err.message);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
async function clearByStatus(status) {
|
|
1764
|
+
const btn = event.target;
|
|
1765
|
+
const originalText = btn.textContent;
|
|
1766
|
+
btn.disabled = true;
|
|
1767
|
+
btn.textContent = 'Clearing...';
|
|
1768
|
+
|
|
1769
|
+
try {
|
|
1770
|
+
const res = await fetch('/ui/messages/clear', {
|
|
1771
|
+
method: 'POST',
|
|
1772
|
+
headers: {
|
|
1773
|
+
'Accept': 'application/json',
|
|
1774
|
+
'Content-Type': 'application/json'
|
|
1775
|
+
},
|
|
1776
|
+
body: JSON.stringify({ status })
|
|
1777
|
+
});
|
|
1778
|
+
const data = await res.json();
|
|
1779
|
+
|
|
1780
|
+
if (data.success) {
|
|
1781
|
+
// Remove cleared messages from DOM
|
|
1782
|
+
const container = document.getElementById('messages-container');
|
|
1783
|
+
if (status === 'all') {
|
|
1784
|
+
container.querySelectorAll('.message-entry').forEach(el => {
|
|
1785
|
+
if (el.dataset.status !== 'pending') el.remove();
|
|
1786
|
+
});
|
|
1787
|
+
} else {
|
|
1788
|
+
container.querySelectorAll('.message-entry[data-status="' + status + '"]').forEach(el => el.remove());
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// Show empty message if no messages left
|
|
1792
|
+
if (container.querySelectorAll('.message-entry').length === 0) {
|
|
1793
|
+
container.innerHTML = '<div class="card empty-state"><p>No messages</p></div>';
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// Hide the clear button
|
|
1797
|
+
btn.style.display = 'none';
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
btn.disabled = false;
|
|
1801
|
+
btn.textContent = originalText;
|
|
1802
|
+
} catch (err) {
|
|
1803
|
+
alert('Error: ' + err.message);
|
|
1804
|
+
btn.disabled = false;
|
|
1805
|
+
btn.textContent = originalText;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
</script>
|
|
1809
|
+
</body>
|
|
1810
|
+
</html>`;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1262
1813
|
function renderKeysPage(keys, error = null, newKey = null) {
|
|
1263
1814
|
const escapeHtml = (str) => {
|
|
1264
1815
|
if (typeof str !== 'string') return '';
|
|
@@ -1275,6 +1826,14 @@ function renderKeysPage(keys, error = null, newKey = null) {
|
|
|
1275
1826
|
<tr id="key-${k.id}">
|
|
1276
1827
|
<td><strong>${escapeHtml(k.name)}</strong></td>
|
|
1277
1828
|
<td><code class="key-value">${escapeHtml(k.key_prefix)}</code></td>
|
|
1829
|
+
<td>
|
|
1830
|
+
${k.webhook_url ? `
|
|
1831
|
+
<span class="webhook-status webhook-configured" title="${escapeHtml(k.webhook_url)}">✓ Configured</span>
|
|
1832
|
+
` : `
|
|
1833
|
+
<span class="webhook-status webhook-none">Not set</span>
|
|
1834
|
+
`}
|
|
1835
|
+
<button type="button" class="btn-sm webhook-btn" data-id="${k.id}" data-name="${escapeHtml(k.name)}" data-url="${escapeHtml(k.webhook_url || '')}" data-token="${escapeHtml(k.webhook_token || '')}">Configure</button>
|
|
1836
|
+
</td>
|
|
1278
1837
|
<td>${formatDate(k.created_at)}</td>
|
|
1279
1838
|
<td>
|
|
1280
1839
|
<button type="button" class="delete-btn" onclick="deleteKey('${k.id}')" title="Delete">×</button>
|
|
@@ -1290,16 +1849,30 @@ function renderKeysPage(keys, error = null, newKey = null) {
|
|
|
1290
1849
|
<link rel="stylesheet" href="/public/style.css">
|
|
1291
1850
|
<style>
|
|
1292
1851
|
.keys-table { width: 100%; border-collapse: collapse; margin-top: 16px; }
|
|
1293
|
-
.keys-table th, .keys-table td { padding: 12px; text-align: left; border-bottom: 1px solid
|
|
1294
|
-
.keys-table th { font-weight: 600; color:
|
|
1295
|
-
.key-value { background:
|
|
1296
|
-
.new-key-banner { background: #
|
|
1297
|
-
.new-key-banner code { background:
|
|
1298
|
-
.delete-btn { background: none; border: none; color: #
|
|
1299
|
-
.delete-btn:hover { color: #
|
|
1300
|
-
.back-link { color:
|
|
1852
|
+
.keys-table th, .keys-table td { padding: 12px; text-align: left; border-bottom: 1px solid #374151; }
|
|
1853
|
+
.keys-table th { font-weight: 600; color: #9ca3af; font-size: 14px; }
|
|
1854
|
+
.key-value { background: #1f2937; padding: 4px 8px; border-radius: 4px; font-size: 13px; color: #e5e7eb; }
|
|
1855
|
+
.new-key-banner { background: #065f46; border: 1px solid #10b981; padding: 16px; border-radius: 8px; margin-bottom: 20px; color: #d1fae5; }
|
|
1856
|
+
.new-key-banner code { background: #1f2937; color: #10b981; padding: 8px 12px; border-radius: 4px; display: block; margin-top: 8px; font-size: 14px; word-break: break-all; }
|
|
1857
|
+
.delete-btn { background: none; border: none; color: #f87171; font-size: 20px; cursor: pointer; padding: 0 4px; line-height: 1; font-weight: bold; }
|
|
1858
|
+
.delete-btn:hover { color: #dc2626; }
|
|
1859
|
+
.back-link { color: #a78bfa; text-decoration: none; font-weight: 500; }
|
|
1301
1860
|
.back-link:hover { text-decoration: underline; }
|
|
1302
|
-
.error-message { background: #
|
|
1861
|
+
.error-message { background: #7f1d1d; color: #fecaca; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
|
|
1862
|
+
.webhook-status { font-size: 12px; padding: 4px 8px; border-radius: 4px; margin-right: 8px; }
|
|
1863
|
+
.webhook-configured { background: #065f46; color: #6ee7b7; }
|
|
1864
|
+
.webhook-none { background: #374151; color: #9ca3af; }
|
|
1865
|
+
.btn-sm { font-size: 12px; padding: 4px 8px; background: #4f46e5; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
|
1866
|
+
.btn-sm:hover { background: #4338ca; }
|
|
1867
|
+
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; }
|
|
1868
|
+
.modal-overlay.active { display: flex; }
|
|
1869
|
+
.modal { background: #1f2937; border-radius: 12px; padding: 24px; max-width: 500px; width: 90%; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); }
|
|
1870
|
+
.modal h3 { margin: 0 0 16px 0; color: #f3f4f6; }
|
|
1871
|
+
.modal label { display: block; margin-bottom: 4px; color: #d1d5db; font-size: 14px; }
|
|
1872
|
+
.modal input { width: 100%; padding: 10px; border: 1px solid #374151; border-radius: 6px; background: #111827; color: #f3f4f6; margin-bottom: 12px; box-sizing: border-box; }
|
|
1873
|
+
.modal input:focus { border-color: #6366f1; outline: none; }
|
|
1874
|
+
.modal-buttons { display: flex; gap: 12px; justify-content: flex-end; margin-top: 16px; }
|
|
1875
|
+
.modal .help-text { font-size: 12px; color: #9ca3af; margin-top: -8px; margin-bottom: 12px; }
|
|
1303
1876
|
</style>
|
|
1304
1877
|
</head>
|
|
1305
1878
|
<body>
|
|
@@ -1340,6 +1913,7 @@ function renderKeysPage(keys, error = null, newKey = null) {
|
|
|
1340
1913
|
<tr>
|
|
1341
1914
|
<th>Name</th>
|
|
1342
1915
|
<th>Key Prefix</th>
|
|
1916
|
+
<th>Webhook</th>
|
|
1343
1917
|
<th>Created</th>
|
|
1344
1918
|
<th></th>
|
|
1345
1919
|
</tr>
|
|
@@ -1351,6 +1925,31 @@ function renderKeysPage(keys, error = null, newKey = null) {
|
|
|
1351
1925
|
`}
|
|
1352
1926
|
</div>
|
|
1353
1927
|
|
|
1928
|
+
<!-- Webhook Modal -->
|
|
1929
|
+
<div id="webhook-modal" class="modal-overlay">
|
|
1930
|
+
<div class="modal">
|
|
1931
|
+
<h3>Configure Webhook for <span id="modal-agent-name"></span></h3>
|
|
1932
|
+
<p style="color: #9ca3af; font-size: 14px; margin-bottom: 16px;">
|
|
1933
|
+
When messages or queue updates are ready, agentgate will POST to this URL.
|
|
1934
|
+
</p>
|
|
1935
|
+
<form id="webhook-form">
|
|
1936
|
+
<input type="hidden" id="webhook-agent-id" name="id">
|
|
1937
|
+
<label for="webhook-url">Webhook URL</label>
|
|
1938
|
+
<input type="url" id="webhook-url" name="webhook_url" placeholder="https://your-agent-gateway.com/webhook">
|
|
1939
|
+
<p class="help-text">The endpoint that will receive POST notifications</p>
|
|
1940
|
+
|
|
1941
|
+
<label for="webhook-token">Authorization Token (optional)</label>
|
|
1942
|
+
<input type="text" id="webhook-token" name="webhook_token" placeholder="secret-token">
|
|
1943
|
+
<p class="help-text">Sent as Bearer token in Authorization header</p>
|
|
1944
|
+
|
|
1945
|
+
<div class="modal-buttons">
|
|
1946
|
+
<button type="button" class="btn-secondary" onclick="closeWebhookModal()">Cancel</button>
|
|
1947
|
+
<button type="submit" class="btn-primary">Save Webhook</button>
|
|
1948
|
+
</div>
|
|
1949
|
+
</form>
|
|
1950
|
+
</div>
|
|
1951
|
+
</div>
|
|
1952
|
+
|
|
1354
1953
|
<script>
|
|
1355
1954
|
function copyKey(key, btn) {
|
|
1356
1955
|
navigator.clipboard.writeText(key).then(() => {
|
|
@@ -1360,6 +1959,56 @@ function renderKeysPage(keys, error = null, newKey = null) {
|
|
|
1360
1959
|
});
|
|
1361
1960
|
}
|
|
1362
1961
|
|
|
1962
|
+
function showWebhookModal(btn) {
|
|
1963
|
+
document.getElementById('webhook-agent-id').value = btn.dataset.id;
|
|
1964
|
+
document.getElementById('modal-agent-name').textContent = btn.dataset.name;
|
|
1965
|
+
document.getElementById('webhook-url').value = btn.dataset.url;
|
|
1966
|
+
document.getElementById('webhook-token').value = btn.dataset.token;
|
|
1967
|
+
document.getElementById('webhook-modal').classList.add('active');
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function closeWebhookModal() {
|
|
1971
|
+
document.getElementById('webhook-modal').classList.remove('active');
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// Attach click handlers to webhook buttons
|
|
1975
|
+
document.querySelectorAll('.webhook-btn').forEach(btn => {
|
|
1976
|
+
btn.addEventListener('click', () => showWebhookModal(btn));
|
|
1977
|
+
});
|
|
1978
|
+
|
|
1979
|
+
document.getElementById('webhook-form').addEventListener('submit', async (e) => {
|
|
1980
|
+
e.preventDefault();
|
|
1981
|
+
const id = document.getElementById('webhook-agent-id').value;
|
|
1982
|
+
const webhookUrl = document.getElementById('webhook-url').value;
|
|
1983
|
+
const webhookToken = document.getElementById('webhook-token').value;
|
|
1984
|
+
|
|
1985
|
+
try {
|
|
1986
|
+
const res = await fetch('/ui/keys/' + id + '/webhook', {
|
|
1987
|
+
method: 'POST',
|
|
1988
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
1989
|
+
body: JSON.stringify({ webhook_url: webhookUrl, webhook_token: webhookToken })
|
|
1990
|
+
});
|
|
1991
|
+
const data = await res.json();
|
|
1992
|
+
|
|
1993
|
+
if (data.success) {
|
|
1994
|
+
closeWebhookModal();
|
|
1995
|
+
// Reload to show updated status
|
|
1996
|
+
window.location.reload();
|
|
1997
|
+
} else {
|
|
1998
|
+
alert(data.error || 'Failed to save webhook');
|
|
1999
|
+
}
|
|
2000
|
+
} catch (err) {
|
|
2001
|
+
alert('Error: ' + err.message);
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
// Close modal on overlay click
|
|
2006
|
+
document.getElementById('webhook-modal').addEventListener('click', (e) => {
|
|
2007
|
+
if (e.target.classList.contains('modal-overlay')) {
|
|
2008
|
+
closeWebhookModal();
|
|
2009
|
+
}
|
|
2010
|
+
});
|
|
2011
|
+
|
|
1363
2012
|
async function deleteKey(id) {
|
|
1364
2013
|
if (!confirm('Delete this API key? Any agents using it will lose access.')) return;
|
|
1365
2014
|
|
|
@@ -1383,7 +2032,7 @@ function renderKeysPage(keys, error = null, newKey = null) {
|
|
|
1383
2032
|
if (count === 0) {
|
|
1384
2033
|
const table = document.querySelector('.keys-table');
|
|
1385
2034
|
if (table) {
|
|
1386
|
-
table.outerHTML = '<p style="color:
|
|
2035
|
+
table.outerHTML = '<p style="color: #9ca3af; text-align: center; padding: 20px;">No API keys yet. Create one above.</p>';
|
|
1387
2036
|
}
|
|
1388
2037
|
}
|
|
1389
2038
|
} else {
|