discovery-media-player 0.1.138 → 0.1.140

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/bin/serve.js CHANGED
@@ -108,8 +108,33 @@ async function pageAccueil(racine) {
108
108
  </div></body></html>`;
109
109
  }
110
110
 
111
- const serveur = http.createServer(async (req, res) => {
112
- const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
111
+ /**
112
+ * ⚠️ LE TRAITEMENT VIT DANS UNE FONCTION, ET C'EST L'ÉCOUTEUR QUI RATTRAPE. Ce corps était passé
113
+ * directement à `createServer` en fonction `async` : `http` n'attend pas la promesse rendue, donc
114
+ * TOUTE exception levée hors du `try` du bas — analyse de l'URL, page d'accueil, lecture du corps —
115
+ * devenait un rejet non géré, et Node sort du processus sur un rejet non géré. UNE SEULE REQUÊTE
116
+ * ANONYME ARRÊTAIT DONC LE SERVEUR : constaté sur la v0.1.139, code de sortie 1, port fermé, les
117
+ * requêtes suivantes refusées. Le `try` existant ne couvrait que `player.handler`.
118
+ *
119
+ * ⚠️ ET LA BASE DE L'URL EST FIXE — L'EN-TÊTE `Host` DU CLIENT N'EN EST PAS UNE. Écrire
120
+ * `new URL(req.url, "http://" + host)` fait entrer une chaîne choisie par l'appelant dans un
121
+ * analyseur qui jette : `Host: [` donne la base `http://[`, invalide, exception. Or rien ici ne lit
122
+ * l'hôte — seuls `pathname` et `searchParams` servent : il n'apportait QUE sa panne.
123
+ *
124
+ * ⚠️ LA BONNE FORME ÉTAIT DÉJÀ ÉCRITE À CÔTÉ, ET CELLE-CI ÉTAIT LA COPIE DIVERGENTE.
125
+ * `server/handler.js` analyse depuis une base interne fixe, et le lien des courriels de re-partage
126
+ * a quitté `req.headers.host` pour `PLAYER_PUBLIC_URL` (cf. `server/__tests__/originePublique.test.js`).
127
+ * Deux fois la même leçon : une origine publique se DÉCLARE, elle ne se devine pas dans un en-tête.
128
+ */
129
+ async function servir(req, res) {
130
+ let url;
131
+ try {
132
+ url = new URL(req.url || "/", "http://interne");
133
+ } catch {
134
+ // La cible de requête elle-même est malformée : c'est le client qui a tort, pas le serveur.
135
+ player.refuserEnTexte(res, 400, "Bad request");
136
+ return;
137
+ }
113
138
 
114
139
  // Point de santé : un orchestrateur doit pouvoir savoir si le processus répond sans ouvrir un
115
140
  // document ni toucher la base.
@@ -165,6 +190,17 @@ const serveur = http.createServer(async (req, res) => {
165
190
  // de l'écriture ne peut plus rien poser, et tenter de le faire jetterait dans le rattrapage.
166
191
  player.refuserEnTexte(res, 500, "Erreur");
167
192
  }
193
+ }
194
+
195
+ const serveur = http.createServer((req, res) => {
196
+ // ⚠️ L'ÉCOUTEUR N'EST PAS `async` : il RATTRAPE. `void servir(...).catch(...)` est précisément ce
197
+ // qui transforme un rejet fatal en réponse. Le `try` autour de `player.handler` reste — il
198
+ // distingue une erreur du player d'une erreur du serveur — ; celui-ci est le filet de tout le
199
+ // reste, y compris de ce que personne n'a encore ajouté au-dessus.
200
+ void servir(req, res).catch((error) => {
201
+ console.error("[player] erreur non rattrapée", error);
202
+ player.refuserEnTexte(res, 500, "Erreur");
203
+ });
168
204
  });
169
205
 
170
206
  /** Corps JSON, borné. Un corps sans fin est une façon peu coûteuse de faire tomber un serveur. */
@@ -185,13 +221,69 @@ function lireCorpsJson(req, maxOctets = 1_000_000) {
185
221
  });
186
222
  }
187
223
 
224
+ /**
225
+ * ⚠️ AUCUN SIGNAL N'ÉTAIT ÉCOUTÉ, ET LE CHOIX ÉTAIT ENTRE LENT ET BRUTAL. Sans gestionnaire, Node
226
+ * PID 1 IGNORE `SIGTERM` (le noyau ne délivre pas à PID 1 un signal sans gestionnaire) : `docker
227
+ * stop` attend dix secondes puis tue. C'est ce que `dumb-init` corrige — il est PID 1, Node est son
228
+ * ENFANT, donc le signal relayé y déclenche l'action par défaut : terminaison IMMÉDIATE. Rapide,
229
+ * mais net : un document en cours de relais, une lecture de présentation, un battement — tranchés
230
+ * au milieu, à CHAQUE déploiement. La troisième voie n'avait jamais été posée.
231
+ *
232
+ * Ici : on cesse d'accepter, on laisse finir ce qui est en vol, on sort. Avec ou sans `dumb-init`.
233
+ *
234
+ * ⚠️ PREMIER PIÈGE — `close()` SEUL N'ARRIVE JAMAIS AU BOUT. Il attend que TOUTES les connexions se
235
+ * ferment, or le keep-alive en garde d'oisives ouvertes plusieurs secondes après leur dernière
236
+ * requête. Un arrêt qui attend ces sockets-là dépasse le délai de l'orchestrateur et se fait tuer :
237
+ * on aurait remplacé un arrêt brutal par un arrêt brutal PLUS LENT. `closeIdleConnections()` ferme
238
+ * ce qui ne sert plus, sans toucher à ce qui travaille.
239
+ *
240
+ * ⚠️ SECOND PIÈGE — SANS ÉCHÉANCE, UNE SEULE REQUÊTE BLOQUÉE TIENT TOUT. Un relais vers un stockage
241
+ * qui ne répond plus n'a aucune raison de finir. Le délai est donc BORNÉ, et volontairement sous le
242
+ * défaut de `docker stop` (dix secondes) : une échéance qui tombe après le couperet ne sert à rien.
243
+ * Passé le délai on coupe ce qui reste — c'est exactement l'ancien comportement, mais seulement pour
244
+ * ce qui n'a pas su finir, et après l'avoir DIT.
245
+ *
246
+ * ⚠️ Le minuteur est `unref()` : un compte à rebours qui empêcherait le processus de sortir
247
+ * retiendrait précisément l'arrêt qu'il surveille.
248
+ */
249
+ const DELAI_ARRET_MS = Math.max(0, Number(process.env.PLAYER_SHUTDOWN_GRACE_MS) || 0) || 8000;
250
+
251
+ function arreterProprement(signal) {
252
+ // ⚠️ UN SECOND SIGNAL SORT TOUT DE SUITE. Qui appuie deux fois sur Ctrl-C demande l'arrêt, pas
253
+ // une explication — et un gestionnaire qui insiste après un second ordre est un processus qu'on
254
+ // finit par tuer à la main.
255
+ if (arreterProprement.enCours) { process.exit(130); return; }
256
+ arreterProprement.enCours = true;
257
+ console.log(`${signal} reçu — arrêt : plus de nouvelle connexion, ${DELAI_ARRET_MS} ms pour finir ce qui est en vol`);
258
+
259
+ const couperet = setTimeout(() => {
260
+ console.warn(`arrêt : délai de ${DELAI_ARRET_MS} ms dépassé, les requêtes encore en vol sont coupées`);
261
+ serveur.closeAllConnections();
262
+ process.exit(1);
263
+ }, DELAI_ARRET_MS);
264
+ couperet.unref();
265
+
266
+ serveur.close(() => { clearTimeout(couperet); console.log("arrêt : tout est fini, sortie propre"); process.exit(0); });
267
+ serveur.closeIdleConnections();
268
+ }
269
+
188
270
  // N'écoute QUE lorsqu'on lance ce fichier. Sans cette garde, un test qui l'importe ouvrirait un
189
- // port — et deux tests en parallèle s'attraperaient sur le même.
190
- if (require.main === module) serveur.listen(PORT, HOST, () => {
191
- const racine = process.env.PLAYER_LOCAL_ROOT;
192
- console.log(`Discovery Media Player — http://localhost:${PORT}`);
193
- console.log(racine ? ` documents : ${racine}` : " documents : aucun dossier local (PLAYER_LOCAL_ROOT)");
194
- console.log(` état : http://localhost:${PORT}/api/doc?contract=1`);
195
- });
271
+ // port — et deux tests en parallèle s'attraperaient sur le même. Les signaux suivent la même règle,
272
+ // et pour une raison de plus : un gestionnaire posé par un IMPORT vit dans le processus de qui
273
+ // importe, et détournerait le Ctrl-C d'un banc qui ne demandait qu'à lire une fonction.
274
+ if (require.main === module) {
275
+ for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => arreterProprement(signal));
276
+ serveur.listen(PORT, HOST, () => {
277
+ const racine = process.env.PLAYER_LOCAL_ROOT;
278
+ // ⚠️ LE PORT OBTENU, PAS LE PORT DEMANDÉ. `PORT=0` demande à l'OS d'en choisir un libre — la
279
+ // ligne affichait alors « localhost:0 », une adresse qui ne mène nulle part, au moment précis
280
+ // où l'on a besoin de savoir où frapper. Une trace de démarrage qui n'aide pas à joindre le
281
+ // serveur ne sert à rien.
282
+ const ouvert = (serveur.address() || {}).port || PORT;
283
+ console.log(`Discovery Media Player — http://localhost:${ouvert}`);
284
+ console.log(racine ? ` documents : ${racine}` : " documents : aucun dossier local (PLAYER_LOCAL_ROOT)");
285
+ console.log(` état : http://localhost:${ouvert}/api/doc?contract=1`);
286
+ });
287
+ }
196
288
 
