privateboard 0.1.38 → 0.1.40
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/dist/boot.js +323 -50
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +323 -50
- package/dist/cli.js.map +1 -1
- package/dist/server.js +200 -40
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/public/__avatar3d_test.html +156 -0
- package/public/agent-profile.css +3 -9
- package/public/agent-profile.js +85 -32
- package/public/app.js +469 -526
- package/public/avatar-3d-snap.js +205 -0
- package/public/avatar-3d.js +792 -0
- package/public/avatar-customizer.html +274 -0
- package/public/avatar3d-editor.css +240 -0
- package/public/avatar3d-editor.js +481 -0
- package/public/avatars/3d/chair.png +0 -0
- package/public/avatars/3d/first-principles.png +0 -0
- package/public/avatars/3d/historian.png +0 -0
- package/public/avatars/3d/long-horizon.png +0 -0
- package/public/avatars/3d/phenomenologist.png +0 -0
- package/public/avatars/3d/socrates.png +0 -0
- package/public/avatars/3d/user-empathy.png +0 -0
- package/public/avatars/3d/value-investor.png +0 -0
- package/public/core-avatars.js +86 -0
- package/public/home-3d-loader.js +15 -4
- package/public/home-3d-mock.js +18 -7
- package/public/home.html +78 -16
- package/public/i18n.js +12 -4
- package/public/icons/avatar_1779855104027.glb +0 -0
- package/public/icons/logo.png +0 -0
- package/public/icons/new-style.glb +0 -0
- package/public/icons/new-style2.glb +0 -0
- package/public/icons/new-style3.glb +0 -0
- package/public/icons/new-style4.glb +0 -0
- package/public/icons/new-style5.glb +0 -0
- package/public/icons/office.glb +0 -0
- package/public/icons/stuff.glb +0 -0
- package/public/index.html +148 -121
- package/public/new-agent.js +46 -20
- package/public/office-viewer.html +340 -0
- package/public/stuff-viewer.html +330 -0
- package/public/thread.css +8 -8
- package/public/user-settings.css +10 -13
- package/public/user-settings.js +86 -78
- package/public/vendor/BufferGeometryUtils.js +1434 -0
- package/public/vendor/DRACOLoader.js +739 -0
- package/public/vendor/GLTFLoader.js +4860 -0
- package/public/vendor/RoomEnvironment.js +185 -0
- package/public/vendor/SkeletonUtils.js +496 -0
- package/public/vendor/draco/draco_decoder.js +34 -0
- package/public/vendor/draco/draco_decoder.wasm +0 -0
- package/public/vendor/draco/draco_encoder.js +33 -0
- package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
- package/public/vendor/meshopt_decoder.module.js +196 -0
- package/public/voice-3d-banner.js +12 -0
- package/public/voice-3d.js +1407 -432
- package/public/voice-replay.js +21 -0
- package/public/avatar-skill.js +0 -629
- package/public/icons/folded-sidebar.png +0 -0
package/public/app.js
CHANGED
|
@@ -566,6 +566,17 @@
|
|
|
566
566
|
// currentRoomId). Stage repaint still bails out when no
|
|
567
567
|
// room is open, since the stage DOM isn't mounted there.
|
|
568
568
|
this.refreshMiniPlayer();
|
|
569
|
+
// The Record button is offered while a replay runs (adjourned
|
|
570
|
+
// rooms), so re-render the header when the replay open-state
|
|
571
|
+
// flips. Guarded so we don't rebuild the header on every
|
|
572
|
+
// per-message item transition — only on open ↔ close.
|
|
573
|
+
const replOpen = this._isReplayActiveForRoom(this.currentRoomId);
|
|
574
|
+
if (replOpen !== this._lastReplayHeaderState) {
|
|
575
|
+
this._lastReplayHeaderState = replOpen;
|
|
576
|
+
if (this.currentRoomId && typeof this.renderHeader === "function") {
|
|
577
|
+
this.renderHeader();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
569
580
|
if (!this.currentRoomId) return;
|
|
570
581
|
this.renderRoundTable();
|
|
571
582
|
});
|
|
@@ -7287,6 +7298,19 @@
|
|
|
7287
7298
|
}
|
|
7288
7299
|
},
|
|
7289
7300
|
|
|
7301
|
+
/** True when a voice replay is currently running for `roomId`.
|
|
7302
|
+
* Drives the Record button's visibility in adjourned rooms ·
|
|
7303
|
+
* recording the replay reconstructs a meeting video (3D stage
|
|
7304
|
+
* driven by the replay + the replay's TTS audio) the same way
|
|
7305
|
+
* a live room records. `getRoomId()` is null for legacy replays
|
|
7306
|
+
* that didn't pass a roomId · treat that as "matches". */
|
|
7307
|
+
_isReplayActiveForRoom(roomId) {
|
|
7308
|
+
const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
|
|
7309
|
+
if (!vr || typeof vr.isOpen !== "function" || !vr.isOpen()) return false;
|
|
7310
|
+
const rid = typeof vr.getRoomId === "function" ? vr.getRoomId() : null;
|
|
7311
|
+
return !rid || rid === roomId;
|
|
7312
|
+
},
|
|
7313
|
+
|
|
7290
7314
|
/** Toggle recording for the current voice room. Idempotent · if
|
|
7291
7315
|
* already recording, stops + downloads; otherwise starts. */
|
|
7292
7316
|
async handleRecordToggle() {
|
|
@@ -7295,15 +7319,30 @@
|
|
|
7295
7319
|
console.warn("[recorder] module not loaded");
|
|
7296
7320
|
return;
|
|
7297
7321
|
}
|
|
7322
|
+
const room = this.currentRoom;
|
|
7323
|
+
const isLive = !!(room && room.status === "live");
|
|
7298
7324
|
if (rec.isRecording()) {
|
|
7299
|
-
|
|
7300
|
-
|
|
7301
|
-
|
|
7302
|
-
|
|
7325
|
+
if (isLive) {
|
|
7326
|
+
// Live recording · open the stop-choice modal instead of
|
|
7327
|
+
// stopping immediately. The user picks whether to also end
|
|
7328
|
+
// the live room (interrupt / wait for speaker / keep room).
|
|
7329
|
+
this.openRecordingStopModal();
|
|
7330
|
+
} else {
|
|
7331
|
+
// Replay / adjourned recording · there's no live room to
|
|
7332
|
+
// interrupt or pause, so skip the lifecycle modal and just
|
|
7333
|
+
// stop + download the clip.
|
|
7334
|
+
await this._stopRecordingAndToast();
|
|
7335
|
+
}
|
|
7303
7336
|
return;
|
|
7304
7337
|
}
|
|
7305
|
-
const room = this.currentRoom;
|
|
7306
7338
|
if (!room || room.deliveryMode !== "voice") return;
|
|
7339
|
+
// Replay / adjourned · the round-table stage is opt-in there
|
|
7340
|
+
// (hidden by default), but the recorder crops the window capture
|
|
7341
|
+
// to the stage's on-screen rect — so the stage MUST be visible
|
|
7342
|
+
// before start() locks the composite size. Force the stage view
|
|
7343
|
+
// and collapse the floating replay player so it doesn't bleed
|
|
7344
|
+
// into the captured region.
|
|
7345
|
+
if (!isLive) this._ensureStageVisibleForRecording();
|
|
7307
7346
|
try {
|
|
7308
7347
|
await rec.start(room.id, room.subject || room.title || "Meeting");
|
|
7309
7348
|
// Recorder is now capturing chunks · play the cinematic
|
|
@@ -7318,6 +7357,44 @@
|
|
|
7318
7357
|
}
|
|
7319
7358
|
},
|
|
7320
7359
|
|
|
7360
|
+
/** Stop + download the active recording and surface the saved
|
|
7361
|
+
* toast. Used by the replay / adjourned stop path where there's
|
|
7362
|
+
* no room lifecycle to reconcile (cf. handleRecordingStopChoice
|
|
7363
|
+
* for the live-room flow). */
|
|
7364
|
+
async _stopRecordingAndToast() {
|
|
7365
|
+
let blob = null;
|
|
7366
|
+
try { blob = await window.BoardroomRecorder.stopAndDownload(); }
|
|
7367
|
+
catch (e) { console.error("[recorder] stopAndDownload failed", e); }
|
|
7368
|
+
if (blob && blob.size > 0) {
|
|
7369
|
+
try {
|
|
7370
|
+
this.showRoundTableToast({
|
|
7371
|
+
kind: "settings",
|
|
7372
|
+
glyph: "↓",
|
|
7373
|
+
htmlText: this.escape(this._t("rec_saved_toast")),
|
|
7374
|
+
lifetimeMs: 5200,
|
|
7375
|
+
});
|
|
7376
|
+
} catch (_) { /* toast best-effort */ }
|
|
7377
|
+
}
|
|
7378
|
+
},
|
|
7379
|
+
|
|
7380
|
+
/** Reveal the round-table stage (opt-in for adjourned rooms) so
|
|
7381
|
+
* the recorder has a non-empty region to crop, and collapse the
|
|
7382
|
+
* floating voice-replay player so its panel stays out of the
|
|
7383
|
+
* captured frame. */
|
|
7384
|
+
_ensureStageVisibleForRecording() {
|
|
7385
|
+
const roomId = this.currentRoomId;
|
|
7386
|
+
if (!roomId) return;
|
|
7387
|
+
try { localStorage.setItem("rt-view-" + roomId, "stage"); } catch (_) { /* noop */ }
|
|
7388
|
+
if (typeof this.applyRoundTableVisibility === "function") {
|
|
7389
|
+
this.applyRoundTableVisibility(roomId);
|
|
7390
|
+
}
|
|
7391
|
+
const vr = (typeof window !== "undefined") ? window.boardroomVoiceReplay : null;
|
|
7392
|
+
if (vr && typeof vr.isOpen === "function" && vr.isOpen()
|
|
7393
|
+
&& typeof vr.collapse === "function") {
|
|
7394
|
+
try { vr.collapse(); } catch (_) { /* noop */ }
|
|
7395
|
+
}
|
|
7396
|
+
},
|
|
7397
|
+
|
|
7321
7398
|
/** Movie-trailer countdown · 4 frames (Ready, 3, 2, 1) each
|
|
7322
7399
|
* ~850 ms with a pop+fade animation. Mounts inside the stage
|
|
7323
7400
|
* so the recording captures the overlay as an opening title
|
|
@@ -8827,29 +8904,42 @@
|
|
|
8827
8904
|
meta = this._t("sidebar_host");
|
|
8828
8905
|
}
|
|
8829
8906
|
|
|
8830
|
-
// Avatar source-of-truth · prefs.
|
|
8831
|
-
//
|
|
8832
|
-
//
|
|
8833
|
-
//
|
|
8834
|
-
//
|
|
8835
|
-
// existed.
|
|
8907
|
+
// Avatar source-of-truth · prefs.avatarUrl (rendered 3D PNG
|
|
8908
|
+
// captured by the avatar customizer) wins. Otherwise we paint
|
|
8909
|
+
// the seed-derived 3D snap (Avatar3DSnap.generate) and fall
|
|
8910
|
+
// back to the initial-letter chip while the async render is
|
|
8911
|
+
// in flight / when WebGL isn't available.
|
|
8836
8912
|
// Sync every avatar slot · the full sidebar foot AND the
|
|
8837
8913
|
// collapsed mini-rail foot both carry [data-user-avatar], so
|
|
8838
8914
|
// querySelectorAll keeps them identical regardless of which is
|
|
8839
8915
|
// currently visible.
|
|
8916
|
+
const url = this.prefs?.avatarUrl;
|
|
8840
8917
|
const seed = this.prefs?.avatarSeed;
|
|
8841
|
-
const
|
|
8842
|
-
|
|
8843
|
-
|
|
8918
|
+
const snap = window.Avatar3DSnap;
|
|
8919
|
+
const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
|
|
8920
|
+
const imgSrc = url || cachedSnap || null;
|
|
8844
8921
|
document.querySelectorAll("[data-user-avatar]").forEach((av) => {
|
|
8845
|
-
if (
|
|
8922
|
+
if (imgSrc) {
|
|
8846
8923
|
av.classList.add("has-pixel-av");
|
|
8847
|
-
av.innerHTML =
|
|
8924
|
+
av.innerHTML = `<img src="${imgSrc}" alt="" style="width:100%;height:100%;object-fit:cover;image-rendering:auto">`;
|
|
8848
8925
|
} else {
|
|
8849
8926
|
av.classList.remove("has-pixel-av");
|
|
8850
8927
|
av.textContent = initial;
|
|
8851
8928
|
}
|
|
8852
8929
|
});
|
|
8930
|
+
if (!url && seed && !cachedSnap && snap && typeof snap.generate === "function") {
|
|
8931
|
+
snap.generate(seed).then((dataUrl) => {
|
|
8932
|
+
if (!dataUrl) return;
|
|
8933
|
+
// Only repaint if the same seed is still in play — guard
|
|
8934
|
+
// against fast-fire regenerate clicks landing stale renders.
|
|
8935
|
+
if (this.prefs?.avatarSeed !== seed) return;
|
|
8936
|
+
if (this.prefs?.avatarUrl) return;
|
|
8937
|
+
document.querySelectorAll("[data-user-avatar]").forEach((av) => {
|
|
8938
|
+
av.classList.add("has-pixel-av");
|
|
8939
|
+
av.innerHTML = `<img src="${dataUrl}" alt="" style="width:100%;height:100%;object-fit:cover;image-rendering:auto">`;
|
|
8940
|
+
});
|
|
8941
|
+
}).catch(() => { /* */ });
|
|
8942
|
+
}
|
|
8853
8943
|
const nm = document.querySelector("[data-user-name]");
|
|
8854
8944
|
if (nm) nm.textContent = name;
|
|
8855
8945
|
const mt = document.querySelector("[data-user-meta]");
|
|
@@ -9538,6 +9628,12 @@
|
|
|
9538
9628
|
if (chat) {
|
|
9539
9629
|
if (this.composerMode === "agent") {
|
|
9540
9630
|
chat.innerHTML = this.renderAgentComposerHtml();
|
|
9631
|
+
// Hire-a-known-mind portraits · upgrade the 2D placeholders
|
|
9632
|
+
// to deterministic 3D voxel renders. Fire-and-forget · the
|
|
9633
|
+
// hydrator lazy-loads three.js + avatar-3d.js on first use,
|
|
9634
|
+
// caches per-seed dataURLs, and re-skips already-rendered
|
|
9635
|
+
// cards. Safe against rapid re-renders (idempotent).
|
|
9636
|
+
try { void this._hydrateCelebrityAvatars3D(); } catch (_) { /* */ }
|
|
9541
9637
|
// Focus the description textarea unless we're showing a preview.
|
|
9542
9638
|
setTimeout(() => {
|
|
9543
9639
|
const ta = chat.querySelector("[data-agent-composer-desc]");
|
|
@@ -12149,19 +12245,25 @@
|
|
|
12149
12245
|
});
|
|
12150
12246
|
},
|
|
12151
12247
|
|
|
12152
|
-
/** Card HTML for one celebrity seed. Avatar
|
|
12153
|
-
*
|
|
12154
|
-
*
|
|
12155
|
-
*
|
|
12248
|
+
/** Card HTML for one celebrity seed. Avatar tile mounts empty
|
|
12249
|
+
* (or with a cached 3D snap if one is hot) and gets painted by
|
|
12250
|
+
* the deterministic 3D voxel renderer asynchronously when WebGL
|
|
12251
|
+
* is available — see `_hydrateCelebrityAvatars3D()`. The 3D
|
|
12252
|
+
* config is derived from `seed.id` via
|
|
12253
|
+
* `deriveDefaultAvatarConfig`, so the portrait is stable across
|
|
12254
|
+
* re-renders + matches the random face shipped with the persona
|
|
12255
|
+
* once it's hired. Intro picks the en/zh field that matches the
|
|
12256
|
+
* active locale; ja/es fall back to en. */
|
|
12156
12257
|
celebrityCardHtml(seed) {
|
|
12157
12258
|
const lang = this.composerLanguage();
|
|
12158
12259
|
const intro = (seed.intro && (seed.intro[lang] || seed.intro.en || "")) || "";
|
|
12159
|
-
|
|
12160
|
-
|
|
12161
|
-
|
|
12162
|
-
const
|
|
12163
|
-
|
|
12164
|
-
|
|
12260
|
+
// Prefer the cached 3D render if a previous open already
|
|
12261
|
+
// hydrated this seed (hot-path through the composer should NOT
|
|
12262
|
+
// flash an empty tile on a second open).
|
|
12263
|
+
const cached = (this._celebrityAvatar3dCache && this._celebrityAvatar3dCache.get(seed.id)) || null;
|
|
12264
|
+
const avatarHtml = cached
|
|
12265
|
+
? `<img class="cmp-celeb-img" data-cmp-celeb-img="${this.escape(seed.id)}" src="${this.escape(cached)}" alt="${this.escape(seed.name)}">`
|
|
12266
|
+
: `<span class="cmp-celeb-img-fallback" data-cmp-celeb-img="${this.escape(seed.id)}" aria-hidden="true">·</span>`;
|
|
12165
12267
|
return `
|
|
12166
12268
|
<button type="button" class="cmp-celeb-card" data-celebrity-seed="${this.escape(seed.id)}" title="${this.escape(seed.name)}">
|
|
12167
12269
|
<span class="cmp-celeb-av">${avatarHtml}</span>
|
|
@@ -12174,6 +12276,172 @@
|
|
|
12174
12276
|
`;
|
|
12175
12277
|
},
|
|
12176
12278
|
|
|
12279
|
+
/** Walk every `.cmp-celeb-card` currently mounted and render a
|
|
12280
|
+
* 3D voxel portrait into each one (writes the image dataURL
|
|
12281
|
+
* back to its `<img data-cmp-celeb-img>` src). Idempotent · the
|
|
12282
|
+
* per-seed cache means subsequent calls only render new seeds.
|
|
12283
|
+
* Lazy-loads three.js + avatar-3d.js on the first call so the
|
|
12284
|
+
* composer's first paint isn't blocked. Skips silently when
|
|
12285
|
+
* WebGL isn't available (the 2D fallback already rendered). */
|
|
12286
|
+
async _hydrateCelebrityAvatars3D() {
|
|
12287
|
+
if (!this._celebrityAvatar3dCache) this._celebrityAvatar3dCache = new Map();
|
|
12288
|
+
// Capability gate · skip on no-WebGL so the 2D fallback remains
|
|
12289
|
+
// the visible portrait. Cheap test, runs every call (the user
|
|
12290
|
+
// could plug in a WebGL-capable display between calls).
|
|
12291
|
+
let canWebGL = false;
|
|
12292
|
+
try {
|
|
12293
|
+
const c = document.createElement("canvas");
|
|
12294
|
+
canWebGL = !!(c.getContext("webgl2") || c.getContext("webgl"));
|
|
12295
|
+
} catch (_) { canWebGL = false; }
|
|
12296
|
+
if (!canWebGL) return;
|
|
12297
|
+
const cards = Array.from(document.querySelectorAll("img[data-cmp-celeb-img], span[data-cmp-celeb-img]"));
|
|
12298
|
+
if (cards.length === 0) return;
|
|
12299
|
+
const want = [];
|
|
12300
|
+
for (const el of cards) {
|
|
12301
|
+
const id = el.getAttribute("data-cmp-celeb-img");
|
|
12302
|
+
if (!id) continue;
|
|
12303
|
+
// Already 3D-rendered? Skip (cached value was injected by the
|
|
12304
|
+
// first paint). The check leans on the dataset marker rather
|
|
12305
|
+
// than reading the src so a re-render that fell back to 2D
|
|
12306
|
+
// gets reprocessed.
|
|
12307
|
+
if (el.tagName === "IMG" && el.dataset.cmpCelebRendered === "1") continue;
|
|
12308
|
+
want.push({ id, el });
|
|
12309
|
+
}
|
|
12310
|
+
if (want.length === 0) return;
|
|
12311
|
+
// Cache hot · just paint and bail without loading three.
|
|
12312
|
+
const hot = want.filter((j) => this._celebrityAvatar3dCache.has(j.id));
|
|
12313
|
+
for (const j of hot) {
|
|
12314
|
+
const url = this._celebrityAvatar3dCache.get(j.id);
|
|
12315
|
+
this._paintCelebrityImg(j.el, url);
|
|
12316
|
+
}
|
|
12317
|
+
const cold = want.filter((j) => !this._celebrityAvatar3dCache.has(j.id));
|
|
12318
|
+
if (cold.length === 0) return;
|
|
12319
|
+
|
|
12320
|
+
// Lazy-load three + avatar-3d once per session. The two imports
|
|
12321
|
+
// race in parallel and resolve together.
|
|
12322
|
+
let THREE, av;
|
|
12323
|
+
try {
|
|
12324
|
+
[THREE, av] = await Promise.all([
|
|
12325
|
+
import("/vendor/three.module.min.js"),
|
|
12326
|
+
import("/avatar-3d.js"),
|
|
12327
|
+
]);
|
|
12328
|
+
} catch (e) {
|
|
12329
|
+
try { console.warn("[celebrity-3d] dep load failed", e); } catch (_) {}
|
|
12330
|
+
return;
|
|
12331
|
+
}
|
|
12332
|
+
// Preload every body / hair / outfit referenced by the cold
|
|
12333
|
+
// seeds before rendering · cross-model swaps need the source
|
|
12334
|
+
// GLB cached before buildAvatar3D walks the skeleton.
|
|
12335
|
+
const cfgs = cold.map((j) => ({ id: j.id, cfg: av.deriveDefaultAvatarConfig(j.id) }));
|
|
12336
|
+
const modelIds = new Set();
|
|
12337
|
+
for (const { cfg } of cfgs) {
|
|
12338
|
+
if (cfg.model) modelIds.add(cfg.model);
|
|
12339
|
+
if (cfg.hairStyle && cfg.hairStyle !== "none") modelIds.add(cfg.hairStyle);
|
|
12340
|
+
if (cfg.outfitStyle) modelIds.add(cfg.outfitStyle);
|
|
12341
|
+
}
|
|
12342
|
+
try {
|
|
12343
|
+
await Promise.all(Array.from(modelIds).map((m) => av.loadAvatar3D(m).catch(() => null)));
|
|
12344
|
+
} catch (_) { /* */ }
|
|
12345
|
+
|
|
12346
|
+
// Shared offscreen renderer · same recipe as home.html's
|
|
12347
|
+
// (transparent BG, ACES tone-map, IBL via PMREM + RoomEnv).
|
|
12348
|
+
// 112×112 is 2× retina for the 56-pixel display tile.
|
|
12349
|
+
const SIZE = 112;
|
|
12350
|
+
const off = document.createElement("canvas");
|
|
12351
|
+
off.width = SIZE * 2; off.height = SIZE * 2;
|
|
12352
|
+
let renderer;
|
|
12353
|
+
try {
|
|
12354
|
+
renderer = new THREE.WebGLRenderer({ canvas: off, antialias: true, alpha: true, preserveDrawingBuffer: true });
|
|
12355
|
+
} catch (e) {
|
|
12356
|
+
try { console.warn("[celebrity-3d] renderer init failed", e); } catch (_) {}
|
|
12357
|
+
return;
|
|
12358
|
+
}
|
|
12359
|
+
renderer.setSize(SIZE * 2, SIZE * 2, false);
|
|
12360
|
+
renderer.setClearColor(0x000000, 0);
|
|
12361
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
12362
|
+
renderer.toneMappingExposure = 1.0;
|
|
12363
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
12364
|
+
|
|
12365
|
+
const scene = new THREE.Scene();
|
|
12366
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x2a3140, 0.5));
|
|
12367
|
+
const key = new THREE.DirectionalLight(0xffffff, 1.2);
|
|
12368
|
+
key.position.set(2, 3, 2.5);
|
|
12369
|
+
scene.add(key);
|
|
12370
|
+
const rim = new THREE.DirectionalLight(0xbfd4ff, 0.4);
|
|
12371
|
+
rim.position.set(-2, 2, -2);
|
|
12372
|
+
scene.add(rim);
|
|
12373
|
+
// Camera FOV mirrors avatar3d-editor's capturePng so the
|
|
12374
|
+
// head-anchor framing math (`applyFaceFraming`) produces the
|
|
12375
|
+
// SAME crop the agent-profile portrait does. Position is
|
|
12376
|
+
// computed per-figure right before render — face mesh is
|
|
12377
|
+
// present at that point.
|
|
12378
|
+
const camera = new THREE.PerspectiveCamera(35, 1, 0.05, 20);
|
|
12379
|
+
|
|
12380
|
+
for (const { id, cfg } of cfgs) {
|
|
12381
|
+
let figure = null;
|
|
12382
|
+
try {
|
|
12383
|
+
figure = av.buildAvatar3D(id, {
|
|
12384
|
+
model: cfg.model,
|
|
12385
|
+
hairStyle: cfg.hairStyle,
|
|
12386
|
+
outfitStyle: cfg.outfitStyle,
|
|
12387
|
+
accessory: cfg.accessory,
|
|
12388
|
+
height: 1.7,
|
|
12389
|
+
skin: cfg.skin, hair: cfg.hair, brow: cfg.brow, outfit: cfg.outfit,
|
|
12390
|
+
browStyle: cfg.browStyle, tieStyle: cfg.tieStyle,
|
|
12391
|
+
tie: cfg.tie, eye: cfg.eye,
|
|
12392
|
+
});
|
|
12393
|
+
} catch (_) { figure = null; }
|
|
12394
|
+
if (!figure) continue;
|
|
12395
|
+
// Faint 3/4 turn for visual interest, then frame on the
|
|
12396
|
+
// face mesh (same routine the agent-profile capture uses)
|
|
12397
|
+
// so every card shows the same head-and-shoulders crop.
|
|
12398
|
+
figure.rotation.y = -0.18;
|
|
12399
|
+
scene.add(figure);
|
|
12400
|
+
try {
|
|
12401
|
+
if (typeof av.applyFaceFraming === "function") {
|
|
12402
|
+
av.applyFaceFraming(camera, figure);
|
|
12403
|
+
}
|
|
12404
|
+
} catch (_) { /* fallback to constructor defaults */ }
|
|
12405
|
+
renderer.render(scene, camera);
|
|
12406
|
+
const dataUrl = renderer.domElement.toDataURL("image/png");
|
|
12407
|
+
this._celebrityAvatar3dCache.set(id, dataUrl);
|
|
12408
|
+
// Find every mounted card with this id (composer can be open
|
|
12409
|
+
// in two surfaces simultaneously in some flows) and paint.
|
|
12410
|
+
document.querySelectorAll(`[data-cmp-celeb-img="${CSS.escape(id)}"]`)
|
|
12411
|
+
.forEach((el) => this._paintCelebrityImg(el, dataUrl));
|
|
12412
|
+
scene.remove(figure);
|
|
12413
|
+
figure.traverse((n) => {
|
|
12414
|
+
if (n.material) {
|
|
12415
|
+
const ms = Array.isArray(n.material) ? n.material : [n.material];
|
|
12416
|
+
for (const m of ms) { try { m.dispose(); } catch (_) {} }
|
|
12417
|
+
}
|
|
12418
|
+
if (n.geometry) { try { n.geometry.dispose(); } catch (_) {} }
|
|
12419
|
+
});
|
|
12420
|
+
}
|
|
12421
|
+
renderer.dispose();
|
|
12422
|
+
},
|
|
12423
|
+
|
|
12424
|
+
/** Internal · set a celebrity-card image to a dataURL, swapping
|
|
12425
|
+
* the placeholder `<span>` for an `<img>` when the initial
|
|
12426
|
+
* paint used the fallback dot. Marks the node so the next
|
|
12427
|
+
* hydrate pass skips it. */
|
|
12428
|
+
_paintCelebrityImg(el, dataUrl) {
|
|
12429
|
+
if (!el || !dataUrl) return;
|
|
12430
|
+
if (el.tagName === "IMG") {
|
|
12431
|
+
el.src = dataUrl;
|
|
12432
|
+
el.dataset.cmpCelebRendered = "1";
|
|
12433
|
+
return;
|
|
12434
|
+
}
|
|
12435
|
+
// Replace the fallback span with an img inside the same `.cmp-celeb-av` tile.
|
|
12436
|
+
const img = document.createElement("img");
|
|
12437
|
+
img.className = "cmp-celeb-img";
|
|
12438
|
+
img.dataset.cmpCelebImg = el.getAttribute("data-cmp-celeb-img") || "";
|
|
12439
|
+
img.src = dataUrl;
|
|
12440
|
+
img.alt = "";
|
|
12441
|
+
img.dataset.cmpCelebRendered = "1";
|
|
12442
|
+
if (el.parentNode) el.parentNode.replaceChild(img, el);
|
|
12443
|
+
},
|
|
12444
|
+
|
|
12177
12445
|
/** Click handler · kick the full-mode persona builder with the
|
|
12178
12446
|
* celebrity's seed description, tag the live job with the seed
|
|
12179
12447
|
* id so the save-success callback can mark it consumed. */
|
|
@@ -12419,8 +12687,8 @@
|
|
|
12419
12687
|
* topped up via the LLM-driven `_topUpCelebritySeeds` path.
|
|
12420
12688
|
*
|
|
12421
12689
|
* Per-entry shape:
|
|
12422
|
-
* · id · kebab-slug · doubles as `
|
|
12423
|
-
* (deterministic
|
|
12690
|
+
* · id · kebab-slug · doubles as `Avatar3DSnap` seed
|
|
12691
|
+
* (deterministic 3D voxel portrait)
|
|
12424
12692
|
* · name · verbatim, no i18n (proper nouns)
|
|
12425
12693
|
* · roleTag · short mono tag · kept English to match the
|
|
12426
12694
|
* mono kicker register used elsewhere
|
|
@@ -12838,8 +13106,13 @@
|
|
|
12838
13106
|
|
|
12839
13107
|
/** Preview card · all generated fields editable inline. */
|
|
12840
13108
|
renderAgentSpecPreviewHtml(spec) { const seed = this.agentSpecAvatarSeed;
|
|
12841
|
-
|
|
12842
|
-
|
|
13109
|
+
// Avatar paints async via Avatar3DSnap (see the post-render
|
|
13110
|
+
// hydration that follows). The frame mounts with a cached
|
|
13111
|
+
// snap if hot, otherwise empty + a CSS placeholder spinner.
|
|
13112
|
+
const snap = window.Avatar3DSnap;
|
|
13113
|
+
const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
|
|
13114
|
+
const avatarSvg = cachedSnap
|
|
13115
|
+
? `<img src="${cachedSnap}" alt="">`
|
|
12843
13116
|
: `<div class="ag-prev-av-empty">—</div>`;
|
|
12844
13117
|
// Reachable models only · filter by `/api/models` cache so the
|
|
12845
13118
|
// user can't pick a model their active credential can't route
|
|
@@ -13284,9 +13557,14 @@
|
|
|
13284
13557
|
this.personaJob.finalGuessRoleTag = data.guessRoleTag || "director";
|
|
13285
13558
|
// Avatar seed · matches Signal-mode's pattern. Random per
|
|
13286
13559
|
// build, user can re-roll on the save card.
|
|
13287
|
-
this.personaJob.avatarSeed = (window.
|
|
13288
|
-
? window.
|
|
13560
|
+
this.personaJob.avatarSeed = (window.Avatar3DSnap && window.Avatar3DSnap.randomSeed)
|
|
13561
|
+
? window.Avatar3DSnap.randomSeed()
|
|
13289
13562
|
: null;
|
|
13563
|
+
// Warm the 3D snap cache so the persona save overlay's
|
|
13564
|
+
// portrait paints instantly when it opens.
|
|
13565
|
+
if (this.personaJob.avatarSeed && window.Avatar3DSnap && typeof window.Avatar3DSnap.generate === "function") {
|
|
13566
|
+
window.Avatar3DSnap.generate(this.personaJob.avatarSeed).catch(() => { /* */ });
|
|
13567
|
+
}
|
|
13290
13568
|
this._closePersonaSse();
|
|
13291
13569
|
this._stopPersonaTick();
|
|
13292
13570
|
this._personaRender();
|
|
@@ -13913,6 +14191,11 @@
|
|
|
13913
14191
|
const e = await r.json().catch(() => ({}));
|
|
13914
14192
|
throw new Error(e.error || ("HTTP " + r.status));
|
|
13915
14193
|
}
|
|
14194
|
+
// Swap the 8-bit avatar for a 3D screenshot (best-effort) before the
|
|
14195
|
+
// roster refresh below picks the new director up.
|
|
14196
|
+
const saved = await r.json().catch(() => null);
|
|
14197
|
+
const newId = saved && (saved.id || (saved.agent && saved.agent.id));
|
|
14198
|
+
if (newId) await this.apply3dPortrait(newId);
|
|
13916
14199
|
// Capture the celebrity seed id (if any) BEFORE nulling
|
|
13917
14200
|
// personaJob below · `_markCelebritySeedConsumed` needs
|
|
13918
14201
|
// it to update localStorage. Tagging happens in
|
|
@@ -13998,9 +14281,19 @@
|
|
|
13998
14281
|
const userModel = this.loadAgentComposerModel();
|
|
13999
14282
|
if (userModel && MODEL_LABELS[userModel]) this.agentSpec.modelV = userModel;
|
|
14000
14283
|
}
|
|
14001
|
-
this.agentSpecAvatarSeed = (window.
|
|
14002
|
-
? window.
|
|
14284
|
+
this.agentSpecAvatarSeed = (window.Avatar3DSnap && window.Avatar3DSnap.randomSeed)
|
|
14285
|
+
? window.Avatar3DSnap.randomSeed()
|
|
14003
14286
|
: null;
|
|
14287
|
+
// Kick off the 3D portrait render so the cache is hot by the
|
|
14288
|
+
// time the preview card mounts + the save handler reads it.
|
|
14289
|
+
if (this.agentSpecAvatarSeed && window.Avatar3DSnap && typeof window.Avatar3DSnap.generate === "function") {
|
|
14290
|
+
const seed = this.agentSpecAvatarSeed;
|
|
14291
|
+
window.Avatar3DSnap.generate(seed).then((dataUrl) => {
|
|
14292
|
+
if (!dataUrl || this.agentSpecAvatarSeed !== seed) return;
|
|
14293
|
+
const f = document.querySelector(".ag-prev-av-frame");
|
|
14294
|
+
if (f) f.innerHTML = `<img src="${dataUrl}" alt="">`;
|
|
14295
|
+
}).catch(() => { /* */ });
|
|
14296
|
+
}
|
|
14004
14297
|
this.agentSpecError = null;
|
|
14005
14298
|
} catch (e) {
|
|
14006
14299
|
const isAbort = (e && (e.name === "AbortError" || /aborted/i.test(String(e.message))));
|
|
@@ -14091,12 +14384,14 @@
|
|
|
14091
14384
|
throw new Error(e.error || ("HTTP " + r.status));
|
|
14092
14385
|
}
|
|
14093
14386
|
const j = await r.json();
|
|
14387
|
+
const newId = j && (j.id || (j.agent && j.agent.id));
|
|
14388
|
+
// 3D screenshot avatar (before refresh, so the roster shows it).
|
|
14389
|
+
if (newId) await this.apply3dPortrait(newId);
|
|
14094
14390
|
await this.refreshAgents?.();
|
|
14095
14391
|
this.agentSpec = null;
|
|
14096
14392
|
this.agentSpecAvatarSeed = null;
|
|
14097
14393
|
this.clearAgentComposerDraft();
|
|
14098
14394
|
this.composerMode = "room";
|
|
14099
|
-
const newId = j && (j.id || (j.agent && j.agent.id));
|
|
14100
14395
|
// Land on the new agent's full profile (mirrors the prior
|
|
14101
14396
|
// inline-preview save flow's post-success navigation).
|
|
14102
14397
|
if (newId && typeof window.boardroomFocusAgent === "function") {
|
|
@@ -14130,14 +14425,19 @@
|
|
|
14130
14425
|
},
|
|
14131
14426
|
|
|
14132
14427
|
rerollAgentSpecAvatar() {
|
|
14133
|
-
|
|
14134
|
-
|
|
14135
|
-
|
|
14136
|
-
|
|
14137
|
-
// renderSeedSvg helper, hence the previous reroll silently
|
|
14138
|
-
// failed.
|
|
14428
|
+
const snap = window.Avatar3DSnap;
|
|
14429
|
+
if (!snap || !snap.randomSeed || !snap.generate) return;
|
|
14430
|
+
const seed = snap.randomSeed();
|
|
14431
|
+
this.agentSpecAvatarSeed = seed;
|
|
14139
14432
|
const frame = document.querySelector(".ag-prev-av-frame");
|
|
14140
|
-
if (frame)
|
|
14433
|
+
if (!frame) return;
|
|
14434
|
+
frame.innerHTML = '<div class="ag-prev-av-empty">…</div>';
|
|
14435
|
+
snap.generate(seed).then((dataUrl) => {
|
|
14436
|
+
if (!dataUrl) return;
|
|
14437
|
+
if (this.agentSpecAvatarSeed !== seed) return; // user rolled again
|
|
14438
|
+
const f2 = document.querySelector(".ag-prev-av-frame");
|
|
14439
|
+
if (f2) f2.innerHTML = `<img src="${dataUrl}" alt="">`;
|
|
14440
|
+
}).catch(() => { /* */ });
|
|
14141
14441
|
},
|
|
14142
14442
|
|
|
14143
14443
|
discardAgentSpec() {
|
|
@@ -14167,6 +14467,28 @@
|
|
|
14167
14467
|
this._runAgentSpecGeneration(desc);
|
|
14168
14468
|
},
|
|
14169
14469
|
|
|
14470
|
+
/** Give a freshly-created director a 3D screenshot avatar. Renders the
|
|
14471
|
+
* director's deterministic default 3D config (derived from its id — the
|
|
14472
|
+
* same look the room + editor show) to a head-and-shoulders PNG and PATCHes
|
|
14473
|
+
* it as the avatar, plus persists the config. Best-effort: on any failure
|
|
14474
|
+
* the director keeps the 8-bit SVG avatar it was created with. Call AFTER
|
|
14475
|
+
* creation (id known) and BEFORE refreshAgents so the roster picks it up. */
|
|
14476
|
+
async apply3dPortrait(agentId) {
|
|
14477
|
+
try {
|
|
14478
|
+
if (!agentId || !window.Avatar3D || typeof window.renderAvatar3DPortrait !== "function") return;
|
|
14479
|
+
const cfg = window.Avatar3D.deriveDefaultAvatarConfig(agentId);
|
|
14480
|
+
const png = await window.renderAvatar3DPortrait(cfg);
|
|
14481
|
+
if (!png) return;
|
|
14482
|
+
await fetch("/api/agents/" + encodeURIComponent(agentId), {
|
|
14483
|
+
method: "PATCH",
|
|
14484
|
+
headers: { "content-type": "application/json" },
|
|
14485
|
+
body: JSON.stringify({ avatarPath: png, avatar3d: cfg }),
|
|
14486
|
+
});
|
|
14487
|
+
} catch (e) {
|
|
14488
|
+
console.warn("[avatar3d] new-director portrait failed; keeping fallback avatar", e);
|
|
14489
|
+
}
|
|
14490
|
+
},
|
|
14491
|
+
|
|
14170
14492
|
/** Read inline-edited values from the Signal preview card and
|
|
14171
14493
|
* POST to /api/agents. Persona-mode save is a separate path
|
|
14172
14494
|
* (`openPersonaConfirmOverlay` → manual-config overlay → its
|
|
@@ -14187,10 +14509,15 @@
|
|
|
14187
14509
|
instruction: read("instruction").trim(),
|
|
14188
14510
|
modelV: read("modelV").trim(),
|
|
14189
14511
|
};
|
|
14190
|
-
// Avatar —
|
|
14512
|
+
// Avatar — rendered 3D portrait from the current seed, embedded
|
|
14513
|
+
// as a PNG data URL. Render is async (lazy three.js init) but
|
|
14514
|
+
// we already kicked it off when the user landed on the preview
|
|
14515
|
+
// card, so the seed should be hot in the per-seed cache by the
|
|
14516
|
+
// time they click save.
|
|
14191
14517
|
let avatarPath = null;
|
|
14192
|
-
|
|
14193
|
-
|
|
14518
|
+
const snap = window.Avatar3DSnap;
|
|
14519
|
+
if (snap && this.agentSpecAvatarSeed && typeof snap.generate === "function") {
|
|
14520
|
+
try { avatarPath = await snap.generate(this.agentSpecAvatarSeed); } catch (_) { avatarPath = null; }
|
|
14194
14521
|
}
|
|
14195
14522
|
// Ability axes · lifted from the spec produced by /api/agents/generate-spec.
|
|
14196
14523
|
// The server validates + clamps + falls back to a heuristic if missing.
|
|
@@ -14214,6 +14541,11 @@
|
|
|
14214
14541
|
throw new Error(e.error || ("HTTP " + r.status));
|
|
14215
14542
|
}
|
|
14216
14543
|
const j = await r.json();
|
|
14544
|
+
// POST /api/agents returns the agent record directly (not wrapped).
|
|
14545
|
+
const newId = j && (j.id || (j.agent && j.agent.id));
|
|
14546
|
+
// Replace the 8-bit avatar with a 3D screenshot before the roster
|
|
14547
|
+
// refresh so the sidebar + profile show the 3D portrait immediately.
|
|
14548
|
+
if (newId) await this.apply3dPortrait(newId);
|
|
14217
14549
|
// Refresh local agent catalog so the new director shows up
|
|
14218
14550
|
// in pickers + sidebar immediately.
|
|
14219
14551
|
await this.refreshAgents?.();
|
|
@@ -14223,8 +14555,6 @@
|
|
|
14223
14555
|
// a future visit to "+ New Agent" should land on a fresh textarea.
|
|
14224
14556
|
this.clearAgentComposerDraft();
|
|
14225
14557
|
this.composerMode = "room";
|
|
14226
|
-
// POST /api/agents returns the agent record directly (not wrapped).
|
|
14227
|
-
const newId = j && (j.id || (j.agent && j.agent.id));
|
|
14228
14558
|
// Land the user on the new agent's full profile page · also
|
|
14229
14559
|
// switches the sidebar to the Agents tab and persists the
|
|
14230
14560
|
// sub-state so a refresh keeps them on the same agent.
|
|
@@ -15293,6 +15623,9 @@
|
|
|
15293
15623
|
<div class="head-actions">
|
|
15294
15624
|
<a href="#" class="resume-btn" data-resume>[ ▶ ${this.escape(this._t("room_resume_verb"))} ]</a>
|
|
15295
15625
|
<a href="#" class="pause-btn" data-pause>[ <span class="pause-icon">❚❚</span> ${this.escape(this._t("room_pause_verb"))} ]</a>
|
|
15626
|
+
${this._isReplayActiveForRoom(r.id)
|
|
15627
|
+
? `<a href="#" class="replay-stop-btn" data-vr-header-stop aria-label="${this.escape(this._t("head_replay_stop_label") || "Stop")}">[ <span class="replay-stop-icon">■</span> ${this.escape(this._t("head_replay_stop_label") || "Stop")} ]</a>`
|
|
15628
|
+
: ""}
|
|
15296
15629
|
<div class="head-cast">${castHtml}</div>
|
|
15297
15630
|
<a href="#" class="head-icon-btn head-add-cast" data-cast-edit-trigger data-tip="${this.escape(this._t("head_add_cast_tip"))}" aria-label="${this.escape(this._t("head_add_cast_label"))}"></a>
|
|
15298
15631
|
<a href="#" class="head-icon-btn head-threads" data-threads-trigger data-tip="${this.escape(this._t("head_threads_tip") || "All private threads in this room")}" aria-label="${this.escape(this._t("head_threads_label") || "All threads")}"></a>
|
|
@@ -15345,10 +15678,12 @@
|
|
|
15345
15678
|
? `<a href="#" class="head-icon-btn head-followup" data-room-followup data-tip="${this.escape(this._t("adj_followup_label"))}" aria-label="${this.escape(this._t("adj_followup_label"))}"></a>`
|
|
15346
15679
|
: `<a href="#" class="head-icon-btn head-adjourn" data-adjourn data-tip="${this.escape(this._t("ib_adjourn_tip"))}" aria-label="${this.escape(this._t("ib_adjourn_label"))}"></a>`}
|
|
15347
15680
|
${(r.deliveryMode === "voice"
|
|
15348
|
-
&& r.status === "live"
|
|
15349
15681
|
&& window.BoardroomRecorder
|
|
15350
15682
|
&& typeof window.BoardroomRecorder.isAvailable === "function"
|
|
15351
|
-
&& window.BoardroomRecorder.isAvailable()
|
|
15683
|
+
&& window.BoardroomRecorder.isAvailable()
|
|
15684
|
+
&& (r.status === "live"
|
|
15685
|
+
|| this._isReplayActiveForRoom(r.id)
|
|
15686
|
+
|| window.BoardroomRecorder.isRecording())) ? (() => {
|
|
15352
15687
|
const isRec = window.BoardroomRecorder.isRecording();
|
|
15353
15688
|
const tip = isRec ? this._t("head_record_stop_tip") : this._t("head_record_tip");
|
|
15354
15689
|
const cls = "head-icon-btn head-record" + (isRec ? " is-recording" : "");
|
|
@@ -17843,17 +18178,30 @@
|
|
|
17843
18178
|
// profile" kept regressing for custom directors.
|
|
17844
18179
|
const agentTag = !isUser && author ? ` data-agent="${this.escape(author.id)}"` : "";
|
|
17845
18180
|
// User avatar mirrors the preference-overlay setting · when
|
|
17846
|
-
// prefs.
|
|
17847
|
-
//
|
|
17848
|
-
//
|
|
17849
|
-
//
|
|
18181
|
+
// prefs.avatarUrl (a captured 3D PNG) is present we use that
|
|
18182
|
+
// directly. Otherwise, if the avatarSeed has a cached 3D snap
|
|
18183
|
+
// we paint it synchronously; if not we fall back to the
|
|
18184
|
+
// initial-letter chip (the async snap render kicks in on the
|
|
18185
|
+
// next renderUserBlock paint).
|
|
17850
18186
|
let userAvHtml;
|
|
17851
18187
|
if (isUser) {
|
|
18188
|
+
const url = this.prefs?.avatarUrl;
|
|
17852
18189
|
const seed = this.prefs?.avatarSeed;
|
|
17853
|
-
|
|
17854
|
-
|
|
18190
|
+
const snap = window.Avatar3DSnap;
|
|
18191
|
+
const cachedSnap = (seed && snap && typeof snap.cacheGet === "function") ? snap.cacheGet(seed) : null;
|
|
18192
|
+
const src = url || cachedSnap || "";
|
|
18193
|
+
if (src) {
|
|
18194
|
+
userAvHtml = `<img class="msg-av" src="${this.escape(src)}" alt="">`;
|
|
17855
18195
|
} else {
|
|
17856
18196
|
userAvHtml = `<div class="msg-av">${this.escape((this.prefs?.name || "Y").charAt(0).toUpperCase())}</div>`;
|
|
18197
|
+
// Kick off the async render so future paints hit cache.
|
|
18198
|
+
if (seed && snap && typeof snap.generate === "function") {
|
|
18199
|
+
snap.generate(seed).then(() => {
|
|
18200
|
+
if (typeof this.renderChat === "function") {
|
|
18201
|
+
try { this.renderChat(); } catch (_) {}
|
|
18202
|
+
}
|
|
18203
|
+
}).catch(() => { /* */ });
|
|
18204
|
+
}
|
|
17857
18205
|
}
|
|
17858
18206
|
}
|
|
17859
18207
|
const avatarHtml = isUser
|
|
@@ -18754,8 +19102,12 @@
|
|
|
18754
19102
|
members.push({
|
|
18755
19103
|
id: "__user__",
|
|
18756
19104
|
name: prefs.name || "You",
|
|
18757
|
-
|
|
19105
|
+
// Prefer the 3D portrait PNG; the seat sprite shows it directly.
|
|
19106
|
+
avatarPath: prefs.avatarUrl || null,
|
|
18758
19107
|
__seed: prefs.avatarSeed || null,
|
|
19108
|
+
// When the user has a saved 3D avatar, the room renders them as a
|
|
19109
|
+
// full 3D figure (like directors) rather than a flat billboard.
|
|
19110
|
+
avatar3d: prefs.avatar3d || null,
|
|
18759
19111
|
__isUser: true,
|
|
18760
19112
|
});
|
|
18761
19113
|
}
|
|
@@ -19048,6 +19400,38 @@
|
|
|
19048
19400
|
return { id: null, state: null };
|
|
19049
19401
|
},
|
|
19050
19402
|
|
|
19403
|
+
/** Is `authorId`'s TTS audio actually PRODUCING SOUND right now? Used by
|
|
19404
|
+
* the 3D room to sync the talking-mouth animation to real audio — the
|
|
19405
|
+
* "speaking" stage state can lead the audio (a streaming-text turn shows
|
|
19406
|
+
* "speaking" before its TTS chunk starts), which made mouths move
|
|
19407
|
+
* silently. This reads the live <audio> element, so it's frame-accurate.
|
|
19408
|
+
* Read each frame from voice-3d's tick (cheap · 0-2 queues). */
|
|
19409
|
+
isSpeakerAudible(authorId) {
|
|
19410
|
+
if (!authorId) return false;
|
|
19411
|
+
// Voice-replay (adjourned playback) drives the seat itself · audible
|
|
19412
|
+
// exactly when its playback state is "speaking".
|
|
19413
|
+
const replay = (typeof window !== "undefined" && window.boardroomVoiceReplay
|
|
19414
|
+
&& typeof window.boardroomVoiceReplay.getActive === "function")
|
|
19415
|
+
? window.boardroomVoiceReplay.getActive() : null;
|
|
19416
|
+
if (replay && replay.authorId) {
|
|
19417
|
+
return replay.authorId === authorId && replay.state === "speaking";
|
|
19418
|
+
}
|
|
19419
|
+
// Live room · a voice queue for this author whose <audio> is playing.
|
|
19420
|
+
const qs = this.voiceQueues;
|
|
19421
|
+
if (qs) {
|
|
19422
|
+
for (const k in qs) {
|
|
19423
|
+
const vq = qs[k];
|
|
19424
|
+
if (!vq || vq.playState !== "playing" || vq.authorId !== authorId) continue;
|
|
19425
|
+
const a = vq.audio;
|
|
19426
|
+
// The "playing" flag is set when audio.play() is invoked; confirm the
|
|
19427
|
+
// element is genuinely running (not paused/ended/buffering at 0).
|
|
19428
|
+
if (!a) return true; // flagged playing, no element to inspect → trust it
|
|
19429
|
+
if (!a.paused && !a.ended && a.currentTime > 0) return true;
|
|
19430
|
+
}
|
|
19431
|
+
}
|
|
19432
|
+
return false;
|
|
19433
|
+
},
|
|
19434
|
+
|
|
19051
19435
|
/** Stage SFX driver · used by BOTH the 3D delegate path and the
|
|
19052
19436
|
* legacy 2D path so the thinking-blip loop + speaker-change
|
|
19053
19437
|
* chime fire regardless of which stage renderer is active.
|
|
@@ -19093,35 +19477,23 @@
|
|
|
19093
19477
|
const floorMode = VALID_FLOORS.includes(tone) ? tone : "constructive";
|
|
19094
19478
|
stage.setAttribute("data-floor", floorMode);
|
|
19095
19479
|
|
|
19096
|
-
// ── 3D stage
|
|
19097
|
-
//
|
|
19098
|
-
//
|
|
19099
|
-
//
|
|
19100
|
-
//
|
|
19101
|
-
//
|
|
19102
|
-
//
|
|
19103
|
-
const
|
|
19104
|
-
|
|
19105
|
-
catch (_) { return true; }
|
|
19106
|
-
})();
|
|
19480
|
+
// ── 3D stage · the only path now ──────────────────────────
|
|
19481
|
+
// The legacy 2D SVG stage was retired · the voice room is
|
|
19482
|
+
// 3D-only. WebGL is required; if it's not available, the stage
|
|
19483
|
+
// simply doesn't paint (caller is expected to gate room entry
|
|
19484
|
+
// on `VS3D.isSupported()` if a no-WebGL fallback ever returns).
|
|
19485
|
+
// The aria-label + HUD + subtitle + SFX tail still runs so
|
|
19486
|
+
// screen readers and the status panels stay in sync regardless.
|
|
19487
|
+
const members = this.roundTableMembers();
|
|
19488
|
+
const positions = this.computeSeatPositions(members);
|
|
19107
19489
|
const VS3D = window.VoiceStage3D;
|
|
19108
|
-
const
|
|
19109
|
-
|
|
19110
|
-
|
|
19111
|
-
if (
|
|
19490
|
+
const sp = this._resolveStageSpeaker();
|
|
19491
|
+
let speakingId = sp && sp.id ? sp.id : null;
|
|
19492
|
+
|
|
19493
|
+
if (VS3D && typeof VS3D.mount === "function" && VS3D.isSupported()) {
|
|
19112
19494
|
try {
|
|
19113
19495
|
VS3D.mount(stage);
|
|
19114
|
-
// Compact speaker resolution · mirrors the longer 2D path
|
|
19115
|
-
// below (priority: voice-replay → audible voice queue →
|
|
19116
|
-
// streaming agent message → chair pending / convene →
|
|
19117
|
-
// queue head). Enough to drive the 3D overlay's bubble +
|
|
19118
|
-
// nameplate-hide while-speaking behaviour.
|
|
19119
|
-
const sp = this._resolveStageSpeaker();
|
|
19120
19496
|
const votePopHtml = this._resolveStageVotePop();
|
|
19121
|
-
// User-spoke bubble · mirrors the 2D `data-rt-user-bubble`
|
|
19122
|
-
// element. Active when the latest user message landed
|
|
19123
|
-
// within the last USER_BUBBLE_TTL_MS, computed once per
|
|
19124
|
-
// render so the bubble's countdown progress is fresh.
|
|
19125
19497
|
const ub = this.userBubble;
|
|
19126
19498
|
const nowMs = Date.now();
|
|
19127
19499
|
const ubActive = !!(ub && ub.text && !ub.dismissed && nowMs < ub.deadline);
|
|
@@ -19130,8 +19502,8 @@
|
|
|
19130
19502
|
(this.USER_BUBBLE_TTL_MS - (ub.deadline - nowMs)) / this.USER_BUBBLE_TTL_MS))
|
|
19131
19503
|
: 0;
|
|
19132
19504
|
VS3D.update({
|
|
19133
|
-
members
|
|
19134
|
-
positions
|
|
19505
|
+
members,
|
|
19506
|
+
positions,
|
|
19135
19507
|
mode: floorMode,
|
|
19136
19508
|
speakerId: sp.id,
|
|
19137
19509
|
speakerState: sp.state,
|
|
@@ -19143,444 +19515,18 @@
|
|
|
19143
19515
|
userWait: !!this.pendingUserMessage,
|
|
19144
19516
|
userBubble: ubActive ? { text: ub.text, progress: ubProgress } : null,
|
|
19145
19517
|
});
|
|
19146
|
-
// The 2D path below ends with calls to renderRoundTableHud
|
|
19147
|
-
// + renderRtSubtitle so the status panel + live subtitle
|
|
19148
|
-
// stay in sync with each renderRoundTable. The 3D delegate
|
|
19149
|
-
// `return`s above the 2D code, so without these two calls
|
|
19150
|
-
// here the HUD div stays empty until some unrelated SSE
|
|
19151
|
-
// (config-event / queue-update) happens to fire them ·
|
|
19152
|
-
// user reported "HUD shows as a thin line, fixed by
|
|
19153
|
-
// toggling the tone" which was exactly that race.
|
|
19154
|
-
this.renderRoundTableHud();
|
|
19155
|
-
this.renderRtSubtitle();
|
|
19156
|
-
// Stage SFX · same call the 2D tail makes (thinking loop +
|
|
19157
|
-
// speaker-change chime). Reuses `sp` resolved above so the
|
|
19158
|
-
// helper sees the exact speaker/state the 3D scene rendered.
|
|
19159
|
-
this._applyStageSfx(sp.id, sp.state);
|
|
19160
|
-
return;
|
|
19161
19518
|
} catch (e) {
|
|
19162
|
-
|
|
19163
|
-
// so the user still gets a working stage. Logged so we can
|
|
19164
|
-
// diagnose later · the toggle stays on (user-flipped only).
|
|
19165
|
-
console.warn("[voice-3d] mount/update failed, falling back to 2D:", e);
|
|
19166
|
-
try { VS3D.unmount(); } catch (_) {}
|
|
19167
|
-
}
|
|
19168
|
-
} else if (VS3D && typeof VS3D.unmount === "function" && stage.classList.contains("is-3d")) {
|
|
19169
|
-
// Toggle just flipped off · tear down the 3D canvas so the
|
|
19170
|
-
// 2D path below paints into a clean stage.
|
|
19171
|
-
try { VS3D.unmount(); } catch (_) {}
|
|
19172
|
-
}
|
|
19173
|
-
|
|
19174
|
-
const seatsHost = stage.querySelector("[data-rt-seats]");
|
|
19175
|
-
if (!seatsHost) return;
|
|
19176
|
-
const members = members3d;
|
|
19177
|
-
const positions = positions3d;
|
|
19178
|
-
|
|
19179
|
-
// Build seat HTML. Z-order via inline style based on the y
|
|
19180
|
-
// coordinate so seats with larger y (front) paint last and
|
|
19181
|
-
// occlude back-row seats / the table edge.
|
|
19182
|
-
const seatsByZ = positions
|
|
19183
|
-
.map((seat, i) => ({ seat, i, zScore: Math.round(seat.y * 10) }))
|
|
19184
|
-
.sort((a, b) => a.zScore - b.zScore);
|
|
19185
|
-
|
|
19186
|
-
// Determine the active speaker AND whether they're thinking
|
|
19187
|
-
// (warming up · no tokens yet) or actively speaking (tokens
|
|
19188
|
-
// flowing). Priority:
|
|
19189
|
-
// 1. Most recent streaming message → that author. State
|
|
19190
|
-
// depends on body content: empty body = thinking,
|
|
19191
|
-
// non-empty = speaking.
|
|
19192
|
-
// 2. Most recent message with an ACTIVE VOICE QUEUE (audio
|
|
19193
|
-
// is being streamed/played for it). Catches chair
|
|
19194
|
-
// templated announcements (announceRoundPrompt +
|
|
19195
|
-
// announceIntervention) that emit voice-chunks without
|
|
19196
|
-
// setting meta.streaming · the user hears them speaking,
|
|
19197
|
-
// so the bubble must surface even though the streaming
|
|
19198
|
-
// flag is false.
|
|
19199
|
-
// 3. currentQueue[0] when status === "speaking" → queue head
|
|
19200
|
-
// just promoted, no message-appended yet → thinking.
|
|
19201
|
-
// 4. Otherwise null (idle).
|
|
19202
|
-
let speakingId = null;
|
|
19203
|
-
let speakerState = null; // "thinking" | "speaking"
|
|
19204
|
-
let replayBody = null; // populated only during voice-replay
|
|
19205
|
-
// (0) Voice-replay override · when an adjourned room is playing
|
|
19206
|
-
// back its transcript via the replay overlay, the live
|
|
19207
|
-
// `streaming` / queue signals are absent (the room is
|
|
19208
|
-
// done). Read the replay's active speaker so the seat
|
|
19209
|
-
// lights up + the bubble + subtitle reflect the playback.
|
|
19210
|
-
// The `body` field powers the subtitle bar at the foot of
|
|
19211
|
-
// the stage (`renderRoundTableSubtitle`). Falls through to
|
|
19212
|
-
// the live-detection paths below when replay isn't active.
|
|
19213
|
-
const replayActive = (typeof window !== "undefined"
|
|
19214
|
-
&& window.boardroomVoiceReplay
|
|
19215
|
-
&& typeof window.boardroomVoiceReplay.getActive === "function")
|
|
19216
|
-
? window.boardroomVoiceReplay.getActive()
|
|
19217
|
-
: null;
|
|
19218
|
-
if (replayActive && replayActive.authorId) {
|
|
19219
|
-
speakingId = replayActive.authorId;
|
|
19220
|
-
speakerState = replayActive.state === "speaking" ? "speaking" : "thinking";
|
|
19221
|
-
replayBody = replayActive.body || "";
|
|
19222
|
-
}
|
|
19223
|
-
const msgs = this.currentMessages || [];
|
|
19224
|
-
// (1) Audible voice queue takes precedence · with cross-director
|
|
19225
|
-
// pipelining, the most-recent streaming message is often the
|
|
19226
|
-
// PRE-WARMED next speaker (B's placeholder lands while A's
|
|
19227
|
-
// audio still plays). The seat that lights up must match
|
|
19228
|
-
// whoever the user is HEARING — not whoever's text is
|
|
19229
|
-
// streaming in the background. Scan voiceQueues for the
|
|
19230
|
-
// playing one and resolve back to its message's author.
|
|
19231
|
-
if (!speakingId && this.voiceQueues) {
|
|
19232
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
19233
|
-
const mm = msgs[i];
|
|
19234
|
-
if (!mm || mm.authorKind !== "agent") continue;
|
|
19235
|
-
const vq = this.voiceQueues[mm.id];
|
|
19236
|
-
if (vq && vq.playState === "playing") {
|
|
19237
|
-
speakingId = mm.authorId;
|
|
19238
|
-
speakerState = "speaking";
|
|
19239
|
-
break;
|
|
19240
|
-
}
|
|
19241
|
-
}
|
|
19242
|
-
}
|
|
19243
|
-
// (2) Streaming message fallback · only relevant when no audible
|
|
19244
|
-
// queue exists (text mode, OR the brief warmup between
|
|
19245
|
-
// message-appended and first voice-chunk). Picks the most
|
|
19246
|
-
// recent streaming agent message that ISN'T pre-warmed —
|
|
19247
|
-
// pre-warmed messages have a voiceQueue with playState
|
|
19248
|
-
// "queued" and should NOT light up a seat.
|
|
19249
|
-
if (!speakingId) {
|
|
19250
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
19251
|
-
const mm = msgs[i];
|
|
19252
|
-
if (!(mm && mm.meta && mm.meta.streaming === true && mm.authorKind === "agent")) continue;
|
|
19253
|
-
// Skip queued (pre-warmed) speakers · their voice waits for
|
|
19254
|
-
// the playing speaker's audio to finish.
|
|
19255
|
-
const vq = this.voiceQueues && this.voiceQueues[mm.id];
|
|
19256
|
-
if (vq && vq.playState === "queued") continue;
|
|
19257
|
-
speakingId = mm.authorId;
|
|
19258
|
-
const body = String(mm.body || "").trim();
|
|
19259
|
-
speakerState = body.length > 0 ? "speaking" : "thinking";
|
|
19260
|
-
break;
|
|
19261
|
-
}
|
|
19262
|
-
}
|
|
19263
|
-
// (2b) Dead branch removed · the old "voice queue exists at all"
|
|
19264
|
-
// path was replaced by the playing-queue priority above.
|
|
19265
|
-
if (false && !speakingId && this.voiceQueues) {
|
|
19266
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
19267
|
-
const mm = msgs[i];
|
|
19268
|
-
if (!mm || mm.authorKind !== "agent") continue;
|
|
19269
|
-
const vq = this.voiceQueues[mm.id];
|
|
19270
|
-
if (vq && vq.playState === "playing") {
|
|
19271
|
-
speakingId = mm.authorId;
|
|
19272
|
-
speakerState = "speaking";
|
|
19273
|
-
break;
|
|
19274
|
-
}
|
|
19275
|
-
}
|
|
19276
|
-
}
|
|
19277
|
-
// (3) Chair preparing (silent prep phase between user input
|
|
19278
|
-
// and the chair's message-appended) · without this hook
|
|
19279
|
-
// the user has no visual signal during the seconds the
|
|
19280
|
-
// chair spends on tools + LLM startup. Show the thinking
|
|
19281
|
-
// bubble on the chair seat so they know the chair is
|
|
19282
|
-
// working. Cleared the moment the chair's real message
|
|
19283
|
-
// appends (hideChairPending fires).
|
|
19284
|
-
if (!speakingId && this.chairPending === true && this.currentChair) {
|
|
19285
|
-
speakingId = this.currentChair.id;
|
|
19286
|
-
speakerState = "thinking";
|
|
19287
|
-
}
|
|
19288
|
-
// (3b) Convening sequence active (auto-pick + chair prep) ·
|
|
19289
|
-
// the chat-side convening card is hidden in voice mode,
|
|
19290
|
-
// so without this the stage shows nothing happening for
|
|
19291
|
-
// the 3-4s the picker LLM + chair tools + LLM startup
|
|
19292
|
-
// take. chairPending above only fires AFTER auto-pick-
|
|
19293
|
-
// complete; conveneState covers the EARLIER picker phase
|
|
19294
|
-
// too. Cleared on the chair's first message-appended.
|
|
19295
|
-
if (!speakingId && this.conveneState && this.currentChair) {
|
|
19296
|
-
speakingId = this.currentChair.id;
|
|
19297
|
-
speakerState = "thinking";
|
|
19298
|
-
}
|
|
19299
|
-
if (!speakingId && Array.isArray(this.currentQueue) && this.currentQueue[0] && this.currentQueue[0].status === "speaking") {
|
|
19300
|
-
speakingId = this.currentQueue[0].agentId;
|
|
19301
|
-
speakerState = "thinking";
|
|
19302
|
-
}
|
|
19303
|
-
|
|
19304
|
-
// Speaker's messageId · derived from the same priority chain
|
|
19305
|
-
// above. Used by the stalled-audio bubble (per-queue watchdog
|
|
19306
|
-
// surfaces on the speaker's bubble + the Skip click needs the
|
|
19307
|
-
// messageId to POST /voice-done). Null when the speaker is
|
|
19308
|
-
// thinking-only (no message id yet) or during replay.
|
|
19309
|
-
let speakingMsgId = null;
|
|
19310
|
-
if (speakingId && this.voiceQueues) {
|
|
19311
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
19312
|
-
const mm = msgs[i];
|
|
19313
|
-
if (!mm || mm.authorKind !== "agent") continue;
|
|
19314
|
-
if (mm.authorId !== speakingId) continue;
|
|
19315
|
-
if (this.voiceQueues[mm.id]) { speakingMsgId = mm.id; break; }
|
|
19519
|
+
console.warn("[voice-3d] mount/update failed:", e);
|
|
19316
19520
|
}
|
|
19317
19521
|
}
|
|
19318
|
-
const stalledQ = (speakingMsgId && this.voiceQueues && this.voiceQueues[speakingMsgId] && this.voiceQueues[speakingMsgId].stalled)
|
|
19319
|
-
? this.voiceQueues[speakingMsgId]
|
|
19320
|
-
: null;
|
|
19321
19522
|
|
|
19322
|
-
// Stage SFX · thinking loop + speaker-change chime.
|
|
19323
|
-
//
|
|
19324
|
-
//
|
|
19325
|
-
|
|
19326
|
-
this._applyStageSfx(speakingId, speakerState);
|
|
19327
|
-
|
|
19328
|
-
const html = seatsByZ.map(({ seat, i }) => {
|
|
19329
|
-
const m = seat.member;
|
|
19330
|
-
const isChair = seat.kind === "chair";
|
|
19331
|
-
const isUser = !!m.__isUser;
|
|
19332
|
-
const qIdx = isUser ? -1 : this.roundTableQueueIndex(m.id);
|
|
19333
|
-
const isSpeaking = !isUser && m.id === speakingId;
|
|
19334
|
-
const isQueued = qIdx >= 1; // anyone after the head
|
|
19335
|
-
|
|
19336
|
-
// Chair sprite · user gets the plain (no-moderator-gem) chair
|
|
19337
|
-
// sprite; the seat reads as "joined the table" not "running it".
|
|
19338
|
-
const chairSvg = this.renderRoundTableChairSvg(isChair);
|
|
19339
|
-
// Avatar · user takes prefs.avatarSeed via AvatarSkill when
|
|
19340
|
-
// available, otherwise falls back to an initial-letter chip
|
|
19341
|
-
// (mirrors the sidebar pattern at app.js:4847). Directors /
|
|
19342
|
-
// chair use their stored avatarPath image.
|
|
19343
|
-
let avatar;
|
|
19344
|
-
if (isUser) {
|
|
19345
|
-
const seed = m.__seed;
|
|
19346
|
-
if (seed && window.AvatarSkill && typeof window.AvatarSkill.generate === "function") {
|
|
19347
|
-
avatar = `<div class="rt-avatar rt-avatar-user has-pixel-av">${window.AvatarSkill.generate(seed)}</div>`;
|
|
19348
|
-
} else {
|
|
19349
|
-
const initial = this.escape(((m.name || "?")[0] || "?").toUpperCase());
|
|
19350
|
-
avatar = `<div class="rt-avatar rt-avatar-user rt-avatar-initial">${initial}</div>`;
|
|
19351
|
-
}
|
|
19352
|
-
} else {
|
|
19353
|
-
// `data-agent` opens the lightweight director overlay (see
|
|
19354
|
-
// public/agent-overlay.js bubble-phase click handler). Tagged
|
|
19355
|
-
// on directors AND the chair so the user can peek at any
|
|
19356
|
-
// seated voice from the stage; user seat is a div, not an
|
|
19357
|
-
// img, and is intentionally not tagged.
|
|
19358
|
-
avatar = `<img class="rt-avatar" data-agent="${this.escape(m.id)}" src="${this.escape(m.avatarPath || "")}" alt="${this.escape(m.name || "")}">`;
|
|
19359
|
-
}
|
|
19360
|
-
// Name plate · adds a small "Chairman / 董事长" title beneath
|
|
19361
|
-
// the user's name so the user seat reads as the room owner /
|
|
19362
|
-
// chairman. Pulled from i18n (`rt_user_title`) so the label
|
|
19363
|
-
// follows the active UI locale: defaults to "Chairman" in
|
|
19364
|
-
// English, "董事长" in Chinese. Directors and the chair
|
|
19365
|
-
// render the plain single-line name.
|
|
19366
|
-
const name = isUser
|
|
19367
|
-
? `<div class="rt-name">${this.escape(m.name || "")}<div class="rt-name-title">${this.escape(this._t("rt_user_title"))}</div></div>`
|
|
19368
|
-
: `<div class="rt-name">${this.escape(m.name || "")}</div>`;
|
|
19369
|
-
const bubbleState = isSpeaking ? speakerState : null;
|
|
19370
|
-
// Bubble carries the speaker's NAME so the user always knows
|
|
19371
|
-
// who's speaking — the name plate beneath/above the seat is
|
|
19372
|
-
// hidden during speaking (display:none in CSS), and without a
|
|
19373
|
-
// name on the bubble itself the user could only guess. Status
|
|
19374
|
-
// word ("thinking" / "speaking") is rendered as a smaller
|
|
19375
|
-
// mono kicker beneath the name. Color + dots animation still
|
|
19376
|
-
// distinguishes thinking (amber) from speaking (lime).
|
|
19377
|
-
// Convene override · while the room is mid-convening, the
|
|
19378
|
-
// chair seat shows the current stage label ("Analyzing topic"
|
|
19379
|
-
// / "Seating directors" / "Preparing remarks") instead of the
|
|
19380
|
-
// generic "Thinking" so the user knows WHICH part of the
|
|
19381
|
-
// setup is in flight. Falls back to the generic label for
|
|
19382
|
-
// every non-chair speaker and for chair turns post-convene.
|
|
19383
|
-
let statusWord = bubbleState === "thinking"
|
|
19384
|
-
? this._t("rt_thinking")
|
|
19385
|
-
: this._t("rt_speaking");
|
|
19386
|
-
if (bubbleState === "thinking" && isChair && this.conveneState) {
|
|
19387
|
-
const stageKey = ({
|
|
19388
|
-
analyzing: "conv_stage_analyzing_title",
|
|
19389
|
-
seating: "conv_stage_seating_title",
|
|
19390
|
-
preparing: "conv_stage_preparing_title",
|
|
19391
|
-
})[this.conveneState.stage];
|
|
19392
|
-
if (stageKey) statusWord = this._t(stageKey);
|
|
19393
|
-
}
|
|
19394
|
-
// Chair-pending phase (server-driven · chair-pending payload.phase).
|
|
19395
|
-
// Maps known phase strings to i18n keys so the bubble label
|
|
19396
|
-
// reflects WHAT silent work is happening — picker LLM, clarify
|
|
19397
|
-
// gate, vote summary, brief stages, etc. Falls back to the
|
|
19398
|
-
// existing convene-stage / "Thinking" label when phase is
|
|
19399
|
-
// empty or unrecognised.
|
|
19400
|
-
if (bubbleState === "thinking" && isChair && this.chairPending && this.chairPendingPhase) {
|
|
19401
|
-
const phaseKey = ({
|
|
19402
|
-
"clarify-deciding": "rt_phase_clarify_deciding",
|
|
19403
|
-
"picker-deciding": "rt_phase_picker_deciding",
|
|
19404
|
-
"next-speaker": "rt_phase_next_speaker",
|
|
19405
|
-
"llm-warming": "rt_phase_llm_warming",
|
|
19406
|
-
"vote-summary": "rt_phase_vote_summary",
|
|
19407
|
-
"brief-extracting": "rt_phase_brief_extracting",
|
|
19408
|
-
"brief-composing": "rt_phase_brief_composing",
|
|
19409
|
-
"brief-writing": "rt_phase_brief_writing",
|
|
19410
|
-
})[this.chairPendingPhase];
|
|
19411
|
-
if (phaseKey) statusWord = this._t(phaseKey);
|
|
19412
|
-
}
|
|
19413
|
-
// Per-message LLM first-token watchdog · client-side belt for
|
|
19414
|
-
// the server's 60s hard cap. When a streaming director message
|
|
19415
|
-
// has gone >8s with no message-token arriving, _localPhase is
|
|
19416
|
-
// flipped to "llm-warming" and the bubble shows that label
|
|
19417
|
-
// until the first token lands (or the server auto-skips).
|
|
19418
|
-
if (bubbleState === "thinking" && !isChair) {
|
|
19419
|
-
const streamingMsg = (this.currentMessages || []).slice().reverse().find(
|
|
19420
|
-
(mm) => mm && mm.authorKind === "agent" && mm.authorId === m.id
|
|
19421
|
-
&& mm.meta && mm.meta.streaming === true,
|
|
19422
|
-
);
|
|
19423
|
-
if (streamingMsg && streamingMsg.meta && streamingMsg.meta._localPhase === "llm-warming") {
|
|
19424
|
-
statusWord = this._t("rt_phase_llm_warming");
|
|
19425
|
-
}
|
|
19426
|
-
}
|
|
19427
|
-
const bubbleCls = bubbleState === "thinking"
|
|
19428
|
-
? "rt-bubble is-thinking"
|
|
19429
|
-
: "rt-bubble";
|
|
19430
|
-
// User bubble · ephemeral, shows latest typed message plus
|
|
19431
|
-
// an `×` close button. Visible only when not dismissed and
|
|
19432
|
-
// the deadline hasn't elapsed. The 10s countdown is
|
|
19433
|
-
// rendered AS the bubble's border via a conic-gradient
|
|
19434
|
-
// driven by `--rt-bubble-user-progress` (0 → 1 over 10s);
|
|
19435
|
-
// the inline-text countdown digit was removed so prose
|
|
19436
|
-
// can use the bubble's full interior width.
|
|
19437
|
-
let bubble = "";
|
|
19438
|
-
if (isUser) {
|
|
19439
|
-
const ub = this.userBubble;
|
|
19440
|
-
if (ub && !ub.dismissed && ub.text && Date.now() < ub.deadline) {
|
|
19441
|
-
const elapsed = this.USER_BUBBLE_TTL_MS - (ub.deadline - Date.now());
|
|
19442
|
-
const progress = Math.min(1, Math.max(0, elapsed / this.USER_BUBBLE_TTL_MS));
|
|
19443
|
-
bubble = `<div class="rt-bubble rt-bubble-user" data-rt-user-bubble style="--rt-bubble-user-progress: ${progress.toFixed(3)}">` +
|
|
19444
|
-
`<span class="rt-bubble-user-text">${this.escape(ub.text)}</span>` +
|
|
19445
|
-
`<button type="button" class="rt-bubble-user-close" data-rt-user-bubble-close aria-label="Dismiss">✕</button>` +
|
|
19446
|
-
`</div>`;
|
|
19447
|
-
}
|
|
19448
|
-
} else if (isChair && this.chairBubble && !this.chairBubble.dismissed
|
|
19449
|
-
&& this.chairBubble.text && Date.now() < this.chairBubble.deadline) {
|
|
19450
|
-
// Chair clarify question · pinned to the chair seat with
|
|
19451
|
-
// border countdown. Takes precedence over the "Speaking"
|
|
19452
|
-
// status bubble since the chair has already finished its
|
|
19453
|
-
// turn at this point (message-final flipped streaming off).
|
|
19454
|
-
const cb = this.chairBubble;
|
|
19455
|
-
const elapsed = this.CHAIR_BUBBLE_TTL_MS - (cb.deadline - Date.now());
|
|
19456
|
-
const progress = Math.min(1, Math.max(0, elapsed / this.CHAIR_BUBBLE_TTL_MS));
|
|
19457
|
-
bubble = `<div class="rt-bubble rt-bubble-chair-clarify" data-rt-chair-bubble style="--rt-bubble-chair-progress: ${progress.toFixed(3)}">` +
|
|
19458
|
-
`<span class="rt-bubble-chair-clarify-text">${this.escape(cb.text)}</span>` +
|
|
19459
|
-
`<button type="button" class="rt-bubble-chair-clarify-close" data-rt-chair-bubble-close aria-label="Dismiss">✕</button>` +
|
|
19460
|
-
`</div>`;
|
|
19461
|
-
} else if (isSpeaking) {
|
|
19462
|
-
// Stalled-audio variant · the chunk-arrival watchdog flipped
|
|
19463
|
-
// q.stalled when no new TTS chunks arrived for ~8s. Repaint
|
|
19464
|
-
// the bubble amber to signal the issue; the 15s auto-skip
|
|
19465
|
-
// inside the watchdog will recover automatically.
|
|
19466
|
-
if (stalledQ && stalledQ.messageId === (this.voiceQueues[speakingMsgId]?.messageId)) {
|
|
19467
|
-
// Audio stalled · the chunk-arrival watchdog flipped
|
|
19468
|
-
// q.stalled when no new TTS chunks arrived for ~8s. Show
|
|
19469
|
-
// an amber state on the bubble so the user understands
|
|
19470
|
-
// why playback paused. The 15s auto-skip in the watchdog
|
|
19471
|
-
// recovers automatically · no manual click affordance.
|
|
19472
|
-
const stalledText = this._t("rt_audio_stalled");
|
|
19473
|
-
bubble = `<div class="${bubbleCls} is-stalled"`
|
|
19474
|
-
+ ` title="${this.escape(stalledText)}">`
|
|
19475
|
-
+ `<span class="rt-bubble-name">${this.escape(m.name || "")}</span>`
|
|
19476
|
-
+ `<span class="rt-bubble-status">${this.escape(stalledText)}</span>`
|
|
19477
|
-
+ `</div>`;
|
|
19478
|
-
} else {
|
|
19479
|
-
bubble = `<div class="${bubbleCls}"><span class="rt-bubble-name">${this.escape(m.name || "")}</span><span class="rt-bubble-status">${this.escape(statusWord)}</span><span class="rt-bubble-dots" aria-hidden="true"><i></i><i></i><i></i></span></div>`;
|
|
19480
|
-
}
|
|
19481
|
-
}
|
|
19482
|
-
const badge = (isQueued && !isSpeaking)
|
|
19483
|
-
? `<div class="rt-badge">${String(qIdx + 1).padStart(2, "0")}</div>`
|
|
19484
|
-
: "";
|
|
19485
|
-
|
|
19486
|
-
// Vote popover on the chair seat · single voice-mode surface
|
|
19487
|
-
// for both phases of the vote flow:
|
|
19488
|
-
// (A) round-prompt phase · chair has just prompted at round
|
|
19489
|
-
// wrap; popover shows [Open vote] [Continue] [Adjourn].
|
|
19490
|
-
// (B) round-end vote phase · awaitingContinue === true;
|
|
19491
|
-
// popover shows the 3 key-points + Continue / Adjourn
|
|
19492
|
-
// AFTER the chair has finished its TTS turn.
|
|
19493
|
-
// The popover is suppressed while the chair is mid-presenting
|
|
19494
|
-
// (preparing, streaming text, or audio still playing). The
|
|
19495
|
-
// user wants to hear the chair speak first, THEN see the
|
|
19496
|
-
// panel — without this gate the panel popped up under the
|
|
19497
|
-
// chair's voice and split the user's attention.
|
|
19498
|
-
const hasActivePrompt = (typeof this.activeRoundPromptId === "function")
|
|
19499
|
-
? !!this.activeRoundPromptId()
|
|
19500
|
-
: false;
|
|
19501
|
-
const isChairBusy = (() => {
|
|
19502
|
-
if (this.chairPending === true) return true;
|
|
19503
|
-
// Chair message landed but voice synthesis hasn't reached
|
|
19504
|
-
// the client yet · the popover would otherwise flash for
|
|
19505
|
-
// 0.5-2s before audio kicks in. See `_chairVoiceAwaiting`
|
|
19506
|
-
// doc + the message-appended hook for the full reasoning.
|
|
19507
|
-
if (this._chairVoiceAwaiting && this._chairVoiceAwaiting.size > 0) return true;
|
|
19508
|
-
const allMsgs = this.currentMessages || [];
|
|
19509
|
-
for (let k = allMsgs.length - 1; k >= 0; k--) {
|
|
19510
|
-
const mm = allMsgs[k];
|
|
19511
|
-
if (!mm || mm.authorKind !== "agent") continue;
|
|
19512
|
-
// Most recent agent turn · streaming text OR active voice
|
|
19513
|
-
// playback counts as "busy". Voice queues stay live for
|
|
19514
|
-
// templated chair messages too (announceRoundPrompt), so
|
|
19515
|
-
// this catches round-prompt voice as well as the LLM
|
|
19516
|
-
// round-end stream.
|
|
19517
|
-
if (mm.meta && mm.meta.streaming === true) return true;
|
|
19518
|
-
if (this.voiceQueues && this.voiceQueues[mm.id]) return true;
|
|
19519
|
-
return false;
|
|
19520
|
-
}
|
|
19521
|
-
return false;
|
|
19522
|
-
})();
|
|
19523
|
-
const showVotePop = isChair && this.currentRoom
|
|
19524
|
-
&& this.currentRoom.status !== "adjourned"
|
|
19525
|
-
&& (this.currentRoom.awaitingContinue === true || hasActivePrompt)
|
|
19526
|
-
&& !isChairBusy;
|
|
19527
|
-
const votePop = showVotePop ? this.renderRoundTableVotePop() : "";
|
|
19528
|
-
|
|
19529
|
-
// Seats whose y is below the stage midline (chair + any
|
|
19530
|
-
// front-row directors) get `rt-seat-below`. CSS flips the
|
|
19531
|
-
// name plate to sit BELOW the chair sprite for these so
|
|
19532
|
-
// names of front-row seats don't land on the table surface
|
|
19533
|
-
// and obscure the props.
|
|
19534
|
-
const isBelow = seat.y > 50;
|
|
19535
|
-
const cls = [
|
|
19536
|
-
"rt-seat",
|
|
19537
|
-
isChair ? "rt-seat-chair" : (isUser ? "rt-seat-user" : "rt-seat-director"),
|
|
19538
|
-
isSpeaking ? "rt-seat-speaking" : "",
|
|
19539
|
-
isSpeaking && speakerState === "thinking" ? "rt-seat-thinking" : "",
|
|
19540
|
-
showVotePop ? "rt-seat-voting" : "",
|
|
19541
|
-
isBelow ? "rt-seat-below" : "",
|
|
19542
|
-
].filter(Boolean).join(" ");
|
|
19523
|
+
// Stage SFX · thinking loop + speaker-change chime. The 2D
|
|
19524
|
+
// path used to compute speakingId/state itself; we reuse the
|
|
19525
|
+
// resolver above so this stays single-source.
|
|
19526
|
+
this._applyStageSfx(sp.id, sp.state);
|
|
19543
19527
|
|
|
19544
|
-
|
|
19545
|
-
|
|
19546
|
-
`left: ${seat.x.toFixed(2)}%`,
|
|
19547
|
-
`top: ${seat.y.toFixed(2)}%`,
|
|
19548
|
-
`--rt-scale: ${seat.scaleHint.toFixed(3)}`,
|
|
19549
|
-
`--seat-i: ${i}`,
|
|
19550
|
-
].join("; ");
|
|
19551
|
-
|
|
19552
|
-
// Wait-marker · only on the user seat, only when the user
|
|
19553
|
-
// has picked "wait — flush after current speaker finishes"
|
|
19554
|
-
// (pendingUserMessage is set). Reads as a small pixel pill
|
|
19555
|
-
// anchored to the seat so a glance at the table answers
|
|
19556
|
-
// "is my queued message still parked?" — clears the moment
|
|
19557
|
-
// the message-appended SSE for the user's body lands.
|
|
19558
|
-
const waitMark = (isUser && this.pendingUserMessage)
|
|
19559
|
-
? `<div class="rt-seat-wait-mark" aria-label="Waiting for current speaker to finish">⌛ WAIT</div>`
|
|
19560
|
-
: "";
|
|
19561
|
-
|
|
19562
|
-
return `
|
|
19563
|
-
<div class="${cls}" data-seat-index="${i}" data-agent-id="${this.escape(m.id)}" style="${style}">
|
|
19564
|
-
${chairSvg}
|
|
19565
|
-
${avatar}
|
|
19566
|
-
${bubble}
|
|
19567
|
-
${badge}
|
|
19568
|
-
${waitMark}
|
|
19569
|
-
${name}
|
|
19570
|
-
${votePop}
|
|
19571
|
-
</div>
|
|
19572
|
-
`;
|
|
19573
|
-
}).join("");
|
|
19574
|
-
|
|
19575
|
-
// Empty state · no directors yet.
|
|
19576
|
-
const empty = members.length <= 1
|
|
19577
|
-
? `<div class="rt-empty"><span>// awaiting directors</span></div>`
|
|
19578
|
-
: "";
|
|
19579
|
-
|
|
19580
|
-
seatsHost.innerHTML = html + empty;
|
|
19581
|
-
|
|
19582
|
-
// Update aria-label to keep screen readers in sync with the
|
|
19583
|
-
// visual state · the speaking-queue strip below remains the
|
|
19528
|
+
// Aria-label keeps screen readers in sync with the visible
|
|
19529
|
+
// speaker · the speaking-queue strip below remains the
|
|
19584
19530
|
// canonical accessible source of truth, this is just a hint.
|
|
19585
19531
|
const speaker = members.find((m) => m.id === speakingId);
|
|
19586
19532
|
const queuedNames = (this.currentQueue || [])
|
|
@@ -19599,18 +19545,15 @@
|
|
|
19599
19545
|
}
|
|
19600
19546
|
stage.setAttribute("aria-label", aria);
|
|
19601
19547
|
|
|
19602
|
-
// Persistent HUD repaint · cheap
|
|
19603
|
-
//
|
|
19604
|
-
//
|
|
19605
|
-
// re-render
|
|
19606
|
-
//
|
|
19548
|
+
// Persistent HUD + live subtitle repaint · cheap and the data
|
|
19549
|
+
// sources are the same room state already consulted above. The
|
|
19550
|
+
// message-token hot path calls renderRtSubtitle directly to
|
|
19551
|
+
// bypass full re-render cost; this call covers queue-update /
|
|
19552
|
+
// config-event / SSE hello cases that flow through here.
|
|
19607
19553
|
this.renderRoundTableHud();
|
|
19608
|
-
// Live subtitle · keep in sync with the same speaker-detection
|
|
19609
|
-
// signals the seats use. Repaints triggered from message-token
|
|
19610
|
-
// already call renderRtSubtitle directly to bypass the full
|
|
19611
|
-
// seat re-render cost; this call covers queue-update / config-
|
|
19612
|
-
// event / SSE hello cases that flow through renderRoundTable.
|
|
19613
19554
|
this.renderRtSubtitle();
|
|
19555
|
+
return;
|
|
19556
|
+
|
|
19614
19557
|
},
|
|
19615
19558
|
|
|
19616
19559
|
/** Paint the top-left HUD console · gamified RPG-style status
|