@imenam/mcp-messenger 1.0.10 → 1.0.13
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 +40 -10
- package/dist/src/core/store.d.ts +23 -1
- package/dist/src/core/store.js +243 -152
- package/dist/src/core/types.d.ts +22 -0
- package/dist/src/mcp/start.js +5 -1
- package/dist/src/mcp/tools.js +29 -16
- package/dist/src/server/app.js +28 -0
- package/dist/src/server/ui/app.js +883 -372
- package/dist/src/server/ui/index.html +92 -27
- package/dist/src/server/ui/styles.css +1228 -368
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,372 +1,883 @@
|
|
|
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
|
-
for (const
|
|
92
|
-
const
|
|
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
|
-
function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function
|
|
206
|
-
const
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
el.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
chevron: ["m9 18 6-6-6-6"],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const state = {
|
|
26
|
+
/**
|
|
27
|
+
* Ce sur quoi la liste des fils est filtrée.
|
|
28
|
+
*
|
|
29
|
+
* `{ kind: "all" }` ne filtre rien ; `{ kind: "agent", key }` retient toutes les boîtes
|
|
30
|
+
* d'un projet ; `{ kind: "box", key }` une seule session. Deux niveaux, parce qu'un
|
|
31
|
+
* agent tient une boîte par session ouverte et que les deux questions se posent.
|
|
32
|
+
*/
|
|
33
|
+
selection: { kind: "all", key: null },
|
|
34
|
+
/** Noms d'agents dépliés dans la colonne de gauche. Tout est replié au départ. */
|
|
35
|
+
expanded: new Set(),
|
|
36
|
+
/** "all" | "unread" | "high" — les puces au-dessus de la liste des fils. */
|
|
37
|
+
filter: "all",
|
|
38
|
+
search: "",
|
|
39
|
+
threadId: null,
|
|
40
|
+
/** Message dont le volet de détail est ouvert, ou null si le volet est fermé. */
|
|
41
|
+
messageId: null,
|
|
42
|
+
collapsed: false,
|
|
43
|
+
/** Annuaire des boîtes, indexé par adresse : rempli par chaque `/admin/overview`. */
|
|
44
|
+
directory: new Map(),
|
|
45
|
+
/** Boîtes regroupées par nom d'agent, dans l'ordre de l'annuaire. */
|
|
46
|
+
groups: [],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const el = {
|
|
50
|
+
status: document.getElementById("status"),
|
|
51
|
+
statusLabel: document.getElementById("status-label"),
|
|
52
|
+
searchBox: document.getElementById("search-box"),
|
|
53
|
+
search: document.getElementById("search"),
|
|
54
|
+
controls: document.getElementById("controls"),
|
|
55
|
+
autoRefresh: document.getElementById("auto-refresh"),
|
|
56
|
+
refresh: document.getElementById("refresh"),
|
|
57
|
+
logout: document.getElementById("logout"),
|
|
58
|
+
login: document.getElementById("login"),
|
|
59
|
+
loginForm: document.getElementById("login-form"),
|
|
60
|
+
loginOrigin: document.getElementById("login-origin"),
|
|
61
|
+
tokenField: document.querySelector("#login-form .field"),
|
|
62
|
+
token: document.getElementById("token"),
|
|
63
|
+
tokenReveal: document.getElementById("token-reveal"),
|
|
64
|
+
tokenError: document.getElementById("token-error"),
|
|
65
|
+
console: document.getElementById("console"),
|
|
66
|
+
agentsPane: document.getElementById("agents-pane"),
|
|
67
|
+
agents: document.getElementById("agents"),
|
|
68
|
+
agentsFoot: document.getElementById("agents-foot"),
|
|
69
|
+
collapse: document.getElementById("collapse"),
|
|
70
|
+
threadsScope: document.getElementById("threads-scope"),
|
|
71
|
+
chips: document.getElementById("chips"),
|
|
72
|
+
threads: document.getElementById("threads"),
|
|
73
|
+
reader: document.getElementById("reader"),
|
|
74
|
+
sheet: document.getElementById("sheet"),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// --- Fabrique de noeuds ----------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function node(tag, className, text) {
|
|
80
|
+
const element = document.createElement(tag);
|
|
81
|
+
if (className) element.className = className;
|
|
82
|
+
if (text !== undefined) element.textContent = text;
|
|
83
|
+
return element;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function icon(name) {
|
|
87
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
88
|
+
svg.setAttribute("class", "icon");
|
|
89
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
90
|
+
svg.setAttribute("aria-hidden", "true");
|
|
91
|
+
for (const d of ICONS[name]) {
|
|
92
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
93
|
+
path.setAttribute("d", d);
|
|
94
|
+
svg.appendChild(path);
|
|
95
|
+
}
|
|
96
|
+
return svg;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function button(className, label, onClick) {
|
|
100
|
+
const element = node("button", className, label);
|
|
101
|
+
element.type = "button";
|
|
102
|
+
element.addEventListener("click", onClick);
|
|
103
|
+
return element;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Bouton de copie : les adresses et les identifiants ne se recopient pas à la main. */
|
|
107
|
+
function copyButton(value, label) {
|
|
108
|
+
const element = node("button", "copy");
|
|
109
|
+
element.type = "button";
|
|
110
|
+
element.title = label;
|
|
111
|
+
element.setAttribute("aria-label", label);
|
|
112
|
+
element.appendChild(icon("copy"));
|
|
113
|
+
element.addEventListener("click", async (event) => {
|
|
114
|
+
event.stopPropagation();
|
|
115
|
+
try {
|
|
116
|
+
await navigator.clipboard.writeText(value);
|
|
117
|
+
element.replaceChildren(icon("check"));
|
|
118
|
+
setTimeout(() => element.replaceChildren(icon("copy")), 1200);
|
|
119
|
+
} catch {
|
|
120
|
+
// Presse-papiers refusé (contexte non sécurisé, permission) : la valeur reste
|
|
121
|
+
// affichée à côté, donc sélectionnable. Rien d'autre à signaler.
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return element;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function tag(className, label, iconName) {
|
|
128
|
+
const element = node("span", className);
|
|
129
|
+
if (iconName) element.appendChild(icon(iconName));
|
|
130
|
+
element.appendChild(document.createTextNode(label));
|
|
131
|
+
return element;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Annuaire des boîtes ---------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Deux noms par boîte, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte
|
|
138
|
+
* de quoi cette session s'occupe. La console montre les deux, et n'affiche l'adresse que
|
|
139
|
+
* là où elle sert à être copiée.
|
|
140
|
+
*
|
|
141
|
+
* Un nom d'agent ne désigne personne : plusieurs sessions du même projet le portent. Dès
|
|
142
|
+
* qu'il est partagé, `name` lui accole le nom de la boîte — sans quoi un fil afficherait
|
|
143
|
+
* « Documentor Dev → Documentor Dev » sans dire de quelles sessions il parle.
|
|
144
|
+
*/
|
|
145
|
+
function party(agentId) {
|
|
146
|
+
const known = state.directory.get(agentId);
|
|
147
|
+
const agentName = (known && known.agent_name) || null;
|
|
148
|
+
const boxName = (known && known.display_name) || null;
|
|
149
|
+
const shared = agentName ? state.groups.some((g) => g.key === agentName && g.boxes.length > 1) : false;
|
|
150
|
+
// Parenthèses, et non un séparateur plat : les participants d'un fil sont eux-mêmes
|
|
151
|
+
// listés avec un point médian, qui ne doit pas se confondre avec cette précision.
|
|
152
|
+
const name = agentName
|
|
153
|
+
? shared
|
|
154
|
+
? `${agentName} (${boxName || shortAddress(agentId)})`
|
|
155
|
+
: agentName
|
|
156
|
+
: shortAddress(agentId);
|
|
157
|
+
return {
|
|
158
|
+
id: agentId,
|
|
159
|
+
name,
|
|
160
|
+
agentName,
|
|
161
|
+
box: boxName,
|
|
162
|
+
registered: !known || known.registered,
|
|
163
|
+
unread: (known && known.unread) || 0,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Nom de la boîte, ou son adresse à défaut.
|
|
169
|
+
*
|
|
170
|
+
* Une boîte n'est nommée que si l'agent l'a fait lui-même (`rename_mailbox`) ; tant qu'il
|
|
171
|
+
* ne l'a pas fait, seule l'adresse la distingue de ses sœurs, et c'est donc elle qu'on lit.
|
|
172
|
+
*/
|
|
173
|
+
function boxLabel(box) {
|
|
174
|
+
return box.display_name || box.id;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function shortAddress(agentId) {
|
|
178
|
+
const id = String(agentId || "");
|
|
179
|
+
return id.length > 12 ? `${id.slice(0, 10)}…` : id || "—";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function initials(label) {
|
|
183
|
+
const words = String(label).split(/[\s_·-]+/).filter(Boolean);
|
|
184
|
+
const letters = words.length >= 2 ? words[0][0] + words[1][0] : String(label).slice(0, 2);
|
|
185
|
+
return letters.toUpperCase();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Teinte stable : la même graine donne toujours la même couleur d'avatar. */
|
|
189
|
+
function colorIndex(seed) {
|
|
190
|
+
let hash = 0;
|
|
191
|
+
for (const char of String(seed)) hash = (hash * 31 + char.charCodeAt(0)) % 100003;
|
|
192
|
+
return hash % AVATAR_COLORS;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Pastille d'initiales. `seed` teinte, `label` écrit, `title` explique au survol. */
|
|
196
|
+
function avatarFor(seed, label, title, extraClass, absent) {
|
|
197
|
+
const classes = ["avatar"];
|
|
198
|
+
if (extraClass) classes.push(extraClass);
|
|
199
|
+
classes.push(absent ? "avatar--absent" : `avatar--c${colorIndex(seed)}`);
|
|
200
|
+
const element = node("span", classes.join(" "), initials(label));
|
|
201
|
+
element.title = title;
|
|
202
|
+
return element;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function avatar(agentId, extraClass) {
|
|
206
|
+
const who = party(agentId);
|
|
207
|
+
const label = who.box || who.agentName || shortAddress(agentId);
|
|
208
|
+
const title = [who.agentName, who.box, agentId].filter(Boolean).join(" — ");
|
|
209
|
+
return avatarFor(agentId, label, title, extraClass, !who.registered);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Dates -----------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
function formatDate(iso) {
|
|
215
|
+
const date = new Date(iso);
|
|
216
|
+
return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleString();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Heure seule aujourd'hui, « hier » la veille, date courte au-delà : la liste doit se survoler. */
|
|
220
|
+
function formatShort(iso) {
|
|
221
|
+
const date = new Date(iso);
|
|
222
|
+
if (Number.isNaN(date.getTime())) return String(iso);
|
|
223
|
+
const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
224
|
+
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
225
|
+
const today = new Date();
|
|
226
|
+
const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
227
|
+
const days = Math.round((midnight - day) / 86400000);
|
|
228
|
+
if (days === 0) return time;
|
|
229
|
+
if (days === 1) return `hier ${time}`;
|
|
230
|
+
return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function plural(count, singular, pluralForm) {
|
|
234
|
+
return `${count} ${count > 1 ? pluralForm || `${singular}s` : singular}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Accès au serveur ------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
async function api(path, params) {
|
|
240
|
+
const url = new URL(path, window.location.origin);
|
|
241
|
+
for (const [key, value] of Object.entries(params || {})) {
|
|
242
|
+
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
|
|
243
|
+
}
|
|
244
|
+
const response = await fetch(url, { headers: { accept: "application/json" }, credentials: "same-origin" });
|
|
245
|
+
if (response.status === 401) {
|
|
246
|
+
setLoggedIn(false);
|
|
247
|
+
throw new Error("unauthorized");
|
|
248
|
+
}
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
const payload = await response.json().catch(() => ({}));
|
|
251
|
+
throw new Error(payload.error || response.statusText);
|
|
252
|
+
}
|
|
253
|
+
return response.json();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function setLoggedIn(loggedIn) {
|
|
257
|
+
el.login.hidden = loggedIn;
|
|
258
|
+
el.console.hidden = !loggedIn;
|
|
259
|
+
el.controls.hidden = !loggedIn;
|
|
260
|
+
el.searchBox.hidden = !loggedIn;
|
|
261
|
+
if (!loggedIn) {
|
|
262
|
+
setStatus("idle", "non connecté");
|
|
263
|
+
el.token.focus();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function setStatus(kind, label) {
|
|
268
|
+
el.status.className = `pill pill--${kind}`;
|
|
269
|
+
el.statusLabel.textContent = label;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --- Colonne des agents ----------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Regroupe les boîtes sous leur nom d'agent.
|
|
276
|
+
*
|
|
277
|
+
* L'agent dit d'où l'on écrit, la boîte quelle session parle : un projet qui a deux
|
|
278
|
+
* sessions ouvertes tient deux boîtes, et la colonne doit montrer cette imbrication plutôt
|
|
279
|
+
* que d'aligner des boîtes qui portent toutes le même nom. Une boîte sans nom d'agent est
|
|
280
|
+
* son propre groupe : rien ne permet de la rattacher à un projet.
|
|
281
|
+
*
|
|
282
|
+
* L'annuaire arrive déjà classé par activité : le groupe hérite du rang de sa boîte la
|
|
283
|
+
* plus récente, sans nouveau tri.
|
|
284
|
+
*/
|
|
285
|
+
function groupByAgent(boxes) {
|
|
286
|
+
const groups = new Map();
|
|
287
|
+
for (const box of boxes) {
|
|
288
|
+
const key = box.agent_name || box.id;
|
|
289
|
+
if (!groups.has(key)) {
|
|
290
|
+
groups.set(key, { key, name: box.agent_name || boxLabel(box), boxes: [], unread: 0 });
|
|
291
|
+
}
|
|
292
|
+
const group = groups.get(key);
|
|
293
|
+
group.boxes.push(box);
|
|
294
|
+
group.unread += box.unread;
|
|
295
|
+
}
|
|
296
|
+
return [...groups.values()];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isSelected(kind, key) {
|
|
300
|
+
return state.selection.kind === kind && state.selection.key === key;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function select(kind, key) {
|
|
304
|
+
// Recliquer sur la sélection courante la relâche : le filtre ne s'emprisonne pas.
|
|
305
|
+
state.selection = isSelected(kind, key) ? { kind: "all", key: null } : { kind, key };
|
|
306
|
+
state.threadId = null;
|
|
307
|
+
state.messageId = null;
|
|
308
|
+
el.sheet.hidden = true;
|
|
309
|
+
el.console.classList.remove("has-sheet");
|
|
310
|
+
refresh();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderAgents(overview) {
|
|
314
|
+
state.directory = new Map(overview.agents.map((box) => [box.id, box]));
|
|
315
|
+
state.groups = groupByAgent(overview.agents);
|
|
316
|
+
el.agentsFoot.textContent = [
|
|
317
|
+
plural(state.groups.length, "agent"),
|
|
318
|
+
plural(overview.agents.length, "boîte"),
|
|
319
|
+
plural(overview.threads, "fil"),
|
|
320
|
+
].join(" · ");
|
|
321
|
+
|
|
322
|
+
el.agents.replaceChildren();
|
|
323
|
+
el.agents.appendChild(allRow(overview));
|
|
324
|
+
for (const group of state.groups) el.agents.appendChild(groupRow(group));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function allRow(overview) {
|
|
328
|
+
const item = node("li");
|
|
329
|
+
const unread = overview.agents.reduce((total, box) => total + box.unread, 0);
|
|
330
|
+
const row = node("button", `agent${isSelected("all", null) ? " is-selected" : ""}`);
|
|
331
|
+
row.type = "button";
|
|
332
|
+
|
|
333
|
+
const star = node("span", "avatar avatar--absent", "∗");
|
|
334
|
+
star.title = "Tous les agents";
|
|
335
|
+
row.appendChild(star);
|
|
336
|
+
|
|
337
|
+
const text = node("div", "agent-text");
|
|
338
|
+
text.appendChild(node("div", "agent-name", "Tous les agents"));
|
|
339
|
+
text.appendChild(node("div", "agent-box", plural(overview.threads, "fil")));
|
|
340
|
+
row.appendChild(text);
|
|
341
|
+
if (unread > 0) row.appendChild(node("span", "count count--accent", String(unread)));
|
|
342
|
+
|
|
343
|
+
row.addEventListener("click", () => select("all", null));
|
|
344
|
+
item.appendChild(row);
|
|
345
|
+
return item;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Un agent et, dépliées sous lui, ses boîtes.
|
|
350
|
+
*
|
|
351
|
+
* Deux zones de clic, deux gestes distincts : le chevron ouvre ou ferme, le nom filtre sur
|
|
352
|
+
* l'ensemble des sessions du projet. Filtrer déplie au passage, pour que la sélection
|
|
353
|
+
* montre toujours ce qu'elle recouvre.
|
|
354
|
+
*/
|
|
355
|
+
function groupRow(group) {
|
|
356
|
+
const item = node("li", "agent-group");
|
|
357
|
+
const open = state.expanded.has(group.key);
|
|
358
|
+
|
|
359
|
+
const header = node("div", `agent-head${open ? " is-open" : ""}`);
|
|
360
|
+
|
|
361
|
+
const toggle = node("button", "agent-toggle");
|
|
362
|
+
toggle.type = "button";
|
|
363
|
+
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
364
|
+
toggle.setAttribute("aria-label", `${open ? "Replier" : "Déplier"} les boîtes de ${group.name}`);
|
|
365
|
+
toggle.appendChild(icon("chevron"));
|
|
366
|
+
toggle.addEventListener("click", () => {
|
|
367
|
+
if (open) state.expanded.delete(group.key);
|
|
368
|
+
else state.expanded.add(group.key);
|
|
369
|
+
renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
|
|
370
|
+
});
|
|
371
|
+
header.appendChild(toggle);
|
|
372
|
+
|
|
373
|
+
const classes = ["agent"];
|
|
374
|
+
if (isSelected("agent", group.key)) classes.push("is-selected");
|
|
375
|
+
if (group.unread > 0) classes.push("has-unread");
|
|
376
|
+
if (group.boxes.every((box) => !box.registered)) classes.push("agent--muted");
|
|
377
|
+
const row = node("button", classes.join(" "));
|
|
378
|
+
row.type = "button";
|
|
379
|
+
row.appendChild(
|
|
380
|
+
avatarFor(
|
|
381
|
+
group.key,
|
|
382
|
+
group.name,
|
|
383
|
+
group.name,
|
|
384
|
+
null,
|
|
385
|
+
group.boxes.every((box) => !box.registered)
|
|
386
|
+
)
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const text = node("div", "agent-text");
|
|
390
|
+
text.appendChild(node("div", "agent-name", group.name));
|
|
391
|
+
text.appendChild(node("div", "agent-box", plural(group.boxes.length, "boîte")));
|
|
392
|
+
row.appendChild(text);
|
|
393
|
+
if (group.unread > 0) row.appendChild(node("span", "count count--accent", String(group.unread)));
|
|
394
|
+
row.title = `${group.name} · ${plural(group.boxes.length, "boîte")}`;
|
|
395
|
+
row.addEventListener("click", () => {
|
|
396
|
+
state.expanded.add(group.key);
|
|
397
|
+
select("agent", group.key);
|
|
398
|
+
});
|
|
399
|
+
header.appendChild(row);
|
|
400
|
+
item.appendChild(header);
|
|
401
|
+
|
|
402
|
+
if (open) {
|
|
403
|
+
const list = node("ul", "box-list");
|
|
404
|
+
for (const box of group.boxes) list.appendChild(boxRow(box));
|
|
405
|
+
item.appendChild(list);
|
|
406
|
+
}
|
|
407
|
+
return item;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function boxRow(box) {
|
|
411
|
+
const item = node("li");
|
|
412
|
+
const classes = ["agent", "box"];
|
|
413
|
+
if (isSelected("box", box.id)) classes.push("is-selected");
|
|
414
|
+
if (box.unread > 0) classes.push("has-unread");
|
|
415
|
+
if (!box.registered) classes.push("agent--muted");
|
|
416
|
+
const row = node("button", classes.join(" "));
|
|
417
|
+
row.type = "button";
|
|
418
|
+
|
|
419
|
+
const label = boxLabel(box);
|
|
420
|
+
row.appendChild(avatarFor(box.id, label, box.id, "avatar--sm", !box.registered));
|
|
421
|
+
|
|
422
|
+
const text = node("div", "agent-text");
|
|
423
|
+
// Sans nom donné par l'agent, l'adresse tient lieu de nom : elle est alors la seule
|
|
424
|
+
// chose qui distingue cette session de ses sœurs.
|
|
425
|
+
const name = node("div", "agent-name", label);
|
|
426
|
+
if (!box.display_name) name.classList.add("mono", "is-absent");
|
|
427
|
+
text.appendChild(name);
|
|
428
|
+
text.appendChild(
|
|
429
|
+
node(
|
|
430
|
+
"div",
|
|
431
|
+
"agent-box",
|
|
432
|
+
box.registered ? `${plural(box.sent, "envoyé")} · ${plural(box.received, "reçu")}` : "jamais connectée"
|
|
433
|
+
)
|
|
434
|
+
);
|
|
435
|
+
row.appendChild(text);
|
|
436
|
+
|
|
437
|
+
if (box.unread > 0) row.appendChild(node("span", "count count--accent", String(box.unread)));
|
|
438
|
+
row.title = box.id;
|
|
439
|
+
row.addEventListener("click", () => select("box", box.id));
|
|
440
|
+
item.appendChild(row);
|
|
441
|
+
return item;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Nombre de fils affiché en pied de colonne, conservé entre deux rendus locaux. */
|
|
445
|
+
function currentThreadCount() {
|
|
446
|
+
return state.threadCount || 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Adresses que la sélection courante recouvre, ou null quand elle ne filtre rien. */
|
|
450
|
+
function selectedAddresses() {
|
|
451
|
+
if (state.selection.kind === "box") return [state.selection.key];
|
|
452
|
+
if (state.selection.kind === "agent") {
|
|
453
|
+
const group = state.groups.find((g) => g.key === state.selection.key);
|
|
454
|
+
return group ? group.boxes.map((box) => box.id) : [];
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function selectionLabel() {
|
|
460
|
+
if (state.selection.kind === "box") {
|
|
461
|
+
const box = state.directory.get(state.selection.key);
|
|
462
|
+
return box ? boxLabel(box) : shortAddress(state.selection.key);
|
|
463
|
+
}
|
|
464
|
+
if (state.selection.kind === "agent") return state.selection.key;
|
|
465
|
+
return "Toutes les boîtes";
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// --- Colonne des fils ------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
function renderThreads(threads) {
|
|
471
|
+
state.threadCount = threads.length;
|
|
472
|
+
el.threadsScope.textContent = `${selectionLabel()} · ${plural(threads.length, "fil")}`;
|
|
473
|
+
|
|
474
|
+
el.threads.replaceChildren();
|
|
475
|
+
if (threads.length === 0) {
|
|
476
|
+
const empty = node("li", "empty");
|
|
477
|
+
empty.appendChild(node("span", "empty-title", "Aucun fil"));
|
|
478
|
+
empty.appendChild(document.createTextNode(emptyReason()));
|
|
479
|
+
el.threads.appendChild(empty);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
for (const thread of threads) el.threads.appendChild(threadRow(thread));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function emptyReason() {
|
|
486
|
+
if (state.search) return "Aucun fil ne correspond à cette recherche.";
|
|
487
|
+
if (state.filter === "unread") return "Tout est lu de ce côté.";
|
|
488
|
+
if (state.filter === "high") return "Aucun message en priorité haute.";
|
|
489
|
+
if (state.selection.kind === "box") return "Cette boîte n'a encore échangé avec personne.";
|
|
490
|
+
if (state.selection.kind === "agent") return "Aucune session de cet agent n'a encore échangé.";
|
|
491
|
+
return "Aucun agent n'a encore écrit sur ce serveur.";
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function threadRow(thread) {
|
|
495
|
+
const item = node("li");
|
|
496
|
+
const classes = ["thread"];
|
|
497
|
+
if (state.threadId === thread.thread_id) classes.push("is-selected");
|
|
498
|
+
if (thread.unread > 0) classes.push("is-unread");
|
|
499
|
+
const row = node("button", classes.join(" "));
|
|
500
|
+
row.type = "button";
|
|
501
|
+
|
|
502
|
+
const top = node("div", "thread-top");
|
|
503
|
+
// Le titre du fil prime ; tant que personne ne l'a nommé, le sujet d'ouverture en tient lieu.
|
|
504
|
+
top.appendChild(node("span", "thread-title", thread.title || thread.subject));
|
|
505
|
+
if (thread.priority === "high") top.appendChild(tag("tag tag--high", "haute"));
|
|
506
|
+
top.appendChild(node("span", "thread-time", formatShort(thread.last_message_at)));
|
|
507
|
+
row.appendChild(top);
|
|
508
|
+
|
|
509
|
+
row.appendChild(node("div", "thread-who", thread.participants.map((id) => party(id).name).join(" · ")));
|
|
510
|
+
|
|
511
|
+
const bottom = node("div", "thread-bottom");
|
|
512
|
+
bottom.appendChild(node("span", "thread-preview", thread.last_message_preview));
|
|
513
|
+
const count = node("span", thread.unread > 0 ? "count count--accent" : "count", String(thread.messages));
|
|
514
|
+
count.title = thread.unread > 0 ? `${plural(thread.messages, "message")}, ${thread.unread} non lu(s)` : plural(thread.messages, "message");
|
|
515
|
+
bottom.appendChild(count);
|
|
516
|
+
row.appendChild(bottom);
|
|
517
|
+
|
|
518
|
+
row.addEventListener("click", () => openThread(thread.thread_id));
|
|
519
|
+
item.appendChild(row);
|
|
520
|
+
return item;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// --- Lecteur du fil --------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
function renderEmptyReader() {
|
|
526
|
+
const empty = node("div", "empty");
|
|
527
|
+
empty.appendChild(node("span", "empty-title", "Aucun fil ouvert"));
|
|
528
|
+
empty.appendChild(document.createTextNode("Choisissez un fil pour lire la conversation."));
|
|
529
|
+
el.reader.replaceChildren(empty);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function renderThread(data) {
|
|
533
|
+
const thread = data.thread || {};
|
|
534
|
+
const messages = data.messages || [];
|
|
535
|
+
const container = document.createDocumentFragment();
|
|
536
|
+
|
|
537
|
+
const head = node("div", "reader-head");
|
|
538
|
+
const opening = messages.length > 0 ? messages[0].subject : "";
|
|
539
|
+
head.appendChild(node("h2", null, thread.title || opening || data.thread_id));
|
|
540
|
+
if (thread.description) head.appendChild(node("p", "reader-desc", thread.description));
|
|
541
|
+
|
|
542
|
+
const participants = [];
|
|
543
|
+
for (const message of messages) {
|
|
544
|
+
for (const id of [message.from, message.to]) {
|
|
545
|
+
if (id && !participants.includes(id)) participants.push(id);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const meta = node("div", "reader-meta");
|
|
550
|
+
const stack = node("div", "avatar-stack");
|
|
551
|
+
for (const id of participants) stack.appendChild(avatar(id, "avatar--sm avatar--stack"));
|
|
552
|
+
meta.appendChild(stack);
|
|
553
|
+
meta.appendChild(
|
|
554
|
+
node(
|
|
555
|
+
"span",
|
|
556
|
+
"reader-participants",
|
|
557
|
+
`${participants.map((id) => party(id).name).join(", ")} · ${plural(messages.length, "message")}`
|
|
558
|
+
)
|
|
559
|
+
);
|
|
560
|
+
const threadId = node("span", "mono muted", data.thread_id);
|
|
561
|
+
meta.appendChild(threadId);
|
|
562
|
+
meta.appendChild(copyButton(data.thread_id, "Copier l'identifiant du fil"));
|
|
563
|
+
head.appendChild(meta);
|
|
564
|
+
container.appendChild(head);
|
|
565
|
+
|
|
566
|
+
const list = node("ul", "messages");
|
|
567
|
+
messages.forEach((message, index) => {
|
|
568
|
+
list.appendChild(messageRow(message, index === messages.length - 1));
|
|
569
|
+
});
|
|
570
|
+
container.appendChild(list);
|
|
571
|
+
|
|
572
|
+
el.reader.replaceChildren(container);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function messageRow(message, isLast) {
|
|
576
|
+
const item = node("li");
|
|
577
|
+
const classes = ["message"];
|
|
578
|
+
if (state.messageId === message.message_id) classes.push("is-open");
|
|
579
|
+
else if (isLast) classes.push("is-last");
|
|
580
|
+
const row = node("div", classes.join(" "));
|
|
581
|
+
|
|
582
|
+
row.appendChild(avatar(message.from));
|
|
583
|
+
|
|
584
|
+
const column = node("div", "message-body-col");
|
|
585
|
+
const head = node("div", "message-head");
|
|
586
|
+
head.appendChild(node("span", "message-from", party(message.from).name));
|
|
587
|
+
head.appendChild(node("span", "message-to", `→ ${party(message.to).name}`));
|
|
588
|
+
if (message.priority === "high") head.appendChild(tag("tag tag--high", "priorité haute"));
|
|
589
|
+
if (message.priority === "low") head.appendChild(tag("tag", "priorité basse"));
|
|
590
|
+
head.appendChild(node("span", "message-time", formatShort(message.created_at)));
|
|
591
|
+
column.appendChild(head);
|
|
592
|
+
|
|
593
|
+
// Le sujet n'est répété que s'il diffère du nom du fil : sinon c'est du bruit.
|
|
594
|
+
if (message.subject && message.subject !== message.thread_title) {
|
|
595
|
+
column.appendChild(node("p", "message-subject", message.subject));
|
|
596
|
+
}
|
|
597
|
+
column.appendChild(node("p", "message-text", message.body || message.preview || ""));
|
|
598
|
+
|
|
599
|
+
const foot = node("div", "message-foot");
|
|
600
|
+
foot.appendChild(
|
|
601
|
+
message.read
|
|
602
|
+
? tag("tag tag--ok", `lu par ${party(message.to).name}`, "check")
|
|
603
|
+
: tag("tag tag--warn", `non lu par ${party(message.to).name}`, "clock")
|
|
604
|
+
);
|
|
605
|
+
const open = state.messageId === message.message_id;
|
|
606
|
+
foot.appendChild(
|
|
607
|
+
button("btn btn--link", open ? "détails ouverts" : "détails", () => {
|
|
608
|
+
if (open) closeSheet();
|
|
609
|
+
else openMessage(message.message_id);
|
|
610
|
+
})
|
|
611
|
+
);
|
|
612
|
+
column.appendChild(foot);
|
|
613
|
+
|
|
614
|
+
row.appendChild(column);
|
|
615
|
+
item.appendChild(row);
|
|
616
|
+
return item;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// --- Volet de détail -------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
function renderSheet(message) {
|
|
622
|
+
const container = document.createDocumentFragment();
|
|
623
|
+
|
|
624
|
+
const head = node("div", "sheet-head");
|
|
625
|
+
const titles = node("div");
|
|
626
|
+
titles.appendChild(node("h3", null, "Détails du message"));
|
|
627
|
+
titles.appendChild(node("p", "sheet-subject", message.subject));
|
|
628
|
+
head.appendChild(titles);
|
|
629
|
+
const close = node("button", "btn btn--icon btn--ghost");
|
|
630
|
+
close.type = "button";
|
|
631
|
+
close.title = "Fermer";
|
|
632
|
+
close.setAttribute("aria-label", "Fermer le volet");
|
|
633
|
+
close.appendChild(icon("close"));
|
|
634
|
+
close.addEventListener("click", closeSheet);
|
|
635
|
+
head.appendChild(close);
|
|
636
|
+
container.appendChild(head);
|
|
637
|
+
|
|
638
|
+
const ids = node("div", "sheet-section");
|
|
639
|
+
ids.appendChild(node("span", "section-label", "Message"));
|
|
640
|
+
ids.appendChild(kvCopy("Identifiant", message.message_id));
|
|
641
|
+
ids.appendChild(kvCopy("Fil", message.thread_id));
|
|
642
|
+
ids.appendChild(kv("Envoyé le", node("span", "kv-value", formatDate(message.created_at))));
|
|
643
|
+
ids.appendChild(kv("Priorité", priorityTag(message.priority)));
|
|
644
|
+
container.appendChild(ids);
|
|
645
|
+
|
|
646
|
+
container.appendChild(partySection("De", message.from));
|
|
647
|
+
container.appendChild(partySection("À", message.to));
|
|
648
|
+
|
|
649
|
+
const deliveries = node("div", "sheet-section");
|
|
650
|
+
deliveries.appendChild(node("span", "section-label", "Distribution"));
|
|
651
|
+
deliveries.appendChild(deliveriesTable(message.deliveries || []));
|
|
652
|
+
container.appendChild(deliveries);
|
|
653
|
+
|
|
654
|
+
el.sheet.replaceChildren(container);
|
|
655
|
+
el.sheet.hidden = false;
|
|
656
|
+
el.console.classList.add("has-sheet");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function kv(key, valueNode) {
|
|
660
|
+
const row = node("div", "kv");
|
|
661
|
+
row.appendChild(node("span", "kv-key", key));
|
|
662
|
+
row.appendChild(valueNode);
|
|
663
|
+
row.appendChild(node("span"));
|
|
664
|
+
return row;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function kvCopy(key, value) {
|
|
668
|
+
const row = node("div", "kv");
|
|
669
|
+
row.appendChild(node("span", "kv-key", key));
|
|
670
|
+
row.appendChild(node("span", "kv-value mono", value));
|
|
671
|
+
row.appendChild(copyButton(value, `Copier ${key.toLowerCase()}`));
|
|
672
|
+
return row;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function priorityTag(priority) {
|
|
676
|
+
const labels = { high: "haute", normal: "normale", low: "basse" };
|
|
677
|
+
return tag(priority === "high" ? "tag tag--high" : "tag", labels[priority] || priority);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Une boîte, telle qu'elle doit se lire : le nom de l'agent, ce dont la session s'occupe,
|
|
682
|
+
* et seulement ensuite l'adresse — la seule chose qui identifie vraiment la session.
|
|
683
|
+
*/
|
|
684
|
+
function partySection(label, agentId) {
|
|
685
|
+
const who = party(agentId);
|
|
686
|
+
const section = node("div", "sheet-section");
|
|
687
|
+
section.appendChild(node("span", "section-label", label));
|
|
688
|
+
|
|
689
|
+
const block = node("div", "party");
|
|
690
|
+
block.appendChild(avatar(agentId, "avatar--sm"));
|
|
691
|
+
|
|
692
|
+
const text = node("div", "agent-text");
|
|
693
|
+
text.appendChild(node("div", "party-name", who.name));
|
|
694
|
+
text.appendChild(node("div", "party-box", who.box || (who.registered ? "boîte sans nom" : "jamais connectée")));
|
|
695
|
+
const addr = node("div", "party-addr");
|
|
696
|
+
addr.appendChild(node("span", "mono", agentId));
|
|
697
|
+
addr.appendChild(copyButton(agentId, "Copier l'adresse"));
|
|
698
|
+
text.appendChild(addr);
|
|
699
|
+
|
|
700
|
+
block.appendChild(text);
|
|
701
|
+
section.appendChild(block);
|
|
702
|
+
return section;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function deliveriesTable(deliveries) {
|
|
706
|
+
const table = node("table", "deliveries");
|
|
707
|
+
const head = node("tr");
|
|
708
|
+
for (const label of ["Agent", "Rôle", "Dossier", "État"]) head.appendChild(node("th", null, label));
|
|
709
|
+
table.appendChild(head);
|
|
710
|
+
|
|
711
|
+
for (const delivery of deliveries) {
|
|
712
|
+
const row = node("tr");
|
|
713
|
+
const who = node("td");
|
|
714
|
+
who.appendChild(document.createTextNode(party(delivery.agent_id).name));
|
|
715
|
+
row.appendChild(who);
|
|
716
|
+
row.appendChild(node("td", "role", delivery.role === "sender" ? "expéditeur" : "destinataire"));
|
|
717
|
+
row.appendChild(node("td", "folder", delivery.folder));
|
|
718
|
+
const stateCell = node("td");
|
|
719
|
+
stateCell.appendChild(
|
|
720
|
+
delivery.read ? tag("tag tag--ok", "lu") : tag("tag tag--warn", delivery.role === "sender" ? "—" : "non lu")
|
|
721
|
+
);
|
|
722
|
+
row.appendChild(stateCell);
|
|
723
|
+
table.appendChild(row);
|
|
724
|
+
}
|
|
725
|
+
return table;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function closeSheet() {
|
|
729
|
+
state.messageId = null;
|
|
730
|
+
el.sheet.hidden = true;
|
|
731
|
+
el.sheet.replaceChildren();
|
|
732
|
+
el.console.classList.remove("has-sheet");
|
|
733
|
+
if (state.threadId) refreshThread();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// --- Navigation ------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
async function openThread(threadId) {
|
|
739
|
+
state.threadId = threadId;
|
|
740
|
+
state.messageId = null;
|
|
741
|
+
el.sheet.hidden = true;
|
|
742
|
+
el.console.classList.remove("has-sheet");
|
|
743
|
+
await refreshThread();
|
|
744
|
+
await refresh({ keepThread: true });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function refreshThread() {
|
|
748
|
+
if (!state.threadId) {
|
|
749
|
+
renderEmptyReader();
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
try {
|
|
753
|
+
renderThread(await api(`/admin/threads/${encodeURIComponent(state.threadId)}`));
|
|
754
|
+
} catch (error) {
|
|
755
|
+
if (error.message !== "unauthorized") el.reader.replaceChildren(node("div", "banner", error.message));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function openMessage(messageId) {
|
|
760
|
+
state.messageId = messageId;
|
|
761
|
+
try {
|
|
762
|
+
renderSheet(await api(`/admin/messages/${encodeURIComponent(messageId)}`));
|
|
763
|
+
await refreshThread();
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (error.message !== "unauthorized") el.sheet.replaceChildren(node("div", "banner", error.message));
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// --- Boucle de rafraîchissement --------------------------------------------
|
|
770
|
+
|
|
771
|
+
async function refresh(options) {
|
|
772
|
+
try {
|
|
773
|
+
const [overview, list] = await Promise.all([
|
|
774
|
+
api("/admin/overview"),
|
|
775
|
+
api("/admin/threads", {
|
|
776
|
+
// Une sélection d'agent recouvre toutes ses sessions : la route prend la liste.
|
|
777
|
+
agent: (selectedAddresses() || []).join(","),
|
|
778
|
+
search: state.search,
|
|
779
|
+
unread_only: state.filter === "unread" ? "true" : undefined,
|
|
780
|
+
priority: state.filter === "high" ? "high" : undefined,
|
|
781
|
+
limit: 100,
|
|
782
|
+
}),
|
|
783
|
+
]);
|
|
784
|
+
setLoggedIn(true);
|
|
785
|
+
setStatus("ok", `connecté · ${window.location.host}`);
|
|
786
|
+
renderAgents(overview);
|
|
787
|
+
renderThreads(list.threads);
|
|
788
|
+
if (!options || !options.keepThread) {
|
|
789
|
+
if (state.threadId) await refreshThread();
|
|
790
|
+
else renderEmptyReader();
|
|
791
|
+
}
|
|
792
|
+
} catch (error) {
|
|
793
|
+
if (error.message === "unauthorized") {
|
|
794
|
+
setStatus("idle", "authentification requise");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
setStatus("ko", `erreur : ${error.message}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// --- Écouteurs -------------------------------------------------------------
|
|
802
|
+
|
|
803
|
+
el.refresh.addEventListener("click", () => refresh({ keepThread: true }));
|
|
804
|
+
|
|
805
|
+
el.collapse.addEventListener("click", () => {
|
|
806
|
+
state.collapsed = !state.collapsed;
|
|
807
|
+
el.console.classList.toggle("is-collapsed", state.collapsed);
|
|
808
|
+
el.collapse.title = state.collapsed ? "Déplier" : "Replier";
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
el.chips.addEventListener("click", (event) => {
|
|
812
|
+
const chip = event.target.closest(".chip");
|
|
813
|
+
if (!chip) return;
|
|
814
|
+
state.filter = chip.dataset.filter;
|
|
815
|
+
for (const other of el.chips.querySelectorAll(".chip")) {
|
|
816
|
+
other.classList.toggle("is-active", other === chip);
|
|
817
|
+
}
|
|
818
|
+
refresh({ keepThread: true });
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
let searchTimer = null;
|
|
822
|
+
el.search.addEventListener("input", (event) => {
|
|
823
|
+
state.search = event.target.value.trim();
|
|
824
|
+
clearTimeout(searchTimer);
|
|
825
|
+
searchTimer = setTimeout(() => refresh({ keepThread: true }), 250);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
el.tokenReveal.addEventListener("click", () => {
|
|
829
|
+
const shown = el.token.type === "text";
|
|
830
|
+
el.token.type = shown ? "password" : "text";
|
|
831
|
+
el.tokenReveal.setAttribute("aria-label", shown ? "Afficher le jeton" : "Masquer le jeton");
|
|
832
|
+
el.token.focus();
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
el.loginForm.addEventListener("submit", async (event) => {
|
|
836
|
+
event.preventDefault();
|
|
837
|
+
el.tokenError.textContent = "";
|
|
838
|
+
el.tokenField.classList.remove("is-invalid");
|
|
839
|
+
const token = el.token.value.trim();
|
|
840
|
+
if (!token) return;
|
|
841
|
+
const response = await fetch("/ui/login", {
|
|
842
|
+
method: "POST",
|
|
843
|
+
headers: { "content-type": "application/json" },
|
|
844
|
+
credentials: "same-origin",
|
|
845
|
+
body: JSON.stringify({ token }),
|
|
846
|
+
}).catch(() => null);
|
|
847
|
+
// Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
|
|
848
|
+
el.token.value = "";
|
|
849
|
+
el.token.type = "password";
|
|
850
|
+
if (!response || !response.ok) {
|
|
851
|
+
el.tokenField.classList.add("is-invalid");
|
|
852
|
+
el.tokenError.textContent =
|
|
853
|
+
response && response.status === 429
|
|
854
|
+
? "Trop de tentatives, réessayez dans quelques minutes."
|
|
855
|
+
: "Jeton refusé. Vérifiez MESSENGER_ADMIN_TOKEN côté serveur.";
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
refresh();
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
el.logout.addEventListener("click", async () => {
|
|
862
|
+
await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
|
|
863
|
+
state.selection = { kind: "all", key: null };
|
|
864
|
+
state.expanded = new Set();
|
|
865
|
+
state.threadId = null;
|
|
866
|
+
state.messageId = null;
|
|
867
|
+
state.directory = new Map();
|
|
868
|
+
state.groups = [];
|
|
869
|
+
el.sheet.hidden = true;
|
|
870
|
+
el.console.classList.remove("has-sheet");
|
|
871
|
+
el.agents.replaceChildren();
|
|
872
|
+
el.threads.replaceChildren();
|
|
873
|
+
renderEmptyReader();
|
|
874
|
+
setLoggedIn(false);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
setInterval(() => {
|
|
878
|
+
if (el.autoRefresh.checked && el.login.hidden) refresh({ keepThread: true });
|
|
879
|
+
}, REFRESH_MS);
|
|
880
|
+
|
|
881
|
+
el.loginOrigin.textContent = `MCP Messenger · ${window.location.host}`;
|
|
882
|
+
renderEmptyReader();
|
|
883
|
+
refresh();
|