197
- module.exports = { serveur, versParametres, pageAccueil };
289
+ module.exports = { serveur, servir, versParametres, pageAccueil, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
@@ -40,6 +40,8 @@ need.
40
40
  "presenceJetons": true,
41
41
  "presenceDurcissement": "inconnu",
42
42
  "presenceFusion": "inconnu",
43
+ "lectureSaturee": { "total": 0, "fenetreS": 0, "derniereIlYaS": null },
44
+ "mesures": { "fenetreS": 0, "seauxMs": [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000], "routes": {}, "base": { "n": 0 }, "statuts": { "ok": 0, "refus4xx": 0, "debit429": 0, "occupe503": 0, "erreur5xx": 0 }, "memoireMio": { "rss": 0, "heap": 0, "tampons": 0 }, "boucleMs": { "n": 0, "moyen": null, "p99": null, "resolutionMs": 20 } },
43
45
  "retentionSweep": false,
44
46
  "hostShare": true,
45
47
  "hostMail": true,
@@ -91,6 +93,66 @@ The three `presence*` fields report what the host has **observed**, not what it
91
93
  | `presenceDurcissement` | `actif` (a hardened call came back), `degrade` (migration 0018 is missing), `inconnu` (nothing attempted in this process — **not** a green light, and process-local: another instance may have seen otherwise) |
92
94
  | `presenceFusion` | `actif` (a heartbeat used the fused contract — one round trip instead of two), `degrade` (migration 0019 is missing: heartbeats cost 3 round trips instead of 2, nothing breaks), `inconnu` (no heartbeat served in this process). Same three states, same trap, same reading rule as the row above |
93
95
 
96
+ ### `mesures` — what this instance has actually lived through
97
+
98
+ `lectureSaturee` (below) answers exactly one question. *Is a route slow? which ones? us or the
99
+ database? how many 5xx? is the event loop slipping?* had **no observable answer at all** — and
100
+ deciding to optimise without them is guessing. Both integrating hosts confirmed they cannot produce
101
+ these numbers from their side.
102
+
103
+ | key | meaning |
104
+ |---|---|
105
+ | `fenetreS` | seconds this process has been running — **the window every total below was counted over** |
106
+ | `seauxMs` | the bucket ladder the percentiles are read off, published **with** the numbers |
107
+ | `routes` | one entry per family of work — `document`, `presentation`, `action`, `fichier`, `carte`, `autre`. Families absent from the object were never exercised in this process |
108
+ | `base` | the same shape, for calls through the `db` capability **you** supply — measured at the seam, so it covers every call, including ones nobody has written yet |
109
+ | `statuts` | responses by class: `ok` (<400), `refus4xx`, `debit429`, `occupe503`, `erreur5xx` |
110
+ | `memoireMio` | `rss`, `heap` (heap used), `tampons` (`arrayBuffers`) in MiB, read at the moment of the request |
111
+ | `boucleMs` | event-loop **delay** — `moyen` and `p99` in ms, with `n` samples and the sampler's `resolutionMs` |
112
+
113
+ ⚠️ **A percentile over buckets is a bound, not a value.** `p95sousMs: 250` reads *"95% of calls
114
+ under 250 ms"* — never *"the 95th is 250 ms"*. That is why the key is named `sousMs`, and why
115
+ `seauxMs` ships alongside: without the ladder you cannot judge how precise the number you are
116
+ reading is. `null` means *past the top of the ladder* (over 10 s), which is itself the answer.
117
+
118
+ ⚠️ **`n: 0` is not `0 ms`.** A family that was never exercised reports `{ "n": 0 }` and nothing
119
+ else, and `boucleMs` with no samples reports `moyen: null` — not a zero that would read as *healthy*.
120
+
121
+ ⚠️ **`boucleMs` is the delay, not the interval.** The sampler observes how long its own timer
122
+ actually took, which at rest equals its resolution; the resolution is subtracted, so an idle
123
+ instance reports about `0` rather than a permanent `20` that would send you hunting a fault that
124
+ does not exist.
125
+
126
+ ⚠️ **No slug, no address, no text.** These are counters and durations. Nothing here names a visitor,
127
+ a document or a presentation — which is what makes it publishable on a card you read without
128
+ ceremony.
129
+
130
+ ⚠️ **Process-local, and reset by every deployment**, exactly like `lectureSaturee` below. Behind a
131
+ load balancer this is the instance that answered, not your deployment. Aggregating is your job.
132
+
133
+ ### `lectureSaturee` — what this instance actually refused
134
+
135
+ The read cache groups concurrent requests for the same presentation state and admits a bounded
136
+ number of them in flight. Past that ceiling it answers **`503` with `Retry-After: 1`** — a refusal,
137
+ not a failure, and deliberately distinguishable from a `500`. That ceiling has existed for a long
138
+ time; **nothing counted how often it was reached**, so the question *"do we actually saturate?"* had
139
+ no observable answer.
140
+
141
+ | key | meaning |
142
+ |---|---|
143
+ | `total` | refusals since this process started |
144
+ | `fenetreS` | how long this process has been running, in seconds — **the window `total` was counted over** |
145
+ | `derniereIlYaS` | seconds since the most recent refusal, or `null` if there has been none |
146
+
147
+ ⚠️ **`total` and `fenetreS` only mean anything together.** `total: 0` does not say *we do not
148
+ saturate*; on a process that started four seconds ago it says *nobody has looked yet*. That is the
149
+ same trap as `inconnu` in the two rows above, and the reason the three keys are returned as one
150
+ object rather than as separate fields you could read apart.
151
+
152
+ ⚠️ **It is process-local.** Behind a load balancer this is the count of the instance that answered,
153
+ not of your deployment. Aggregating is your job — and letting you believe otherwise would be worse
154
+ than returning nothing.
155
+
94
156
  ⚠️ **Before you upgrade, do not read `presenceDurcissement` or `presenceFusion`.** They are *reports
95
157
  of execution*: on an instance where nothing is running they say `inconnu`, which means *nobody
96
158
  looked* — not *the migration is there*. A pre-flight check built on one of them silently passes on
@@ -441,6 +503,67 @@ not from a list of column names borrowed from someone else's — a coarse check
441
503
  positives to excuse, it has a pattern to derive. The question each row must be able to answer is
442
504
  *which document was this written for*; the key that answers it is yours to name.
443
505
 
506
+ **Your `bot` plugin owns the assistant's behaviour — all of it.** The player ships the assistant's
507
+ markup and wires **none** of its sixty-four controls: no browser bundle, no inline script. That has
508
+ been true since the first commit, and until 26/08 it was undocumented while `docs/CONFIGURATION.md`
509
+ claimed the opposite. Two consequences you must act on:
510
+
511
+ - **Declare `wiresVoice: true`** on the object you pass as `ctx.plugins.bot` if you wire the voice
512
+ controls. Without it — or with a merely truthy value rather than exactly `true` — the three voice
513
+ buttons and the audio-consent step are not rendered at all. `ELEVENLABS_API_KEY` alone no longer
514
+ shows them: the key proves the *server* can synthesise, never that a click leads anywhere, and a
515
+ button that leads to silence is a broken promise made in your name.
516
+ - **`bot-tts` now requires a `sessionId`**, bound to the requested `slug`, and the text must match
517
+ something the assistant said in that session. The player reads `listMessages(sessionId)` and
518
+ treats a message as the assistant's when its `role` is `bot`, `assistant` or `ai`, taking the text
519
+ from `text` or `content`. **Anything it cannot read counts as "not said"** — an unrecognised shape
520
+ yields an empty set and every request is refused. On the one route that spends money, *"I could
521
+ not verify"* must read as **no**, never as *go ahead*.
522
+
523
+ ⚠️ **The player does not delegate that check to your plugin**, for the reason already stated above
524
+ about session binding: a security property of the player cannot depend on code the player does not
525
+ contain. It reads the messages and decides itself.
526
+
527
+ The message text is read from `text`, `content` or `body`, first non-empty wins. `body` is there
528
+ because a host said so **before** hitting it: its messages carry `body` and nothing else, its `role`
529
+ was a correct `bot`, and the reader would have returned an empty string for every message — an empty
530
+ set, so every request refused, on a perfectly correct integration. If your field is none of those
531
+ three, tell us and we widen the list. The field name carries no security; the **role** filter does.
532
+
533
+ **If you write to the `tts-cache` bucket yourself, write the trace too.** Retention removes an object
534
+ only when its fingerprint has a row in `doc_tts_objects`, and only the player's own route writes that
535
+ row. Anything your code puts in that bucket is therefore invisible to the sweep — **permanently**,
536
+ not just for the objects already there.
537
+
538
+ This is not hypothetical: an integrating host reported 908 objects it had written itself, under
539
+ **exactly** the player's naming — same digest, same two files, same bucket root. Its own comment says
540
+ the parity was deliberate, so that one clip serves both surfaces. Nothing about the name distinguishes
541
+ its objects from the player's; only the missing row does.
542
+
543
+ So `doc_tts_objects` is a **host write point**, not an internal table. Write it with the same
544
+ fingerprint the player computes, and nothing else:
545
+
546
+ ```
547
+ hash = sha256(voiceId + "|" + modelId + "|v2|" + spokenText) -- hex, lowercase
548
+ ```
549
+
550
+ ```sql
551
+ insert into public.doc_tts_objects (hash) values ($1)
552
+ on conflict (hash) do nothing;
553
+ ```
554
+
555
+ ⚠️ **Never write the text**, in any column. The table holds a fingerprint and a date on purpose: the
556
+ bucket may already hold personal data, and writing the text would recreate it in the database — this
557
+ time queryable. `hash` is the primary key, so the insert is idempotent; a clip regenerated under a new
558
+ voice yields a new fingerprint and a new row, which is correct.
559
+
560
+ RLS is on with **no policy**, so nothing reaches it except a role that bypasses RLS — the
561
+ `service_role` key the `db` capability already uses. No grant, no schema change, no new migration:
562
+ apply `0021` and write.
563
+
564
+ Objects written before you start writing the trace stay untraceable for good. The sweep counts rows,
565
+ so it can say *"no trace outside the window survives"* — never *"the bucket is clean"*.
566
+
444
567
  **A database error carries its status as a number, not inside its message.** Set `statusCode` (or
445
568
  `status`) on whatever `db.request` throws. Both contexts shipped here already do; a host that
446
569
  implements the seam itself may not, and the player then has to guess from the text.
package/docs/RETENTION.md CHANGED
@@ -111,8 +111,45 @@ Agent-guided walkthrough: **purged 13 months** after `last_at`.
111
111
  |---|---|---|
112
112
  | `player_rate_limits.key` | may contain an **IP in the clear** (`hshare:<ip>`) or an email | row purged as soon as `expires_at` has passed (opportunistically, on every pass) |
113
113
 
114
+ ## Voice cache (`doc_tts_objects` + the `tts-cache` bucket)
115
+
116
+ Every synthesis writes two objects to the **public** `tts-cache` bucket — `<fingerprint>.mp3` and
117
+ `<fingerprint>.json` (per-character alignment). The fingerprint is a digest of voice + model +
118
+ **spoken** text. They are **purged 13 months** after `created_at`: the two objects first, then the
119
+ row — never the other way round, because erasing the trace first would leave the objects
120
+ permanently unreachable.
121
+
122
+ | column | contents | fate |
123
+ |---|---|---|
124
+ | `doc_tts_objects.hash` | the fingerprint — **never the text** | purged with the row, after both bucket objects |
125
+ | `doc_tts_objects.created_at` | when the object was written | the window is measured on it |
126
+
127
+ ⚠️ **Why a table exists at all.** The objects are named by a digest that ties back to no row, and
128
+ the host `storage` capability exposes `put` and `remove` — never `list`. Before this table the
129
+ bucket could not be swept at all: there was nothing to walk. This is not a policy that was missing,
130
+ it is the trace. The row records a fingerprint and a date and nothing else: writing the text here
131
+ would recreate, inside the database, whatever personal data the bucket may already hold — and make
132
+ it queryable, which is strictly worse than not having it.
133
+
134
+ ⚠️ **A visitor chooses what goes in.** `bot-tts` accepts the caller's text, so a unique text leaves
135
+ an MP3 and a JSON in a public bucket. The grouping and ceilings added in 0.1.140 bound the cost per
136
+ hour; only this window bounds the **duration**.
137
+
114
138
  ## Limits stated rather than left unsaid
115
139
 
140
+ - ⚠️ **`fichiersErreur` can be high without any removal having failed.** Each fingerprint has two
141
+ objects, and the alignment `.json` is not always there — the provider does not always return one.
142
+ Measured on an integrating host's bucket on 27/08: **552 `.mp3` for 356 `.json`**, so 196 audio
143
+ files legitimately have no companion to remove. The count is deliberately not masked, but read it
144
+ with that in mind: a first sweep reporting two hundred "errors" may have failed at nothing.
145
+ - **If you write to the `tts-cache` bucket yourself, write the trace too**, or your objects are
146
+ invisible to the sweep permanently — see *Voice* in `docs/HOST-CONTRACT.md` for the exact
147
+ fingerprint and the insert. The same host had written **908 objects under the player's exact
148
+ naming**; nothing but the missing row distinguished them.
149
+ - **Voice-cache objects written before migration 0021 have no trace, and never will.** The sweep
150
+ can only reach what a row points at, and no row was ever written for them. They stay in the
151
+ bucket until an operator removes them by hand. The census counts rows, so it cannot see them
152
+ either — it can say "no trace past the window survives", never "the bucket is clean".
116
153
  - **Orphaned attachments**: purging the rows erases the bucket file only if the host context
117
154
  provides `storage.remove` (an optional capability). Without it, the URL becomes unreachable from
118
155
  the product but the object survives in the bucket — said here rather than simulated.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.138",
3
+ "version": "0.1.140",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
@@ -74,7 +74,9 @@
74
74
  "test:e2e": "node -e \"require('fs').existsSync('vitest.e2e.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.e2e.config.mjs",
75
75
  "test:base": "node -e \"require('fs').existsSync('vitest.base.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.base.config.mjs",
76
76
  "test:charge": "node -e \"require('fs').existsSync('vitest.charge.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.charge.config.mjs",
77
- "test:campagne": "node -e \"require('fs').existsSync('vitest.campagne.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.campagne.config.mjs"
77
+ "test:campagne": "node -e \"require('fs').existsSync('vitest.campagne.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.campagne.config.mjs",
78
+ "test:endurance": "node -e \"require('fs').existsSync('vitest.endurance.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.endurance.config.mjs",
79
+ "test:stats": "node -e \"require('fs').existsSync('vitest.stats.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.stats.config.mjs"
78
80
  },
