@imenam/mcp-messenger 1.0.9 → 1.0.11
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 +46 -14
- package/dist/src/client/messenger-client.d.ts +1 -0
- package/dist/src/core/store.d.ts +26 -1
- package/dist/src/core/store.js +264 -146
- package/dist/src/core/types.d.ts +29 -0
- package/dist/src/mcp/start.js +7 -2
- package/dist/src/mcp/tools.js +150 -37
- package/dist/src/server/app.js +25 -2
- package/dist/src/server/ui/app.js +703 -320
- package/dist/src/server/ui/index.html +92 -27
- package/dist/src/server/ui/styles.css +1147 -328
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,320 +1,703 @@
|
|
|
1
|
-
// Console de supervision : lecture seule sur les routes /admin du serveur.
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
state.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
1
|
+
// Console de supervision : lecture seule sur les routes /admin du serveur.
|
|
2
|
+
//
|
|
3
|
+
// Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
|
|
4
|
+
// jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
|
|
5
|
+
// écrites en dur dans ce fichier.
|
|
6
|
+
//
|
|
7
|
+
// L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
|
|
8
|
+
// ce script ne voit jamais le jeton et n'écrit rien dans le navigateur.
|
|
9
|
+
|
|
10
|
+
const REFRESH_MS = 5000;
|
|
11
|
+
|
|
12
|
+
// Une adresse ne dit rien à qui lit la console : elle sert de graine à la couleur de
|
|
13
|
+
// l'avatar, pour que la même boîte garde la même teinte d'un écran à l'autre.
|
|
14
|
+
const AVATAR_COLORS = 8;
|
|
15
|
+
|
|
16
|
+
const ICONS = {
|
|
17
|
+
copy: ["M9 3h9a2 2 0 0 1 2 2v9", "M5 7h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z"],
|
|
18
|
+
check: ["M20 6 9 17l-5-5"],
|
|
19
|
+
clock: ["M12 6v6l4 2", "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z"],
|
|
20
|
+
close: ["M18 6 6 18", "m6 6 12 12"],
|
|
21
|
+
link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const state = {
|
|
25
|
+
/** Adresse de la boîte sur laquelle la liste des fils est filtrée, ou null. */
|
|
26
|
+
agent: null,
|
|
27
|
+
/** "all" | "unread" | "high" — les puces au-dessus de la liste des fils. */
|
|
28
|
+
filter: "all",
|
|
29
|
+
search: "",
|
|
30
|
+
threadId: null,
|
|
31
|
+
/** Message dont le volet de détail est ouvert, ou null si le volet est fermé. */
|
|
32
|
+
messageId: null,
|
|
33
|
+
collapsed: false,
|
|
34
|
+
/** Annuaire des boîtes, indexé par adresse : rempli par chaque `/admin/overview`. */
|
|
35
|
+
directory: new Map(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const el = {
|
|
39
|
+
status: document.getElementById("status"),
|
|
40
|
+
statusLabel: document.getElementById("status-label"),
|
|
41
|
+
searchBox: document.getElementById("search-box"),
|
|
42
|
+
search: document.getElementById("search"),
|
|
43
|
+
controls: document.getElementById("controls"),
|
|
44
|
+
autoRefresh: document.getElementById("auto-refresh"),
|
|
45
|
+
refresh: document.getElementById("refresh"),
|
|
46
|
+
logout: document.getElementById("logout"),
|
|
47
|
+
login: document.getElementById("login"),
|
|
48
|
+
loginForm: document.getElementById("login-form"),
|
|
49
|
+
loginOrigin: document.getElementById("login-origin"),
|
|
50
|
+
tokenField: document.querySelector("#login-form .field"),
|
|
51
|
+
token: document.getElementById("token"),
|
|
52
|
+
tokenReveal: document.getElementById("token-reveal"),
|
|
53
|
+
tokenError: document.getElementById("token-error"),
|
|
54
|
+
console: document.getElementById("console"),
|
|
55
|
+
agentsPane: document.getElementById("agents-pane"),
|
|
56
|
+
agents: document.getElementById("agents"),
|
|
57
|
+
agentsFoot: document.getElementById("agents-foot"),
|
|
58
|
+
collapse: document.getElementById("collapse"),
|
|
59
|
+
threadsScope: document.getElementById("threads-scope"),
|
|
60
|
+
chips: document.getElementById("chips"),
|
|
61
|
+
threads: document.getElementById("threads"),
|
|
62
|
+
reader: document.getElementById("reader"),
|
|
63
|
+
sheet: document.getElementById("sheet"),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// --- Fabrique de noeuds ----------------------------------------------------
|
|
67
|
+
|
|
68
|
+
function node(tag, className, text) {
|
|
69
|
+
const element = document.createElement(tag);
|
|
70
|
+
if (className) element.className = className;
|
|
71
|
+
if (text !== undefined) element.textContent = text;
|
|
72
|
+
return element;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function icon(name) {
|
|
76
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
77
|
+
svg.setAttribute("class", "icon");
|
|
78
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
79
|
+
svg.setAttribute("aria-hidden", "true");
|
|
80
|
+
for (const d of ICONS[name]) {
|
|
81
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
82
|
+
path.setAttribute("d", d);
|
|
83
|
+
svg.appendChild(path);
|
|
84
|
+
}
|
|
85
|
+
return svg;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function button(className, label, onClick) {
|
|
89
|
+
const element = node("button", className, label);
|
|
90
|
+
element.type = "button";
|
|
91
|
+
element.addEventListener("click", onClick);
|
|
92
|
+
return element;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Bouton de copie : les adresses et les identifiants ne se recopient pas à la main. */
|
|
96
|
+
function copyButton(value, label) {
|
|
97
|
+
const element = node("button", "copy");
|
|
98
|
+
element.type = "button";
|
|
99
|
+
element.title = label;
|
|
100
|
+
element.setAttribute("aria-label", label);
|
|
101
|
+
element.appendChild(icon("copy"));
|
|
102
|
+
element.addEventListener("click", async (event) => {
|
|
103
|
+
event.stopPropagation();
|
|
104
|
+
try {
|
|
105
|
+
await navigator.clipboard.writeText(value);
|
|
106
|
+
element.replaceChildren(icon("check"));
|
|
107
|
+
setTimeout(() => element.replaceChildren(icon("copy")), 1200);
|
|
108
|
+
} catch {
|
|
109
|
+
// Presse-papiers refusé (contexte non sécurisé, permission) : la valeur reste
|
|
110
|
+
// affichée à côté, donc sélectionnable. Rien d'autre à signaler.
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return element;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function tag(className, label, iconName) {
|
|
117
|
+
const element = node("span", className);
|
|
118
|
+
if (iconName) element.appendChild(icon(iconName));
|
|
119
|
+
element.appendChild(document.createTextNode(label));
|
|
120
|
+
return element;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Annuaire des boîtes ---------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Deux noms par boîte, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte
|
|
127
|
+
* de quoi cette session s'occupe. La console montre les deux, et n'affiche l'adresse que
|
|
128
|
+
* là où elle sert à être copiée.
|
|
129
|
+
*/
|
|
130
|
+
function party(agentId) {
|
|
131
|
+
const known = state.directory.get(agentId);
|
|
132
|
+
return {
|
|
133
|
+
id: agentId,
|
|
134
|
+
name: (known && known.agent_name) || shortAddress(agentId),
|
|
135
|
+
box: (known && known.display_name) || null,
|
|
136
|
+
registered: !known || known.registered,
|
|
137
|
+
unread: (known && known.unread) || 0,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function shortAddress(agentId) {
|
|
142
|
+
const id = String(agentId || "");
|
|
143
|
+
return id.length > 12 ? `${id.slice(0, 10)}…` : id || "—";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function initials(agentId) {
|
|
147
|
+
const name = party(agentId).name;
|
|
148
|
+
const words = name.split(/[\s_-]+/).filter(Boolean);
|
|
149
|
+
const letters = words.length >= 2 ? words[0][0] + words[1][0] : name.slice(0, 2);
|
|
150
|
+
return letters.toUpperCase();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Teinte stable : la même adresse donne toujours la même couleur d'avatar. */
|
|
154
|
+
function colorIndex(agentId) {
|
|
155
|
+
let hash = 0;
|
|
156
|
+
for (const char of String(agentId)) hash = (hash * 31 + char.charCodeAt(0)) % 100003;
|
|
157
|
+
return hash % AVATAR_COLORS;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function avatar(agentId, extraClass) {
|
|
161
|
+
const who = party(agentId);
|
|
162
|
+
const classes = ["avatar"];
|
|
163
|
+
if (extraClass) classes.push(extraClass);
|
|
164
|
+
classes.push(who.registered ? `avatar--c${colorIndex(agentId)}` : "avatar--absent");
|
|
165
|
+
const element = node("span", classes.join(" "), initials(agentId));
|
|
166
|
+
element.title = who.box ? `${who.name} — ${who.box}` : who.name;
|
|
167
|
+
return element;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Dates -----------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function formatDate(iso) {
|
|
173
|
+
const date = new Date(iso);
|
|
174
|
+
return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleString();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Heure seule aujourd'hui, « hier » la veille, date courte au-delà : la liste doit se survoler. */
|
|
178
|
+
function formatShort(iso) {
|
|
179
|
+
const date = new Date(iso);
|
|
180
|
+
if (Number.isNaN(date.getTime())) return String(iso);
|
|
181
|
+
const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
182
|
+
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
183
|
+
const today = new Date();
|
|
184
|
+
const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
185
|
+
const days = Math.round((midnight - day) / 86400000);
|
|
186
|
+
if (days === 0) return time;
|
|
187
|
+
if (days === 1) return `hier ${time}`;
|
|
188
|
+
return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function plural(count, singular, pluralForm) {
|
|
192
|
+
return `${count} ${count > 1 ? pluralForm || `${singular}s` : singular}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- Accès au serveur ------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
async function api(path, params) {
|
|
198
|
+
const url = new URL(path, window.location.origin);
|
|
199
|
+
for (const [key, value] of Object.entries(params || {})) {
|
|
200
|
+
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
|
|
201
|
+
}
|
|
202
|
+
const response = await fetch(url, { headers: { accept: "application/json" }, credentials: "same-origin" });
|
|
203
|
+
if (response.status === 401) {
|
|
204
|
+
setLoggedIn(false);
|
|
205
|
+
throw new Error("unauthorized");
|
|
206
|
+
}
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
const payload = await response.json().catch(() => ({}));
|
|
209
|
+
throw new Error(payload.error || response.statusText);
|
|
210
|
+
}
|
|
211
|
+
return response.json();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function setLoggedIn(loggedIn) {
|
|
215
|
+
el.login.hidden = loggedIn;
|
|
216
|
+
el.console.hidden = !loggedIn;
|
|
217
|
+
el.controls.hidden = !loggedIn;
|
|
218
|
+
el.searchBox.hidden = !loggedIn;
|
|
219
|
+
if (!loggedIn) {
|
|
220
|
+
setStatus("idle", "non connecté");
|
|
221
|
+
el.token.focus();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function setStatus(kind, label) {
|
|
226
|
+
el.status.className = `pill pill--${kind}`;
|
|
227
|
+
el.statusLabel.textContent = label;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- Colonne des agents ----------------------------------------------------
|
|
231
|
+
|
|
232
|
+
function renderAgents(overview) {
|
|
233
|
+
state.directory = new Map(overview.agents.map((agent) => [agent.id, agent]));
|
|
234
|
+
el.agentsFoot.textContent = [
|
|
235
|
+
plural(overview.agents.length, "agent"),
|
|
236
|
+
plural(overview.messages, "message"),
|
|
237
|
+
plural(overview.threads, "fil"),
|
|
238
|
+
].join(" · ");
|
|
239
|
+
|
|
240
|
+
el.agents.replaceChildren();
|
|
241
|
+
el.agents.appendChild(agentRow(null, overview));
|
|
242
|
+
for (const agent of overview.agents) el.agents.appendChild(agentRow(agent, overview));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function agentRow(agent, overview) {
|
|
246
|
+
const item = node("li");
|
|
247
|
+
const selected = agent ? state.agent === agent.id : state.agent === null;
|
|
248
|
+
const unread = agent ? agent.unread : overview.agents.reduce((total, a) => total + a.unread, 0);
|
|
249
|
+
|
|
250
|
+
const classes = ["agent"];
|
|
251
|
+
if (selected) classes.push("is-selected");
|
|
252
|
+
if (unread > 0) classes.push("has-unread");
|
|
253
|
+
if (agent && !agent.registered) classes.push("agent--muted");
|
|
254
|
+
const row = node("button", classes.join(" "));
|
|
255
|
+
row.type = "button";
|
|
256
|
+
|
|
257
|
+
if (agent) {
|
|
258
|
+
row.appendChild(avatar(agent.id));
|
|
259
|
+
} else {
|
|
260
|
+
const all = node("span", "avatar avatar--absent", "∗");
|
|
261
|
+
all.title = "Tous les agents";
|
|
262
|
+
row.appendChild(all);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const text = node("div", "agent-text");
|
|
266
|
+
text.appendChild(node("div", "agent-name", agent ? party(agent.id).name : "Tous les agents"));
|
|
267
|
+
if (agent) {
|
|
268
|
+
// Une boîte jamais connectée n'a pas de nom : le dire vaut mieux qu'une ligne vide.
|
|
269
|
+
const boxName = agent.display_name || (agent.registered ? "boîte sans nom" : "jamais connectée");
|
|
270
|
+
const box = node("div", "agent-box", boxName);
|
|
271
|
+
if (!agent.display_name) box.classList.add("is-absent");
|
|
272
|
+
text.appendChild(box);
|
|
273
|
+
} else {
|
|
274
|
+
text.appendChild(node("div", "agent-box", plural(overview.threads, "fil")));
|
|
275
|
+
}
|
|
276
|
+
row.appendChild(text);
|
|
277
|
+
|
|
278
|
+
if (unread > 0) row.appendChild(node("span", "count count--accent", String(unread)));
|
|
279
|
+
row.title = agent ? agent.id : "Tous les agents";
|
|
280
|
+
|
|
281
|
+
row.addEventListener("click", () => {
|
|
282
|
+
const next = agent ? agent.id : null;
|
|
283
|
+
state.agent = state.agent === next ? null : next;
|
|
284
|
+
state.threadId = null;
|
|
285
|
+
state.messageId = null;
|
|
286
|
+
refresh();
|
|
287
|
+
});
|
|
288
|
+
item.appendChild(row);
|
|
289
|
+
return item;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- Colonne des fils ------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
function renderThreads(threads) {
|
|
295
|
+
const scope = state.agent ? party(state.agent).name : "Toutes les boîtes";
|
|
296
|
+
el.threadsScope.textContent = `${scope} · ${plural(threads.length, "fil")}`;
|
|
297
|
+
|
|
298
|
+
el.threads.replaceChildren();
|
|
299
|
+
if (threads.length === 0) {
|
|
300
|
+
const empty = node("li", "empty");
|
|
301
|
+
empty.appendChild(node("span", "empty-title", "Aucun fil"));
|
|
302
|
+
empty.appendChild(document.createTextNode(emptyReason()));
|
|
303
|
+
el.threads.appendChild(empty);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
for (const thread of threads) el.threads.appendChild(threadRow(thread));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function emptyReason() {
|
|
310
|
+
if (state.search) return "Aucun fil ne correspond à cette recherche.";
|
|
311
|
+
if (state.filter === "unread") return "Tout est lu de ce côté.";
|
|
312
|
+
if (state.filter === "high") return "Aucun message en priorité haute.";
|
|
313
|
+
if (state.agent) return "Cette boîte n'a encore échangé avec personne.";
|
|
314
|
+
return "Aucun agent n'a encore écrit sur ce serveur.";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function threadRow(thread) {
|
|
318
|
+
const item = node("li");
|
|
319
|
+
const classes = ["thread"];
|
|
320
|
+
if (state.threadId === thread.thread_id) classes.push("is-selected");
|
|
321
|
+
if (thread.unread > 0) classes.push("is-unread");
|
|
322
|
+
const row = node("button", classes.join(" "));
|
|
323
|
+
row.type = "button";
|
|
324
|
+
|
|
325
|
+
const top = node("div", "thread-top");
|
|
326
|
+
// Le titre du fil prime ; tant que personne ne l'a nommé, le sujet d'ouverture en tient lieu.
|
|
327
|
+
top.appendChild(node("span", "thread-title", thread.title || thread.subject));
|
|
328
|
+
if (thread.priority === "high") top.appendChild(tag("tag tag--high", "haute"));
|
|
329
|
+
top.appendChild(node("span", "thread-time", formatShort(thread.last_message_at)));
|
|
330
|
+
row.appendChild(top);
|
|
331
|
+
|
|
332
|
+
row.appendChild(node("div", "thread-who", thread.participants.map((id) => party(id).name).join(" · ")));
|
|
333
|
+
|
|
334
|
+
const bottom = node("div", "thread-bottom");
|
|
335
|
+
bottom.appendChild(node("span", "thread-preview", thread.last_message_preview));
|
|
336
|
+
const count = node("span", thread.unread > 0 ? "count count--accent" : "count", String(thread.messages));
|
|
337
|
+
count.title = thread.unread > 0 ? `${plural(thread.messages, "message")}, ${thread.unread} non lu(s)` : plural(thread.messages, "message");
|
|
338
|
+
bottom.appendChild(count);
|
|
339
|
+
row.appendChild(bottom);
|
|
340
|
+
|
|
341
|
+
row.addEventListener("click", () => openThread(thread.thread_id));
|
|
342
|
+
item.appendChild(row);
|
|
343
|
+
return item;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- Lecteur du fil --------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
function renderEmptyReader() {
|
|
349
|
+
const empty = node("div", "empty");
|
|
350
|
+
empty.appendChild(node("span", "empty-title", "Aucun fil ouvert"));
|
|
351
|
+
empty.appendChild(document.createTextNode("Choisissez un fil pour lire la conversation."));
|
|
352
|
+
el.reader.replaceChildren(empty);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function renderThread(data) {
|
|
356
|
+
const thread = data.thread || {};
|
|
357
|
+
const messages = data.messages || [];
|
|
358
|
+
const container = document.createDocumentFragment();
|
|
359
|
+
|
|
360
|
+
const head = node("div", "reader-head");
|
|
361
|
+
const opening = messages.length > 0 ? messages[0].subject : "";
|
|
362
|
+
head.appendChild(node("h2", null, thread.title || opening || data.thread_id));
|
|
363
|
+
if (thread.description) head.appendChild(node("p", "reader-desc", thread.description));
|
|
364
|
+
|
|
365
|
+
const participants = [];
|
|
366
|
+
for (const message of messages) {
|
|
367
|
+
for (const id of [message.from, message.to]) {
|
|
368
|
+
if (id && !participants.includes(id)) participants.push(id);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const meta = node("div", "reader-meta");
|
|
373
|
+
const stack = node("div", "avatar-stack");
|
|
374
|
+
for (const id of participants) stack.appendChild(avatar(id, "avatar--sm avatar--stack"));
|
|
375
|
+
meta.appendChild(stack);
|
|
376
|
+
meta.appendChild(
|
|
377
|
+
node(
|
|
378
|
+
"span",
|
|
379
|
+
"reader-participants",
|
|
380
|
+
`${participants.map((id) => party(id).name).join(", ")} · ${plural(messages.length, "message")}`
|
|
381
|
+
)
|
|
382
|
+
);
|
|
383
|
+
const threadId = node("span", "mono muted", data.thread_id);
|
|
384
|
+
meta.appendChild(threadId);
|
|
385
|
+
meta.appendChild(copyButton(data.thread_id, "Copier l'identifiant du fil"));
|
|
386
|
+
head.appendChild(meta);
|
|
387
|
+
container.appendChild(head);
|
|
388
|
+
|
|
389
|
+
const list = node("ul", "messages");
|
|
390
|
+
messages.forEach((message, index) => {
|
|
391
|
+
list.appendChild(messageRow(message, index === messages.length - 1));
|
|
392
|
+
});
|
|
393
|
+
container.appendChild(list);
|
|
394
|
+
|
|
395
|
+
el.reader.replaceChildren(container);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function messageRow(message, isLast) {
|
|
399
|
+
const item = node("li");
|
|
400
|
+
const classes = ["message"];
|
|
401
|
+
if (state.messageId === message.message_id) classes.push("is-open");
|
|
402
|
+
else if (isLast) classes.push("is-last");
|
|
403
|
+
const row = node("div", classes.join(" "));
|
|
404
|
+
|
|
405
|
+
row.appendChild(avatar(message.from));
|
|
406
|
+
|
|
407
|
+
const column = node("div", "message-body-col");
|
|
408
|
+
const head = node("div", "message-head");
|
|
409
|
+
head.appendChild(node("span", "message-from", party(message.from).name));
|
|
410
|
+
head.appendChild(node("span", "message-to", `→ ${party(message.to).name}`));
|
|
411
|
+
if (message.priority === "high") head.appendChild(tag("tag tag--high", "priorité haute"));
|
|
412
|
+
if (message.priority === "low") head.appendChild(tag("tag", "priorité basse"));
|
|
413
|
+
head.appendChild(node("span", "message-time", formatShort(message.created_at)));
|
|
414
|
+
column.appendChild(head);
|
|
415
|
+
|
|
416
|
+
// Le sujet n'est répété que s'il diffère du nom du fil : sinon c'est du bruit.
|
|
417
|
+
if (message.subject && message.subject !== message.thread_title) {
|
|
418
|
+
column.appendChild(node("p", "message-subject", message.subject));
|
|
419
|
+
}
|
|
420
|
+
column.appendChild(node("p", "message-text", message.body || message.preview || ""));
|
|
421
|
+
|
|
422
|
+
const foot = node("div", "message-foot");
|
|
423
|
+
foot.appendChild(
|
|
424
|
+
message.read
|
|
425
|
+
? tag("tag tag--ok", `lu par ${party(message.to).name}`, "check")
|
|
426
|
+
: tag("tag tag--warn", `non lu par ${party(message.to).name}`, "clock")
|
|
427
|
+
);
|
|
428
|
+
const open = state.messageId === message.message_id;
|
|
429
|
+
foot.appendChild(
|
|
430
|
+
button("btn btn--link", open ? "détails ouverts" : "détails", () => {
|
|
431
|
+
if (open) closeSheet();
|
|
432
|
+
else openMessage(message.message_id);
|
|
433
|
+
})
|
|
434
|
+
);
|
|
435
|
+
column.appendChild(foot);
|
|
436
|
+
|
|
437
|
+
row.appendChild(column);
|
|
438
|
+
item.appendChild(row);
|
|
439
|
+
return item;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// --- Volet de détail -------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
function renderSheet(message) {
|
|
445
|
+
const container = document.createDocumentFragment();
|
|
446
|
+
|
|
447
|
+
const head = node("div", "sheet-head");
|
|
448
|
+
const titles = node("div");
|
|
449
|
+
titles.appendChild(node("h3", null, "Détails du message"));
|
|
450
|
+
titles.appendChild(node("p", "sheet-subject", message.subject));
|
|
451
|
+
head.appendChild(titles);
|
|
452
|
+
const close = node("button", "btn btn--icon btn--ghost");
|
|
453
|
+
close.type = "button";
|
|
454
|
+
close.title = "Fermer";
|
|
455
|
+
close.setAttribute("aria-label", "Fermer le volet");
|
|
456
|
+
close.appendChild(icon("close"));
|
|
457
|
+
close.addEventListener("click", closeSheet);
|
|
458
|
+
head.appendChild(close);
|
|
459
|
+
container.appendChild(head);
|
|
460
|
+
|
|
461
|
+
const ids = node("div", "sheet-section");
|
|
462
|
+
ids.appendChild(node("span", "section-label", "Message"));
|
|
463
|
+
ids.appendChild(kvCopy("Identifiant", message.message_id));
|
|
464
|
+
ids.appendChild(kvCopy("Fil", message.thread_id));
|
|
465
|
+
ids.appendChild(kv("Envoyé le", node("span", "kv-value", formatDate(message.created_at))));
|
|
466
|
+
ids.appendChild(kv("Priorité", priorityTag(message.priority)));
|
|
467
|
+
container.appendChild(ids);
|
|
468
|
+
|
|
469
|
+
container.appendChild(partySection("De", message.from));
|
|
470
|
+
container.appendChild(partySection("À", message.to));
|
|
471
|
+
|
|
472
|
+
const deliveries = node("div", "sheet-section");
|
|
473
|
+
deliveries.appendChild(node("span", "section-label", "Distribution"));
|
|
474
|
+
deliveries.appendChild(deliveriesTable(message.deliveries || []));
|
|
475
|
+
container.appendChild(deliveries);
|
|
476
|
+
|
|
477
|
+
el.sheet.replaceChildren(container);
|
|
478
|
+
el.sheet.hidden = false;
|
|
479
|
+
el.console.classList.add("has-sheet");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function kv(key, valueNode) {
|
|
483
|
+
const row = node("div", "kv");
|
|
484
|
+
row.appendChild(node("span", "kv-key", key));
|
|
485
|
+
row.appendChild(valueNode);
|
|
486
|
+
row.appendChild(node("span"));
|
|
487
|
+
return row;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function kvCopy(key, value) {
|
|
491
|
+
const row = node("div", "kv");
|
|
492
|
+
row.appendChild(node("span", "kv-key", key));
|
|
493
|
+
row.appendChild(node("span", "kv-value mono", value));
|
|
494
|
+
row.appendChild(copyButton(value, `Copier ${key.toLowerCase()}`));
|
|
495
|
+
return row;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function priorityTag(priority) {
|
|
499
|
+
const labels = { high: "haute", normal: "normale", low: "basse" };
|
|
500
|
+
return tag(priority === "high" ? "tag tag--high" : "tag", labels[priority] || priority);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Une boîte, telle qu'elle doit se lire : le nom de l'agent, ce dont la session s'occupe,
|
|
505
|
+
* et seulement ensuite l'adresse — la seule chose qui identifie vraiment la session.
|
|
506
|
+
*/
|
|
507
|
+
function partySection(label, agentId) {
|
|
508
|
+
const who = party(agentId);
|
|
509
|
+
const section = node("div", "sheet-section");
|
|
510
|
+
section.appendChild(node("span", "section-label", label));
|
|
511
|
+
|
|
512
|
+
const block = node("div", "party");
|
|
513
|
+
block.appendChild(avatar(agentId, "avatar--sm"));
|
|
514
|
+
|
|
515
|
+
const text = node("div", "agent-text");
|
|
516
|
+
text.appendChild(node("div", "party-name", who.name));
|
|
517
|
+
text.appendChild(node("div", "party-box", who.box || (who.registered ? "boîte sans nom" : "jamais connectée")));
|
|
518
|
+
const addr = node("div", "party-addr");
|
|
519
|
+
addr.appendChild(node("span", "mono", agentId));
|
|
520
|
+
addr.appendChild(copyButton(agentId, "Copier l'adresse"));
|
|
521
|
+
text.appendChild(addr);
|
|
522
|
+
|
|
523
|
+
block.appendChild(text);
|
|
524
|
+
section.appendChild(block);
|
|
525
|
+
return section;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function deliveriesTable(deliveries) {
|
|
529
|
+
const table = node("table", "deliveries");
|
|
530
|
+
const head = node("tr");
|
|
531
|
+
for (const label of ["Agent", "Rôle", "Dossier", "État"]) head.appendChild(node("th", null, label));
|
|
532
|
+
table.appendChild(head);
|
|
533
|
+
|
|
534
|
+
for (const delivery of deliveries) {
|
|
535
|
+
const row = node("tr");
|
|
536
|
+
const who = node("td");
|
|
537
|
+
who.appendChild(document.createTextNode(party(delivery.agent_id).name));
|
|
538
|
+
row.appendChild(who);
|
|
539
|
+
row.appendChild(node("td", "role", delivery.role === "sender" ? "expéditeur" : "destinataire"));
|
|
540
|
+
row.appendChild(node("td", "folder", delivery.folder));
|
|
541
|
+
const stateCell = node("td");
|
|
542
|
+
stateCell.appendChild(
|
|
543
|
+
delivery.read ? tag("tag tag--ok", "lu") : tag("tag tag--warn", delivery.role === "sender" ? "—" : "non lu")
|
|
544
|
+
);
|
|
545
|
+
row.appendChild(stateCell);
|
|
546
|
+
table.appendChild(row);
|
|
547
|
+
}
|
|
548
|
+
return table;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function closeSheet() {
|
|
552
|
+
state.messageId = null;
|
|
553
|
+
el.sheet.hidden = true;
|
|
554
|
+
el.sheet.replaceChildren();
|
|
555
|
+
el.console.classList.remove("has-sheet");
|
|
556
|
+
if (state.threadId) refreshThread();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// --- Navigation ------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
async function openThread(threadId) {
|
|
562
|
+
state.threadId = threadId;
|
|
563
|
+
state.messageId = null;
|
|
564
|
+
el.sheet.hidden = true;
|
|
565
|
+
el.console.classList.remove("has-sheet");
|
|
566
|
+
await refreshThread();
|
|
567
|
+
await refresh({ keepThread: true });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function refreshThread() {
|
|
571
|
+
if (!state.threadId) {
|
|
572
|
+
renderEmptyReader();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
renderThread(await api(`/admin/threads/${encodeURIComponent(state.threadId)}`));
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (error.message !== "unauthorized") el.reader.replaceChildren(node("div", "banner", error.message));
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function openMessage(messageId) {
|
|
583
|
+
state.messageId = messageId;
|
|
584
|
+
try {
|
|
585
|
+
renderSheet(await api(`/admin/messages/${encodeURIComponent(messageId)}`));
|
|
586
|
+
await refreshThread();
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (error.message !== "unauthorized") el.sheet.replaceChildren(node("div", "banner", error.message));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// --- Boucle de rafraîchissement --------------------------------------------
|
|
593
|
+
|
|
594
|
+
async function refresh(options) {
|
|
595
|
+
try {
|
|
596
|
+
const [overview, list] = await Promise.all([
|
|
597
|
+
api("/admin/overview"),
|
|
598
|
+
api("/admin/threads", {
|
|
599
|
+
agent: state.agent,
|
|
600
|
+
search: state.search,
|
|
601
|
+
unread_only: state.filter === "unread" ? "true" : undefined,
|
|
602
|
+
priority: state.filter === "high" ? "high" : undefined,
|
|
603
|
+
limit: 100,
|
|
604
|
+
}),
|
|
605
|
+
]);
|
|
606
|
+
setLoggedIn(true);
|
|
607
|
+
setStatus("ok", `connecté · ${window.location.host}`);
|
|
608
|
+
renderAgents(overview);
|
|
609
|
+
renderThreads(list.threads);
|
|
610
|
+
if (!options || !options.keepThread) {
|
|
611
|
+
if (state.threadId) await refreshThread();
|
|
612
|
+
else renderEmptyReader();
|
|
613
|
+
}
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (error.message === "unauthorized") {
|
|
616
|
+
setStatus("idle", "authentification requise");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
setStatus("ko", `erreur : ${error.message}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// --- Écouteurs -------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
el.refresh.addEventListener("click", () => refresh({ keepThread: true }));
|
|
626
|
+
|
|
627
|
+
el.collapse.addEventListener("click", () => {
|
|
628
|
+
state.collapsed = !state.collapsed;
|
|
629
|
+
el.console.classList.toggle("is-collapsed", state.collapsed);
|
|
630
|
+
el.collapse.title = state.collapsed ? "Déplier" : "Replier";
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
el.chips.addEventListener("click", (event) => {
|
|
634
|
+
const chip = event.target.closest(".chip");
|
|
635
|
+
if (!chip) return;
|
|
636
|
+
state.filter = chip.dataset.filter;
|
|
637
|
+
for (const other of el.chips.querySelectorAll(".chip")) {
|
|
638
|
+
other.classList.toggle("is-active", other === chip);
|
|
639
|
+
}
|
|
640
|
+
refresh({ keepThread: true });
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
let searchTimer = null;
|
|
644
|
+
el.search.addEventListener("input", (event) => {
|
|
645
|
+
state.search = event.target.value.trim();
|
|
646
|
+
clearTimeout(searchTimer);
|
|
647
|
+
searchTimer = setTimeout(() => refresh({ keepThread: true }), 250);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
el.tokenReveal.addEventListener("click", () => {
|
|
651
|
+
const shown = el.token.type === "text";
|
|
652
|
+
el.token.type = shown ? "password" : "text";
|
|
653
|
+
el.tokenReveal.setAttribute("aria-label", shown ? "Afficher le jeton" : "Masquer le jeton");
|
|
654
|
+
el.token.focus();
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
el.loginForm.addEventListener("submit", async (event) => {
|
|
658
|
+
event.preventDefault();
|
|
659
|
+
el.tokenError.textContent = "";
|
|
660
|
+
el.tokenField.classList.remove("is-invalid");
|
|
661
|
+
const token = el.token.value.trim();
|
|
662
|
+
if (!token) return;
|
|
663
|
+
const response = await fetch("/ui/login", {
|
|
664
|
+
method: "POST",
|
|
665
|
+
headers: { "content-type": "application/json" },
|
|
666
|
+
credentials: "same-origin",
|
|
667
|
+
body: JSON.stringify({ token }),
|
|
668
|
+
}).catch(() => null);
|
|
669
|
+
// Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
|
|
670
|
+
el.token.value = "";
|
|
671
|
+
el.token.type = "password";
|
|
672
|
+
if (!response || !response.ok) {
|
|
673
|
+
el.tokenField.classList.add("is-invalid");
|
|
674
|
+
el.tokenError.textContent =
|
|
675
|
+
response && response.status === 429
|
|
676
|
+
? "Trop de tentatives, réessayez dans quelques minutes."
|
|
677
|
+
: "Jeton refusé. Vérifiez MESSENGER_ADMIN_TOKEN côté serveur.";
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
refresh();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
el.logout.addEventListener("click", async () => {
|
|
684
|
+
await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
|
|
685
|
+
state.agent = null;
|
|
686
|
+
state.threadId = null;
|
|
687
|
+
state.messageId = null;
|
|
688
|
+
state.directory = new Map();
|
|
689
|
+
el.sheet.hidden = true;
|
|
690
|
+
el.console.classList.remove("has-sheet");
|
|
691
|
+
el.agents.replaceChildren();
|
|
692
|
+
el.threads.replaceChildren();
|
|
693
|
+
renderEmptyReader();
|
|
694
|
+
setLoggedIn(false);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
setInterval(() => {
|
|
698
|
+
if (el.autoRefresh.checked && el.login.hidden) refresh({ keepThread: true });
|
|
699
|
+
}, REFRESH_MS);
|
|
700
|
+
|
|
701
|
+
el.loginOrigin.textContent = `MCP Messenger · ${window.location.host}`;
|
|
702
|
+
renderEmptyReader();
|
|
703
|
+
refresh();
|