privateboard 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Voice Replay · adjourned-room transcript playback.
3
+ *
4
+ * Public API · `window.boardroomVoiceReplay`:
5
+ * open({ roomId, messages, members, chair })
6
+ * close()
7
+ * isOpen()
8
+ *
9
+ * Flow on `open`:
10
+ * 1. Key gate · GET /api/voices · if no usable provider beyond
11
+ * `browser` is configured, swap into the key-prompt mode
12
+ * (small CTA that deep-links to user-settings).
13
+ * 2. Build playlist · filter messages to chair + directors (and
14
+ * optionally user, off by default), drop system / procedural
15
+ * markers. Earliest first.
16
+ * 3. Mount the floating overlay · controls + speaker card +
17
+ * progress + per-message preview.
18
+ * 4. Synthesise + play sequentially via a single `<audio>`
19
+ * element, pre-fetching N+1 while N plays for gapless
20
+ * handoff. Each message is highlighted + smooth-scrolled
21
+ * into view in the chat as it begins.
22
+ *
23
+ * Skipped always: system messages, round-open / round-prompt /
24
+ * settings / no-brief / convening / chair-pick / web-search status
25
+ * markers (anything where `meta.kind` is one of the procedural
26
+ * kinds the chat renders as inline cards rather than spoken text).
27
+ */
28
+ (function (root) {
29
+ "use strict";
30
+
31
+ /** Procedural meta kinds that carry no speakable content · the
32
+ * chat renders these as cards (round dividers, milestones,
33
+ * status updates) so reading them aloud just adds noise. */
34
+ const PROCEDURAL_KINDS = new Set([
35
+ "round-open",
36
+ "round-prompt",
37
+ "settings",
38
+ "no-brief",
39
+ "convening",
40
+ "chair-pick",
41
+ "web-search",
42
+ "web-search-result",
43
+ "tool-use",
44
+ ]);
45
+
46
+ /** Phase labels rotated through the loading state while the
47
+ * first message synthesises. Same vocabulary as the rest of
48
+ * the app's voice surface. */
49
+ const STATE = {
50
+ overlay: null,
51
+ audio: null,
52
+ playlist: [],
53
+ members: [],
54
+ chair: null,
55
+ idx: 0,
56
+ paused: false,
57
+ speed: 1,
58
+ skipUser: true,
59
+ abortCtrl: null,
60
+ prefetched: new Map(), // idx → { audioBase64, mimeType }
61
+ };
62
+
63
+ function isOpen() {
64
+ return !!STATE.overlay;
65
+ }
66
+
67
+ /** Build the ordered playlist · keep chronological order, drop
68
+ * procedural / system messages, optionally drop user messages.
69
+ * Each entry carries everything the playback loop and the UI
70
+ * preview need so we don't re-derive on every step. */
71
+ function buildPlaylist(messages, opts) {
72
+ const skipUser = opts && opts.skipUser !== false; // default true
73
+ const members = (opts && opts.members) || [];
74
+ const chair = (opts && opts.chair) || null;
75
+ const byId = new Map(members.map((a) => [a.id, a]));
76
+ if (chair) byId.set(chair.id, chair);
77
+ const out = [];
78
+ for (const m of messages) {
79
+ if (!m || !m.body || !m.body.trim()) continue;
80
+ if (m.authorKind === "system") continue;
81
+ const kind = m.meta && typeof m.meta.kind === "string" ? m.meta.kind : null;
82
+ if (kind && PROCEDURAL_KINDS.has(kind)) continue;
83
+ // Skip the streaming placeholder that hasn't finalized yet
84
+ // (its body is empty or its meta marks it as in-flight).
85
+ if (m.meta && m.meta.streaming === true) continue;
86
+ const isUser = m.authorKind === "user";
87
+ if (isUser && skipUser) continue;
88
+ const agent = m.authorId ? byId.get(m.authorId) : null;
89
+ out.push({
90
+ messageId: m.id,
91
+ kind: isUser ? "user" : (agent && agent.roleKind === "moderator" ? "chair" : "director"),
92
+ authorId: m.authorId || null,
93
+ authorName: agent
94
+ ? agent.name
95
+ : isUser
96
+ ? "you"
97
+ : (m.authorKind === "agent" ? "Director" : ""),
98
+ authorRole: agent && agent.roleTag ? agent.roleTag : "",
99
+ authorAvatar: agent && agent.avatarPath ? agent.avatarPath : null,
100
+ body: m.body.trim(),
101
+ });
102
+ }
103
+ return out;
104
+ }
105
+
106
+ // ─── Key gate ────────────────────────────────────────────────
107
+ async function checkUsableTtsKey() {
108
+ try {
109
+ const r = await fetch("/api/voices");
110
+ if (!r.ok) return false;
111
+ const j = await r.json();
112
+ const list = Array.isArray(j.voices) ? j.voices : [];
113
+ // browser provider is the always-on no-cost fallback; for a
114
+ // satisfying replay experience we want a real TTS key.
115
+ return list.some((v) => v && v.provider !== "browser" && v.configured);
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ // ─── Open / close ────────────────────────────────────────────
122
+ async function open(opts) {
123
+ if (isOpen()) close();
124
+ const messages = (opts && opts.messages) || [];
125
+ const members = (opts && opts.members) || [];
126
+ const chair = (opts && opts.chair) || null;
127
+ STATE.members = members;
128
+ STATE.chair = chair;
129
+ STATE.skipUser = true;
130
+ STATE.speed = 1;
131
+ STATE.paused = false;
132
+ STATE.idx = 0;
133
+ STATE.prefetched = new Map();
134
+
135
+ // Mount overlay shell first so the user gets immediate feedback
136
+ // (loading state) while the key check + playlist build run.
137
+ STATE.overlay = mountOverlay();
138
+ setBusy(true, "Checking voice configuration…");
139
+
140
+ const usable = await checkUsableTtsKey();
141
+ if (!usable) {
142
+ renderKeyPrompt();
143
+ return;
144
+ }
145
+
146
+ const playlist = buildPlaylist(messages, { members, chair, skipUser: STATE.skipUser });
147
+ if (playlist.length === 0) {
148
+ renderEmpty();
149
+ return;
150
+ }
151
+ STATE.playlist = playlist;
152
+ setBusy(false);
153
+ renderPlayer();
154
+ void playCurrent();
155
+ }
156
+
157
+ function close() {
158
+ if (STATE.audio) {
159
+ try { STATE.audio.pause(); } catch { /* noop */ }
160
+ STATE.audio.src = "";
161
+ STATE.audio = null;
162
+ }
163
+ if (STATE.abortCtrl) {
164
+ try { STATE.abortCtrl.abort(); } catch { /* noop */ }
165
+ STATE.abortCtrl = null;
166
+ }
167
+ if (STATE.overlay) {
168
+ try { STATE.overlay.remove(); } catch { /* noop */ }
169
+ STATE.overlay = null;
170
+ }
171
+ clearActiveHighlight();
172
+ STATE.playlist = [];
173
+ STATE.prefetched = new Map();
174
+ }
175
+
176
+ // ─── Mount + render ──────────────────────────────────────────
177
+ function mountOverlay() {
178
+ const el = document.createElement("div");
179
+ el.className = "voice-replay-overlay";
180
+ el.setAttribute("role", "region");
181
+ el.setAttribute("aria-label", "Voice replay");
182
+ el.innerHTML = `
183
+ <div class="vr-head">
184
+ <span class="vr-kicker"><span class="vr-kicker-glyph">♪</span> voice replay</span>
185
+ <button type="button" class="vr-close" data-vr-close aria-label="Close">✕</button>
186
+ </div>
187
+ <div class="vr-body" data-vr-body>
188
+ <div class="vr-spinner-row">
189
+ <span class="vr-spinner-dots">
190
+ <span class="vr-spinner-dot"></span>
191
+ <span class="vr-spinner-dot"></span>
192
+ <span class="vr-spinner-dot"></span>
193
+ </span>
194
+ <span class="vr-spinner-text" data-vr-spinner-text>Loading…</span>
195
+ </div>
196
+ </div>
197
+ `;
198
+ document.body.appendChild(el);
199
+ el.addEventListener("click", (ev) => {
200
+ const target = ev.target;
201
+ if (!target || !(target instanceof Element)) return;
202
+ if (target.closest("[data-vr-close]")) { ev.preventDefault(); close(); return; }
203
+ if (target.closest("[data-vr-pause]")) { ev.preventDefault(); togglePause(); return; }
204
+ if (target.closest("[data-vr-skip]")) { ev.preventDefault(); skipCurrent(); return; }
205
+ if (target.closest("[data-vr-speed]")) { ev.preventDefault(); cycleSpeed(); return; }
206
+ if (target.closest("[data-vr-include-user]")) { ev.preventDefault(); toggleIncludeUser(); return; }
207
+ if (target.closest("[data-vr-config]")) {
208
+ ev.preventDefault();
209
+ // Dismiss the replay overlay first · without this the
210
+ // user-settings panel mounts on top while the replay
211
+ // overlay's key-prompt is still visible behind it,
212
+ // reading as two stacked dialogs. The user came here
213
+ // to configure a key — voice replay has nothing more to
214
+ // do until they come back and re-trigger it.
215
+ close();
216
+ if (typeof root.openUserSettings === "function") {
217
+ root.openUserSettings({ section: "keys", focusProvider: "minimax" });
218
+ }
219
+ return;
220
+ }
221
+ });
222
+ return el;
223
+ }
224
+
225
+ function setBusy(busy, msg) {
226
+ if (!STATE.overlay) return;
227
+ const t = STATE.overlay.querySelector("[data-vr-spinner-text]");
228
+ if (t && busy) t.textContent = msg || "Working…";
229
+ }
230
+
231
+ function renderKeyPrompt() {
232
+ if (!STATE.overlay) return;
233
+ const body = STATE.overlay.querySelector("[data-vr-body]");
234
+ if (!body) return;
235
+ body.innerHTML = `
236
+ <div class="vr-key-prompt">
237
+ <div class="vr-key-icon">♪</div>
238
+ <div class="vr-key-text">
239
+ <div class="vr-key-title">No TTS key configured</div>
240
+ <div class="vr-key-deck">Voice replay needs a TTS provider · MiniMax, ElevenLabs, or OpenAI. Add one in settings, then come back.</div>
241
+ </div>
242
+ <div class="vr-key-actions">
243
+ <button type="button" class="vr-cta" data-vr-config>[ Configure ]</button>
244
+ <button type="button" class="vr-ghost" data-vr-close>Dismiss</button>
245
+ </div>
246
+ </div>
247
+ `;
248
+ }
249
+
250
+ function renderEmpty() {
251
+ if (!STATE.overlay) return;
252
+ const body = STATE.overlay.querySelector("[data-vr-body]");
253
+ if (!body) return;
254
+ body.innerHTML = `
255
+ <div class="vr-key-prompt">
256
+ <div class="vr-key-icon">○</div>
257
+ <div class="vr-key-text">
258
+ <div class="vr-key-title">Nothing to replay</div>
259
+ <div class="vr-key-deck">This room has no playable messages — the directors haven't spoken yet, or every message was a system marker.</div>
260
+ </div>
261
+ <div class="vr-key-actions">
262
+ <button type="button" class="vr-ghost" data-vr-close>Dismiss</button>
263
+ </div>
264
+ </div>
265
+ `;
266
+ }
267
+
268
+ function renderPlayer() {
269
+ if (!STATE.overlay) return;
270
+ const body = STATE.overlay.querySelector("[data-vr-body]");
271
+ if (!body) return;
272
+ const cur = STATE.playlist[STATE.idx];
273
+ if (!cur) return;
274
+ const total = STATE.playlist.length;
275
+ const pct = Math.round(((STATE.idx) / total) * 100);
276
+ const avatarHtml = cur.authorAvatar
277
+ ? `<img class="vr-avatar" src="${escapeAttr(cur.authorAvatar)}" alt="${escapeAttr(cur.authorName)}">`
278
+ : `<div class="vr-avatar vr-avatar-placeholder">${escapeText((cur.authorName || "?").charAt(0).toUpperCase())}</div>`;
279
+ const roleLine = cur.authorRole
280
+ ? `<span class="vr-author-role"> · ${escapeText(cur.authorRole)}</span>`
281
+ : "";
282
+ body.innerHTML = `
283
+ <div class="vr-speaker">
284
+ ${avatarHtml}
285
+ <div class="vr-speaker-text">
286
+ <div class="vr-speaker-name">${escapeText(cur.authorName || "—")}${roleLine}</div>
287
+ <div class="vr-speaker-kind">${escapeText(cur.kind)}</div>
288
+ </div>
289
+ </div>
290
+ <div class="vr-preview" data-vr-preview>${escapeText(truncatePreview(cur.body))}</div>
291
+ <div class="vr-progress-row">
292
+ <span class="vr-progress-counter">${STATE.idx + 1} / ${total}</span>
293
+ <div class="vr-progress-bar"><div class="vr-progress-fill" style="width: ${pct}%"></div></div>
294
+ <span class="vr-progress-pct">${pct}%</span>
295
+ </div>
296
+ <div class="vr-controls">
297
+ <button type="button" class="vr-btn" data-vr-pause>${STATE.paused ? "▶ Resume" : "❚❚ Pause"}</button>
298
+ <button type="button" class="vr-btn" data-vr-skip>⏭ Skip</button>
299
+ <button type="button" class="vr-btn vr-btn-speed" data-vr-speed>${STATE.speed}×</button>
300
+ <label class="vr-toggle">
301
+ <input type="checkbox" data-vr-include-user${STATE.skipUser ? "" : " checked"} aria-label="Include my interjections">
302
+ <span>include me</span>
303
+ </label>
304
+ </div>
305
+ `;
306
+ }
307
+
308
+ // ─── Playback loop ───────────────────────────────────────────
309
+ async function playCurrent() {
310
+ if (!STATE.overlay) return;
311
+ const cur = STATE.playlist[STATE.idx];
312
+ if (!cur) { close(); return; }
313
+ renderPlayer();
314
+ highlightActive(cur.messageId);
315
+ let payload;
316
+ try {
317
+ payload = await fetchAudio(STATE.idx);
318
+ } catch (e) {
319
+ // Surface error inline · don't crash the whole player.
320
+ const body = STATE.overlay && STATE.overlay.querySelector("[data-vr-body]");
321
+ if (body) {
322
+ body.insertAdjacentHTML("beforeend", `
323
+ <div class="vr-error">${escapeText(e && e.message ? e.message : String(e))}</div>
324
+ `);
325
+ }
326
+ return;
327
+ }
328
+ if (!STATE.overlay) return;
329
+ if (!payload || !payload.audioBase64) {
330
+ // Skip silently to next message (e.g. browser-provider fallback).
331
+ advance();
332
+ return;
333
+ }
334
+ if (STATE.audio) { try { STATE.audio.pause(); } catch { /* noop */ } }
335
+ STATE.audio = new Audio(`data:${payload.mimeType || "audio/mp3"};base64,${payload.audioBase64}`);
336
+ STATE.audio.playbackRate = STATE.speed;
337
+ STATE.audio.addEventListener("ended", () => advance());
338
+ STATE.audio.addEventListener("error", () => advance());
339
+ if (!STATE.paused) {
340
+ try { await STATE.audio.play(); }
341
+ catch (e) {
342
+ // Autoplay block · pause and let the user click resume.
343
+ STATE.paused = true;
344
+ renderPlayer();
345
+ }
346
+ }
347
+ // Pre-fetch the next message while this one plays so the
348
+ // handoff is gapless. Single in-flight pre-fetch.
349
+ void prefetch(STATE.idx + 1);
350
+ }
351
+
352
+ function advance() {
353
+ clearActiveHighlight();
354
+ STATE.idx += 1;
355
+ if (STATE.idx >= STATE.playlist.length) {
356
+ // Playback complete · keep the overlay open with a "done"
357
+ // message so the user can dismiss explicitly.
358
+ const body = STATE.overlay && STATE.overlay.querySelector("[data-vr-body]");
359
+ if (body) {
360
+ body.innerHTML = `
361
+ <div class="vr-key-prompt">
362
+ <div class="vr-key-icon vr-key-icon-done">✓</div>
363
+ <div class="vr-key-text">
364
+ <div class="vr-key-title">Replay complete</div>
365
+ <div class="vr-key-deck">Every message in this room has played back. Close to return to the chat.</div>
366
+ </div>
367
+ <div class="vr-key-actions">
368
+ <button type="button" class="vr-ghost" data-vr-close>Close</button>
369
+ </div>
370
+ </div>
371
+ `;
372
+ }
373
+ return;
374
+ }
375
+ void playCurrent();
376
+ }
377
+
378
+ async function fetchAudio(idx) {
379
+ if (idx < 0 || idx >= STATE.playlist.length) return null;
380
+ if (STATE.prefetched.has(idx)) {
381
+ const cached = STATE.prefetched.get(idx);
382
+ STATE.prefetched.delete(idx);
383
+ return cached;
384
+ }
385
+ const item = STATE.playlist[idx];
386
+ const r = await fetch("/api/voices/by-message/" + encodeURIComponent(item.messageId), {
387
+ method: "POST",
388
+ headers: { "content-type": "application/json" },
389
+ body: JSON.stringify({ asUser: item.kind === "user" }),
390
+ });
391
+ if (!r.ok) {
392
+ const j = await r.json().catch(() => ({}));
393
+ const code = j && j.code ? j.code : "tts-error";
394
+ const msg = (j && j.error) || ("HTTP " + r.status);
395
+ // Tag the error so callers can route to the key-prompt if
396
+ // the failure was provider-key related.
397
+ const err = new Error(msg);
398
+ err.code = code;
399
+ throw err;
400
+ }
401
+ return r.json();
402
+ }
403
+
404
+ async function prefetch(idx) {
405
+ if (idx < 0 || idx >= STATE.playlist.length) return;
406
+ if (STATE.prefetched.has(idx)) return;
407
+ try {
408
+ const payload = await fetchAudio(idx);
409
+ if (payload) STATE.prefetched.set(idx, payload);
410
+ } catch { /* swallow · the real fetch on advance will re-error */ }
411
+ }
412
+
413
+ // ─── Controls ────────────────────────────────────────────────
414
+ function togglePause() {
415
+ if (!STATE.audio) {
416
+ STATE.paused = !STATE.paused;
417
+ renderPlayer();
418
+ return;
419
+ }
420
+ if (STATE.audio.paused) {
421
+ try { void STATE.audio.play(); } catch { /* noop */ }
422
+ STATE.paused = false;
423
+ } else {
424
+ try { STATE.audio.pause(); } catch { /* noop */ }
425
+ STATE.paused = true;
426
+ }
427
+ renderPlayer();
428
+ }
429
+
430
+ function skipCurrent() {
431
+ if (STATE.audio) { try { STATE.audio.pause(); } catch { /* noop */ } }
432
+ advance();
433
+ }
434
+
435
+ function cycleSpeed() {
436
+ const order = [1, 1.25, 1.5, 2];
437
+ const i = order.indexOf(STATE.speed);
438
+ STATE.speed = order[(i + 1) % order.length];
439
+ if (STATE.audio) STATE.audio.playbackRate = STATE.speed;
440
+ renderPlayer();
441
+ }
442
+
443
+ function toggleIncludeUser() {
444
+ STATE.skipUser = !STATE.skipUser;
445
+ // Rebuild playlist from scratch · we kept the original state
446
+ // on the calling side via app.currentMessages / members /
447
+ // chair. Re-derive from the live app state.
448
+ const app = root.app;
449
+ if (!app) { renderPlayer(); return; }
450
+ const newPlaylist = buildPlaylist(
451
+ Array.isArray(app.currentMessages) ? app.currentMessages.slice() : [],
452
+ { members: STATE.members, chair: STATE.chair, skipUser: STATE.skipUser },
453
+ );
454
+ if (newPlaylist.length === 0) { renderEmpty(); return; }
455
+ // Try to keep the user near the same speaker · find the
456
+ // playlist entry whose messageId matches the currently-playing
457
+ // one; if the toggle removed that message, snap to the closest
458
+ // surviving index.
459
+ const curId = STATE.playlist[STATE.idx]?.messageId;
460
+ let nextIdx = newPlaylist.findIndex((p) => p.messageId === curId);
461
+ if (nextIdx < 0) {
462
+ // Closest survivor · binary-style scan forward from curIdx.
463
+ const oldIdx = STATE.idx;
464
+ for (let i = oldIdx; i < STATE.playlist.length; i += 1) {
465
+ const id = STATE.playlist[i]?.messageId;
466
+ const found = newPlaylist.findIndex((p) => p.messageId === id);
467
+ if (found >= 0) { nextIdx = found; break; }
468
+ }
469
+ if (nextIdx < 0) nextIdx = Math.min(oldIdx, newPlaylist.length - 1);
470
+ }
471
+ STATE.playlist = newPlaylist;
472
+ STATE.idx = Math.max(0, nextIdx);
473
+ STATE.prefetched = new Map();
474
+ if (STATE.audio) { try { STATE.audio.pause(); } catch { /* noop */ } }
475
+ void playCurrent();
476
+ }
477
+
478
+ // ─── Chat highlight / scroll-into-view ───────────────────────
479
+ function highlightActive(messageId) {
480
+ clearActiveHighlight();
481
+ if (!messageId) return;
482
+ const el = document.querySelector(`[data-message-id="${cssAttrEscape(messageId)}"]`);
483
+ if (!el) return;
484
+ el.classList.add("is-replay-active");
485
+ // Inject the floating "▶ SPEAKING" chip · pinned absolute
486
+ // so it doesn't disturb the bubble's flow. The dot inside
487
+ // pulses to make the audio activity visceral. Removed in
488
+ // clearActiveHighlight on advance / close.
489
+ const chip = document.createElement("div");
490
+ chip.className = "vr-now-playing";
491
+ chip.setAttribute("data-vr-now-playing", "1");
492
+ chip.innerHTML =
493
+ '<span class="vr-np-mark">▶</span>' +
494
+ '<span class="vr-np-dot" aria-hidden="true"></span>' +
495
+ '<span class="vr-np-text">speaking</span>';
496
+ el.appendChild(chip);
497
+ try {
498
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
499
+ } catch { /* noop */ }
500
+ }
501
+ function clearActiveHighlight() {
502
+ document.querySelectorAll(".is-replay-active").forEach((n) => n.classList.remove("is-replay-active"));
503
+ document.querySelectorAll('[data-vr-now-playing="1"]').forEach((n) => n.remove());
504
+ }
505
+
506
+ // ─── Helpers ─────────────────────────────────────────────────
507
+ function escapeText(s) {
508
+ return String(s == null ? "" : s)
509
+ .replace(/&/g, "&amp;")
510
+ .replace(/</g, "&lt;")
511
+ .replace(/>/g, "&gt;")
512
+ .replace(/"/g, "&quot;");
513
+ }
514
+ function escapeAttr(s) {
515
+ return escapeText(s).replace(/'/g, "&#39;");
516
+ }
517
+ function cssAttrEscape(s) {
518
+ return String(s == null ? "" : s).replace(/(["\\])/g, "\\$1");
519
+ }
520
+ function truncatePreview(body) {
521
+ const flat = String(body || "").replace(/\s+/g, " ").trim();
522
+ return flat.length > 240 ? flat.slice(0, 237) + "…" : flat;
523
+ }
524
+
525
+ // Public API.
526
+ root.boardroomVoiceReplay = {
527
+ open: open,
528
+ close: close,
529
+ isOpen: isOpen,
530
+ // Exposed for testing.
531
+ _internals: { buildPlaylist, PROCEDURAL_KINDS },
532
+ };
533
+ })(typeof window !== "undefined" ? window : globalThis);