79
81
  "engines": {
80
82
  "node": ">=22.13.0"
package/server/cache.js CHANGED
@@ -75,6 +75,12 @@ function creerCache(options) {
75
75
  // résultat en mémoire.
76
76
  const maxEnVol = Math.max(1, Number(options && options.maxEnVol) || 128);
77
77
  const now = (options && options.now) || (() => Date.now());
78
+ // ⚠️ CE QUI EST REFUSÉ NE LAISSAIT AUCUNE TRACE. Le plafond d'admission existe depuis longtemps
79
+ // et il rend un 503 propre — mais rien ne comptait combien de fois il avait servi. La question
80
+ // « sature-t-on, en vrai ? » n'avait donc AUCUNE réponse observable, et c'est précisément celle
81
+ // dont dépend la décision d'optimiser ou non le chemin chaud. Optimiser sans elle, c'est deviner.
82
+ let nSatures = 0;
83
+ let dernierSature = null;
78
84
  /** @type {Map<string, { echeance: number, promesse: Promise<unknown>, poids: number, enVol: boolean, rendrePlace?: () => void }>} */
79
85
  const entrees = new Map();
80
86
  let poidsTotal = 0;
@@ -123,7 +129,11 @@ function creerCache(options) {
123
129
  // existe pour absorber. On ne refuse que ce qui coûterait une requête DE PLUS.
124
130
  if (vue && (vue.enVol || vue.echeance > t)) return vue.promesse;
125
131
 
126
- if (nEnVol >= maxEnVol) throw erreurSaturation(maxEnVol);
132
+ if (nEnVol >= maxEnVol) {
133
+ nSatures += 1;
134
+ dernierSature = t;
135
+ throw erreurSaturation(maxEnVol);
136
+ }
127
137
 
128
138
  const promesse = Promise.resolve().then(produire);
129
139
  // ⚠️ UN DÉCOMPTE IDEMPOTENT PAR PROMESSE. Deux chemins libèrent la place (résolution, et
@@ -166,12 +176,33 @@ function creerCache(options) {
166
176
  return promesse;
167
177
  },
168
178
 
179
+ /**
180
+ * Oublie ce qui est RETENU, jamais ce qui est en vol — la même règle que l'éviction : retirer
181
+ * une demande en cours ne libère rien et casserait le regroupement pour ses appelants.
182
+ *
183
+ * ⚠️ EXISTE PARCE QU'UN CACHE DE MODULE SURVIT À UN BANC. Deux bancs qui demandent la même clé
184
+ * dans le même processus : le second est servi par la mémoire du premier et n'observe donc PAS
185
+ * ce qu'il croit observer — il a compté zéro appel réseau et conclu que la route n'appelait
186
+ * pas. Un banc qui ne remet pas cet état à zéro mesure le banc d'avant.
187
+ */
188
+ vider() { for (const [k, e] of [...entrees]) if (!e.enVol) oublier(k); },
189
+
169
190
  /** Pour les tests et l'exploitation : ce que la table contient réellement. */
170
191
  taille: () => entrees.size,
171
192
  /** Poids cumulé des résultats retenus, en OCTETS UTF-8. */
172
193
  poids: () => poidsTotal,
173
194
  /** Demandes actuellement en vol — ce que le plafond d'admission borne. */
174
195
  enVol: () => nEnVol,
196
+ /**
197
+ * Ce que le plafond a refusé depuis le démarrage de ce processus.
198
+ *
199
+ * ⚠️ UN TOTAL SEUL MENT PAR OMISSION, et c'est pour ça que `dernier` l'accompagne. « 0 refus »
200
+ * ne veut pas dire « on ne sature pas » : ça peut vouloir dire « ce processus vient de
201
+ * démarrer ». La même règle que `presenceFusion` dans la carte — un rapport d'exécution n'est
202
+ * pas un inventaire, et une absence d'observation n'est pas une preuve. Celui qui LIT reste
203
+ * responsable de savoir depuis quand ce processus regarde ; la carte, elle, le lui dit.
204
+ */
205
+ satures: () => ({ total: nSatures, dernier: dernierSature }),
175
206
  };
176
207
  }
177
208
 
@@ -57,4 +57,37 @@ function estConflit(erreur) {
57
57
  return /→\s*409\b/.test(String((erreur && erreur.message) || ""));
58
58
  }
59
59
 
60
- module.exports = { estConflit };
60
+ // ⚠️ « CETTE FONCTION N'EXISTE PAS ICI » VIT AVEC « CE CONFLIT EST UN CONFLIT », ET PAS AILLEURS.
61
+ // Elle habitait `presentations.js`, où elle était née ; deux autres modules en ont désormais besoin
62
+ // pour replier sur un chemin en mémoire quand une migration n'a pas été appliquée. La recopier
63
+ // aurait donné deux définitions d'un même fait — exactement ce que ce fichier existe pour empêcher.
64
+ // `presentations.js` la ré-exporte : sa surface publique ne bouge pas.
65
+ /**
66
+ * Cette erreur dit-elle « CETTE SIGNATURE N'EXISTE PAS », et rien d'autre ?
67
+ *
68
+ * ⚠️ C'EST LA QUESTION QUI MANQUAIT, ET SON ABSENCE RETIRAIT UNE PROTECTION. Le repli vers l'ancien
69
+ * contrat se déclenchait sur N'IMPORTE QUELLE exception : un `ECONNRESET`, un 500, un délai dépassé
70
+ * valaient « migration 0018 absente », et le processus restait dégradé — sans contrôle anti-usurpation
71
+ * — jusqu'à son redémarrage. Une panne réseau d'une seconde désarmait une garde de sécurité sur une
72
+ * base pourtant entièrement migrée.
73
+ *
74
+ * C'est la règle du jour appliquée au code de production : **un mécanisme qui ne peut pas mesurer doit
75
+ * refuser de conclure, pas conclure par défaut.** Ici, ne pas savoir distinguer PGRST202 d'un timeout
76
+ * ne rendait pas le repli prudent — il le rendait automatique.
77
+ *
78
+ * PostgREST rend `PGRST202` quand aucune fonction ne correspond au jeu d'arguments nommés. On accepte
79
+ * les DEUX formes que nos contextes produisent (code analysé, ou message contenant le code / la phrase
80
+ * de PostgREST) — et RIEN d'autre : un statut 404 seul ne suffit pas, il peut venir d'ailleurs.
81
+ */
82
+ function signatureAbsente(erreur) {
83
+ if (!erreur) return false;
84
+ const code = erreur.details && (erreur.details.code || (erreur.details.error && erreur.details.error.code));
85
+ if (code === "PGRST202") return true;
86
+ // ⚠️ PAS DE `erreur &&` ICI : la garde de la première ligne l'a déjà tranché. Le garder ne
87
+ // protégeait de rien et APPRENAIT AU LECTEUR QUE `erreur` PEUT ÊTRE NULLE À CET ENDROIT — ce qui
88
+ // est faux. Un test qui ne peut pas échouer ne coûte pas un cycle, il coûte une lecture.
89
+ const texte = String(erreur.message || "");
90
+ return texte.includes("PGRST202") || /Could not find the function/i.test(texte);
91
+ }
92
+
93
+ module.exports = { estConflit, signatureAbsente };
@@ -10,6 +10,31 @@ const { esc } = require("./texte");
10
10
  let PLAYER = null;
11
11
  const init = (ctx) => { PLAYER = ctx; };
12
12
 
13
+ /**
14
+ * La voix se propose-t-elle au visiteur ? DEUX conditions, et la seconde est arrivée le 26/08.
15
+ *
16
+ * ⚠️ LA CLÉ SEULE NE PROUVE RIEN, ET C'EST POURTANT CE QU'ELLE PRÉTENDAIT. Elle dit que le SERVEUR
17
+ * sait synthétiser ; elle ne dit rien de ce qui arrive quand on clique. Or ce paquet ne câble AUCUN
18
+ * des soixante-quatre contrôles de cet assistant — il en livre le balisage, l'hôte livre le
19
+ * comportement. Les trois boutons de voix et l'étape de consentement audio étaient les SEULS dont
20
+ * l'apparition dépendait d'un secret serveur : les soixante autres s'affichent toujours, donc un
21
+ * hôte qui embarque cet assistant sait qu'il doit les brancher. Ceux-là, non — une clé posée les
22
+ * faisait apparaître, et un visiteur qui cliquait obtenait le silence.
23
+ *
24
+ * Signalé le 26/08 par un hôte intégrateur qui est allé chercher qui appelait `bot-tts` et n'a
25
+ * trouvé personne. Ce fichier portait DÉJÀ la règle, dans son banc : « une porte "écouter la
26
+ * présentation" qui mène au silence est une promesse cassée ». Elle était vérifiée SANS clé,
27
+ * jamais AVEC — le seul cas où la porte pouvait exister.
28
+ *
29
+ * ⚠️ `=== true` PLUTÔT QUE VÉRIDIQUE. Une fonction, une chaîne ou un objet posé là par accident
30
+ * ouvrirait la porte : on exige une DÉCLARATION, pas une présence.
31
+ */
32
+ function voixProposable() {
33
+ if (!process.env.ELEVENLABS_API_KEY) return false;
34
+ const bot = PLAYER && PLAYER.plugins && PLAYER.plugins.bot;
35
+ return !!(bot && bot.wiresVoice === true);
36
+ }
37
+
13
38
  const BOT_CSS = `
14
39
  /* Doc + chat côte à côte (ces styles ne vivent sinon que dans LIVE_CSS, réservé au mode présentation). */
15
40
  .lrow{flex:1;display:flex;min-height:0;position:relative}
@@ -539,15 +564,15 @@ function botMarkup(share, pitch) {
539
564
  const acc = esc(share.bot_accent || "#15130f");
540
565
  const avatar = share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : (share.bot_name ? esc(String(share.bot_name).trim().charAt(0).toUpperCase()) : "◆");
541
566
  // Voix (ElevenLabs) : les boutons 🔊 ne sont proposés que si une clé est configurée côté serveur.
542
- const voiceBtn = process.env.ELEVENLABS_API_KEY ? `<button class=botc-voice id=botcVoice title="Écouter la présentation" aria-pressed=false>${ICONS.mute}</button>` : "";
543
- const pVoiceBtn = process.env.ELEVENLABS_API_KEY ? `<button id=botpVoice title="Écouter">${ICONS.mute}</button>` : "";
567
+ const voiceBtn = voixProposable() ? `<button class=botc-voice id=botcVoice title="Écouter la présentation" aria-pressed=false>${ICONS.mute}</button>` : "";
568
+ const pVoiceBtn = voixProposable() ? `<button id=botpVoice title="Écouter">${ICONS.mute}</button>` : "";
544
569
  // Étape 2 du sélecteur de départ (uniquement si la voix est disponible) : le CONSENTEMENT audio se donne
545
570
  // ICI, clairement, avant de lancer la présentation — audio interactif (voix + chat) ou par écrit.
546
571
  const hasVClips = !!share.bot_vclips;
547
572
  const videoDoor = hasVClips ? `<button class=botw-door id=doorVideo><i>${ICONS.play}</i><span>En vidéo avec ${name}<small>${name} vous présente face caméra — le format le plus vivant</small></span><em class=botw-tag>Populaire</em></button>` : "";
548
573
  const vNote = !hasVClips && share.video_layout ? `<p class=botw-note>🎬 La présentation vidéo arrive bientôt sur ce document.</p>` : "";
549
- const s2 = process.env.ELEVENLABS_API_KEY ? `<div class="botw-card botw-s2"><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><p class=botw-q>Parfait ! Comment préférez-vous suivre la présentation ?</p>${videoDoor}<button class=botw-door id=doorVoice><i>${ICONS.sound}</i><span>${hasVClips ? "En audio" : `Avec la voix de ${name}`}<small>Audio interactif — écoutez la présentation et posez vos questions dans le chat à tout moment</small></span>${hasVClips ? "" : `<em class=botw-tag>Populaire</em>`}</button><button class=botw-door id=doorSilent><i>${ICONS.chat}</i><span>Par écrit, dans le chat<small>${name} écrit page après page, en silence — à votre rythme</small></span></button>${vNote}<button class=botw-back id=botwBack>← Revenir aux options</button></div>` : "";
550
- return `<div class="botc min" id=botc style="--bacc:${acc}"><div class=botc-grab id=botcGrab><i></i></div><div class=botc-h><span class=botc-av>${avatar}</span><div><b>${name}</b><span class=botc-sub>${sub}</span></div>${voiceBtn}<button class=botc-gearbtn id=botcGearBtn title="Réglages d'affichage">${ICONS.gear}</button><button class=botc-min id=botcMin title=Réduire>${ICONS.min}</button></div><button class=botc-back id=botcBack>${ICONS.prev}<span>Revenir à la présentation</span></button><div class=botc-msgs id=botcMsgs><div class=botc-choices id=botcChoices></div></div><button class=botc-resume id=botcResume>Reprendre la présentation</button><div class=botc-in><input id=botcText placeholder="Écrivez votre message…" autocomplete=off maxlength=1000><button id=botcSend title=Envoyer>${ICONS.send}</button></div></div><button class=botc-fab id=botcFab style="--bacc:${acc}" title="Assistant & réglages">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}<span class=botc-badge id=botcBadge></span><span class=botc-gear>${ICONS.gear}</span><span class=fab-gear>${ICONS.gear}</span></button><button class=botc-fab2 id=botcFab2 title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${ICONS.chat}<span class=botc-badge id=botcBadge2></span></button>${process.env.ELEVENLABS_API_KEY ? `<button class="botc-fab2 botc-fab3" id=botcVoice2 title="Couper la voix"></button>` : ""}<button class="botc-fab2 botc-fab4" id=botcPlay2 title="Relancer une visite">${ICONS.play}</button><button class=botc-peek id=botcPeek></button><div class=fabmenu id=fabMenu style="--bacc:${acc}"><div class=fm-l0 id=fmL0><button class=fm-row data-p=pDisp><span class=fm-rl>Affichage</span><em id=fmVDisp></em>${ICONS.next}</button><button class=fm-row data-p=pLang><span class=fm-rl>Langue</span><em id=fmVLang></em>${ICONS.next}</button><button class=fm-row data-p=pLook><span class=fm-rl>Apparence</span><em id=fmVLook></em>${ICONS.next}</button></div><div class=fm-p id=pDisp><button class=fm-back>${ICONS.prev}<span class=fm-rl>Affichage</span></button><div class=fm-seg id=fmDisp><button data-v=panel>Panneau</button><button data-v=bubble>Bulle</button><button data-v=cap>Barre</button><button data-v=audio>Audio seul</button></div></div><div class=fm-p id=pLang><button class=fm-back>${ICONS.prev}<span class=fm-rl>Langue</span></button><div class=fm-seg id=fmLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div></div><div class=fm-p id=pLook><button class=fm-back>${ICONS.prev}<span class=fm-rl>Apparence</span></button><div class=fm-sec>Thème</div><div class=fm-seg id=fmTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=fmStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div><div class=botw id=botw style="--bacc:${acc}"><button class=botw-x id=botwX aria-label=Fermer>${ICONS.close}</button><div class=botw-card><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><div class=botw-lang id=botwLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div>${pitch ? `<p class=botw-pitch>${esc(pitch)}</p>` : ""}<p class=botw-q>Comment souhaitez-vous découvrir ce document ?</p><button class=botw-door id=doorPresent><i>${ICONS.play}</i><span>Je me laisse guider<small>${name} vous présente le document, à votre rythme</small></span><em class=botw-tag>Recommandé</em></button><button class=botw-door id=doorRead><i>${ICONS.book}</i><span>Je le parcours seul<small>Lecture libre — l'assistant reste disponible</small></span></button><button class=botw-door id=doorChat><i>${ICONS.chat}</i><span>J'ai des questions<small>Échangez directement avec ${name}</small></span></button></div>${s2}</div><div class=botp id=botp style="--bacc:${acc}"><div class=botp-prog id=botpProg><i id=botpFill></i></div><div class=botp-cap id=botpCap></div><div class=botp-chips id=botpChips></div><div class=botp-ctl><button class=pp id=botpPP aria-label="Lecture / pause">${ICONS.pause}${ICONS.play}</button>${pVoiceBtn}<button id=botpFs title="Plein écran">${ICONS.fs}</button><button id=botpChat title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}</button><button id=botpMore title=Options>${ICONS.more}</button></div><button class=botp-big id=botpBig aria-label=Reprendre>${ICONS.play}</button><div class=rot-hint id=rotHint><i class=rh-ph><svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="3" width="10" height="18" rx="2.5"/><path d="M11 18.2h2"/></svg></i><div class=rh-t><b>Plein écran</b><span>Tournez votre téléphone</span></div></div><div class=botp-menu id=botpMenu><button id=bmChat>${ICONS.chat}<span>Poser une question</span></button><button id=bmCall>${ICONS.cal}<span>Être rappelé / prendre RDV</span></button><button id=bmDl>${ICONS.dl}<span>Télécharger le document</span></button><button id=bmRestart>${ICONS.restart}<span>Recommencer la présentation</span></button><button id=bmRead>${ICONS.book}<span>Consulter tranquillement</span></button><button id=bmSet class=bm-set>${ICONS.gear}<span>Réglages</span></button></div><div class="botp-menu botp-set" id=botpSet><button class=bs-back id=bsBack>${ICONS.prev}<span>Réglages</span></button><div class=fm-sec>Vitesse de lecture</div><div class=fm-seg id=msSpd><button data-v=1>1×</button><button data-v=1.5>1,5×</button><button data-v=2>2×</button></div><div class=fm-sec>Thème</div><div class=fm-seg id=msTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=msStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div>`;
574
+ const s2 = voixProposable() ? `<div class="botw-card botw-s2"><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><p class=botw-q>Parfait ! Comment préférez-vous suivre la présentation ?</p>${videoDoor}<button class=botw-door id=doorVoice><i>${ICONS.sound}</i><span>${hasVClips ? "En audio" : `Avec la voix de ${name}`}<small>Audio interactif — écoutez la présentation et posez vos questions dans le chat à tout moment</small></span>${hasVClips ? "" : `<em class=botw-tag>Populaire</em>`}</button><button class=botw-door id=doorSilent><i>${ICONS.chat}</i><span>Par écrit, dans le chat<small>${name} écrit page après page, en silence — à votre rythme</small></span></button>${vNote}<button class=botw-back id=botwBack>← Revenir aux options</button></div>` : "";
575
+ return `<div class="botc min" id=botc style="--bacc:${acc}"><div class=botc-grab id=botcGrab><i></i></div><div class=botc-h><span class=botc-av>${avatar}</span><div><b>${name}</b><span class=botc-sub>${sub}</span></div>${voiceBtn}<button class=botc-gearbtn id=botcGearBtn title="Réglages d'affichage">${ICONS.gear}</button><button class=botc-min id=botcMin title=Réduire>${ICONS.min}</button></div><button class=botc-back id=botcBack>${ICONS.prev}<span>Revenir à la présentation</span></button><div class=botc-msgs id=botcMsgs><div class=botc-choices id=botcChoices></div></div><button class=botc-resume id=botcResume>Reprendre la présentation</button><div class=botc-in><input id=botcText placeholder="Écrivez votre message…" autocomplete=off maxlength=1000><button id=botcSend title=Envoyer>${ICONS.send}</button></div></div><button class=botc-fab id=botcFab style="--bacc:${acc}" title="Assistant & réglages">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}<span class=botc-badge id=botcBadge></span><span class=botc-gear>${ICONS.gear}</span><span class=fab-gear>${ICONS.gear}</span></button><button class=botc-fab2 id=botcFab2 title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${ICONS.chat}<span class=botc-badge id=botcBadge2></span></button>${voixProposable() ? `<button class="botc-fab2 botc-fab3" id=botcVoice2 title="Couper la voix"></button>` : ""}<button class="botc-fab2 botc-fab4" id=botcPlay2 title="Relancer une visite">${ICONS.play}</button><button class=botc-peek id=botcPeek></button><div class=fabmenu id=fabMenu style="--bacc:${acc}"><div class=fm-l0 id=fmL0><button class=fm-row data-p=pDisp><span class=fm-rl>Affichage</span><em id=fmVDisp></em>${ICONS.next}</button><button class=fm-row data-p=pLang><span class=fm-rl>Langue</span><em id=fmVLang></em>${ICONS.next}</button><button class=fm-row data-p=pLook><span class=fm-rl>Apparence</span><em id=fmVLook></em>${ICONS.next}</button></div><div class=fm-p id=pDisp><button class=fm-back>${ICONS.prev}<span class=fm-rl>Affichage</span></button><div class=fm-seg id=fmDisp><button data-v=panel>Panneau</button><button data-v=bubble>Bulle</button><button data-v=cap>Barre</button><button data-v=audio>Audio seul</button></div></div><div class=fm-p id=pLang><button class=fm-back>${ICONS.prev}<span class=fm-rl>Langue</span></button><div class=fm-seg id=fmLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div></div><div class=fm-p id=pLook><button class=fm-back>${ICONS.prev}<span class=fm-rl>Apparence</span></button><div class=fm-sec>Thème</div><div class=fm-seg id=fmTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=fmStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div><div class=botw id=botw style="--bacc:${acc}"><button class=botw-x id=botwX aria-label=Fermer>${ICONS.close}</button><div class=botw-card><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><div class=botw-lang id=botwLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div>${pitch ? `<p class=botw-pitch>${esc(pitch)}</p>` : ""}<p class=botw-q>Comment souhaitez-vous découvrir ce document ?</p><button class=botw-door id=doorPresent><i>${ICONS.play}</i><span>Je me laisse guider<small>${name} vous présente le document, à votre rythme</small></span><em class=botw-tag>Recommandé</em></button><button class=botw-door id=doorRead><i>${ICONS.book}</i><span>Je le parcours seul<small>Lecture libre — l'assistant reste disponible</small></span></button><button class=botw-door id=doorChat><i>${ICONS.chat}</i><span>J'ai des questions<small>Échangez directement avec ${name}</small></span></button></div>${s2}</div><div class=botp id=botp style="--bacc:${acc}"><div class=botp-prog id=botpProg><i id=botpFill></i></div><div class=botp-cap id=botpCap></div><div class=botp-chips id=botpChips></div><div class=botp-ctl><button class=pp id=botpPP aria-label="Lecture / pause">${ICONS.pause}${ICONS.play}</button>${pVoiceBtn}<button id=botpFs title="Plein écran">${ICONS.fs}</button><button id=botpChat title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}</button><button id=botpMore title=Options>${ICONS.more}</button></div><button class=botp-big id=botpBig aria-label=Reprendre>${ICONS.play}</button><div class=rot-hint id=rotHint><i class=rh-ph><svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="3" width="10" height="18" rx="2.5"/><path d="M11 18.2h2"/></svg></i><div class=rh-t><b>Plein écran</b><span>Tournez votre téléphone</span></div></div><div class=botp-menu id=botpMenu><button id=bmChat>${ICONS.chat}<span>Poser une question</span></button><button id=bmCall>${ICONS.cal}<span>Être rappelé / prendre RDV</span></button><button id=bmDl>${ICONS.dl}<span>Télécharger le document</span></button><button id=bmRestart>${ICONS.restart}<span>Recommencer la présentation</span></button><button id=bmRead>${ICONS.book}<span>Consulter tranquillement</span></button><button id=bmSet class=bm-set>${ICONS.gear}<span>Réglages</span></button></div><div class="botp-menu botp-set" id=botpSet><button class=bs-back id=bsBack>${ICONS.prev}<span>Réglages</span></button><div class=fm-sec>Vitesse de lecture</div><div class=fm-seg id=msSpd><button data-v=1>1×</button><button data-v=1.5>1,5×</button><button data-v=2>2×</button></div><div class=fm-sec>Thème</div><div class=fm-seg id=msTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=msStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div>`;
551
576
  }
552
577
 
553
578
  module.exports = { init, BOT_CSS, ICO, ICONS, botMarkup };