agent-relay-server 0.1.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 +177 -91
- package/bin/agent-relay-codex.ts +547 -0
- package/codex/README.md +80 -0
- package/codex/app-client.ts +239 -0
- package/codex/hooks/session-start.ts +114 -0
- package/codex/install-codex.ps1 +47 -0
- package/codex/install-codex.sh +75 -0
- package/codex/live-sidecar.ts +606 -0
- package/codex/plugin/.codex-plugin/plugin.json +25 -0
- package/codex/plugin/skills/agent-relay/SKILL.md +28 -0
- package/codex/relay.ts +116 -0
- package/codex/start-live.sh +64 -0
- package/package.json +14 -3
- package/public/index.html +1078 -446
- package/src/config.ts +8 -0
- package/src/db.ts +49 -20
- package/src/index.ts +5 -1
- package/src/routes.ts +83 -15
- package/src/sse.ts +115 -0
- package/src/types.ts +6 -0
package/public/index.html
CHANGED
|
@@ -1,501 +1,1133 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
2
|
+
<html lang="en" data-bs-theme="dark">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Agent Relay</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230d1117'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='%2358a6ff'/%3E%3Ccircle cx='6' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='6' cy='24' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='24' r='2.5' fill='%233fb950'/%3E%3Cline x1='8' y1='9.5' x2='13' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='9.5' x2='19' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='8' y1='22.5' x2='13' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='22.5' x2='19' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3C/svg%3E">
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css">
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css">
|
|
7
10
|
<style>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
[x-cloak] { display: none !important; }
|
|
12
|
+
|
|
13
|
+
.ar-sidebar {
|
|
14
|
+
width: 250px; min-width: 250px; height: 100vh; position: sticky; top: 0;
|
|
15
|
+
background: var(--tblr-bg-surface); border-right: 1px solid var(--tblr-border-color);
|
|
16
|
+
display: flex; flex-direction: column;
|
|
13
17
|
}
|
|
14
|
-
|
|
15
|
-
.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
.
|
|
20
|
-
.
|
|
21
|
-
|
|
22
|
-
.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
.
|
|
31
|
-
.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
.
|
|
40
|
-
.
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
|
|
46
|
-
.msg-
|
|
47
|
-
.msg-
|
|
48
|
-
.msg-
|
|
49
|
-
|
|
50
|
-
.msg-body {
|
|
51
|
-
|
|
52
|
-
.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.agent-actions button { background: none; border: none; color: var(--text-dim); font-size: 13px; cursor: pointer; padding: 2px 4px; }
|
|
61
|
-
.agent-actions button.delete:hover { color: var(--red); }
|
|
62
|
-
.agent-actions button.rename:hover { color: var(--accent); }
|
|
63
|
-
.agent-label { color: var(--yellow); font-weight: 600; }
|
|
64
|
-
.msg-claimed { font-size: 11px; color: var(--green); }
|
|
65
|
-
.msg-claimable { font-size: 11px; color: var(--yellow); }
|
|
66
|
-
.msg.claimed { border-left: 2px solid var(--green); }
|
|
67
|
-
.msg-thread-link { font-size: 11px; color: var(--accent); cursor: pointer; text-decoration: underline; }
|
|
68
|
-
.reply-banner { display: flex; align-items: center; gap: 8px; padding: 6px 16px; background: var(--bg); border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-dim); }
|
|
69
|
-
.reply-banner .cancel { cursor: pointer; color: var(--red); margin-left: auto; }
|
|
70
|
-
|
|
71
|
-
.thread-overlay { position: fixed; top: 0; right: 0; width: 450px; max-width: 100vw; height: 100vh; background: var(--surface); border-left: 1px solid var(--border); z-index: 100; display: flex; flex-direction: column; box-shadow: -4px 0 24px rgba(0,0,0,0.4); }
|
|
72
|
-
.thread-overlay .panel-head { flex-shrink: 0; }
|
|
73
|
-
.thread-overlay .panel-body { flex: 1; overflow-y: auto; }
|
|
74
|
-
.thread-close { cursor: pointer; color: var(--text-dim); font-size: 18px; }
|
|
75
|
-
.thread-close:hover { color: var(--text); }
|
|
76
|
-
|
|
77
|
-
.compose { padding: 16px; border-top: 1px solid var(--border); }
|
|
78
|
-
.compose-row { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
|
79
|
-
.compose input, .compose textarea, .compose select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 6px 10px; font-size: 13px; font-family: inherit; }
|
|
80
|
-
.compose input, .compose select { flex: 1; min-width: 0; }
|
|
81
|
-
.compose textarea { width: 100%; min-height: 60px; resize: vertical; }
|
|
82
|
-
.compose button { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 6px 16px; font-size: 13px; cursor: pointer; font-weight: 600; }
|
|
83
|
-
.compose button:hover { opacity: 0.9; }
|
|
84
|
-
|
|
85
|
-
.empty { text-align: center; padding: 40px 16px; color: var(--text-dim); font-size: 14px; }
|
|
86
|
-
.filter-row { display: flex; gap: 8px; padding: 8px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
|
87
|
-
.filter-row select, .filter-row input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 4px 8px; font-size: 12px; flex: 1; min-width: 0; }
|
|
88
|
-
|
|
89
|
-
@media (max-width: 500px) {
|
|
90
|
-
.container { padding: 8px; }
|
|
91
|
-
header { margin-bottom: 12px; }
|
|
92
|
-
.grid { gap: 12px; }
|
|
93
|
-
.compose-row { flex-direction: column; }
|
|
94
|
-
.compose-row input, .compose-row select { width: 100%; }
|
|
95
|
-
.msg-footer { flex-wrap: wrap; }
|
|
96
|
-
.agent-meta { word-break: break-all; }
|
|
97
|
-
.stats { gap: 8px; }
|
|
18
|
+
.ar-sidebar .nav-link { color: var(--tblr-body-color); border-radius: 6px; margin: 2px 8px; padding: 8px 12px; }
|
|
19
|
+
.ar-sidebar .nav-link:hover { background: var(--tblr-bg-surface-secondary); }
|
|
20
|
+
.ar-sidebar .nav-link.active { background: var(--tblr-primary); color: #fff; }
|
|
21
|
+
.ar-sidebar .nav-link .ti { font-size: 18px; margin-right: 8px; vertical-align: -2px; }
|
|
22
|
+
|
|
23
|
+
.ar-logo { padding: 16px 20px; font-weight: 700; font-size: 16px; border-bottom: 1px solid var(--tblr-border-color); }
|
|
24
|
+
.ar-logo .ti { color: var(--tblr-primary); margin-right: 6px; }
|
|
25
|
+
|
|
26
|
+
.ar-sidebar-footer { margin-top: auto; padding: 12px 16px; border-top: 1px solid var(--tblr-border-color); font-size: 12px; }
|
|
27
|
+
|
|
28
|
+
.view-fit { display: flex; flex-direction: column; height: calc(100vh - 2rem); }
|
|
29
|
+
|
|
30
|
+
.status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
|
31
|
+
.status-dot.online { background: var(--tblr-success); box-shadow: 0 0 6px var(--tblr-success); }
|
|
32
|
+
.status-dot.online.not-ready { animation: pulse-dot 1.5s ease-in-out infinite; }
|
|
33
|
+
.status-dot.idle { background: var(--tblr-warning); }
|
|
34
|
+
.status-dot.busy { background: var(--tblr-danger); }
|
|
35
|
+
.status-dot.offline { background: var(--tblr-secondary); opacity: 0.5; }
|
|
36
|
+
|
|
37
|
+
@keyframes pulse-dot {
|
|
38
|
+
0%, 100% { opacity: 1; box-shadow: 0 0 6px var(--tblr-success); }
|
|
39
|
+
50% { opacity: 0.4; box-shadow: none; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.agent-card { transition: background 0.15s; cursor: pointer; }
|
|
43
|
+
.agent-card:hover { background: var(--tblr-bg-surface-secondary); }
|
|
44
|
+
.agent-card.selected { border-left: 3px solid var(--tblr-primary); }
|
|
45
|
+
.agent-card .agent-actions { opacity: 0; transition: opacity 0.15s; }
|
|
46
|
+
.agent-card:hover .agent-actions { opacity: 1; }
|
|
47
|
+
|
|
48
|
+
.agent-label { color: var(--tblr-warning); font-weight: 600; }
|
|
49
|
+
|
|
50
|
+
.msg-card { border-left: 3px solid transparent; }
|
|
51
|
+
.msg-card.claimed { border-left-color: var(--tblr-success); }
|
|
52
|
+
.msg-card.thread-child { margin-left: 24px; border-left-color: var(--tblr-border-color); }
|
|
53
|
+
|
|
54
|
+
.msg-body { white-space: pre-wrap; word-break: break-word; font-size: 13px; }
|
|
55
|
+
|
|
56
|
+
.fade-in { animation: fadeIn 0.2s ease-in; }
|
|
57
|
+
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
58
|
+
|
|
59
|
+
.stat-card .ti { font-size: 32px; opacity: 0.3; }
|
|
60
|
+
|
|
61
|
+
@media (max-width: 768px) {
|
|
62
|
+
.ar-sidebar { display: none; }
|
|
63
|
+
.mobile-nav { display: flex !important; }
|
|
98
64
|
}
|
|
99
65
|
</style>
|
|
100
66
|
</head>
|
|
101
67
|
<body>
|
|
102
|
-
<div class="
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
68
|
+
<div x-data="relay" x-init="init()" class="d-flex">
|
|
69
|
+
|
|
70
|
+
<!-- Sidebar -->
|
|
71
|
+
<aside class="ar-sidebar d-none d-md-flex">
|
|
72
|
+
<div class="ar-logo"><i class="ti ti-vector-triangle"></i>Agent Relay</div>
|
|
73
|
+
<nav class="nav flex-column py-2">
|
|
74
|
+
<a href="#" class="nav-link" :class="{ active: view === 'overview' }" @click.prevent="switchView('overview')">
|
|
75
|
+
<i class="ti ti-dashboard"></i>Overview
|
|
76
|
+
</a>
|
|
77
|
+
<a href="#" class="nav-link" :class="{ active: view === 'agents' }" @click.prevent="switchView('agents')">
|
|
78
|
+
<i class="ti ti-robot"></i>Agents
|
|
79
|
+
<span class="badge bg-success text-white ms-auto" x-text="onlineCount"></span>
|
|
80
|
+
</a>
|
|
81
|
+
<a href="#" class="nav-link" :class="{ active: view === 'messages' }" @click.prevent="switchView('messages')">
|
|
82
|
+
<i class="ti ti-messages"></i>Messages
|
|
83
|
+
</a>
|
|
84
|
+
<a href="#" class="nav-link" :class="{ active: view === 'analytics' }" @click.prevent="switchView('analytics')">
|
|
85
|
+
<i class="ti ti-chart-area-line"></i>Analytics
|
|
86
|
+
</a>
|
|
87
|
+
</nav>
|
|
88
|
+
<div class="ar-sidebar-footer">
|
|
89
|
+
<div class="d-flex align-items-center gap-2 mb-2">
|
|
90
|
+
<span class="status-dot" :class="connected ? 'online' : 'offline'"></span>
|
|
91
|
+
<span class="small" x-text="connected ? 'Live' : 'Reconnecting…'"></span>
|
|
120
92
|
</div>
|
|
121
|
-
<div class="
|
|
122
|
-
<
|
|
93
|
+
<div class="d-flex align-items-center gap-2 mb-2">
|
|
94
|
+
<label class="form-check form-switch mb-0">
|
|
95
|
+
<input type="checkbox" class="form-check-input" x-model="showOffline">
|
|
96
|
+
<span class="form-check-label small">Show offline</span>
|
|
97
|
+
</label>
|
|
123
98
|
</div>
|
|
99
|
+
<div class="text-muted small" x-show="stats.version" x-text="'v' + stats.version"></div>
|
|
124
100
|
</div>
|
|
101
|
+
</aside>
|
|
125
102
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
103
|
+
<!-- Mobile nav -->
|
|
104
|
+
<div class="mobile-nav d-none border-bottom p-2 gap-1 position-fixed top-0 w-100 bg-dark" style="z-index:50">
|
|
105
|
+
<template x-for="v in ['overview','agents','messages','analytics']">
|
|
106
|
+
<button class="btn btn-sm" :class="view === v ? 'btn-primary' : 'btn-ghost-secondary'" @click="switchView(v)" x-text="v.charAt(0).toUpperCase() + v.slice(1)"></button>
|
|
107
|
+
</template>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<!-- Main content -->
|
|
111
|
+
<main class="flex-grow-1 overflow-auto" style="height: 100vh">
|
|
112
|
+
<div class="container-xl py-3">
|
|
113
|
+
|
|
114
|
+
<!-- ==================== OVERVIEW ==================== -->
|
|
115
|
+
<div x-show="view === 'overview'" x-cloak class="fade-in">
|
|
116
|
+
|
|
117
|
+
<!-- Stats row -->
|
|
118
|
+
<div class="row g-3 mb-4">
|
|
119
|
+
<div class="col-sm-6 col-lg-3">
|
|
120
|
+
<div class="card">
|
|
121
|
+
<div class="card-body">
|
|
122
|
+
<div class="d-flex align-items-center">
|
|
123
|
+
<div>
|
|
124
|
+
<div class="text-secondary small">Total Agents</div>
|
|
125
|
+
<div class="h1 mb-0" x-text="stats.agents ?? 0"></div>
|
|
126
|
+
</div>
|
|
127
|
+
<i class="ti ti-robot ms-auto stat-card"></i>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="col-sm-6 col-lg-3">
|
|
133
|
+
<div class="card">
|
|
134
|
+
<div class="card-body">
|
|
135
|
+
<div class="d-flex align-items-center">
|
|
136
|
+
<div>
|
|
137
|
+
<div class="text-secondary small">Online</div>
|
|
138
|
+
<div class="h1 mb-0 text-success" x-text="onlineCount"></div>
|
|
139
|
+
</div>
|
|
140
|
+
<i class="ti ti-circle-check ms-auto stat-card"></i>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
<div class="col-sm-6 col-lg-3">
|
|
146
|
+
<div class="card">
|
|
147
|
+
<div class="card-body">
|
|
148
|
+
<div class="d-flex align-items-center">
|
|
149
|
+
<div>
|
|
150
|
+
<div class="text-secondary small">Messages (24h)</div>
|
|
151
|
+
<div class="h1 mb-0 text-info" x-text="stats.messages24h ?? 0"></div>
|
|
152
|
+
</div>
|
|
153
|
+
<i class="ti ti-mail ms-auto stat-card"></i>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
<div class="col-sm-6 col-lg-3">
|
|
159
|
+
<div class="card">
|
|
160
|
+
<div class="card-body">
|
|
161
|
+
<div class="d-flex align-items-center">
|
|
162
|
+
<div>
|
|
163
|
+
<div class="text-secondary small">Total Messages</div>
|
|
164
|
+
<div class="h1 mb-0" x-text="stats.messages ?? 0"></div>
|
|
165
|
+
</div>
|
|
166
|
+
<i class="ti ti-database ms-auto stat-card"></i>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<!-- Two-column: Agents + Recent messages -->
|
|
174
|
+
<div class="row g-3">
|
|
175
|
+
<div class="col-lg-5">
|
|
176
|
+
<div class="card">
|
|
177
|
+
<div class="card-header d-flex align-items-center">
|
|
178
|
+
<h3 class="card-title">Agents</h3>
|
|
179
|
+
<span class="badge bg-success text-white ms-auto" x-text="onlineCount + ' online'"></span>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="list-group list-group-flush" style="max-height: 60vh; overflow-y: auto">
|
|
182
|
+
<template x-for="a in sortedAgents.slice(0, 20)" :key="a.id">
|
|
183
|
+
<div class="list-group-item d-flex align-items-center gap-2" style="cursor:pointer" @click="selectedAgent = a.id; switchView('messages')">
|
|
184
|
+
<span class="status-dot" :class="[a.status, a.status !== 'offline' && !a.ready ? 'not-ready' : '']"></span>
|
|
185
|
+
<div class="flex-grow-1 min-width-0">
|
|
186
|
+
<div class="text-truncate">
|
|
187
|
+
<template x-if="a.label">
|
|
188
|
+
<span><span class="agent-label" x-text="a.label"></span> <span class="text-secondary small" x-text="a.name || a.id.slice(-8)"></span></span>
|
|
189
|
+
</template>
|
|
190
|
+
<template x-if="!a.label">
|
|
191
|
+
<span x-text="a.name || a.id.slice(-12)"></span>
|
|
192
|
+
</template>
|
|
193
|
+
</div>
|
|
194
|
+
<div class="text-secondary small text-truncate" x-text="a.id"></div>
|
|
195
|
+
</div>
|
|
196
|
+
<span class="text-secondary small" x-text="timeAgo(a.lastSeen)"></span>
|
|
197
|
+
</div>
|
|
198
|
+
</template>
|
|
199
|
+
<template x-if="sortedAgents.length === 0">
|
|
200
|
+
<div class="list-group-item text-secondary text-center">No agents</div>
|
|
201
|
+
</template>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div class="col-lg-7">
|
|
207
|
+
<div class="card">
|
|
208
|
+
<div class="card-header d-flex align-items-center">
|
|
209
|
+
<h3 class="card-title">Recent Messages</h3>
|
|
210
|
+
<span class="badge bg-info text-white ms-auto" x-text="messages.length + ' loaded'"></span>
|
|
211
|
+
</div>
|
|
212
|
+
<div class="card-body p-0" style="max-height: 60vh; overflow-y: auto">
|
|
213
|
+
<template x-for="m in messages.slice(0, 15)" :key="m.id">
|
|
214
|
+
<div class="p-3 border-bottom">
|
|
215
|
+
<div class="d-flex align-items-center gap-2 mb-1">
|
|
216
|
+
<span class="fw-bold small" x-text="displayTarget(m.from)"></span>
|
|
217
|
+
<i class="ti ti-arrow-right text-secondary" style="font-size:12px"></i>
|
|
218
|
+
<span class="small" x-text="displayTarget(m.to)"></span>
|
|
219
|
+
<span class="text-secondary small ms-auto" x-text="'#' + m.id"></span>
|
|
220
|
+
</div>
|
|
221
|
+
<div class="msg-body text-secondary" x-text="(m.body || '').slice(0, 200) + (m.body?.length > 200 ? '…' : '')"></div>
|
|
222
|
+
<div class="d-flex gap-2 mt-1">
|
|
223
|
+
<span class="text-secondary small" x-text="timeAgo(m.createdAt)"></span>
|
|
224
|
+
<template x-if="m.channel">
|
|
225
|
+
<span class="badge bg-warning-lt small" x-text="'#' + m.channel"></span>
|
|
226
|
+
</template>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</template>
|
|
230
|
+
<template x-if="messages.length === 0">
|
|
231
|
+
<div class="p-3 text-secondary text-center">No messages</div>
|
|
232
|
+
</template>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
132
237
|
</div>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
238
|
+
|
|
239
|
+
<!-- ==================== AGENTS ==================== -->
|
|
240
|
+
<div x-show="view === 'agents'" x-cloak class="fade-in">
|
|
241
|
+
|
|
242
|
+
<div class="d-flex align-items-center mb-3 gap-3 flex-wrap">
|
|
243
|
+
<h2 class="page-title mb-0">Agents</h2>
|
|
244
|
+
<div class="ms-auto d-flex gap-2 align-items-center">
|
|
245
|
+
<select class="form-select form-select-sm" style="width:auto" x-model="agentSort">
|
|
246
|
+
<option value="status">Sort: Status</option>
|
|
247
|
+
<option value="name">Sort: Name</option>
|
|
248
|
+
<option value="lastSeen">Sort: Last seen</option>
|
|
249
|
+
<option value="created">Sort: Created</option>
|
|
250
|
+
</select>
|
|
251
|
+
<button class="btn btn-sm btn-ghost-secondary" @click="agentSortDir = agentSortDir === 'asc' ? 'desc' : 'asc'">
|
|
252
|
+
<i class="ti" :class="agentSortDir === 'asc' ? 'ti-sort-ascending' : 'ti-sort-descending'"></i>
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div class="row g-3">
|
|
258
|
+
<template x-for="a in sortedAgents" :key="a.id">
|
|
259
|
+
<div class="col-md-6 col-xl-4">
|
|
260
|
+
<div class="card agent-card" :class="{ selected: selectedAgent === a.id }">
|
|
261
|
+
<div class="card-body">
|
|
262
|
+
<div class="d-flex align-items-start gap-2">
|
|
263
|
+
<span class="status-dot mt-1" :class="[a.status, a.status !== 'offline' && !a.ready ? 'not-ready' : '']" :title="a.status !== 'offline' && !a.ready ? 'Starting up…' : a.status"></span>
|
|
264
|
+
<div class="flex-grow-1 min-width-0">
|
|
265
|
+
<div class="d-flex align-items-center gap-2">
|
|
266
|
+
<template x-if="a.label">
|
|
267
|
+
<span class="agent-label text-truncate" x-text="a.label"></span>
|
|
268
|
+
</template>
|
|
269
|
+
<span class="text-truncate" :class="a.label ? 'text-secondary small' : 'fw-bold'" x-text="a.name || a.id.slice(-12)"></span>
|
|
270
|
+
</div>
|
|
271
|
+
<div class="text-secondary small text-truncate mt-1" x-text="a.id"></div>
|
|
272
|
+
<div class="d-flex gap-1 mt-1 flex-wrap">
|
|
273
|
+
<template x-if="a.machine">
|
|
274
|
+
<span class="badge bg-secondary-lt" x-text="a.machine"></span>
|
|
275
|
+
</template>
|
|
276
|
+
<template x-if="a.rig">
|
|
277
|
+
<span class="badge bg-primary-lt" x-text="a.rig"></span>
|
|
278
|
+
</template>
|
|
279
|
+
<template x-for="tag in (a.tags || [])" :key="tag">
|
|
280
|
+
<span class="badge bg-cyan-lt" x-text="tag"></span>
|
|
281
|
+
</template>
|
|
282
|
+
</div>
|
|
283
|
+
<template x-if="a.capabilities && a.capabilities.length">
|
|
284
|
+
<div class="d-flex gap-1 mt-1 flex-wrap">
|
|
285
|
+
<template x-for="cap in a.capabilities" :key="cap">
|
|
286
|
+
<span class="badge bg-purple-lt" x-text="'⚡' + cap"></span>
|
|
287
|
+
</template>
|
|
288
|
+
</div>
|
|
289
|
+
</template>
|
|
290
|
+
<div class="text-secondary small mt-2">
|
|
291
|
+
<span x-text="'Last seen: ' + timeAgo(a.lastSeen)"></span>
|
|
292
|
+
<span class="ms-2" x-text="'Created: ' + fmtTime(a.createdAt)"></span>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="agent-actions d-flex gap-1">
|
|
296
|
+
<button class="btn btn-sm btn-ghost-secondary p-1" title="Send message" @click.stop="openComposeToAgent(a)">
|
|
297
|
+
<i class="ti ti-send"></i>
|
|
298
|
+
</button>
|
|
299
|
+
<button class="btn btn-sm btn-ghost-secondary p-1" title="Rename" @click.stop="openRename(a)">
|
|
300
|
+
<i class="ti ti-pencil"></i>
|
|
301
|
+
</button>
|
|
302
|
+
<button class="btn btn-sm btn-ghost-secondary p-1" title="Filter messages" @click.stop="selectedAgent = a.id; switchView('messages')">
|
|
303
|
+
<i class="ti ti-filter"></i>
|
|
304
|
+
</button>
|
|
305
|
+
<template x-if="a.status === 'offline'">
|
|
306
|
+
<button class="btn btn-sm btn-ghost-danger p-1" title="Delete" @click.stop="openConfirm('Delete Agent', 'Delete agent ' + displayName(a) + '? This cannot be undone.', () => doDeleteAgent(a.id))">
|
|
307
|
+
<i class="ti ti-trash"></i>
|
|
308
|
+
</button>
|
|
309
|
+
</template>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
</template>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<template x-if="sortedAgents.length === 0">
|
|
319
|
+
<div class="card">
|
|
320
|
+
<div class="card-body text-center text-secondary py-5">
|
|
321
|
+
<i class="ti ti-robot-off" style="font-size:48px; opacity:0.3"></i>
|
|
322
|
+
<p class="mt-2" x-text="showOffline ? 'No agents registered' : 'No agents online — enable Show Offline'"></p>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</template>
|
|
136
326
|
</div>
|
|
137
|
-
|
|
138
|
-
|
|
327
|
+
|
|
328
|
+
<!-- ==================== MESSAGES ==================== -->
|
|
329
|
+
<div x-show="view === 'messages'" x-cloak class="fade-in">
|
|
330
|
+
|
|
331
|
+
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
|
332
|
+
<h2 class="page-title mb-0">Messages</h2>
|
|
333
|
+
<div class="ms-auto d-flex gap-2 align-items-center flex-wrap">
|
|
334
|
+
<select class="form-select form-select-sm" style="width: auto; min-width: 160px" x-model="selectedAgent" @change="fetchMessages()">
|
|
335
|
+
<option value="">All agents</option>
|
|
336
|
+
<template x-for="a in agents" :key="a.id">
|
|
337
|
+
<option :value="a.id" x-text="displayName(a) + ' [' + a.id.slice(-6) + ']'"></option>
|
|
338
|
+
</template>
|
|
339
|
+
</select>
|
|
340
|
+
<select class="form-select form-select-sm" style="width: auto; min-width: 120px" x-model="tagFilter">
|
|
341
|
+
<option value="">All tags</option>
|
|
342
|
+
<template x-for="tag in uniqueTags" :key="tag">
|
|
343
|
+
<option :value="tag" x-text="'# ' + tag"></option>
|
|
344
|
+
</template>
|
|
345
|
+
</select>
|
|
346
|
+
<input type="text" class="form-control form-control-sm" style="width: 140px" placeholder="Channel filter" x-model.debounce.300ms="channelFilter" @input="fetchMessages()">
|
|
347
|
+
<button class="btn btn-sm btn-primary" @click="openCompose()">
|
|
348
|
+
<i class="ti ti-send me-1"></i>Compose
|
|
349
|
+
</button>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<!-- Reply banner -->
|
|
354
|
+
<template x-if="replyTo">
|
|
355
|
+
<div class="alert alert-info d-flex align-items-center py-2 mb-3">
|
|
356
|
+
<i class="ti ti-corner-up-left me-2"></i>
|
|
357
|
+
<span>Replying to <strong x-text="'#' + replyTo.id"></strong> from <strong x-text="displayTarget(replyTo.from)"></strong></span>
|
|
358
|
+
<button class="btn btn-sm btn-ghost-secondary ms-auto" @click="cancelReply()"><i class="ti ti-x"></i></button>
|
|
359
|
+
</div>
|
|
360
|
+
</template>
|
|
361
|
+
|
|
362
|
+
<!-- Message feed -->
|
|
363
|
+
<div class="card">
|
|
364
|
+
<div class="card-body p-0" style="max-height: calc(100vh - 180px); overflow-y: auto">
|
|
365
|
+
<template x-for="group in groupedMessages" :key="group.threadId">
|
|
366
|
+
<div>
|
|
367
|
+
<template x-for="m in group.messages" :key="m.id">
|
|
368
|
+
<div class="msg-card p-3 border-bottom" :class="{ claimed: m.claimedBy, 'thread-child': m.id !== group.threadId }">
|
|
369
|
+
<div class="d-flex align-items-center gap-2 mb-1">
|
|
370
|
+
<span class="fw-bold small" x-text="displayTarget(m.from)"></span>
|
|
371
|
+
<i class="ti ti-arrow-right text-secondary" style="font-size:12px"></i>
|
|
372
|
+
<span class="small" x-text="displayTarget(m.to)"></span>
|
|
373
|
+
<span class="text-secondary small ms-auto" x-text="'#' + m.id + ' · ' + fmtTime(m.createdAt)"></span>
|
|
374
|
+
</div>
|
|
375
|
+
<template x-if="m.subject">
|
|
376
|
+
<div class="fw-bold small mb-1" x-text="m.subject"></div>
|
|
377
|
+
</template>
|
|
378
|
+
<div class="msg-body" x-text="m.body"></div>
|
|
379
|
+
<div class="d-flex align-items-center gap-2 mt-2 flex-wrap">
|
|
380
|
+
<button class="btn btn-sm btn-ghost-primary py-0 px-1" @click="startReply(m)">
|
|
381
|
+
<i class="ti ti-corner-up-left" style="font-size:14px"></i> Reply
|
|
382
|
+
</button>
|
|
383
|
+
<template x-if="m.claimable && !m.claimedBy">
|
|
384
|
+
<button class="btn btn-sm btn-ghost-warning py-0 px-1" @click="doClaim(m.id)">
|
|
385
|
+
<i class="ti ti-hand-grab" style="font-size:14px"></i> Claim
|
|
386
|
+
</button>
|
|
387
|
+
</template>
|
|
388
|
+
<button class="btn btn-sm btn-ghost-danger py-0 px-1" @click="openConfirm('Delete Message', 'Delete message #' + m.id + '?', () => doDeleteMessage(m.id))">
|
|
389
|
+
<i class="ti ti-trash" style="font-size:14px"></i>
|
|
390
|
+
</button>
|
|
391
|
+
<template x-if="m.channel">
|
|
392
|
+
<span class="badge bg-warning-lt" x-text="'#' + m.channel"></span>
|
|
393
|
+
</template>
|
|
394
|
+
<template x-if="m.claimedBy">
|
|
395
|
+
<span class="badge bg-success-lt" x-text="'claimed by ' + displayTarget(m.claimedBy)"></span>
|
|
396
|
+
</template>
|
|
397
|
+
<template x-if="m.claimable && !m.claimedBy">
|
|
398
|
+
<span class="badge bg-warning-lt">claimable</span>
|
|
399
|
+
</template>
|
|
400
|
+
<template x-if="m.id === group.threadId && group.messages.length > 1">
|
|
401
|
+
<button class="btn btn-sm btn-ghost-secondary py-0 px-1 ms-auto" @click="openThread(group.threadId)">
|
|
402
|
+
<i class="ti ti-messages" style="font-size:14px"></i>
|
|
403
|
+
<span x-text="(group.messages.length - 1) + ' replies'"></span>
|
|
404
|
+
</button>
|
|
405
|
+
</template>
|
|
406
|
+
<template x-if="m.replyTo && m.id !== group.threadId">
|
|
407
|
+
<span class="text-secondary small ms-auto" x-text="'reply to #' + m.replyTo"></span>
|
|
408
|
+
</template>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
</template>
|
|
412
|
+
</div>
|
|
413
|
+
</template>
|
|
414
|
+
<template x-if="filteredMessages.length === 0">
|
|
415
|
+
<div class="p-4 text-center text-secondary">
|
|
416
|
+
<i class="ti ti-messages-off" style="font-size:48px; opacity:0.3"></i>
|
|
417
|
+
<p class="mt-2">No messages</p>
|
|
418
|
+
</div>
|
|
419
|
+
</template>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
139
422
|
</div>
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
423
|
+
|
|
424
|
+
<!-- ==================== ANALYTICS ==================== -->
|
|
425
|
+
<div x-show="view === 'analytics'" x-cloak class="fade-in">
|
|
426
|
+
<h2 class="page-title mb-3">Analytics</h2>
|
|
427
|
+
|
|
428
|
+
<div class="row g-3 mb-3">
|
|
429
|
+
<div class="col-sm-6 col-lg-3">
|
|
430
|
+
<div class="card">
|
|
431
|
+
<div class="card-body text-center">
|
|
432
|
+
<div class="text-secondary small">Total Agents</div>
|
|
433
|
+
<div class="h2 mb-0" x-text="stats.agents ?? 0"></div>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
<div class="col-sm-6 col-lg-3">
|
|
438
|
+
<div class="card">
|
|
439
|
+
<div class="card-body text-center">
|
|
440
|
+
<div class="text-secondary small">Online Now</div>
|
|
441
|
+
<div class="h2 mb-0 text-success" x-text="onlineCount"></div>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
<div class="col-sm-6 col-lg-3">
|
|
446
|
+
<div class="card">
|
|
447
|
+
<div class="card-body text-center">
|
|
448
|
+
<div class="text-secondary small">Messages (24h)</div>
|
|
449
|
+
<div class="h2 mb-0 text-info" x-text="stats.messages24h ?? 0"></div>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
<div class="col-sm-6 col-lg-3">
|
|
454
|
+
<div class="card">
|
|
455
|
+
<div class="card-body text-center">
|
|
456
|
+
<div class="text-secondary small">All-time Messages</div>
|
|
457
|
+
<div class="h2 mb-0" x-text="stats.messages ?? 0"></div>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
|
|
463
|
+
<div class="row g-3">
|
|
464
|
+
<div class="col-lg-8">
|
|
465
|
+
<div class="card">
|
|
466
|
+
<div class="card-header"><h3 class="card-title">Message Volume</h3></div>
|
|
467
|
+
<div class="card-body">
|
|
468
|
+
<div id="chart-volume" style="height: 280px"></div>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
<div class="col-lg-4">
|
|
473
|
+
<div class="card">
|
|
474
|
+
<div class="card-header"><h3 class="card-title">Agent Status</h3></div>
|
|
475
|
+
<div class="card-body">
|
|
476
|
+
<div id="chart-status" style="height: 280px"></div>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
<div class="row g-3 mt-0">
|
|
483
|
+
<div class="col-lg-12">
|
|
484
|
+
<div class="card">
|
|
485
|
+
<div class="card-header"><h3 class="card-title">Messages per Agent (Top 10)</h3></div>
|
|
486
|
+
<div class="card-body">
|
|
487
|
+
<div id="chart-agents" style="height: 280px"></div>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
143
492
|
</div>
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
493
|
+
|
|
494
|
+
</div>
|
|
495
|
+
</main>
|
|
496
|
+
|
|
497
|
+
<!-- ==================== COMPOSE MODAL ==================== -->
|
|
498
|
+
<div class="modal modal-blur" :class="{ show: composeOpen }" :style="composeOpen ? 'display:block' : 'display:none'" tabindex="-1" @click.self="composeOpen = false">
|
|
499
|
+
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
500
|
+
<div class="modal-content">
|
|
501
|
+
<div class="modal-header">
|
|
502
|
+
<h5 class="modal-title">
|
|
503
|
+
<template x-if="replyTo">
|
|
504
|
+
<span>Reply to #<span x-text="replyTo.id"></span></span>
|
|
505
|
+
</template>
|
|
506
|
+
<template x-if="!replyTo">
|
|
507
|
+
<span>Compose Message</span>
|
|
508
|
+
</template>
|
|
509
|
+
</h5>
|
|
510
|
+
<button class="btn-close" @click="composeOpen = false"></button>
|
|
148
511
|
</div>
|
|
149
|
-
<div class="
|
|
150
|
-
<
|
|
151
|
-
|
|
512
|
+
<div class="modal-body">
|
|
513
|
+
<div class="row g-3">
|
|
514
|
+
<div class="col-md-6">
|
|
515
|
+
<label class="form-label">From</label>
|
|
516
|
+
<select class="form-select" x-model="compose.from">
|
|
517
|
+
<option value="">Select sender…</option>
|
|
518
|
+
<template x-for="a in composeAgents" :key="a.id">
|
|
519
|
+
<option :value="a.id" x-text="displayName(a) + ' [' + a.id.slice(-6) + ']'"></option>
|
|
520
|
+
</template>
|
|
521
|
+
</select>
|
|
522
|
+
</div>
|
|
523
|
+
<div class="col-md-6">
|
|
524
|
+
<label class="form-label">To</label>
|
|
525
|
+
<select class="form-select" x-model="compose.to">
|
|
526
|
+
<option value="">Select recipient…</option>
|
|
527
|
+
<option value="broadcast">📢 Broadcast (all)</option>
|
|
528
|
+
<optgroup label="Labels">
|
|
529
|
+
<template x-for="lbl in uniqueLabels" :key="lbl">
|
|
530
|
+
<option :value="'label:' + lbl" x-text="'★ ' + lbl"></option>
|
|
531
|
+
</template>
|
|
532
|
+
</optgroup>
|
|
533
|
+
<optgroup label="Capabilities">
|
|
534
|
+
<template x-for="cap in uniqueCaps" :key="cap">
|
|
535
|
+
<option :value="'cap:' + cap" x-text="'⚡ ' + cap"></option>
|
|
536
|
+
</template>
|
|
537
|
+
</optgroup>
|
|
538
|
+
<optgroup label="Tags">
|
|
539
|
+
<template x-for="tag in uniqueTags" :key="tag">
|
|
540
|
+
<option :value="'tag:' + tag" x-text="'# ' + tag"></option>
|
|
541
|
+
</template>
|
|
542
|
+
</optgroup>
|
|
543
|
+
<optgroup label="Agents">
|
|
544
|
+
<template x-for="a in composeAgents" :key="a.id">
|
|
545
|
+
<option :value="a.id" x-text="displayName(a) + ' [' + a.id.slice(-6) + ']'"></option>
|
|
546
|
+
</template>
|
|
547
|
+
</optgroup>
|
|
548
|
+
</select>
|
|
549
|
+
</div>
|
|
550
|
+
<div class="col-md-6">
|
|
551
|
+
<label class="form-label">Channel <span class="text-secondary">(optional)</span></label>
|
|
552
|
+
<input type="text" class="form-control" x-model="compose.channel" placeholder="e.g. general">
|
|
553
|
+
</div>
|
|
554
|
+
<div class="col-md-6">
|
|
555
|
+
<label class="form-label">Subject <span class="text-secondary">(optional)</span></label>
|
|
556
|
+
<input type="text" class="form-control" x-model="compose.subject">
|
|
557
|
+
</div>
|
|
558
|
+
<div class="col-12">
|
|
559
|
+
<label class="form-label">Message</label>
|
|
560
|
+
<textarea class="form-control" rows="5" x-model="compose.body" placeholder="Type your message…" x-ref="composeBody"></textarea>
|
|
561
|
+
</div>
|
|
562
|
+
<div class="col-12">
|
|
563
|
+
<label class="form-check">
|
|
564
|
+
<input type="checkbox" class="form-check-input" x-model="compose.claimable">
|
|
565
|
+
<span class="form-check-label">Claimable task (only one agent can claim)</span>
|
|
566
|
+
</label>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
152
569
|
</div>
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
<
|
|
156
|
-
|
|
570
|
+
<div class="modal-footer">
|
|
571
|
+
<button class="btn btn-ghost-secondary" @click="composeOpen = false">Cancel</button>
|
|
572
|
+
<button class="btn btn-primary" @click="doSend()">
|
|
573
|
+
<i class="ti ti-send me-1"></i>Send
|
|
574
|
+
</button>
|
|
157
575
|
</div>
|
|
158
576
|
</div>
|
|
159
577
|
</div>
|
|
160
578
|
</div>
|
|
161
|
-
|
|
579
|
+
<div class="modal-backdrop fade show" x-show="composeOpen" x-cloak></div>
|
|
580
|
+
|
|
581
|
+
<!-- ==================== THREAD MODAL ==================== -->
|
|
582
|
+
<div class="modal modal-blur" :class="{ show: threadOpen }" :style="threadOpen ? 'display:block' : 'display:none'" tabindex="-1" @click.self="threadOpen = false">
|
|
583
|
+
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
|
584
|
+
<div class="modal-content">
|
|
585
|
+
<div class="modal-header">
|
|
586
|
+
<h5 class="modal-title">Thread</h5>
|
|
587
|
+
<button class="btn-close" @click="threadOpen = false"></button>
|
|
588
|
+
</div>
|
|
589
|
+
<div class="modal-body p-0">
|
|
590
|
+
<template x-for="m in threadMessages" :key="m.id">
|
|
591
|
+
<div class="p-3 border-bottom" :class="{ 'ms-4 border-start': m.id !== threadMessages[0]?.id }">
|
|
592
|
+
<div class="d-flex align-items-center gap-2 mb-1">
|
|
593
|
+
<span class="fw-bold small" x-text="displayTarget(m.from)"></span>
|
|
594
|
+
<i class="ti ti-arrow-right text-secondary" style="font-size:12px"></i>
|
|
595
|
+
<span class="small" x-text="displayTarget(m.to)"></span>
|
|
596
|
+
<span class="text-secondary small ms-auto" x-text="'#' + m.id + ' · ' + fmtTime(m.createdAt)"></span>
|
|
597
|
+
</div>
|
|
598
|
+
<template x-if="m.subject">
|
|
599
|
+
<div class="fw-bold small mb-1" x-text="m.subject"></div>
|
|
600
|
+
</template>
|
|
601
|
+
<div class="msg-body" x-text="m.body"></div>
|
|
602
|
+
</div>
|
|
603
|
+
</template>
|
|
604
|
+
<template x-if="threadMessages.length === 0">
|
|
605
|
+
<div class="p-4 text-center text-secondary">Loading…</div>
|
|
606
|
+
</template>
|
|
607
|
+
</div>
|
|
608
|
+
<div class="modal-footer">
|
|
609
|
+
<button class="btn btn-ghost-secondary" @click="threadOpen = false">Close</button>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
<div class="modal-backdrop fade show" x-show="threadOpen" x-cloak></div>
|
|
162
615
|
|
|
163
|
-
|
|
164
|
-
<div class="
|
|
165
|
-
|
|
166
|
-
|
|
616
|
+
<!-- ==================== CONFIRM MODAL ==================== -->
|
|
617
|
+
<div class="modal modal-blur" :class="{ show: confirmModal.show }" :style="confirmModal.show ? 'display:block' : 'display:none'" tabindex="-1" @click.self="confirmModal.show = false">
|
|
618
|
+
<div class="modal-dialog modal-sm modal-dialog-centered">
|
|
619
|
+
<div class="modal-content">
|
|
620
|
+
<div class="modal-header">
|
|
621
|
+
<h5 class="modal-title" x-text="confirmModal.title"></h5>
|
|
622
|
+
<button class="btn-close" @click="confirmModal.show = false"></button>
|
|
623
|
+
</div>
|
|
624
|
+
<div class="modal-body" x-text="confirmModal.message"></div>
|
|
625
|
+
<div class="modal-footer">
|
|
626
|
+
<button class="btn btn-ghost-secondary" @click="confirmModal.show = false">Cancel</button>
|
|
627
|
+
<button class="btn btn-danger" @click="confirmModal.action?.(); confirmModal.show = false">Confirm</button>
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
<div class="modal-backdrop fade show" x-show="confirmModal.show" x-cloak></div>
|
|
633
|
+
|
|
634
|
+
<!-- ==================== RENAME MODAL ==================== -->
|
|
635
|
+
<div class="modal modal-blur" :class="{ show: renameModal.show }" :style="renameModal.show ? 'display:block' : 'display:none'" tabindex="-1" @click.self="renameModal.show = false">
|
|
636
|
+
<div class="modal-dialog modal-sm modal-dialog-centered">
|
|
637
|
+
<div class="modal-content">
|
|
638
|
+
<div class="modal-header">
|
|
639
|
+
<h5 class="modal-title">Rename Agent</h5>
|
|
640
|
+
<button class="btn-close" @click="renameModal.show = false"></button>
|
|
641
|
+
</div>
|
|
642
|
+
<div class="modal-body">
|
|
643
|
+
<label class="form-label">Label</label>
|
|
644
|
+
<input type="text" class="form-control" x-model="renameModal.label" placeholder="Human-friendly label (empty to clear)" x-ref="renameInput"
|
|
645
|
+
@keydown.enter="doRename()">
|
|
646
|
+
</div>
|
|
647
|
+
<div class="modal-footer">
|
|
648
|
+
<button class="btn btn-ghost-secondary" @click="renameModal.show = false">Cancel</button>
|
|
649
|
+
<button class="btn btn-primary" @click="doRename()">Save</button>
|
|
650
|
+
</div>
|
|
651
|
+
</div>
|
|
652
|
+
</div>
|
|
167
653
|
</div>
|
|
168
|
-
<div class="
|
|
654
|
+
<div class="modal-backdrop fade show" x-show="renameModal.show" x-cloak></div>
|
|
655
|
+
|
|
169
656
|
</div>
|
|
170
657
|
|
|
658
|
+
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
|
|
659
|
+
<script src="https://cdn.jsdelivr.net/npm/apexcharts@latest/dist/apexcharts.min.js"></script>
|
|
660
|
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
|
171
661
|
<script>
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
let replyTo = null; // {id, from, body} of message being replied to
|
|
178
|
-
|
|
179
|
-
// Display helpers — prefer label, fallback to name, fallback to id
|
|
180
|
-
function displayName(a) { return a?.label || a?.name || a?.id || '?'; }
|
|
181
|
-
function displayById(id) {
|
|
182
|
-
if (!id) return '';
|
|
183
|
-
if (id.startsWith('tag:')) return '#' + id.slice(4);
|
|
184
|
-
if (id.startsWith('cap:')) return '⚡' + id.slice(4);
|
|
185
|
-
if (id.startsWith('label:')) return '★' + id.slice(6);
|
|
186
|
-
if (id === 'broadcast') return '📢 broadcast';
|
|
187
|
-
const a = agentsById[id];
|
|
188
|
-
return a ? displayName(a) : id;
|
|
189
|
-
}
|
|
662
|
+
function relay() {
|
|
663
|
+
const load = (key, fallback) => {
|
|
664
|
+
try { const v = localStorage.getItem('ar-' + key); return v !== null ? JSON.parse(v) : fallback; }
|
|
665
|
+
catch { return fallback; }
|
|
666
|
+
};
|
|
190
667
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const s = await r.json();
|
|
195
|
-
document.getElementById('stat-agents').textContent = s.agents;
|
|
196
|
-
document.getElementById('stat-online').textContent = s.online;
|
|
197
|
-
document.getElementById('stat-msgs24').textContent = s.messagesLast24h;
|
|
198
|
-
document.getElementById('stat-msgs').textContent = s.messages;
|
|
199
|
-
} catch {}
|
|
200
|
-
}
|
|
668
|
+
return {
|
|
669
|
+
// Navigation
|
|
670
|
+
view: load('view', 'overview'),
|
|
201
671
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
renderAgents();
|
|
208
|
-
updateAgentFilter();
|
|
209
|
-
} catch {}
|
|
210
|
-
}
|
|
672
|
+
// Persisted preferences
|
|
673
|
+
showOffline: load('showOffline', false),
|
|
674
|
+
autoRefresh: load('autoRefresh', true),
|
|
675
|
+
agentSort: load('agentSort', 'status'),
|
|
676
|
+
agentSortDir: load('agentSortDir', 'asc'),
|
|
211
677
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (agentId) params.set('for', agentId);
|
|
218
|
-
if (channel) params.set('channel', channel);
|
|
219
|
-
params.set('limit', '100');
|
|
220
|
-
const r = await fetch(`${API}/messages?${params}`);
|
|
221
|
-
messages = await r.json();
|
|
222
|
-
renderMessages();
|
|
223
|
-
} catch {}
|
|
224
|
-
}
|
|
678
|
+
// Data
|
|
679
|
+
agents: [],
|
|
680
|
+
agentsById: {},
|
|
681
|
+
messages: [],
|
|
682
|
+
stats: {},
|
|
225
683
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const renameable = a.id !== 'user';
|
|
234
|
-
const actions = `
|
|
235
|
-
<span class="agent-actions">
|
|
236
|
-
${renameable ? `<button class="rename" onclick="event.stopPropagation(); doRenameAgent('${esc(a.id)}')" title="Rename (set label)">✎</button>` : ''}
|
|
237
|
-
${deletable ? `<button class="delete" onclick="event.stopPropagation(); doDeleteAgent('${esc(a.id)}')" title="Delete agent">×</button>` : ''}
|
|
238
|
-
</span>`;
|
|
239
|
-
const nameHtml = a.label
|
|
240
|
-
? `<span class="agent-label">${esc(a.label)}</span> <span style="color:var(--text-dim);font-size:11px;">(${esc(a.name)})</span>`
|
|
241
|
-
: esc(a.name);
|
|
242
|
-
return `
|
|
243
|
-
<div class="agent ${selectedAgent === a.id ? 'selected' : ''}" onclick="selectAgent('${esc(a.id)}')">
|
|
244
|
-
${actions}
|
|
245
|
-
<div class="agent-name"><span class="dot ${a.status}"></span>${nameHtml}</div>
|
|
246
|
-
<div class="agent-meta">${esc(a.id)} ${a.machine ? '@ ' + esc(a.machine) : ''} ${a.rig ? '(' + esc(a.rig) + ')' : ''} · ${new Date(a.createdAt).toLocaleTimeString()}</div>
|
|
247
|
-
${a.tags.length ? `<div class="agent-tags">${a.tags.map(t => `<span class="tag">${esc(t)}</span>`).join('')}</div>` : ''}
|
|
248
|
-
</div>`;
|
|
249
|
-
}).join('');
|
|
250
|
-
}
|
|
684
|
+
// UI state
|
|
685
|
+
selectedAgent: '',
|
|
686
|
+
replyTo: null,
|
|
687
|
+
composeOpen: false,
|
|
688
|
+
threadOpen: false,
|
|
689
|
+
threadMessages: [],
|
|
690
|
+
connected: false,
|
|
251
691
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const next = prompt(`Label for "${id}" (blank to clear):`, current);
|
|
255
|
-
if (next === null) return;
|
|
256
|
-
const r = await fetch(`${API}/agents/${encodeURIComponent(id)}/label`, {
|
|
257
|
-
method: 'PATCH',
|
|
258
|
-
headers: { 'Content-Type': 'application/json' },
|
|
259
|
-
body: JSON.stringify({ label: next.trim() || null }),
|
|
260
|
-
});
|
|
261
|
-
if (!r.ok) {
|
|
262
|
-
const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
|
|
263
|
-
alert(`Rename failed: ${err.error || 'unknown error'}`);
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
refresh();
|
|
267
|
-
}
|
|
692
|
+
// Compose form
|
|
693
|
+
compose: { from: '', to: '', body: '', channel: '', subject: '', claimable: false },
|
|
268
694
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (!r.ok) {
|
|
273
|
-
const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
|
|
274
|
-
alert(`Delete failed: ${err.error || 'unknown error'}`);
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
if (selectedAgent === id) selectedAgent = null;
|
|
278
|
-
refresh();
|
|
279
|
-
}
|
|
695
|
+
// Modals
|
|
696
|
+
confirmModal: { show: false, title: '', message: '', action: null },
|
|
697
|
+
renameModal: { show: false, agentId: '', label: '' },
|
|
280
698
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (m.threadId) counts[m.threadId] = (counts[m.threadId] || 0) + 1;
|
|
285
|
-
}
|
|
286
|
-
return counts;
|
|
287
|
-
}
|
|
699
|
+
// Filters
|
|
700
|
+
channelFilter: '',
|
|
701
|
+
tagFilter: '',
|
|
288
702
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (!messages.length) { el.innerHTML = '<div class="empty">No messages</div>'; return; }
|
|
292
|
-
const tc = threadCounts();
|
|
293
|
-
// Group by thread, sort threads newest-first, messages within thread chronologically
|
|
294
|
-
const threads = new Map();
|
|
295
|
-
for (const m of messages) {
|
|
296
|
-
const tid = m.threadId || m.id;
|
|
297
|
-
if (!threads.has(tid)) threads.set(tid, []);
|
|
298
|
-
threads.get(tid).push(m);
|
|
299
|
-
}
|
|
300
|
-
for (const msgs of threads.values()) msgs.sort((a, b) => a.id - b.id);
|
|
301
|
-
const sorted = [...threads.values()]
|
|
302
|
-
.sort((a, b) => b[b.length - 1].id - a[a.length - 1].id)
|
|
303
|
-
.flat();
|
|
304
|
-
el.innerHTML = sorted.map(m => {
|
|
305
|
-
const replies = (tc[m.threadId] || 1) - 1;
|
|
306
|
-
const isReply = m.replyTo != null;
|
|
307
|
-
const claimInfo = m.claimable
|
|
308
|
-
? (m.claimedBy
|
|
309
|
-
? `<span class="msg-claimed">claimed by ${esc(m.claimedBy)}</span>`
|
|
310
|
-
: `<span class="msg-claimable">claimable</span>`)
|
|
311
|
-
: '';
|
|
312
|
-
const claimBtn = m.claimable && !m.claimedBy
|
|
313
|
-
? `<button class="msg-claim-btn" onclick="doClaim(${m.id})">Claim</button>`
|
|
314
|
-
: '';
|
|
315
|
-
return `
|
|
316
|
-
<div class="msg${isReply ? ' thread-child' : ''}${m.claimedBy ? ' claimed' : ''}" data-id="${m.id}">
|
|
317
|
-
<div class="msg-head">
|
|
318
|
-
<span><span class="msg-from" title="${esc(m.from)}">${esc(displayById(m.from))}</span> <span class="msg-to" title="${esc(m.to)}">→ ${esc(displayById(m.to))}</span></span>
|
|
319
|
-
<span class="msg-time">#${m.id} · ${new Date(m.createdAt).toLocaleString()}</span>
|
|
320
|
-
</div>
|
|
321
|
-
${m.subject ? `<div class="msg-subject">${esc(m.subject)}</div>` : ''}
|
|
322
|
-
<div class="msg-body">${esc(m.body)}</div>
|
|
323
|
-
${m.channel ? `<div class="msg-channel">#${esc(m.channel)}</div>` : ''}
|
|
324
|
-
<div class="msg-footer">
|
|
325
|
-
<button class="msg-reply-btn" onclick="startReply(${m.id}, '${esc(m.from)}')">Reply</button>
|
|
326
|
-
${claimBtn}
|
|
327
|
-
<button class="msg-delete-btn" onclick="doDeleteMessage(${m.id})">Delete</button>
|
|
328
|
-
${claimInfo}
|
|
329
|
-
${!isReply && replies > 0 ? `<span class="msg-thread-link" onclick="openThread(${m.threadId})">${replies} repl${replies === 1 ? 'y' : 'ies'}</span>` : ''}
|
|
330
|
-
${isReply ? `<span class="msg-thread-info">reply to #${m.replyTo}</span>` : ''}
|
|
331
|
-
</div>
|
|
332
|
-
</div>`;
|
|
333
|
-
}).join('');
|
|
334
|
-
}
|
|
703
|
+
// Charts
|
|
704
|
+
chartInstances: {},
|
|
335
705
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
sel.innerHTML = '<option value="">All agents</option>' +
|
|
340
|
-
agents.map(a => `<option value="${esc(a.id)}" ${a.id === current ? 'selected' : ''}>${esc(displayName(a))} [${esc(a.id.slice(-6))}]</option>`).join('');
|
|
341
|
-
updateComposeDropdowns();
|
|
342
|
-
}
|
|
706
|
+
// SSE + polling
|
|
707
|
+
_es: null,
|
|
708
|
+
_statsTimer: null,
|
|
343
709
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const showOffline = document.getElementById('show-offline').checked;
|
|
351
|
-
const visible = showOffline ? agents : agents.filter(a => a.status !== 'offline');
|
|
352
|
-
|
|
353
|
-
// Tags/caps/labels follow the same filter so we don't suggest routing to empty groups
|
|
354
|
-
const tags = [...new Set(visible.flatMap(a => a.tags))].sort();
|
|
355
|
-
const caps = [...new Set(visible.flatMap(a => a.capabilities))].sort();
|
|
356
|
-
const labels = [...new Set(visible.map(a => a.label).filter(Boolean))].sort();
|
|
357
|
-
|
|
358
|
-
fromSel.innerHTML = '<option value="">From...</option>' +
|
|
359
|
-
visible.map(a => `<option value="${esc(a.id)}" ${a.id === fromVal ? 'selected' : ''}>${esc(displayName(a))} [${esc(a.id.slice(-6))}]</option>`).join('');
|
|
360
|
-
|
|
361
|
-
toSel.innerHTML = '<option value="">To...</option>' +
|
|
362
|
-
'<option value="broadcast">📢 Broadcast (all)</option>' +
|
|
363
|
-
labels.map(l => `<option value="label:${esc(l)}" ${('label:'+l) === toVal ? 'selected' : ''}>★ ${esc(l)}</option>`).join('') +
|
|
364
|
-
caps.map(c => `<option value="cap:${esc(c)}" ${('cap:'+c) === toVal ? 'selected' : ''}>⚡ cap: ${esc(c)}</option>`).join('') +
|
|
365
|
-
tags.map(t => `<option value="tag:${esc(t)}" ${('tag:'+t) === toVal ? 'selected' : ''}>#${esc(t)}</option>`).join('') +
|
|
366
|
-
visible.map(a => `<option value="${esc(a.id)}" ${a.id === toVal ? 'selected' : ''}>${esc(displayName(a))} [${esc(a.id.slice(-6))}]</option>`).join('');
|
|
367
|
-
|
|
368
|
-
// Restore selections even if the selected agent is now filtered out
|
|
369
|
-
if (fromVal && !fromSel.querySelector(`option[value="${CSS.escape(fromVal)}"]`)) {
|
|
370
|
-
fromSel.insertAdjacentHTML('beforeend', `<option value="${esc(fromVal)}" selected>${esc(fromVal)} (offline)</option>`);
|
|
371
|
-
}
|
|
372
|
-
if (toVal && !toSel.querySelector(`option[value="${CSS.escape(toVal)}"]`)) {
|
|
373
|
-
toSel.insertAdjacentHTML('beforeend', `<option value="${esc(toVal)}" selected>${esc(toVal)} (offline)</option>`);
|
|
374
|
-
}
|
|
375
|
-
if (fromVal) fromSel.value = fromVal;
|
|
376
|
-
if (toVal) toSel.value = toVal;
|
|
377
|
-
}
|
|
710
|
+
async init() {
|
|
711
|
+
await this.refresh();
|
|
712
|
+
this.connectSSE();
|
|
713
|
+
// Slow poll for stats only — SSE handles real-time data
|
|
714
|
+
this._statsTimer = setInterval(() => this.fetchStats(), 30_000);
|
|
378
715
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
716
|
+
this.$watch('showOffline', v => this.save('showOffline', v));
|
|
717
|
+
this.$watch('agentSort', v => this.save('agentSort', v));
|
|
718
|
+
this.$watch('agentSortDir', v => this.save('agentSortDir', v));
|
|
719
|
+
this.$watch('view', v => {
|
|
720
|
+
this.save('view', v);
|
|
721
|
+
if (v === 'analytics') this.$nextTick(() => this.renderCharts());
|
|
722
|
+
});
|
|
723
|
+
},
|
|
385
724
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const channel = document.getElementById('c-channel').value.trim() || undefined;
|
|
391
|
-
const subject = document.getElementById('c-subject').value.trim() || undefined;
|
|
392
|
-
if (!from || !to || !body) return alert('from, to, and body required');
|
|
393
|
-
const claimable = document.getElementById('c-claimable').checked;
|
|
394
|
-
const payload = { from, to, body, channel, subject };
|
|
395
|
-
if (replyTo) payload.replyTo = replyTo.id;
|
|
396
|
-
if (claimable) payload.claimable = true;
|
|
397
|
-
const r = await fetch(`${API}/messages`, {
|
|
398
|
-
method: 'POST',
|
|
399
|
-
headers: { 'Content-Type': 'application/json' },
|
|
400
|
-
body: JSON.stringify(payload),
|
|
401
|
-
});
|
|
402
|
-
if (!r.ok) {
|
|
403
|
-
const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
|
|
404
|
-
alert(`Send failed: ${err.error || 'unknown error'}`);
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
document.getElementById('c-body').value = '';
|
|
408
|
-
document.getElementById('c-subject').value = '';
|
|
409
|
-
cancelReply();
|
|
410
|
-
fetchMessages();
|
|
411
|
-
}
|
|
725
|
+
connectSSE() {
|
|
726
|
+
if (this._es) this._es.close();
|
|
727
|
+
const es = new EventSource(window.location.origin + '/api/events');
|
|
728
|
+
this._es = es;
|
|
412
729
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
replyTo = { id: msgId, from: fromAgent };
|
|
416
|
-
document.getElementById('reply-banner').style.display = 'flex';
|
|
417
|
-
document.getElementById('reply-to-label').textContent = `#${msgId} from ${fromAgent}`;
|
|
418
|
-
// Auto-set "to" as the original sender
|
|
419
|
-
const toSel = document.getElementById('c-to');
|
|
420
|
-
toSel.value = fromAgent;
|
|
421
|
-
document.getElementById('c-body').focus();
|
|
422
|
-
}
|
|
730
|
+
es.addEventListener('connected', () => { this.connected = true; });
|
|
731
|
+
es.onerror = () => { this.connected = false; };
|
|
423
732
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
733
|
+
es.addEventListener('message.new', (e) => {
|
|
734
|
+
const msg = JSON.parse(e.data);
|
|
735
|
+
if (this.messages.some(m => m.id === msg.id)) return;
|
|
736
|
+
if (this.selectedAgent && msg.from !== this.selectedAgent && msg.to !== this.selectedAgent) return;
|
|
737
|
+
if (this.channelFilter && msg.channel !== this.channelFilter) return;
|
|
738
|
+
this.messages.push(msg);
|
|
739
|
+
if (this.messages.length > 200) this.messages.shift();
|
|
740
|
+
this.stats.messages = (this.stats.messages ?? 0) + 1;
|
|
741
|
+
});
|
|
428
742
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
el.innerHTML = thread.map((m, i) => `
|
|
440
|
-
<div class="msg${i > 0 ? ' thread-child' : ''}" style="margin: 8px;">
|
|
441
|
-
<div class="msg-head">
|
|
442
|
-
<span><span class="msg-from" title="${esc(m.from)}">${esc(displayById(m.from))}</span> <span class="msg-to" title="${esc(m.to)}">→ ${esc(displayById(m.to))}</span></span>
|
|
443
|
-
<span class="msg-time">#${m.id} · ${new Date(m.createdAt).toLocaleString()}</span>
|
|
444
|
-
</div>
|
|
445
|
-
${m.subject ? `<div class="msg-subject">${esc(m.subject)}</div>` : ''}
|
|
446
|
-
<div class="msg-body">${esc(m.body)}</div>
|
|
447
|
-
</div>
|
|
448
|
-
`).join('');
|
|
449
|
-
document.getElementById('thread-overlay').style.display = 'flex';
|
|
450
|
-
} catch (e) {
|
|
451
|
-
alert(`Thread load failed: ${e.message || e}`);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
743
|
+
es.addEventListener('agent.status', (e) => {
|
|
744
|
+
const data = JSON.parse(e.data);
|
|
745
|
+
const idx = this.agents.findIndex(a => a.id === data.id);
|
|
746
|
+
if (idx >= 0) {
|
|
747
|
+
this.agents[idx] = data;
|
|
748
|
+
} else {
|
|
749
|
+
this.agents.push(data);
|
|
750
|
+
}
|
|
751
|
+
this.agentsById[data.id] = data;
|
|
752
|
+
});
|
|
454
753
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
754
|
+
es.addEventListener('agent.removed', (e) => {
|
|
755
|
+
const { id } = JSON.parse(e.data);
|
|
756
|
+
this.agents = this.agents.filter(a => a.id !== id);
|
|
757
|
+
delete this.agentsById[id];
|
|
758
|
+
});
|
|
458
759
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
alert(`Delete failed: ${err.error || 'unknown error'}`);
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
fetchMessages();
|
|
468
|
-
fetchStats();
|
|
469
|
-
}
|
|
760
|
+
es.addEventListener('message.claimed', (e) => {
|
|
761
|
+
const { messageId, claimedBy } = JSON.parse(e.data);
|
|
762
|
+
const msg = this.messages.find(m => m.id === messageId);
|
|
763
|
+
if (msg) msg.claimedBy = claimedBy;
|
|
764
|
+
});
|
|
470
765
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
766
|
+
es.addEventListener('message.deleted', (e) => {
|
|
767
|
+
const { messageId } = JSON.parse(e.data);
|
|
768
|
+
this.messages = this.messages.filter(m => m.id !== messageId);
|
|
769
|
+
});
|
|
770
|
+
},
|
|
771
|
+
|
|
772
|
+
save(key, value) {
|
|
773
|
+
localStorage.setItem('ar-' + key, JSON.stringify(value));
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
switchView(v) {
|
|
777
|
+
this.view = v;
|
|
778
|
+
if (v === 'messages') this.fetchMessages();
|
|
779
|
+
},
|
|
780
|
+
|
|
781
|
+
// ── API ──
|
|
782
|
+
|
|
783
|
+
async api(method, path, body) {
|
|
784
|
+
const opts = { method, headers: {} };
|
|
785
|
+
if (body) {
|
|
786
|
+
opts.headers['Content-Type'] = 'application/json';
|
|
787
|
+
opts.body = JSON.stringify(body);
|
|
788
|
+
}
|
|
789
|
+
const r = await fetch(window.location.origin + '/api' + path, opts);
|
|
790
|
+
if (!r.ok) {
|
|
791
|
+
const text = await r.text();
|
|
792
|
+
throw new Error(text || r.statusText);
|
|
793
|
+
}
|
|
794
|
+
return r.json();
|
|
795
|
+
},
|
|
796
|
+
|
|
797
|
+
async refresh() {
|
|
798
|
+
await Promise.all([this.fetchStats(), this.fetchAgents(), this.fetchMessages()]);
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
async fetchStats() {
|
|
802
|
+
try { this.stats = await this.api('GET', '/stats'); } catch {}
|
|
803
|
+
},
|
|
804
|
+
|
|
805
|
+
async fetchAgents() {
|
|
806
|
+
try {
|
|
807
|
+
this.agents = await this.api('GET', '/agents');
|
|
808
|
+
this.agentsById = {};
|
|
809
|
+
for (const a of this.agents) this.agentsById[a.id] = a;
|
|
810
|
+
} catch {}
|
|
811
|
+
},
|
|
812
|
+
|
|
813
|
+
async fetchMessages() {
|
|
814
|
+
try {
|
|
815
|
+
let path = '/messages?limit=100';
|
|
816
|
+
if (this.selectedAgent) path += '&for=' + encodeURIComponent(this.selectedAgent);
|
|
817
|
+
if (this.channelFilter) path += '&channel=' + encodeURIComponent(this.channelFilter);
|
|
818
|
+
this.messages = await this.api('GET', path);
|
|
819
|
+
} catch {}
|
|
820
|
+
},
|
|
821
|
+
|
|
822
|
+
// ── Computed ──
|
|
823
|
+
|
|
824
|
+
get onlineCount() {
|
|
825
|
+
return this.agents.filter(a => a.status === 'online').length;
|
|
826
|
+
},
|
|
827
|
+
|
|
828
|
+
get sortedAgents() {
|
|
829
|
+
let list = this.showOffline ? [...this.agents] : this.agents.filter(a => a.status === 'online');
|
|
830
|
+
const dir = this.agentSortDir === 'desc' ? -1 : 1;
|
|
831
|
+
return list.sort((a, b) => {
|
|
832
|
+
let cmp = 0;
|
|
833
|
+
switch (this.agentSort) {
|
|
834
|
+
case 'name': cmp = this.displayName(a).localeCompare(this.displayName(b)); break;
|
|
835
|
+
case 'status': {
|
|
836
|
+
const order = { online: 0, busy: 1, idle: 2, offline: 3 };
|
|
837
|
+
cmp = (order[a.status] ?? 9) - (order[b.status] ?? 9);
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
case 'lastSeen': cmp = new Date(b.lastSeen) - new Date(a.lastSeen); break;
|
|
841
|
+
case 'created': cmp = new Date(b.createdAt) - new Date(a.createdAt); break;
|
|
842
|
+
}
|
|
843
|
+
return cmp * dir;
|
|
844
|
+
});
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
get filteredMessages() {
|
|
848
|
+
if (!this.tagFilter) return this.messages;
|
|
849
|
+
const tag = this.tagFilter;
|
|
850
|
+
return this.messages.filter(m => {
|
|
851
|
+
if (m.to === 'tag:' + tag) return true;
|
|
852
|
+
const fromAgent = this.agentsById[m.from];
|
|
853
|
+
if (fromAgent?.tags?.includes(tag)) return true;
|
|
854
|
+
const toAgent = this.agentsById[m.to];
|
|
855
|
+
if (toAgent?.tags?.includes(tag)) return true;
|
|
856
|
+
return false;
|
|
857
|
+
});
|
|
858
|
+
},
|
|
859
|
+
|
|
860
|
+
get groupedMessages() {
|
|
861
|
+
const threads = new Map();
|
|
862
|
+
for (const m of this.filteredMessages) {
|
|
863
|
+
const tid = m.threadId || m.id;
|
|
864
|
+
if (!threads.has(tid)) threads.set(tid, { threadId: tid, messages: [] });
|
|
865
|
+
threads.get(tid).messages.push(m);
|
|
866
|
+
}
|
|
867
|
+
for (const group of threads.values()) {
|
|
868
|
+
group.messages.sort((a, b) => a.id - b.id);
|
|
869
|
+
}
|
|
870
|
+
return [...threads.values()].sort((a, b) => {
|
|
871
|
+
const aLast = a.messages[a.messages.length - 1].id;
|
|
872
|
+
const bLast = b.messages[b.messages.length - 1].id;
|
|
873
|
+
return bLast - aLast;
|
|
874
|
+
});
|
|
875
|
+
},
|
|
876
|
+
|
|
877
|
+
get composeAgents() {
|
|
878
|
+
return this.showOffline ? this.agents : this.agents.filter(a => a.status === 'online');
|
|
879
|
+
},
|
|
880
|
+
|
|
881
|
+
get uniqueLabels() {
|
|
882
|
+
return [...new Set(this.agents.filter(a => a.label).map(a => a.label))];
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
get uniqueCaps() {
|
|
886
|
+
return [...new Set(this.agents.flatMap(a => a.capabilities || []))];
|
|
887
|
+
},
|
|
888
|
+
|
|
889
|
+
get uniqueTags() {
|
|
890
|
+
return [...new Set(this.agents.flatMap(a => a.tags || []))];
|
|
891
|
+
},
|
|
892
|
+
|
|
893
|
+
// ── Display helpers ──
|
|
894
|
+
|
|
895
|
+
displayName(agent) {
|
|
896
|
+
if (!agent) return '?';
|
|
897
|
+
return agent.label || agent.name || agent.id.slice(-12);
|
|
898
|
+
},
|
|
899
|
+
|
|
900
|
+
displayTarget(target) {
|
|
901
|
+
if (!target) return '?';
|
|
902
|
+
if (target === 'broadcast') return 'broadcast';
|
|
903
|
+
if (target.startsWith('tag:')) return '#' + target.slice(4);
|
|
904
|
+
if (target.startsWith('cap:')) return target.slice(4);
|
|
905
|
+
if (target.startsWith('label:')) return target.slice(6);
|
|
906
|
+
const agent = this.agentsById[target];
|
|
907
|
+
return agent ? this.displayName(agent) : target.slice(-8);
|
|
908
|
+
},
|
|
909
|
+
|
|
910
|
+
timeAgo(iso) {
|
|
911
|
+
if (!iso) return '';
|
|
912
|
+
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
|
913
|
+
if (diff < 60) return Math.floor(diff) + 's ago';
|
|
914
|
+
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
|
915
|
+
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
|
916
|
+
return Math.floor(diff / 86400) + 'd ago';
|
|
917
|
+
},
|
|
918
|
+
|
|
919
|
+
fmtTime(iso) {
|
|
920
|
+
if (!iso) return '';
|
|
921
|
+
return new Date(iso).toLocaleString();
|
|
922
|
+
},
|
|
923
|
+
|
|
924
|
+
// ── Actions ──
|
|
925
|
+
|
|
926
|
+
openCompose() {
|
|
927
|
+
if (!this.replyTo) {
|
|
928
|
+
this.compose = { from: 'user', to: '', body: '', channel: '', subject: '', claimable: false };
|
|
929
|
+
}
|
|
930
|
+
this.composeOpen = true;
|
|
931
|
+
this.$nextTick(() => this.$refs.composeBody?.focus());
|
|
932
|
+
},
|
|
933
|
+
|
|
934
|
+
openComposeToAgent(agent) {
|
|
935
|
+
this.replyTo = null;
|
|
936
|
+
this.compose = { from: 'user', to: agent.id, body: '', channel: '', subject: '', claimable: false };
|
|
937
|
+
this.composeOpen = true;
|
|
938
|
+
this.$nextTick(() => this.$refs.composeBody?.focus());
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
startReply(msg) {
|
|
942
|
+
this.replyTo = { id: msg.id, from: msg.from };
|
|
943
|
+
this.compose.to = msg.from;
|
|
944
|
+
this.compose.from = '';
|
|
945
|
+
this.compose.body = '';
|
|
946
|
+
this.compose.channel = msg.channel || '';
|
|
947
|
+
this.compose.subject = '';
|
|
948
|
+
this.compose.claimable = false;
|
|
949
|
+
this.openCompose();
|
|
950
|
+
},
|
|
951
|
+
|
|
952
|
+
cancelReply() {
|
|
953
|
+
this.replyTo = null;
|
|
954
|
+
},
|
|
955
|
+
|
|
956
|
+
async doSend() {
|
|
957
|
+
if (!this.compose.from || !this.compose.to || !this.compose.body) {
|
|
958
|
+
alert('From, To, and Message are required.');
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const payload = {
|
|
962
|
+
from: this.compose.from,
|
|
963
|
+
to: this.compose.to,
|
|
964
|
+
body: this.compose.body,
|
|
965
|
+
};
|
|
966
|
+
if (this.compose.channel) payload.channel = this.compose.channel;
|
|
967
|
+
if (this.compose.subject) payload.subject = this.compose.subject;
|
|
968
|
+
if (this.replyTo) payload.replyTo = this.replyTo.id;
|
|
969
|
+
if (this.compose.claimable) payload.claimable = true;
|
|
970
|
+
|
|
971
|
+
try {
|
|
972
|
+
await this.api('POST', '/messages', payload);
|
|
973
|
+
this.composeOpen = false;
|
|
974
|
+
this.replyTo = null;
|
|
975
|
+
this.compose = { from: '', to: '', body: '', channel: '', subject: '', claimable: false };
|
|
976
|
+
} catch (e) {
|
|
977
|
+
alert('Send failed: ' + e.message);
|
|
978
|
+
}
|
|
979
|
+
},
|
|
980
|
+
|
|
981
|
+
async doClaim(msgId) {
|
|
982
|
+
if (!this.compose.from && !this.selectedAgent) {
|
|
983
|
+
alert('Select a "From" agent first (open Compose to pick one).');
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const agentId = this.compose.from || this.selectedAgent;
|
|
987
|
+
try {
|
|
988
|
+
const result = await this.api('POST', '/messages/' + msgId + '/claim', { agentId });
|
|
989
|
+
if (!result.ok) alert('Claim failed: ' + (result.error || 'unknown'));
|
|
990
|
+
} catch (e) {
|
|
991
|
+
alert('Claim failed: ' + e.message);
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
|
|
995
|
+
async doDeleteMessage(id) {
|
|
996
|
+
try {
|
|
997
|
+
await this.api('DELETE', '/messages/' + id);
|
|
998
|
+
this.messages = this.messages.filter(m => m.id !== id);
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
alert('Delete failed: ' + e.message);
|
|
1001
|
+
}
|
|
1002
|
+
},
|
|
1003
|
+
|
|
1004
|
+
async openThread(threadId) {
|
|
1005
|
+
this.threadMessages = [];
|
|
1006
|
+
this.threadOpen = true;
|
|
1007
|
+
try {
|
|
1008
|
+
this.threadMessages = await this.api('GET', '/messages/' + threadId + '/thread');
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
alert('Failed to load thread: ' + e.message);
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
|
|
1014
|
+
openRename(agent) {
|
|
1015
|
+
this.renameModal = { show: true, agentId: agent.id, label: agent.label || '' };
|
|
1016
|
+
this.$nextTick(() => this.$refs.renameInput?.focus());
|
|
1017
|
+
},
|
|
1018
|
+
|
|
1019
|
+
async doRename() {
|
|
1020
|
+
const label = this.renameModal.label.trim() || null;
|
|
1021
|
+
try {
|
|
1022
|
+
await this.api('PATCH', '/agents/' + this.renameModal.agentId + '/label', { label });
|
|
1023
|
+
this.renameModal.show = false;
|
|
1024
|
+
} catch (e) {
|
|
1025
|
+
alert('Rename failed: ' + e.message);
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
|
|
1029
|
+
openConfirm(title, message, action) {
|
|
1030
|
+
this.confirmModal = { show: true, title, message, action };
|
|
1031
|
+
},
|
|
1032
|
+
|
|
1033
|
+
async doDeleteAgent(id) {
|
|
1034
|
+
try {
|
|
1035
|
+
await this.api('DELETE', '/agents/' + id);
|
|
1036
|
+
if (this.selectedAgent === id) this.selectedAgent = '';
|
|
1037
|
+
this.agents = this.agents.filter(a => a.id !== id);
|
|
1038
|
+
delete this.agentsById[id];
|
|
1039
|
+
} catch (e) {
|
|
1040
|
+
alert('Delete failed: ' + e.message);
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
1043
|
+
|
|
1044
|
+
// ── Charts ──
|
|
1045
|
+
|
|
1046
|
+
renderCharts() {
|
|
1047
|
+
this.renderVolumeChart();
|
|
1048
|
+
this.renderStatusChart();
|
|
1049
|
+
this.renderAgentChart();
|
|
1050
|
+
},
|
|
1051
|
+
|
|
1052
|
+
renderVolumeChart() {
|
|
1053
|
+
if (this.chartInstances.volume) this.chartInstances.volume.destroy();
|
|
1054
|
+
const el = document.querySelector('#chart-volume');
|
|
1055
|
+
if (!el) return;
|
|
1056
|
+
|
|
1057
|
+
const buckets = {};
|
|
1058
|
+
for (const m of this.messages) {
|
|
1059
|
+
const day = m.createdAt ? new Date(m.createdAt).toISOString().split('T')[0] : null;
|
|
1060
|
+
if (day) buckets[day] = (buckets[day] || 0) + 1;
|
|
1061
|
+
}
|
|
1062
|
+
const sorted = Object.entries(buckets).sort((a, b) => a[0].localeCompare(b[0]));
|
|
1063
|
+
|
|
1064
|
+
this.chartInstances.volume = new ApexCharts(el, {
|
|
1065
|
+
chart: { type: 'area', height: 280, background: 'transparent', toolbar: { show: false } },
|
|
1066
|
+
theme: { mode: 'dark' },
|
|
1067
|
+
series: [{ name: 'Messages', data: sorted.map(([d, c]) => ({ x: d, y: c })) }],
|
|
1068
|
+
xaxis: { type: 'datetime' },
|
|
1069
|
+
stroke: { curve: 'smooth', width: 2 },
|
|
1070
|
+
fill: { type: 'gradient', gradient: { opacityFrom: 0.4, opacityTo: 0 } },
|
|
1071
|
+
dataLabels: { enabled: false },
|
|
1072
|
+
colors: ['#4299e1'],
|
|
1073
|
+
grid: { borderColor: 'rgba(255,255,255,0.06)' },
|
|
1074
|
+
});
|
|
1075
|
+
this.chartInstances.volume.render();
|
|
1076
|
+
},
|
|
1077
|
+
|
|
1078
|
+
renderStatusChart() {
|
|
1079
|
+
if (this.chartInstances.status) this.chartInstances.status.destroy();
|
|
1080
|
+
const el = document.querySelector('#chart-status');
|
|
1081
|
+
if (!el) return;
|
|
1082
|
+
|
|
1083
|
+
const counts = { online: 0, idle: 0, busy: 0, offline: 0 };
|
|
1084
|
+
for (const a of this.agents) counts[a.status] = (counts[a.status] || 0) + 1;
|
|
1085
|
+
const labels = Object.keys(counts).filter(k => counts[k] > 0);
|
|
1086
|
+
const series = labels.map(k => counts[k]);
|
|
1087
|
+
const colorMap = { online: '#48bb78', idle: '#ecc94b', busy: '#f56565', offline: '#718096' };
|
|
1088
|
+
|
|
1089
|
+
this.chartInstances.status = new ApexCharts(el, {
|
|
1090
|
+
chart: { type: 'donut', height: 280, background: 'transparent' },
|
|
1091
|
+
theme: { mode: 'dark' },
|
|
1092
|
+
series,
|
|
1093
|
+
labels,
|
|
1094
|
+
colors: labels.map(l => colorMap[l] || '#718096'),
|
|
1095
|
+
legend: { position: 'bottom' },
|
|
1096
|
+
dataLabels: { enabled: true },
|
|
1097
|
+
});
|
|
1098
|
+
this.chartInstances.status.render();
|
|
1099
|
+
},
|
|
1100
|
+
|
|
1101
|
+
renderAgentChart() {
|
|
1102
|
+
if (this.chartInstances.agents) this.chartInstances.agents.destroy();
|
|
1103
|
+
const el = document.querySelector('#chart-agents');
|
|
1104
|
+
if (!el) return;
|
|
483
1105
|
|
|
484
|
-
|
|
1106
|
+
const counts = {};
|
|
1107
|
+
for (const m of this.messages) {
|
|
1108
|
+
const name = this.displayTarget(m.from);
|
|
1109
|
+
counts[name] = (counts[name] || 0) + 1;
|
|
1110
|
+
}
|
|
1111
|
+
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
485
1112
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
1113
|
+
this.chartInstances.agents = new ApexCharts(el, {
|
|
1114
|
+
chart: { type: 'bar', height: 280, background: 'transparent', toolbar: { show: false } },
|
|
1115
|
+
theme: { mode: 'dark' },
|
|
1116
|
+
series: [{ name: 'Messages', data: sorted.map(([, c]) => c) }],
|
|
1117
|
+
xaxis: { categories: sorted.map(([n]) => n) },
|
|
1118
|
+
colors: ['#4299e1'],
|
|
1119
|
+
plotOptions: { bar: { borderRadius: 4, distributed: true } },
|
|
1120
|
+
legend: { show: false },
|
|
1121
|
+
grid: { borderColor: 'rgba(255,255,255,0.06)' },
|
|
1122
|
+
});
|
|
1123
|
+
this.chartInstances.agents.render();
|
|
1124
|
+
},
|
|
1125
|
+
};
|
|
491
1126
|
}
|
|
492
|
-
refresh();
|
|
493
|
-
setInterval(() => {
|
|
494
|
-
if (document.getElementById('auto-refresh').checked) refresh();
|
|
495
|
-
}, 3000);
|
|
496
1127
|
|
|
497
|
-
document.
|
|
498
|
-
|
|
1128
|
+
document.addEventListener('alpine:init', () => {
|
|
1129
|
+
Alpine.data('relay', relay);
|
|
1130
|
+
});
|
|
499
1131
|
</script>
|
|
500
1132
|
</body>
|
|
501
1133
|
</html>
|