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 +102 -10
- package/docs/HOST-CONTRACT.md +123 -0
- package/docs/RETENTION.md +37 -0
- package/package.json +4 -2
- package/server/cache.js +32 -1
- package/server/erreurs-base.js +34 -1
- package/server/gabarit-agent.js +29 -4
- package/server/handler.js +88 -1
- package/server/mesures.js +206 -0
- package/server/presentations.js +1 -28
- package/server/retention.js +70 -4
- package/server/routes-agent.js +298 -53
- package/server/shares.js +174 -56
- package/supabase/init.sql +179 -4
- package/supabase/migrations/0020-mesures-bornees.sql +28 -16
- package/supabase/migrations/0021-empreintes-de-voix.sql +49 -0
- package/supabase/migrations/0022-statistiques-agregees.sql +156 -0
- package/supabase/migrations/0023-bornes-des-sessions-internes.sql +84 -0
- package/supabase/recensement-retention.sql +11 -1
package/bin/serve.js
CHANGED
|
@@ -108,8 +108,33 @@ async function pageAccueil(racine) {
|
|
|
108
108
|
</div></body></html>`;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 };
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -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.
|
|
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)
|
|
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
|
|
package/server/erreurs-base.js
CHANGED
|
@@ -57,4 +57,37 @@ function estConflit(erreur) {
|
|
|
57
57
|
return /→\s*409\b/.test(String((erreur && erreur.message) || ""));
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
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 };
|
package/server/gabarit-agent.js
CHANGED
|
@@ -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 =
|
|
543
|
-
const pVoiceBtn =
|
|
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 =
|
|
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>${
|
|
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 };
|