privateboard 0.1.23 → 0.1.25
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 +1404 -1378
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1404 -1378
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1370 -1359
- 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/agent-overlay.css +82 -0
- package/public/agent-overlay.js +459 -17
- package/public/agent-profile.js +34 -54
- package/public/app-updater.css +318 -0
- package/public/app-updater.js +247 -0
- package/public/app.js +1590 -691
- package/public/home.html +1 -1
- package/public/i18n.js +477 -52
- package/public/icons/floor.png +0 -0
- package/public/index.html +600 -213
- package/public/keys-store.js +112 -1
- package/public/mention-picker.js +573 -0
- package/public/new-agent.js +17 -7
- package/public/onboarding.js +108 -117
- package/public/themes.css +44 -0
- package/public/user-settings.css +503 -3
- package/public/user-settings.js +526 -217
- package/public/voice-replay.css +33 -20
- package/public/voice-replay.js +16 -0
package/public/app.js
CHANGED
|
@@ -93,20 +93,6 @@
|
|
|
93
93
|
* miss falls back to the raw voiceId so the row never blocks on
|
|
94
94
|
* the fetch. */
|
|
95
95
|
voiceLabels: {},
|
|
96
|
-
/** Interest-driven topic recommendations · the home composer's
|
|
97
|
-
* "找你可能感兴趣的话题" tray. Always exactly 6 items (or
|
|
98
|
-
* fewer if no batch has been generated yet) — every fresh
|
|
99
|
-
* generation wipes the previous batch server-side, so
|
|
100
|
-
* there's no pagination or history. `loaded` flips true
|
|
101
|
-
* after the first `/api/topic-recs` fetch so first-paint
|
|
102
|
-
* can show a sensible empty state. `job` is the live
|
|
103
|
-
* generation job (id + phase + pct + detail) or null when
|
|
104
|
-
* idle. */
|
|
105
|
-
topicRecs: {
|
|
106
|
-
items: [],
|
|
107
|
-
loaded: false,
|
|
108
|
-
job: null, // { id, phase, label, pct, detail, eventSource }
|
|
109
|
-
},
|
|
110
96
|
rooms: [],
|
|
111
97
|
currentRoomId: null,
|
|
112
98
|
currentRoom: null,
|
|
@@ -121,6 +107,16 @@
|
|
|
121
107
|
currentChair: null, // chair agent for the current room
|
|
122
108
|
currentQueue: [],
|
|
123
109
|
voiceQueues: {},
|
|
110
|
+
/** Cross-director pipeline · pre-warmed messages whose chat
|
|
111
|
+
* bubble should be HIDDEN (data-prewarmed) until their audio
|
|
112
|
+
* starts playing. Set membership is the source of truth so the
|
|
113
|
+
* attribute survives any `renderChat()` re-render — without this,
|
|
114
|
+
* message-updated SSE (or brief-update / round-ended) wipes the
|
|
115
|
+
* attribute and the still-pre-warmed bubble flashes its streaming
|
|
116
|
+
* animation alongside the current speaker. Populated in
|
|
117
|
+
* appendMessageDom, drained in _revealPrewarmedBubble, baked into
|
|
118
|
+
* messageHtml so every render preserves the hidden state. */
|
|
119
|
+
_prewarmedHidden: new Set(),
|
|
124
120
|
/** Mini-player anchor · when set, the cross-room bar persists
|
|
125
121
|
* for this voice room throughout its whole live/paused life
|
|
126
122
|
* (between speakers, during votes / clarify, idle phases) —
|
|
@@ -586,15 +582,28 @@
|
|
|
586
582
|
closeBtn.addEventListener("click", dismiss, { once: true });
|
|
587
583
|
},
|
|
588
584
|
|
|
589
|
-
/** Refetch /api/keys
|
|
590
|
-
* user-settings on close so the requireModelKey gate
|
|
591
|
-
* user's just-configured
|
|
585
|
+
/** Refetch /api/keys + /api/credentials + the /api/models cache.
|
|
586
|
+
* Called by user-settings on close so the requireModelKey gate
|
|
587
|
+
* sees the user's just-configured credentials without a page
|
|
588
|
+
* reload. `this.keys` covers voice + skill rows;
|
|
589
|
+
* `window.keysStore.llmCredentials` covers LLM credentials. */
|
|
592
590
|
async refreshKeys() {
|
|
593
591
|
try {
|
|
594
592
|
const r = await fetch("/api/keys");
|
|
595
|
-
if (
|
|
596
|
-
|
|
597
|
-
|
|
593
|
+
if (r.ok) {
|
|
594
|
+
const j = await r.json();
|
|
595
|
+
this.keys = Object.fromEntries((j.keys || []).map((k) => [k.provider, k]));
|
|
596
|
+
}
|
|
597
|
+
} catch (e) { /* ignore */ }
|
|
598
|
+
try {
|
|
599
|
+
if (window.keysStore && typeof window.keysStore.fetchLlmCredentials === "function") {
|
|
600
|
+
await window.keysStore.fetchLlmCredentials();
|
|
601
|
+
}
|
|
602
|
+
} catch (e) { /* ignore */ }
|
|
603
|
+
try {
|
|
604
|
+
if (typeof window.boardroomModelsRefresh === "function") {
|
|
605
|
+
await window.boardroomModelsRefresh();
|
|
606
|
+
}
|
|
598
607
|
} catch (e) { /* ignore */ }
|
|
599
608
|
},
|
|
600
609
|
|
|
@@ -788,6 +797,12 @@
|
|
|
788
797
|
|
|
789
798
|
// ── Room lifecycle ────────────────────────────────────────
|
|
790
799
|
async openRoom(roomId) {
|
|
800
|
+
// Close the @-mention picker if open · its director list is
|
|
801
|
+
// scoped to the previous room and would point at a stale cast
|
|
802
|
+
// mid-navigation. Idempotent · no-op when already closed.
|
|
803
|
+
if (window.MentionPicker && typeof window.MentionPicker.close === "function") {
|
|
804
|
+
window.MentionPicker.close();
|
|
805
|
+
}
|
|
791
806
|
// Hand off SSE to a background tracker BEFORE closing the
|
|
792
807
|
// foreground · openRoom is the room-to-room nav path
|
|
793
808
|
// (closeRoom only fires when going back to "no room") so
|
|
@@ -896,6 +911,11 @@
|
|
|
896
911
|
this.agentSpec = null;
|
|
897
912
|
this.agentSpecGenerating = false;
|
|
898
913
|
this.agentSpecError = null;
|
|
914
|
+
// Cross-director pre-warm hidden set · scope is per-room.
|
|
915
|
+
// Carrying over from a prior room would mark messages in the
|
|
916
|
+
// new room as hidden if message IDs collide (rare but
|
|
917
|
+
// defensive). Empty on every room open.
|
|
918
|
+
this._prewarmedHidden = new Set();
|
|
899
919
|
// Entering a room silences the agent-build ambient (the user
|
|
900
920
|
// has shifted focus away from the agent composer). If a
|
|
901
921
|
// build is still running, the sidebar Building row remains
|
|
@@ -1248,6 +1268,17 @@
|
|
|
1248
1268
|
roundNum: data.roundNum || 1,
|
|
1249
1269
|
createdAt: data.createdAt,
|
|
1250
1270
|
});
|
|
1271
|
+
// Client-side LLM first-token watchdog · mirrors the TTS
|
|
1272
|
+
// chunk-arrival watchdog in mechanism but for the LLM stream
|
|
1273
|
+
// itself. Streaming agent message just landed · start an 8s
|
|
1274
|
+
// timer; if no message-token arrives in that window, flip
|
|
1275
|
+
// _localPhase to "llm-warming" so the round-table bubble's
|
|
1276
|
+
// status word changes to "Warming up" and the user sees the
|
|
1277
|
+
// model is still working (not just frozen). Server-side 60s
|
|
1278
|
+
// hard cap will eventually auto-skip if it really hung.
|
|
1279
|
+
if (data.authorKind === "agent" && data.meta && data.meta.streaming === true) {
|
|
1280
|
+
this._startLlmFirstTokenWatchdog(data.messageId);
|
|
1281
|
+
}
|
|
1251
1282
|
// Chair-pending placeholder · clear the moment ANY agent
|
|
1252
1283
|
// message lands (chair OR director). The placeholder is a
|
|
1253
1284
|
// stand-in while the chair did silent server-side work
|
|
@@ -1258,7 +1289,26 @@
|
|
|
1258
1289
|
// OR a director turn — only the chair-restricted clear left
|
|
1259
1290
|
// the placeholder lingering when the picker decided no
|
|
1260
1291
|
// intervention was needed.
|
|
1261
|
-
|
|
1292
|
+
// Hide chair-pending on agent message-appended · BUT keep
|
|
1293
|
+
// it visible when the incoming message is a TEMPLATED chair
|
|
1294
|
+
// vote message (round-prompt / round-end / intervention) in
|
|
1295
|
+
// voice mode. Those have meta.streaming === false (the body
|
|
1296
|
+
// is template-built, not LLM-streamed), so neither the
|
|
1297
|
+
// streaming-message path nor the message-token path lights
|
|
1298
|
+
// up the chair seat — there'd be a 0.5-2s dark window
|
|
1299
|
+
// between message-appended and first voice-chunk. Keep
|
|
1300
|
+
// chair-pending visible during that window; the voice-chunk
|
|
1301
|
+
// handler clears _chairVoiceAwaiting and the next render
|
|
1302
|
+
// takes over from there.
|
|
1303
|
+
const isTemplatedChairVote = !!(
|
|
1304
|
+
data.authorKind === "agent"
|
|
1305
|
+
&& this.currentChair && data.authorId === this.currentChair.id
|
|
1306
|
+
&& data.meta
|
|
1307
|
+
&& data.meta.streaming === false
|
|
1308
|
+
&& (data.meta.kind === "round-prompt" || data.meta.kind === "round-end" || data.meta.kind === "intervention")
|
|
1309
|
+
&& this.currentRoom && this.currentRoom.deliveryMode === "voice"
|
|
1310
|
+
);
|
|
1311
|
+
if (data.authorKind === "agent" && !isTemplatedChairVote) {
|
|
1262
1312
|
this.hideChairPending();
|
|
1263
1313
|
}
|
|
1264
1314
|
// Chair vote-trigger message · register it as awaiting voice
|
|
@@ -1407,6 +1457,14 @@
|
|
|
1407
1457
|
if (!msg) return;
|
|
1408
1458
|
const wasEmpty = !String(msg.body || "").trim();
|
|
1409
1459
|
msg.body += data.delta;
|
|
1460
|
+
// First token landed · disarm the first-token watchdog and
|
|
1461
|
+
// drop the local "warming" phase if it was set. Cheap no-op
|
|
1462
|
+
// when the watchdog was never armed for this message (e.g.
|
|
1463
|
+
// user-authored message, system message).
|
|
1464
|
+
this._clearLlmFirstTokenWatchdog(data.messageId);
|
|
1465
|
+
if (msg.meta && msg.meta._localPhase === "llm-warming") {
|
|
1466
|
+
delete msg.meta._localPhase;
|
|
1467
|
+
}
|
|
1410
1468
|
// Mirror the body onto any voiceQueue for this message so
|
|
1411
1469
|
// the cross-room mini-player can still show the "lyrics"
|
|
1412
1470
|
// line after the user navigates away (SSE disconnects, but
|
|
@@ -1441,6 +1499,22 @@
|
|
|
1441
1499
|
msg.meta = msg.meta || {};
|
|
1442
1500
|
msg.meta.streaming = false;
|
|
1443
1501
|
msg.meta.speakerStatus = "final";
|
|
1502
|
+
if (msg.meta._localPhase === "llm-warming") delete msg.meta._localPhase;
|
|
1503
|
+
}
|
|
1504
|
+
// Stream finished · disarm the first-token watchdog as a
|
|
1505
|
+
// safety net (normally cleared by the first message-token,
|
|
1506
|
+
// but a stream that errors without a single token still
|
|
1507
|
+
// emits message-final, so this catches it).
|
|
1508
|
+
this._clearLlmFirstTokenWatchdog(data.messageId);
|
|
1509
|
+
// Reveal hidden pre-warmed bubble · if this message had NO
|
|
1510
|
+
// voice queue (text-mode rooms, OR voice-mode rooms where TTS
|
|
1511
|
+
// failed silently for every sentence so no voice-chunk ever
|
|
1512
|
+
// arrived), _fireVoiceDone's promote path can't reveal it.
|
|
1513
|
+
// The voice path (queued → promoted on prior audio.ended)
|
|
1514
|
+
// still owns reveals when a queue exists; this branch is the
|
|
1515
|
+
// safety net for "LLM said something but audio never came".
|
|
1516
|
+
if (!this.voiceQueues || !this.voiceQueues[data.messageId]) {
|
|
1517
|
+
this._revealPrewarmedBubble(data.messageId);
|
|
1444
1518
|
}
|
|
1445
1519
|
// Chair clarify · in voice mode, the chair's clarifying
|
|
1446
1520
|
// question just finished streaming. Pin the question text to
|
|
@@ -1467,7 +1541,14 @@
|
|
|
1467
1541
|
// renderRtSubtitle's fallback branch); in text mode it
|
|
1468
1542
|
// hides immediately since no voice clip is queued.
|
|
1469
1543
|
this.renderRtSubtitle();
|
|
1470
|
-
|
|
1544
|
+
// Keep the chat bubble's streaming pulse animation alive
|
|
1545
|
+
// while TTS audio is still playing for this message. LLM
|
|
1546
|
+
// text done ≠ speaker done · the user is still hearing the
|
|
1547
|
+
// sentence. _fireVoiceDone explicitly clears the class when
|
|
1548
|
+
// audio actually ends. In text-mode rooms (no voice queue)
|
|
1549
|
+
// this collapses to the original `false` behaviour.
|
|
1550
|
+
const ttsStillActive = !!(this.voiceQueues && this.voiceQueues[data.messageId]);
|
|
1551
|
+
this.updateMessageBodyDom(data.messageId, msg ? msg.body : "", ttsStillActive);
|
|
1471
1552
|
// A director just stopped streaming → the manual round-end
|
|
1472
1553
|
// button may have flipped from disabled to enabled, and the
|
|
1473
1554
|
// auto-continue countdown may now be eligible to start.
|
|
@@ -1480,6 +1561,10 @@
|
|
|
1480
1561
|
const msg = this.currentMessages.find((m) => m.id === data.messageId);
|
|
1481
1562
|
if (msg) msg.meta = { ...(msg.meta || {}), error: data.message };
|
|
1482
1563
|
this.updateMessageBodyDom(data.messageId, msg ? msg.body : `[error: ${data.message}]`, false);
|
|
1564
|
+
// Reveal hidden pre-warmed bubble · the LLM stream failed for
|
|
1565
|
+
// this speaker, no audio will play, so the user needs to see
|
|
1566
|
+
// the error message in chat rather than have it stay hidden.
|
|
1567
|
+
this._revealPrewarmedBubble(data.messageId);
|
|
1483
1568
|
});
|
|
1484
1569
|
|
|
1485
1570
|
this.sse.addEventListener("message-removed", (e) => {
|
|
@@ -1487,10 +1572,14 @@
|
|
|
1487
1572
|
// Stop any voice playback for this message (e.g. READY control token)
|
|
1488
1573
|
const vq = this.voiceQueues[data.messageId];
|
|
1489
1574
|
if (vq) {
|
|
1575
|
+
if (vq.watchdogId) { try { clearInterval(vq.watchdogId); } catch(_) {} vq.watchdogId = null; }
|
|
1490
1576
|
if (vq.audio) { try { vq.audio.pause(); } catch(_) {} }
|
|
1491
1577
|
delete this.voiceQueues[data.messageId];
|
|
1492
1578
|
this.refreshMiniPlayer();
|
|
1493
1579
|
}
|
|
1580
|
+
// Also disarm first-token watchdog · message gone means no
|
|
1581
|
+
// further token-arrival signal matters.
|
|
1582
|
+
this._clearLlmFirstTokenWatchdog(data.messageId);
|
|
1494
1583
|
// Drop the empty placeholder bubble.
|
|
1495
1584
|
this.currentMessages = this.currentMessages.filter((m) => m.id !== data.messageId);
|
|
1496
1585
|
const article = document.querySelector(`[data-message-id="${data.messageId}"]`);
|
|
@@ -1523,6 +1612,16 @@
|
|
|
1523
1612
|
if (fresh && this._chairVoiceAwaiting) {
|
|
1524
1613
|
this._chairVoiceAwaiting.delete(data.messageId);
|
|
1525
1614
|
}
|
|
1615
|
+
// Chair templated voice has actually started · NOW hide the
|
|
1616
|
+
// chair-pending placeholder (we deferred it earlier in the
|
|
1617
|
+
// message-appended handler for templated chair vote messages
|
|
1618
|
+
// so the user wasn't staring at a dark chair seat for the
|
|
1619
|
+
// 0.5-2s window between message-appended and first voice-
|
|
1620
|
+
// chunk). The voice queue will take over rendering via the
|
|
1621
|
+
// playing-queue check in renderRoundTable.
|
|
1622
|
+
if (fresh && this.chairPending) {
|
|
1623
|
+
this.hideChairPending();
|
|
1624
|
+
}
|
|
1526
1625
|
// Chair gavel SFX · fire ONCE when fresh chair voice starts
|
|
1527
1626
|
// streaming, so the user hears the courtroom "knock-knock"
|
|
1528
1627
|
// calling for attention before the chair speaks. Detection:
|
|
@@ -1578,6 +1677,34 @@
|
|
|
1578
1677
|
this.drainVoiceQueue(roomId, data.messageId);
|
|
1579
1678
|
});
|
|
1580
1679
|
|
|
1680
|
+
// TTS billing failure · MiniMax insufficient-balance / ElevenLabs
|
|
1681
|
+
// out-of-credits / paid-plan-required gates. Server-side TTS
|
|
1682
|
+
// streamers catch the tagged error and forward this event so the
|
|
1683
|
+
// user gets an actionable overlay (with a top-up CTA) instead of
|
|
1684
|
+
// silent failure. `openPaidOverlay` is itself idempotent (it
|
|
1685
|
+
// no-ops when the overlay node is already in the DOM), so this
|
|
1686
|
+
// handler can stay dumb · repeated events in a round just hit
|
|
1687
|
+
// the early return inside the overlay function · no flashing.
|
|
1688
|
+
this.sse.addEventListener("voice-error", (e) => {
|
|
1689
|
+
try {
|
|
1690
|
+
const data = JSON.parse(e.data);
|
|
1691
|
+
if (!data || data.code !== "paid-plan-required") return;
|
|
1692
|
+
if (window.AgentProfileVoice && typeof window.AgentProfileVoice.openPaidOverlay === "function") {
|
|
1693
|
+
window.AgentProfileVoice.openPaidOverlay({
|
|
1694
|
+
provider: data.provider || "",
|
|
1695
|
+
upgradeUrl: data.upgradeUrl || "",
|
|
1696
|
+
message: data.message || "",
|
|
1697
|
+
});
|
|
1698
|
+
} else if (data.message) {
|
|
1699
|
+
// Fallback if the overlay module didn't load · still tell
|
|
1700
|
+
// the user what happened rather than failing silently.
|
|
1701
|
+
alert(data.message + (data.upgradeUrl ? ("\n\n" + data.upgradeUrl) : ""));
|
|
1702
|
+
}
|
|
1703
|
+
} catch (err) {
|
|
1704
|
+
console.warn("[voice-error] handler failed:", err);
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1581
1708
|
// Full body+meta replacement · used by tool-use rows whose
|
|
1582
1709
|
// status flips running → done|failed once the side-effect
|
|
1583
1710
|
// (URL fetch) completes. We re-render the affected message in
|
|
@@ -2249,6 +2376,14 @@
|
|
|
2249
2376
|
// LLM startup). Show a transient placeholder so the user has
|
|
2250
2377
|
// visible feedback until the real chair bubble arrives.
|
|
2251
2378
|
this.showChairPending(payload?.phase || "");
|
|
2379
|
+
} else if (kind === "auto-skipped") {
|
|
2380
|
+
// Server-side timeout fired (TTS / LLM / picker / clarify)
|
|
2381
|
+
// and the orchestrator auto-recovered. Surface a 3s toast
|
|
2382
|
+
// so the user understands WHY the room jumped forward. The
|
|
2383
|
+
// skip itself has already happened — this is purely the
|
|
2384
|
+
// notification. messageId in payload (when present) is the
|
|
2385
|
+
// target message id; reserved for future per-bubble hints.
|
|
2386
|
+
this._showAutoSkipToast(payload?.phase || "", payload?.reason || "");
|
|
2252
2387
|
} else if (kind === "room-opened") {
|
|
2253
2388
|
// no-op: we already have full state
|
|
2254
2389
|
} else if (kind === "auto-pick-started") {
|
|
@@ -2517,7 +2652,7 @@
|
|
|
2517
2652
|
},
|
|
2518
2653
|
|
|
2519
2654
|
// ── Actions ───────────────────────────────────────────────
|
|
2520
|
-
async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick, deliveryMode
|
|
2655
|
+
async createRoom({ subject, agentIds, mode, intensity, briefStyle, autoPick, deliveryMode }) {
|
|
2521
2656
|
const r = await fetch("/api/rooms", {
|
|
2522
2657
|
method: "POST",
|
|
2523
2658
|
headers: { "content-type": "application/json" },
|
|
@@ -2529,11 +2664,6 @@
|
|
|
2529
2664
|
briefStyle: briefStyle || "auto",
|
|
2530
2665
|
deliveryMode: deliveryMode === "voice" ? "voice" : "text",
|
|
2531
2666
|
...(autoPick ? { autoPick: true } : {}),
|
|
2532
|
-
// seedContext · attached when the user opened this room
|
|
2533
|
-
// from a topic-rec card. Backend writes it to the
|
|
2534
|
-
// opening message's meta so the chair grounds clarify
|
|
2535
|
-
// in the actual source snippets.
|
|
2536
|
-
...(seedContext ? { seedContext } : {}),
|
|
2537
2667
|
}),
|
|
2538
2668
|
});
|
|
2539
2669
|
if (!r.ok) {
|
|
@@ -2661,9 +2791,24 @@
|
|
|
2661
2791
|
// If a director is mid-turn AND we don't already have a queued
|
|
2662
2792
|
// message, ask the user how to proceed.
|
|
2663
2793
|
if (this.isAgentSpeaking() && !this.pendingUserMessage) {
|
|
2664
|
-
|
|
2794
|
+
// Drain @-mentions NOW so the choice modal can carry them
|
|
2795
|
+
// along to interrupt / queue · waiting until handleSendChoice
|
|
2796
|
+
// would be too late (the textarea is cleared by then, and
|
|
2797
|
+
// the mention-picker filters by handle-still-present-in-text).
|
|
2798
|
+
const midTurnMentions = (window.MentionPicker && typeof window.MentionPicker.consumePendingMentions === "function")
|
|
2799
|
+
? window.MentionPicker.consumePendingMentions(text)
|
|
2800
|
+
: [];
|
|
2801
|
+
this.openSendChoiceModal(text, midTurnMentions);
|
|
2665
2802
|
return true;
|
|
2666
2803
|
}
|
|
2804
|
+
// Drain any pending @-mentions the user picked via the
|
|
2805
|
+
// mention-picker overlay. The picker filters out handles the
|
|
2806
|
+
// user later backspaced out, so the resulting id list is
|
|
2807
|
+
// already consistent with the text body. Falls back to an
|
|
2808
|
+
// empty array when mention-picker.js hasn't loaded yet.
|
|
2809
|
+
const mentions = (window.MentionPicker && typeof window.MentionPicker.consumePendingMentions === "function")
|
|
2810
|
+
? window.MentionPicker.consumePendingMentions(text)
|
|
2811
|
+
: [];
|
|
2667
2812
|
input.value = "";
|
|
2668
2813
|
// Shrink the textarea back to its single-line baseline ·
|
|
2669
2814
|
// typed content may have grown the field, and the cleared
|
|
@@ -2673,7 +2818,7 @@
|
|
|
2673
2818
|
if (input.matches?.(".ib-textarea[data-send-input]")) {
|
|
2674
2819
|
this.autosizeRoomInputTextarea();
|
|
2675
2820
|
}
|
|
2676
|
-
this.sendMessage(text).catch((err) => alert("Send failed: " + err.message));
|
|
2821
|
+
this.sendMessage(text, mentions).catch((err) => alert("Send failed: " + err.message));
|
|
2677
2822
|
return true;
|
|
2678
2823
|
},
|
|
2679
2824
|
|
|
@@ -2894,20 +3039,25 @@
|
|
|
2894
3039
|
} catch { /* private-mode etc. — silently ignore */ }
|
|
2895
3040
|
},
|
|
2896
3041
|
|
|
2897
|
-
/**
|
|
2898
|
-
*
|
|
2899
|
-
*
|
|
2900
|
-
*
|
|
2901
|
-
*
|
|
2902
|
-
*
|
|
2903
|
-
*
|
|
2904
|
-
*
|
|
2905
|
-
* pre-flight gate (topic-recs trigger, convene a room, etc.)
|
|
2906
|
-
* even though every model with a baiId is fully reachable. */
|
|
3042
|
+
/** True iff the user has at least one LLM credential AND one is
|
|
3043
|
+
* flagged active. LLM credentials live in their own table
|
|
3044
|
+
* (multi-instance, see `/api/credentials`) — they're NOT in
|
|
3045
|
+
* `this.keys`, which only tracks voice + skill keys from
|
|
3046
|
+
* `/api/keys`. Read through the shared `/api/models` cache so
|
|
3047
|
+
* the server-side authority (active credential → reachable
|
|
3048
|
+
* provider) wins; fall back to the local keys-store when the
|
|
3049
|
+
* models cache hasn't loaded yet (cold start). */
|
|
2907
3050
|
hasAnyModelKey() {
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
3051
|
+
try {
|
|
3052
|
+
const cache = (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
|
|
3053
|
+
if (cache && typeof cache.hasAnyKey === "boolean") return cache.hasAnyKey;
|
|
3054
|
+
} catch (_) { /* */ }
|
|
3055
|
+
try {
|
|
3056
|
+
const creds = (window.keysStore && Array.isArray(window.keysStore.llmCredentials))
|
|
3057
|
+
? window.keysStore.llmCredentials : [];
|
|
3058
|
+
const activeId = window.keysStore ? window.keysStore.activeLlmCredentialId : null;
|
|
3059
|
+
return creds.length > 0 && !!activeId && creds.some((c) => c.id === activeId);
|
|
3060
|
+
} catch (_) { return false; }
|
|
2911
3061
|
},
|
|
2912
3062
|
|
|
2913
3063
|
/** Pure cache read · true when at least one voice provider
|
|
@@ -2988,8 +3138,12 @@
|
|
|
2988
3138
|
/** Modal · "an agent is speaking · interrupt or wait?". Mirrors the
|
|
2989
3139
|
* pause-choice overlay's chrome (.pc-overlay / .pc-modal) so the
|
|
2990
3140
|
* visual treatment is consistent across modal prompts. */
|
|
2991
|
-
openSendChoiceModal(text) {
|
|
3141
|
+
openSendChoiceModal(text, mentions) {
|
|
2992
3142
|
this.closeSendChoiceModal();
|
|
3143
|
+
// Stash mentions alongside text so handleSendChoice forwards
|
|
3144
|
+
// them on interrupt / queue. Falls back to [] when called from
|
|
3145
|
+
// legacy / non-mention paths.
|
|
3146
|
+
this._sendChoiceMentions = Array.isArray(mentions) ? mentions.slice() : [];
|
|
2993
3147
|
const speaker = this.currentQueue[0]
|
|
2994
3148
|
? this.agentsById[this.currentQueue[0].agentId]
|
|
2995
3149
|
: null;
|
|
@@ -3036,10 +3190,12 @@
|
|
|
3036
3190
|
if (el) el.remove();
|
|
3037
3191
|
this._sendChoiceText = null;
|
|
3038
3192
|
this._sendChoiceSpeakerId = null;
|
|
3193
|
+
this._sendChoiceMentions = null;
|
|
3039
3194
|
},
|
|
3040
3195
|
handleSendChoice(choice) {
|
|
3041
3196
|
const text = this._sendChoiceText || "";
|
|
3042
3197
|
const speakerId = this._sendChoiceSpeakerId;
|
|
3198
|
+
const mentions = Array.isArray(this._sendChoiceMentions) ? this._sendChoiceMentions.slice() : [];
|
|
3043
3199
|
const input = document.querySelector('.input-bar input, [data-send-input]');
|
|
3044
3200
|
this.closeSendChoiceModal();
|
|
3045
3201
|
if (choice === "cancel" || !text.trim()) return;
|
|
@@ -3047,7 +3203,7 @@
|
|
|
3047
3203
|
input.value = "";
|
|
3048
3204
|
}
|
|
3049
3205
|
if (choice === "interrupt") {
|
|
3050
|
-
this.sendMessage(text).catch((err) => alert("Send failed: " + err.message));
|
|
3206
|
+
this.sendMessage(text, mentions).catch((err) => alert("Send failed: " + err.message));
|
|
3051
3207
|
return;
|
|
3052
3208
|
}
|
|
3053
3209
|
if (choice === "queue") {
|
|
@@ -3063,7 +3219,7 @@
|
|
|
3063
3219
|
// turns, AFTER current speaker finishes and BEFORE the next
|
|
3064
3220
|
// speaker starts. The placeholder clears when the
|
|
3065
3221
|
// message-appended SSE comes back.
|
|
3066
|
-
this.sendMessage(text,
|
|
3222
|
+
this.sendMessage(text, mentions, "after-speaker").catch((err) => {
|
|
3067
3223
|
alert("Queue failed: " + err.message);
|
|
3068
3224
|
this.pendingUserMessage = null;
|
|
3069
3225
|
this.pendingForSpeakerId = null;
|
|
@@ -6616,19 +6772,10 @@
|
|
|
6616
6772
|
const raw = localStorage.getItem("boardroom.composer");
|
|
6617
6773
|
if (raw) saved = JSON.parse(raw);
|
|
6618
6774
|
} catch { /* ignore */ }
|
|
6619
|
-
// seedContext · pre-fetched web snippets the user attached
|
|
6620
|
-
// by clicking a topic-rec card on the home composer. Round-
|
|
6621
|
-
// tripped through localStorage so a page refresh between
|
|
6622
|
-
// pick and convene doesn't lose the attached source
|
|
6623
|
-
// material. Shape: { topicRecId?: string, snippets?: [] }.
|
|
6624
|
-
const savedSeed = saved && typeof saved.seedContext === "object" && saved.seedContext
|
|
6625
|
-
? saved.seedContext
|
|
6626
|
-
: null;
|
|
6627
6775
|
this.composerState = {
|
|
6628
6776
|
...this.DEFAULT_COMPOSER,
|
|
6629
6777
|
...(saved || {}),
|
|
6630
6778
|
subject: (saved && typeof saved.subject === "string") ? saved.subject : "",
|
|
6631
|
-
seedContext: savedSeed,
|
|
6632
6779
|
};
|
|
6633
6780
|
return this.composerState;
|
|
6634
6781
|
},
|
|
@@ -6636,10 +6783,10 @@
|
|
|
6636
6783
|
saveComposerState() {
|
|
6637
6784
|
if (!this.composerState) return;
|
|
6638
6785
|
try {
|
|
6639
|
-
const { directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject
|
|
6786
|
+
const { directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject } = this.composerState;
|
|
6640
6787
|
localStorage.setItem(
|
|
6641
6788
|
"boardroom.composer",
|
|
6642
|
-
JSON.stringify({ directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject
|
|
6789
|
+
JSON.stringify({ directorIds, mode, intensity, deliveryMode, autoPickDirectors, subject }),
|
|
6643
6790
|
);
|
|
6644
6791
|
} catch { /* ignore */ }
|
|
6645
6792
|
},
|
|
@@ -6682,15 +6829,6 @@
|
|
|
6682
6829
|
// overlay — typing in this view + Enter creates the room.
|
|
6683
6830
|
const head = document.querySelector("[data-room-head]");
|
|
6684
6831
|
if (head) head.innerHTML = ""; // CSS hides via html.no-room
|
|
6685
|
-
// One-shot lazy fetch · the composer's "or try a starter"
|
|
6686
|
-
// tray renders the latest topic recommendations when any
|
|
6687
|
-
// exist (replacing the legacy hardcoded starters). Skipping
|
|
6688
|
-
// when already loaded keeps re-renders cheap; the trigger
|
|
6689
|
-
// button's SSE path explicitly refreshes after a successful
|
|
6690
|
-
// generation so the list lands without polling.
|
|
6691
|
-
if (!this.topicRecs.loaded) {
|
|
6692
|
-
void this.refreshTopicRecs();
|
|
6693
|
-
}
|
|
6694
6832
|
|
|
6695
6833
|
// Strip stale follow-up fragments inserted by renderFollowUp-
|
|
6696
6834
|
// Fragments() when a follow-up room was previously open. These
|
|
@@ -6789,7 +6927,7 @@
|
|
|
6789
6927
|
* centred. Called after composer render and on window resize.
|
|
6790
6928
|
*
|
|
6791
6929
|
* The 0.7 threshold (was "strictly > viewport") catches the
|
|
6792
|
-
* in-between case where the input +
|
|
6930
|
+
* in-between case where the input + scenario ad cards fit
|
|
6793
6931
|
* vertically but only just — centred layout would visually
|
|
6794
6932
|
* cram the hero against the top edge. Flipping to overflow
|
|
6795
6933
|
* mode at 70% gives the hero comfortable breathing room
|
|
@@ -7681,17 +7819,23 @@
|
|
|
7681
7819
|
sparkles + horizon ticks + CSS scanlines (in CSS).
|
|
7682
7820
|
Hidden in is-initial via opacity. -->
|
|
7683
7821
|
<div class="search-results-deco">${RESULTS_DECO_SVG}</div>
|
|
7684
|
-
<!-- Hero ·
|
|
7685
|
-
|
|
7686
|
-
|
|
7822
|
+
<!-- Hero · matches the new-room composer's two-row header ·
|
|
7823
|
+
a mono caps greeting (cmp-greet · "Good afternoon, Kay")
|
|
7824
|
+
above the headline. The old "across every room ..." sub-
|
|
7825
|
+
line moved INSIDE the search-card's topbar so the card
|
|
7826
|
+
itself surfaces its scope, matching .cmp-input-frame. -->
|
|
7687
7827
|
<div class="search-hero">
|
|
7828
|
+
<div class="cmp-greet">${this.escape(this.composerGreeting(this.composerLanguage(), (this.prefs?.name || "you").trim() || "you"))}</div>
|
|
7688
7829
|
<h1 class="search-hero-title">Search every conversation</h1>
|
|
7689
|
-
<p class="search-hero-sub">across every room · keyword · message body · room name</p>
|
|
7690
7830
|
</div>
|
|
7691
|
-
<!-- Card · is-initial =
|
|
7692
|
-
|
|
7693
|
-
meta beside it,
|
|
7831
|
+
<!-- Card · is-initial = framed input with a brand-tinted
|
|
7832
|
+
topbar hosting the scope hint, then the input below;
|
|
7833
|
+
has-results = flex row with the meta beside it, topbar
|
|
7834
|
+
collapsed via CSS. Mirrors new-room .cmp-input-frame. -->
|
|
7694
7835
|
<div class="search-card${lastQuery ? "" : " is-empty"}" data-search-card>
|
|
7836
|
+
<div class="search-topbar">
|
|
7837
|
+
<span class="search-topbar-hint">across every room · keyword · message body · room name</span>
|
|
7838
|
+
</div>
|
|
7695
7839
|
<div class="search-input-wrap">
|
|
7696
7840
|
<span class="search-input-icon" aria-hidden="true">
|
|
7697
7841
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
|
@@ -9159,6 +9303,10 @@
|
|
|
9159
9303
|
this.agentSpec = null;
|
|
9160
9304
|
this.agentSpecGenerating = false;
|
|
9161
9305
|
}
|
|
9306
|
+
// Scenario placeholder hint is per-visit · drop it on any
|
|
9307
|
+
// composer-mode change so the next render shows the default
|
|
9308
|
+
// framing copy unless the user clicks a scenario again.
|
|
9309
|
+
this._scenarioPlaceholder = null;
|
|
9162
9310
|
// Make sure the room main view is the visible one — otherwise
|
|
9163
9311
|
// our composer would render into a hidden container. Three
|
|
9164
9312
|
// possible "previous" states: agent profile, all-reports, or a
|
|
@@ -9235,6 +9383,15 @@
|
|
|
9235
9383
|
* was retired at the user's request — it was a CTA-style nudge
|
|
9236
9384
|
* redundant with the heading question above the textarea. */
|
|
9237
9385
|
_composerSubjectPlaceholder() {
|
|
9386
|
+
// Scenario cards stash their starter subject here when
|
|
9387
|
+
// clicked · we surface it as the placeholder so the user
|
|
9388
|
+
// sees the suggested framing without it overwriting any
|
|
9389
|
+
// draft they've already typed. Cleared on submit and on
|
|
9390
|
+
// composer-mode switch so it doesn't follow the user
|
|
9391
|
+
// across sessions / view changes.
|
|
9392
|
+
if (typeof this._scenarioPlaceholder === "string" && this._scenarioPlaceholder.length > 0) {
|
|
9393
|
+
return this._scenarioPlaceholder;
|
|
9394
|
+
}
|
|
9238
9395
|
return "Convening a room can stress-test a thesis, force a decision you've been avoiding, or surface the angle nobody on your real team brings.";
|
|
9239
9396
|
},
|
|
9240
9397
|
|
|
@@ -9435,8 +9592,6 @@
|
|
|
9435
9592
|
placeholder: this._composerSubjectPlaceholder(),
|
|
9436
9593
|
convene: "Convene",
|
|
9437
9594
|
tuneLabel: "tune",
|
|
9438
|
-
starterLabel: "starter",
|
|
9439
|
-
starterCaption: "or try a starter",
|
|
9440
9595
|
pickerLabel: "Pick directors",
|
|
9441
9596
|
directorsLabel: (n) => `${n} director${n === 1 ? "" : "s"}`,
|
|
9442
9597
|
directorsAdd: "add",
|
|
@@ -9494,92 +9649,22 @@
|
|
|
9494
9649
|
// unmistakeably a select) without taking up visual real estate.
|
|
9495
9650
|
const toneLbl = this._t("cmp_tone_label");
|
|
9496
9651
|
const intensityLbl = this._t("cmp_intensity_label");
|
|
9497
|
-
//
|
|
9498
|
-
//
|
|
9499
|
-
//
|
|
9500
|
-
//
|
|
9501
|
-
//
|
|
9502
|
-
//
|
|
9503
|
-
// the rec's subject + attaches its seedContext
|
|
9504
|
-
// snippets so the room opens grounded in the source
|
|
9505
|
-
// material.
|
|
9506
|
-
// 2. Legacy hardcoded starters from window.BOARDROOM_STARTERS.
|
|
9507
|
-
// Show only when there are no recommendations yet (or
|
|
9508
|
-
// when recs are still loading on first paint) so a
|
|
9509
|
-
// brand-new user sees something useful in the tray.
|
|
9510
|
-
const recs = (this.topicRecs.items || []).slice(0, 5);
|
|
9511
|
-
// Card markup lives in `topicRecCardHtml` so the
|
|
9512
|
-
// initial render path and any later append paths can't
|
|
9513
|
-
// drift in shape / attributes.
|
|
9514
|
-
const recCards = recs.map((rec) => this.topicRecCardHtml(rec)).join("");
|
|
9515
|
-
|
|
9516
|
-
const showLegacyStarters = recs.length === 0;
|
|
9517
|
-
const starters = showLegacyStarters && Array.isArray(window.BOARDROOM_STARTERS)
|
|
9518
|
-
? window.BOARDROOM_STARTERS
|
|
9519
|
-
: [];
|
|
9520
|
-
const starterCards = starters.map((q, idx) => {
|
|
9521
|
-
const tag = (q.tag || "").replace(/^\/\/\s*/, "");
|
|
9522
|
-
return `
|
|
9523
|
-
<button type="button" class="cmp-starter" data-composer-starter="${idx}">
|
|
9524
|
-
<div class="cmp-starter-tag">${this.escape(tag)}</div>
|
|
9525
|
-
<div class="cmp-starter-text">${this.escape(q.text || "")}</div>
|
|
9526
|
-
<div class="cmp-starter-arrow">→</div>
|
|
9527
|
-
</button>
|
|
9528
|
-
`;
|
|
9529
|
-
}).join("");
|
|
9530
|
-
// Trigger card · disguised as the first row in the
|
|
9531
|
-
// starters grid, so the "recommend" action lives in the
|
|
9532
|
-
// same visual rhythm as the suggestions it produces. The
|
|
9533
|
-
// card has TWO states wired through the same DOM hooks
|
|
9534
|
-
// ([data-trec-label] / [data-trec-detail] / [data-trec-pct]
|
|
9535
|
-
// / [data-trec-bar]) so the SSE handler can patch them
|
|
9536
|
-
// in place without re-rendering the whole composer:
|
|
9537
|
-
// · IDLE (no job) · tag = "✦ discover", text = the
|
|
9538
|
-
// localised label (EN/ZH via i18n), arrow = "+".
|
|
9539
|
-
// Looks like a starter but with a lime accent +
|
|
9540
|
-
// dashed bottom rule.
|
|
9541
|
-
// · BUSY (job live) · tag = phase label (LLM-emitted,
|
|
9542
|
-
// stays English — those come from the orchestrator's
|
|
9543
|
-
// phaseLabels constant), text = animated dots +
|
|
9544
|
-
// phase detail, arrow = "N%". A thin lime progress
|
|
9545
|
-
// bar lives along the bottom edge and fills as the
|
|
9546
|
-
// pipeline ticks.
|
|
9652
|
+
// Scenario ad cards · 5 hand-picked use-cases that double
|
|
9653
|
+
// as long-form promotional units AND one-click room
|
|
9654
|
+
// presets. Each card autoconfigures tone + intensity + a
|
|
9655
|
+
// sensible 3-director cast, then drops a starter subject
|
|
9656
|
+
// into the composer so the user lands on a working room
|
|
9657
|
+
// shape instead of staring at a blank form.
|
|
9547
9658
|
//
|
|
9548
|
-
//
|
|
9549
|
-
//
|
|
9550
|
-
//
|
|
9551
|
-
//
|
|
9552
|
-
//
|
|
9553
|
-
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
|
-
const
|
|
9557
|
-
const triggerStarting = this._t("cmp_recs_trigger_starting");
|
|
9558
|
-
const triggerCard = `
|
|
9559
|
-
<button type="button"
|
|
9560
|
-
class="cmp-starter cmp-recs-trigger-card${triggerBusy ? " is-busy" : ""}"
|
|
9561
|
-
data-cmp-recs-trigger
|
|
9562
|
-
data-topic-rec-progress
|
|
9563
|
-
${triggerBusy ? "disabled" : ""}
|
|
9564
|
-
title="${this.escape(triggerLabel)}">
|
|
9565
|
-
<div class="cmp-starter-tag" data-trec-label>${this.escape(triggerBusy ? (job.label || triggerStarting) : `✦ ${triggerTag}`)}</div>
|
|
9566
|
-
<div class="cmp-starter-text">
|
|
9567
|
-
${triggerBusy
|
|
9568
|
-
? `<span class="cmp-recs-trigger-dots" aria-hidden="true"><i></i><i></i><i></i></span><span data-trec-detail>${this.escape(job.detail || "")}</span>`
|
|
9569
|
-
: `<span>${this.escape(triggerLabel)}</span>`}
|
|
9570
|
-
</div>
|
|
9571
|
-
<div class="cmp-starter-arrow" data-trec-pct>${this.escape(triggerBusy ? (job.pct ? `${job.pct}%` : "·") : "+")}</div>
|
|
9572
|
-
<div class="cmp-recs-trigger-bar" aria-hidden="true"><div class="cmp-recs-trigger-fill" data-trec-bar style="width: ${triggerBusy ? (job.pct || 0) : 0}%"></div></div>
|
|
9573
|
-
</button>
|
|
9574
|
-
`;
|
|
9575
|
-
|
|
9576
|
-
// Trigger card always leads; suggestions (or legacy
|
|
9577
|
-
// starters as empty-state fallback) follow.
|
|
9578
|
-
const trayCards = triggerCard + (recs.length > 0 ? recCards : starterCards);
|
|
9579
|
-
// Pagination is intentionally OFF — the server keeps only
|
|
9580
|
-
// the latest batch (6 rows). Every fresh generation wipes
|
|
9581
|
-
// the previous batch, so there's never "older" data to
|
|
9582
|
-
// page back to. The "+ N more" surface is gone.
|
|
9659
|
+
// Card model (kept in one place so copy + visuals + click
|
|
9660
|
+
// behaviour stay aligned · see this.SCENARIO_CARDS):
|
|
9661
|
+
// { id, icon, tag, headline, body, tonePill, directors,
|
|
9662
|
+
// starterSubject, tone, intensity }
|
|
9663
|
+
// The button is a single click target; the inner avatar
|
|
9664
|
+
// strip uses [data-agent-profile] (handled in capture by
|
|
9665
|
+
// agent-profile.js) so users can still inspect a director
|
|
9666
|
+
// without launching the scenario.
|
|
9667
|
+
const scenarioCards = this.scenarioAdCardsHtml();
|
|
9583
9668
|
|
|
9584
9669
|
return `
|
|
9585
9670
|
<section class="cmp">
|
|
@@ -9672,13 +9757,13 @@
|
|
|
9672
9757
|
</div>
|
|
9673
9758
|
</div>
|
|
9674
9759
|
|
|
9675
|
-
<div class="cmp-
|
|
9760
|
+
<div class="cmp-scenarios">
|
|
9676
9761
|
<div class="cmp-starters-rule">
|
|
9677
9762
|
<span class="cmp-starters-rule-line"></span>
|
|
9678
|
-
<span class="cmp-starters-rule-label">${this.escape(
|
|
9763
|
+
<span class="cmp-starters-rule-label">${this.escape(this._t("cmp_scenarios_caption"))}</span>
|
|
9679
9764
|
<span class="cmp-starters-rule-line"></span>
|
|
9680
9765
|
</div>
|
|
9681
|
-
<div class="cmp-
|
|
9766
|
+
<div class="cmp-scenarios-stack">${scenarioCards}</div>
|
|
9682
9767
|
</div>
|
|
9683
9768
|
</section>
|
|
9684
9769
|
`;
|
|
@@ -9785,19 +9870,16 @@
|
|
|
9785
9870
|
}
|
|
9786
9871
|
},
|
|
9787
9872
|
|
|
9788
|
-
/** Tiny route
|
|
9789
|
-
*
|
|
9790
|
-
*
|
|
9791
|
-
*
|
|
9792
|
-
|
|
9793
|
-
|
|
9794
|
-
|
|
9795
|
-
const
|
|
9796
|
-
|
|
9797
|
-
|
|
9798
|
-
if (d) return "direct";
|
|
9799
|
-
if (o) return "OR";
|
|
9800
|
-
return "";
|
|
9873
|
+
/** Tiny route caption · returns "via {provider}" when the active
|
|
9874
|
+
* LLM provider is a multi-model carrier (openrouter / bai), or
|
|
9875
|
+
* "" when single-model active (the per-group header already
|
|
9876
|
+
* carries the provider identity, no caption needed). */
|
|
9877
|
+
modelRouteCaption() {
|
|
9878
|
+
const cache = (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
|
|
9879
|
+
const active = cache && cache.activeLlmProvider;
|
|
9880
|
+
const cls = cache && cache.activeLlmClassification;
|
|
9881
|
+
if (!active || cls !== "multi-model") return "";
|
|
9882
|
+
return "via " + this.providerLabel(active);
|
|
9801
9883
|
},
|
|
9802
9884
|
|
|
9803
9885
|
loadAgentComposerModel() {
|
|
@@ -9921,20 +10003,47 @@
|
|
|
9921
10003
|
});
|
|
9922
10004
|
},
|
|
9923
10005
|
|
|
9924
|
-
/**
|
|
9925
|
-
*
|
|
9926
|
-
*
|
|
9927
|
-
|
|
10006
|
+
/** Card HTML for one celebrity seed. Avatar comes from
|
|
10007
|
+
* AvatarSkill (deterministic from seed.id), so the portrait
|
|
10008
|
+
* is the same every render. Intro picks the en/zh field
|
|
10009
|
+
* that matches the active locale; ja/es fall back to en. */
|
|
10010
|
+
celebrityCardHtml(seed) {
|
|
9928
10011
|
const lang = this.composerLanguage();
|
|
9929
|
-
const
|
|
9930
|
-
const
|
|
9931
|
-
|
|
9932
|
-
|
|
9933
|
-
|
|
9934
|
-
|
|
9935
|
-
|
|
9936
|
-
|
|
9937
|
-
|
|
10012
|
+
const intro = (seed.intro && (seed.intro[lang] || seed.intro.en || "")) || "";
|
|
10013
|
+
const avatarUrl = (window.AvatarSkill && typeof window.AvatarSkill.generateDataUrl === "function")
|
|
10014
|
+
? window.AvatarSkill.generateDataUrl(seed.id)
|
|
10015
|
+
: "";
|
|
10016
|
+
const avatarHtml = avatarUrl
|
|
10017
|
+
? `<img class="cmp-celeb-img" src="${this.escape(avatarUrl)}" alt="${this.escape(seed.name)}">`
|
|
10018
|
+
: `<span class="cmp-celeb-img-fallback" aria-hidden="true">·</span>`;
|
|
10019
|
+
return `
|
|
10020
|
+
<button type="button" class="cmp-celeb-card" data-celebrity-seed="${this.escape(seed.id)}" title="${this.escape(seed.name)}">
|
|
10021
|
+
<span class="cmp-celeb-av">${avatarHtml}</span>
|
|
10022
|
+
<span class="cmp-celeb-body">
|
|
10023
|
+
<span class="cmp-celeb-name">${this.escape(seed.name)}</span>
|
|
10024
|
+
<span class="cmp-celeb-role">${this.escape(seed.roleTag || "")}</span>
|
|
10025
|
+
<span class="cmp-celeb-intro">${this.escape(intro)}</span>
|
|
10026
|
+
</span>
|
|
10027
|
+
</button>
|
|
10028
|
+
`;
|
|
10029
|
+
},
|
|
10030
|
+
|
|
10031
|
+
/** Click handler · kick the full-mode persona builder with the
|
|
10032
|
+
* celebrity's seed description, tag the live job with the seed
|
|
10033
|
+
* id so the save-success callback can mark it consumed. */
|
|
10034
|
+
applyCelebritySeed(id) {
|
|
10035
|
+
if (typeof id !== "string" || !id) return;
|
|
10036
|
+
const pool = this._celebrityPool();
|
|
10037
|
+
const seed = pool.find((s) => s.id === id);
|
|
10038
|
+
if (!seed) return;
|
|
10039
|
+
// Persist the description as the recovery draft (so a failed
|
|
10040
|
+
// build can be retried via the existing error-card flow),
|
|
10041
|
+
// then start the build. After startFullPersonaBuild() returns,
|
|
10042
|
+
// `this.personaJob` is populated; tag it.
|
|
10043
|
+
this._agentComposerLastDesc = seed.description;
|
|
10044
|
+
this.startFullPersonaBuild(seed.description).then(() => {
|
|
10045
|
+
if (this.personaJob) this.personaJob.celebritySeedId = seed.id;
|
|
10046
|
+
}).catch(() => { /* startFullPersonaBuild surfaces its own error */ });
|
|
9938
10047
|
},
|
|
9939
10048
|
|
|
9940
10049
|
/** Stages shown during agent generation · each ticks active → done
|
|
@@ -10150,21 +10259,246 @@
|
|
|
10150
10259
|
`;
|
|
10151
10260
|
},
|
|
10152
10261
|
|
|
10153
|
-
/**
|
|
10154
|
-
*
|
|
10155
|
-
|
|
10156
|
-
|
|
10157
|
-
|
|
10158
|
-
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
|
|
10262
|
+
/** Celebrity seed cards · 12 hand-curated famous figures that
|
|
10263
|
+
* act as one-click presets for the full-mode persona builder.
|
|
10264
|
+
* Click → `applyCelebritySeed(id)` → `startFullPersonaBuild(seed.description)`
|
|
10265
|
+
* → existing 7-phase build → confirmation overlay → save.
|
|
10266
|
+
* On save success the seed id is marked consumed and never
|
|
10267
|
+
* re-offered (see `_markCelebritySeedConsumed`). Pool stays
|
|
10268
|
+
* topped up via the LLM-driven `_topUpCelebritySeeds` path.
|
|
10269
|
+
*
|
|
10270
|
+
* Per-entry shape:
|
|
10271
|
+
* · id · kebab-slug · doubles as `AvatarSkill` seed
|
|
10272
|
+
* (deterministic 8-bit portrait)
|
|
10273
|
+
* · name · verbatim, no i18n (proper nouns)
|
|
10274
|
+
* · roleTag · short mono tag · kept English to match the
|
|
10275
|
+
* mono kicker register used elsewhere
|
|
10276
|
+
* · intro · { en, zh } one-line tagline shown on the card;
|
|
10277
|
+
* ja / es fall back to en (existing pattern)
|
|
10278
|
+
* · description · seed text handed to `startFullPersonaBuild`;
|
|
10279
|
+
* drives the full ReAct + 7-phase synthesis */
|
|
10280
|
+
CELEBRITY_SEEDS_V1: [
|
|
10281
|
+
{
|
|
10282
|
+
id: "elon-musk",
|
|
10283
|
+
name: "Elon Musk",
|
|
10284
|
+
roleTag: "founder",
|
|
10285
|
+
intro: {
|
|
10286
|
+
en: "First-principles industrialist · electric / orbital / planetary",
|
|
10287
|
+
zh: "第一性原理产业家 · 电动 / 轨道 / 行星尺度",
|
|
10288
|
+
},
|
|
10289
|
+
description: "A first-principles industrialist in the mold of Elon Musk — strips problems to physics, picks impossible-looking goals on civilizational time horizons (electric ground transport, orbital launch reuse, planetary multi-species existence), ships through aggressive execution. Refuses any plan whose constraints are 'industry standard' rather than physically forced.",
|
|
10290
|
+
},
|
|
10291
|
+
{
|
|
10292
|
+
id: "marc-andreessen",
|
|
10293
|
+
name: "Marc Andreessen",
|
|
10294
|
+
roleTag: "venture",
|
|
10295
|
+
intro: {
|
|
10296
|
+
en: "Tech-optimist venture partner · software is eating the world",
|
|
10297
|
+
zh: "技术乐观主义风险投资人 · 软件正在吞噬世界",
|
|
10298
|
+
},
|
|
10299
|
+
description: "A tech-optimist venture partner in the mold of Marc Andreessen — believes software is eating every industry, hunts for category-defining founders, frames every market in terms of long-arc compounding. Pushes founders to think 10x, distrusts incrementalism, treats history of tech disruption as the only valid reference class.",
|
|
10300
|
+
},
|
|
10301
|
+
{
|
|
10302
|
+
id: "paul-graham",
|
|
10303
|
+
name: "Paul Graham",
|
|
10304
|
+
roleTag: "essayist",
|
|
10305
|
+
intro: {
|
|
10306
|
+
en: "Startup essayist · make something people want",
|
|
10307
|
+
zh: "创业散文家 · 做用户真的想要的东西",
|
|
10308
|
+
},
|
|
10309
|
+
description: "An essayist-investor in the mold of Paul Graham — writes in clear short sentences, reasons from concrete observations of founder behaviour, distrusts hype and corporate vocabulary. Reduces strategy to two tests: do users love it, and is the team relentlessly resourceful. Treats writing as a debugging tool for thinking.",
|
|
10310
|
+
},
|
|
10311
|
+
{
|
|
10312
|
+
id: "wittgenstein",
|
|
10313
|
+
name: "Ludwig Wittgenstein",
|
|
10314
|
+
roleTag: "philosopher",
|
|
10315
|
+
intro: {
|
|
10316
|
+
en: "Language philosopher · the limits of my language are the limits of my world",
|
|
10317
|
+
zh: "语言哲学家 · 我语言的界限即我世界的界限",
|
|
10318
|
+
},
|
|
10319
|
+
description: "A language philosopher in the mold of Ludwig Wittgenstein — refuses any argument until the meaning of each word is shown in actual use. Treats philosophical problems as confusions of grammar, not deep mysteries. Pushes the room to describe what is happening rather than what we think is happening. Will stop a discussion cold to ask 'what would count as evidence that we are wrong'.",
|
|
10320
|
+
},
|
|
10321
|
+
{
|
|
10322
|
+
id: "charlie-munger",
|
|
10323
|
+
name: "Charlie Munger",
|
|
10324
|
+
roleTag: "investor",
|
|
10325
|
+
intro: {
|
|
10326
|
+
en: "Mental-models compounder · invert, always invert",
|
|
10327
|
+
zh: "心智模型复利大师 · 反过来想,永远反过来想",
|
|
10328
|
+
},
|
|
10329
|
+
description: "A latticework-of-mental-models investor in the mold of Charlie Munger — assembles answers by combining biology, history, psychology, and engineering. Inverts every question (what would make us fail?), insists on multidisciplinary referents, dismisses any argument that depends on a single discipline's framing. Patient, dry, allergic to the institutional imperative.",
|
|
10330
|
+
},
|
|
10331
|
+
{
|
|
10332
|
+
id: "steve-jobs",
|
|
10333
|
+
name: "Steve Jobs",
|
|
10334
|
+
roleTag: "product",
|
|
10335
|
+
intro: {
|
|
10336
|
+
en: "Product taste-maker · design is how it works",
|
|
10337
|
+
zh: "产品品味教父 · 设计是它如何运作",
|
|
10338
|
+
},
|
|
10339
|
+
description: "A product taste-maker in the mold of Steve Jobs — believes great products come from saying no to a thousand things, that taste is a real skill, that the user's hand-and-eye experience IS the product. Treats every feature decision as a question of what to cut. Refuses any spec that doesn't survive a 5-second hands-on demo.",
|
|
10340
|
+
},
|
|
10341
|
+
{
|
|
10342
|
+
id: "wang-dingding",
|
|
10343
|
+
name: "汪丁丁",
|
|
10344
|
+
roleTag: "economist",
|
|
10345
|
+
intro: {
|
|
10346
|
+
en: "Institutional economist · ideas first, institutions follow",
|
|
10347
|
+
zh: "制度经济学家 · 思想先行,制度跟随",
|
|
10348
|
+
},
|
|
10349
|
+
description: "An institutional economist in the mold of 汪丁丁 — reasons across economics, philosophy, and Chinese intellectual history. Treats every policy question as a question of what kind of human being the institution produces over time. Refuses purely quantitative framings; insists the room name the implicit anthropology behind any proposal.",
|
|
10350
|
+
},
|
|
10351
|
+
{
|
|
10352
|
+
id: "naval-ravikant",
|
|
10353
|
+
name: "Naval Ravikant",
|
|
10354
|
+
roleTag: "philosopher",
|
|
10355
|
+
intro: {
|
|
10356
|
+
en: "Modern philosopher of wealth · seek leverage, not effort",
|
|
10357
|
+
zh: "现代财富哲学家 · 追求杠杆,不要苦劳",
|
|
10358
|
+
},
|
|
10359
|
+
description: "A modern aphorist in the mold of Naval Ravikant — compresses arguments to single tweet-length lines, frames every life decision in terms of leverage (capital, labour, code, media), specific knowledge, and accountability. Distrusts traditional career narratives, treats reading and reflection as the primary capital-allocation activities.",
|
|
10360
|
+
},
|
|
10361
|
+
{
|
|
10362
|
+
id: "peter-thiel",
|
|
10363
|
+
name: "Peter Thiel",
|
|
10364
|
+
roleTag: "contrarian",
|
|
10365
|
+
intro: {
|
|
10366
|
+
en: "Contrarian VC · what important truth do very few agree with you on",
|
|
10367
|
+
zh: "反共识 VC · 你相信什么很少有人同意的重要真理",
|
|
10368
|
+
},
|
|
10369
|
+
description: "A contrarian venture capitalist in the mold of Peter Thiel — opens every conversation with 'what important truth do very few people agree with you on', treats competition as wasteful, hunts for monopolies and definite optimism. Distrusts consensus, refuses to validate ideas on social proof, reads history as a sequence of contingent crossings rather than progress.",
|
|
10370
|
+
},
|
|
10371
|
+
{
|
|
10372
|
+
id: "richard-feynman",
|
|
10373
|
+
name: "Richard Feynman",
|
|
10374
|
+
roleTag: "physicist",
|
|
10375
|
+
intro: {
|
|
10376
|
+
en: "Intuitive physicist · what I cannot create, I do not understand",
|
|
10377
|
+
zh: "直觉物理学家 · 我不能创造的,我就不理解",
|
|
10378
|
+
},
|
|
10379
|
+
description: "A first-principles physicist-teacher in the mold of Richard Feynman — explains hard ideas in everyday words, refuses to use jargon as a shield, demands the simplest experiment that would falsify any claim. Treats not understanding something as the most exciting state to be in. Will derive answers from scratch rather than quote them.",
|
|
10380
|
+
},
|
|
10381
|
+
{
|
|
10382
|
+
id: "jensen-huang",
|
|
10383
|
+
name: "Jensen Huang",
|
|
10384
|
+
roleTag: "founder",
|
|
10385
|
+
intro: {
|
|
10386
|
+
en: "Accelerated-computing founder · the more you buy, the more you save",
|
|
10387
|
+
zh: "加速计算缔造者 · 越买越省",
|
|
10388
|
+
},
|
|
10389
|
+
description: "An accelerated-computing visionary in the mold of Jensen Huang — sees every computing problem through the lens of parallel hardware, picks platform-scale bets a decade ahead of demand, builds developer ecosystems as a moat. Treats software, hardware, and developer mind-share as a single optimisation problem. Patient with technology, impatient with execution.",
|
|
10390
|
+
},
|
|
10391
|
+
{
|
|
10392
|
+
id: "ray-dalio",
|
|
10393
|
+
name: "Ray Dalio",
|
|
10394
|
+
roleTag: "macro",
|
|
10395
|
+
intro: {
|
|
10396
|
+
en: "Principles-based macro investor · pain + reflection = progress",
|
|
10397
|
+
zh: "原则派宏观投资人 · 痛苦 + 反思 = 进步",
|
|
10398
|
+
},
|
|
10399
|
+
description: "A principles-based macro investor in the mold of Ray Dalio — frames every market call as a debt-cycle / productivity-cycle / political-cycle interaction, insists every decision is written down as a testable principle, treats radical transparency and idea-meritocracy as institutional disciplines. Refuses unstructured opinions; asks the room to make their reasoning auditable.",
|
|
10400
|
+
},
|
|
10162
10401
|
],
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10402
|
+
|
|
10403
|
+
/* ─── Celebrity seed · localStorage state + pool helpers ───
|
|
10404
|
+
Single key `boardroom.celebrity_seeds` carries two arrays:
|
|
10405
|
+
· consumed · ids of seeds the user has successfully built
|
|
10406
|
+
a director from. Never re-offered.
|
|
10407
|
+
· generated · LLM-produced extra seeds, appended over time
|
|
10408
|
+
when the pool falls below the 12 threshold.
|
|
10409
|
+
Pool = (CELEBRITY_SEEDS_V1 ∪ generated) − consumed. */
|
|
10410
|
+
_CELEBRITY_KEY: "boardroom.celebrity_seeds",
|
|
10411
|
+
_loadCelebrityState() {
|
|
10412
|
+
try {
|
|
10413
|
+
const raw = localStorage.getItem(this._CELEBRITY_KEY);
|
|
10414
|
+
if (!raw) return { consumed: [], generated: [] };
|
|
10415
|
+
const j = JSON.parse(raw);
|
|
10416
|
+
return {
|
|
10417
|
+
consumed: Array.isArray(j?.consumed) ? j.consumed.filter((x) => typeof x === "string") : [],
|
|
10418
|
+
generated: Array.isArray(j?.generated) ? j.generated.filter((x) => x && typeof x === "object" && typeof x.id === "string") : [],
|
|
10419
|
+
};
|
|
10420
|
+
} catch { return { consumed: [], generated: [] }; }
|
|
10421
|
+
},
|
|
10422
|
+
_saveCelebrityState(state) {
|
|
10423
|
+
try { localStorage.setItem(this._CELEBRITY_KEY, JSON.stringify(state)); }
|
|
10424
|
+
catch { /* swallow · localStorage may be locked */ }
|
|
10425
|
+
},
|
|
10426
|
+
/** Returns all seeds (v1 + generated) NOT in consumed[]. Used as
|
|
10427
|
+
* the source pool for the random pick on each render. */
|
|
10428
|
+
_celebrityPool() {
|
|
10429
|
+
const state = this._loadCelebrityState();
|
|
10430
|
+
const consumed = new Set(state.consumed);
|
|
10431
|
+
const all = [...this.CELEBRITY_SEEDS_V1, ...state.generated];
|
|
10432
|
+
// Deduplicate by id · LLM might occasionally collide with the
|
|
10433
|
+
// hand-curated v1 catalog despite the excludeIds preamble.
|
|
10434
|
+
const seen = new Set();
|
|
10435
|
+
const out = [];
|
|
10436
|
+
for (const s of all) {
|
|
10437
|
+
if (!s || typeof s.id !== "string") continue;
|
|
10438
|
+
if (consumed.has(s.id)) continue;
|
|
10439
|
+
if (seen.has(s.id)) continue;
|
|
10440
|
+
seen.add(s.id);
|
|
10441
|
+
out.push(s);
|
|
10442
|
+
}
|
|
10443
|
+
return out;
|
|
10444
|
+
},
|
|
10445
|
+
/** Fisher-Yates partial · pick up to n distinct entries from pool. */
|
|
10446
|
+
_pickRandomCelebrities(n) {
|
|
10447
|
+
const pool = this._celebrityPool().slice();
|
|
10448
|
+
const take = Math.min(n, pool.length);
|
|
10449
|
+
for (let i = 0; i < take; i++) {
|
|
10450
|
+
const j = i + Math.floor(Math.random() * (pool.length - i));
|
|
10451
|
+
const tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
|
|
10452
|
+
}
|
|
10453
|
+
return pool.slice(0, take);
|
|
10454
|
+
},
|
|
10455
|
+
/** Tag a seed as consumed (called after a successful persona
|
|
10456
|
+
* save). If the pool then dips below 12, fire-and-forget the
|
|
10457
|
+
* LLM top-up — silently swallowed on failure since the user's
|
|
10458
|
+
* primary action (creating a director) already succeeded. */
|
|
10459
|
+
_markCelebritySeedConsumed(id) {
|
|
10460
|
+
if (typeof id !== "string" || !id) return;
|
|
10461
|
+
const state = this._loadCelebrityState();
|
|
10462
|
+
if (!state.consumed.includes(id)) {
|
|
10463
|
+
state.consumed.push(id);
|
|
10464
|
+
this._saveCelebrityState(state);
|
|
10465
|
+
}
|
|
10466
|
+
if (this._celebrityPool().length < 12) {
|
|
10467
|
+
void this._topUpCelebritySeeds().catch(() => { /* swallow */ });
|
|
10468
|
+
}
|
|
10469
|
+
},
|
|
10470
|
+
/** POST /api/agents/celebrity-seed with all known ids (v1 +
|
|
10471
|
+
* generated + consumed) as excludeIds, append the returned
|
|
10472
|
+
* fresh seed to localStorage.generated. */
|
|
10473
|
+
async _topUpCelebritySeeds() {
|
|
10474
|
+
const state = this._loadCelebrityState();
|
|
10475
|
+
const excludeIds = [
|
|
10476
|
+
...this.CELEBRITY_SEEDS_V1.map((s) => s.id),
|
|
10477
|
+
...state.generated.map((s) => s.id),
|
|
10478
|
+
...state.consumed,
|
|
10479
|
+
];
|
|
10480
|
+
const r = await fetch("/api/agents/celebrity-seed", {
|
|
10481
|
+
method: "POST",
|
|
10482
|
+
headers: { "content-type": "application/json" },
|
|
10483
|
+
body: JSON.stringify({ excludeIds }),
|
|
10484
|
+
});
|
|
10485
|
+
if (!r.ok) throw new Error("celebrity-seed " + r.status);
|
|
10486
|
+
const j = await r.json().catch(() => ({}));
|
|
10487
|
+
const seed = j && typeof j === "object" ? j.seed : null;
|
|
10488
|
+
if (!seed || typeof seed.id !== "string" || typeof seed.name !== "string") {
|
|
10489
|
+
throw new Error("celebrity-seed · bad payload");
|
|
10490
|
+
}
|
|
10491
|
+
const fresh = this._loadCelebrityState();
|
|
10492
|
+
// Guard against the rare race where the server returns an id
|
|
10493
|
+
// we already have (we asked it to exclude, but trust no one).
|
|
10494
|
+
const known = new Set([
|
|
10495
|
+
...this.CELEBRITY_SEEDS_V1.map((s) => s.id),
|
|
10496
|
+
...fresh.generated.map((s) => s.id),
|
|
10497
|
+
]);
|
|
10498
|
+
if (known.has(seed.id)) return;
|
|
10499
|
+
fresh.generated.push(seed);
|
|
10500
|
+
this._saveCelebrityState(fresh);
|
|
10501
|
+
},
|
|
10168
10502
|
|
|
10169
10503
|
/** Persisted toggle · "signal" (current quick path) or "full"
|
|
10170
10504
|
* (the deep 5-10 min persona builder). Defaults to signal so
|
|
@@ -10192,7 +10526,8 @@
|
|
|
10192
10526
|
ctaHint: this._t("ag_cmp_cta_hint"),
|
|
10193
10527
|
manual: this._t("ag_cmp_manual"),
|
|
10194
10528
|
generating: this._t("ag_cmp_generating"),
|
|
10195
|
-
|
|
10529
|
+
celebrityCaption: this._t("ag_cmp_celebrity_caption"),
|
|
10530
|
+
};
|
|
10196
10531
|
// Full-mode build in flight or finished · separate render path.
|
|
10197
10532
|
// Returns a different surface (SSE progress block or save card)
|
|
10198
10533
|
// that hides the textarea + starters · the user is committed to
|
|
@@ -10223,14 +10558,12 @@
|
|
|
10223
10558
|
const ctaHint = builderMode === "full"
|
|
10224
10559
|
? "5–10 min · ReAct research + 7-phase build"
|
|
10225
10560
|
: t.ctaHint;
|
|
10226
|
-
|
|
10227
|
-
|
|
10228
|
-
|
|
10229
|
-
|
|
10230
|
-
|
|
10231
|
-
|
|
10232
|
-
</button>
|
|
10233
|
-
`).join("");
|
|
10561
|
+
// Celebrity seed cards · 6 random portraits from the pool of
|
|
10562
|
+
// (CELEBRITY_SEEDS_V1 ∪ generated) − consumed. New random
|
|
10563
|
+
// pick on every render so users browse a rotating set.
|
|
10564
|
+
const celebrityCards = this._pickRandomCelebrities(6)
|
|
10565
|
+
.map((seed) => this.celebrityCardHtml(seed))
|
|
10566
|
+
.join("");
|
|
10234
10567
|
return `
|
|
10235
10568
|
<section class="cmp ag-cmp">
|
|
10236
10569
|
<div class="cmp-bg-deco" aria-hidden="true">${this.composerBgDecoSvg("agent")}</div>
|
|
@@ -10340,13 +10673,13 @@
|
|
|
10340
10673
|
` : ""}
|
|
10341
10674
|
|
|
10342
10675
|
${!generating ? `
|
|
10343
|
-
<div class="cmp-
|
|
10676
|
+
<div class="cmp-celeb-stack">
|
|
10344
10677
|
<div class="cmp-starters-rule">
|
|
10345
10678
|
<span class="cmp-starters-rule-line"></span>
|
|
10346
|
-
<span class="cmp-starters-rule-label">${this.escape(t.
|
|
10679
|
+
<span class="cmp-starters-rule-label">${this.escape(t.celebrityCaption)}</span>
|
|
10347
10680
|
<span class="cmp-starters-rule-line"></span>
|
|
10348
10681
|
</div>
|
|
10349
|
-
<div class="cmp-
|
|
10682
|
+
<div class="cmp-celeb-grid">${celebrityCards}</div>
|
|
10350
10683
|
</div>
|
|
10351
10684
|
` : ""}
|
|
10352
10685
|
</section>
|
|
@@ -10358,9 +10691,22 @@
|
|
|
10358
10691
|
const avatarSvg = (window.AvatarSkill && seed)
|
|
10359
10692
|
? window.AvatarSkill.generate(seed, { size: 96 })
|
|
10360
10693
|
: `<div class="ag-prev-av-empty">—</div>`;
|
|
10361
|
-
//
|
|
10362
|
-
//
|
|
10363
|
-
|
|
10694
|
+
// Reachable models only · filter by `/api/models` cache so the
|
|
10695
|
+
// user can't pick a model their active credential can't route
|
|
10696
|
+
// (e.g. Claude when active credential is OpenAI direct). Falls
|
|
10697
|
+
// back to AGENT_COMPOSER_MODELS only when the cache hasn't
|
|
10698
|
+
// loaded yet — rare cold-start path, browser will re-render
|
|
10699
|
+
// after the first refresh.
|
|
10700
|
+
const cache = (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
|
|
10701
|
+
const reachable = (cache && Array.isArray(cache.reachable) && cache.reachable.length > 0)
|
|
10702
|
+
? cache.reachable.map((m) => ({
|
|
10703
|
+
v: m.modelV,
|
|
10704
|
+
label: m.displayName,
|
|
10705
|
+
provider: this.providerLabel(m.provider),
|
|
10706
|
+
deck: m.deck || "",
|
|
10707
|
+
}))
|
|
10708
|
+
: AGENT_COMPOSER_MODELS;
|
|
10709
|
+
const modelGroups = reachable.reduce((acc, m) => {
|
|
10364
10710
|
const last = acc[acc.length - 1];
|
|
10365
10711
|
if (!last || last.provider !== m.provider) {
|
|
10366
10712
|
acc.push({ provider: m.provider, items: [m] });
|
|
@@ -11402,6 +11748,12 @@
|
|
|
11402
11748
|
const e = await r.json().catch(() => ({}));
|
|
11403
11749
|
throw new Error(e.error || ("HTTP " + r.status));
|
|
11404
11750
|
}
|
|
11751
|
+
// Capture the celebrity seed id (if any) BEFORE nulling
|
|
11752
|
+
// personaJob below · `_markCelebritySeedConsumed` needs
|
|
11753
|
+
// it to update localStorage. Tagging happens in
|
|
11754
|
+
// `applyCelebritySeed` when the build was kicked from a
|
|
11755
|
+
// celebrity card; null for manual textarea builds.
|
|
11756
|
+
const celebritySeedId = this.personaJob?.celebritySeedId || null;
|
|
11405
11757
|
// Sync local state to mirror successful save. Null
|
|
11406
11758
|
// `personaJob` BEFORE refreshAgents so the sidebar
|
|
11407
11759
|
// re-render inside refreshAgents drops the Building
|
|
@@ -11410,6 +11762,10 @@
|
|
|
11410
11762
|
this.personaJob = null;
|
|
11411
11763
|
this._personaOverlayShown = false;
|
|
11412
11764
|
await this.refreshAgents?.();
|
|
11765
|
+
// Mark the celebrity seed consumed AFTER save + refresh
|
|
11766
|
+
// both succeed. Fire-and-forget LLM top-up runs when the
|
|
11767
|
+
// pool dips below 12 — never blocks the user's flow.
|
|
11768
|
+
if (celebritySeedId) this._markCelebritySeedConsumed(celebritySeedId);
|
|
11413
11769
|
this.composerMode = "room";
|
|
11414
11770
|
this.setComposerMode?.("room");
|
|
11415
11771
|
this.renderEmptyState();
|
|
@@ -12179,12 +12535,12 @@
|
|
|
12179
12535
|
current = this.loadAgentBuilderMode();
|
|
12180
12536
|
} else if (kind === "agent-model") {
|
|
12181
12537
|
// Reachable-only model catalog · pulls from the shared
|
|
12182
|
-
// /api/models cache
|
|
12183
|
-
//
|
|
12184
|
-
//
|
|
12185
|
-
//
|
|
12186
|
-
//
|
|
12187
|
-
// the
|
|
12538
|
+
// /api/models cache. Under the single-active-LLM-provider
|
|
12539
|
+
// invariant, every reachable model arrives via the SAME
|
|
12540
|
+
// active carrier — so per-row route badges are redundant
|
|
12541
|
+
// (a single "via {provider}" caption above the picker
|
|
12542
|
+
// covers it for multi-model carriers). For single-model
|
|
12543
|
+
// active, the per-group header already names the family.
|
|
12188
12544
|
const cache = (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
|
|
12189
12545
|
if (cache && Array.isArray(cache.reachable) && cache.reachable.length > 0) {
|
|
12190
12546
|
opts = cache.reachable.map((m) => ({
|
|
@@ -12192,7 +12548,6 @@
|
|
|
12192
12548
|
label: m.displayName,
|
|
12193
12549
|
hint: m.deck || "",
|
|
12194
12550
|
provider: this.providerLabel(m.provider),
|
|
12195
|
-
badge: this.modelRouteBadge(m),
|
|
12196
12551
|
}));
|
|
12197
12552
|
} else {
|
|
12198
12553
|
opts = AGENT_COMPOSER_MODELS.map((m) => ({
|
|
@@ -12200,7 +12555,6 @@
|
|
|
12200
12555
|
label: m.label,
|
|
12201
12556
|
hint: m.deck,
|
|
12202
12557
|
provider: m.provider,
|
|
12203
|
-
badge: "",
|
|
12204
12558
|
}));
|
|
12205
12559
|
// Kick off a refresh in the background so the next open
|
|
12206
12560
|
// shows reachable-only data. No re-render of the current
|
|
@@ -12214,25 +12568,28 @@
|
|
|
12214
12568
|
return;
|
|
12215
12569
|
}
|
|
12216
12570
|
// For the agent-model picker we group rows by provider with a
|
|
12217
|
-
// tiny header label between groups.
|
|
12218
|
-
//
|
|
12571
|
+
// tiny header label between groups. A single "via {provider}"
|
|
12572
|
+
// caption sits at the top of the popover when a multi-model
|
|
12573
|
+
// carrier is the active LLM provider (every reachable model
|
|
12574
|
+
// arrives through the SAME carrier under the single-active
|
|
12575
|
+
// invariant). Tone / intensity stay a flat list.
|
|
12219
12576
|
let rows;
|
|
12220
12577
|
if (kind === "agent-model") {
|
|
12221
12578
|
const groups = [];
|
|
12579
|
+
const caption = this.modelRouteCaption();
|
|
12580
|
+
if (caption) {
|
|
12581
|
+
groups.push(`<div class="cmp-dd-caption">${this.escape(caption)}</div>`);
|
|
12582
|
+
}
|
|
12222
12583
|
let lastProv = null;
|
|
12223
12584
|
for (const o of opts) {
|
|
12224
12585
|
if (o.provider !== lastProv) {
|
|
12225
12586
|
groups.push(`<div class="cmp-dd-group">${this.escape(o.provider)}</div>`);
|
|
12226
12587
|
lastProv = o.provider;
|
|
12227
12588
|
}
|
|
12228
|
-
const badge = o.badge
|
|
12229
|
-
? `<span class="cmp-dd-opt-route">${this.escape(o.badge)}</span>`
|
|
12230
|
-
: "";
|
|
12231
12589
|
groups.push(`
|
|
12232
12590
|
<button type="button" class="cmp-dd-opt${o.v === current ? " active" : ""}" data-cmp-dd-pick="${this.escape(o.v)}" data-cmp-dd-kind="${this.escape(kind)}">
|
|
12233
12591
|
<span class="cmp-dd-opt-label">${this.escape(o.label)}</span>
|
|
12234
12592
|
<span class="cmp-dd-opt-hint">${this.escape(o.hint)}</span>
|
|
12235
|
-
${badge}
|
|
12236
12593
|
</button>
|
|
12237
12594
|
`);
|
|
12238
12595
|
}
|
|
@@ -12394,16 +12751,14 @@
|
|
|
12394
12751
|
intensity: state.intensity,
|
|
12395
12752
|
deliveryMode: state.deliveryMode,
|
|
12396
12753
|
autoPick: useAutoPick,
|
|
12397
|
-
seedContext: state.seedContext || null,
|
|
12398
12754
|
});
|
|
12399
12755
|
// Clear the saved draft now that the room is convened — next
|
|
12400
12756
|
// visit to "+ New Room" should land on a fresh textarea, not
|
|
12401
|
-
// re-show the just-submitted subject.
|
|
12402
|
-
//
|
|
12403
|
-
//
|
|
12404
|
-
// NEXT room the user opens.
|
|
12757
|
+
// re-show the just-submitted subject. The scenario-card
|
|
12758
|
+
// placeholder hint is also a one-shot · drop it so the next
|
|
12759
|
+
// composer paint shows the default framing copy again.
|
|
12405
12760
|
state.subject = "";
|
|
12406
|
-
|
|
12761
|
+
this._scenarioPlaceholder = null;
|
|
12407
12762
|
this.saveComposerState();
|
|
12408
12763
|
} catch (e) {
|
|
12409
12764
|
if (btn) btn.classList.remove("busy");
|
|
@@ -12411,349 +12766,238 @@
|
|
|
12411
12766
|
}
|
|
12412
12767
|
},
|
|
12413
12768
|
|
|
12414
|
-
/**
|
|
12415
|
-
*
|
|
12416
|
-
*
|
|
12417
|
-
*
|
|
12418
|
-
*
|
|
12419
|
-
*
|
|
12420
|
-
*
|
|
12421
|
-
|
|
12422
|
-
|
|
12423
|
-
|
|
12424
|
-
|
|
12425
|
-
|
|
12426
|
-
|
|
12427
|
-
|
|
12428
|
-
|
|
12429
|
-
|
|
12430
|
-
|
|
12431
|
-
|
|
12432
|
-
|
|
12433
|
-
|
|
12434
|
-
|
|
12435
|
-
|
|
12436
|
-
|
|
12437
|
-
|
|
12438
|
-
|
|
12439
|
-
|
|
12440
|
-
|
|
12441
|
-
|
|
12442
|
-
|
|
12443
|
-
|
|
12444
|
-
|
|
12445
|
-
|
|
12446
|
-
|
|
12447
|
-
|
|
12448
|
-
|
|
12449
|
-
|
|
12450
|
-
|
|
12451
|
-
|
|
12452
|
-
|
|
12453
|
-
|
|
12454
|
-
|
|
12455
|
-
|
|
12456
|
-
|
|
12457
|
-
|
|
12458
|
-
|
|
12459
|
-
|
|
12460
|
-
|
|
12461
|
-
|
|
12462
|
-
|
|
12463
|
-
|
|
12464
|
-
|
|
12465
|
-
|
|
12466
|
-
|
|
12467
|
-
|
|
12468
|
-
|
|
12469
|
-
|
|
12470
|
-
|
|
12471
|
-
|
|
12472
|
-
:
|
|
12473
|
-
|
|
12474
|
-
|
|
12475
|
-
|
|
12476
|
-
|
|
12477
|
-
|
|
12478
|
-
|
|
12479
|
-
|
|
12480
|
-
|
|
12481
|
-
|
|
12482
|
-
|
|
12483
|
-
|
|
12484
|
-
|
|
12485
|
-
|
|
12769
|
+
/** Scenario use-case cards · 12 hand-curated boardroom
|
|
12770
|
+
* situations that also act as one-click room presets. Six
|
|
12771
|
+
* are picked at random on each composer render, so the user
|
|
12772
|
+
* sees a rotating set instead of a fixed list.
|
|
12773
|
+
*
|
|
12774
|
+
* Each card:
|
|
12775
|
+
* · id · stable kebab key (used in click handler)
|
|
12776
|
+
* · tagKey · i18n key for the uppercase mono kicker
|
|
12777
|
+
* (e.g. "INVESTOR · PITCH")
|
|
12778
|
+
* · headlineKey · i18n key for the short serif italic title
|
|
12779
|
+
* · bodyKey · i18n key for the italic 1-2 line intro
|
|
12780
|
+
* · directors · 2-3 director slugs the click auto-picks
|
|
12781
|
+
* · tone · room mode set on click
|
|
12782
|
+
* · intensity · room intensity set on click
|
|
12783
|
+
* · subjectKey · i18n key for the starter subject text
|
|
12784
|
+
* dropped into the composer
|
|
12785
|
+
*
|
|
12786
|
+
* Tone/intensity vocab matches what the room schema accepts
|
|
12787
|
+
* (brainstorm | constructive | research | debate | critique
|
|
12788
|
+
* × calm | sharp). Cards differentiate by content alone — no
|
|
12789
|
+
* accent colour, no rail, no icon. */
|
|
12790
|
+
SCENARIO_CARDS: [
|
|
12791
|
+
{
|
|
12792
|
+
id: "investor-pitch",
|
|
12793
|
+
tagKey: "scn_pitch_tag",
|
|
12794
|
+
headlineKey: "scn_pitch_headline",
|
|
12795
|
+
bodyKey: "scn_pitch_body",
|
|
12796
|
+
directors: ["socrates", "value-investor", "first-principles"],
|
|
12797
|
+
tone: "debate",
|
|
12798
|
+
intensity: "sharp",
|
|
12799
|
+
subjectKey: "scn_pitch_subject",
|
|
12800
|
+
},
|
|
12801
|
+
{
|
|
12802
|
+
id: "product-brainstorm",
|
|
12803
|
+
tagKey: "scn_brainstorm_tag",
|
|
12804
|
+
headlineKey: "scn_brainstorm_headline",
|
|
12805
|
+
bodyKey: "scn_brainstorm_body",
|
|
12806
|
+
directors: ["first-principles", "user-empathy", "long-horizon"],
|
|
12807
|
+
tone: "brainstorm",
|
|
12808
|
+
intensity: "calm",
|
|
12809
|
+
subjectKey: "scn_brainstorm_subject",
|
|
12810
|
+
},
|
|
12811
|
+
{
|
|
12812
|
+
id: "topic-pk",
|
|
12813
|
+
tagKey: "scn_pk_tag",
|
|
12814
|
+
headlineKey: "scn_pk_headline",
|
|
12815
|
+
bodyKey: "scn_pk_body",
|
|
12816
|
+
directors: ["socrates", "value-investor"],
|
|
12817
|
+
tone: "debate",
|
|
12818
|
+
intensity: "sharp",
|
|
12819
|
+
subjectKey: "scn_pk_subject",
|
|
12820
|
+
},
|
|
12821
|
+
{
|
|
12822
|
+
id: "find-flaws",
|
|
12823
|
+
tagKey: "scn_audit_tag",
|
|
12824
|
+
headlineKey: "scn_audit_headline",
|
|
12825
|
+
bodyKey: "scn_audit_body",
|
|
12826
|
+
directors: ["socrates", "first-principles", "long-horizon"],
|
|
12827
|
+
tone: "critique",
|
|
12828
|
+
intensity: "sharp",
|
|
12829
|
+
subjectKey: "scn_audit_subject",
|
|
12830
|
+
},
|
|
12831
|
+
{
|
|
12832
|
+
id: "research-learn",
|
|
12833
|
+
tagKey: "scn_research_tag",
|
|
12834
|
+
headlineKey: "scn_research_headline",
|
|
12835
|
+
bodyKey: "scn_research_body",
|
|
12836
|
+
directors: ["first-principles", "long-horizon", "user-empathy"],
|
|
12837
|
+
tone: "research",
|
|
12838
|
+
intensity: "calm",
|
|
12839
|
+
subjectKey: "scn_research_subject",
|
|
12840
|
+
},
|
|
12841
|
+
{
|
|
12842
|
+
id: "negotiation-prep",
|
|
12843
|
+
tagKey: "scn_negotiation_tag",
|
|
12844
|
+
headlineKey: "scn_negotiation_headline",
|
|
12845
|
+
bodyKey: "scn_negotiation_body",
|
|
12846
|
+
directors: ["socrates", "value-investor", "user-empathy"],
|
|
12847
|
+
tone: "debate",
|
|
12848
|
+
intensity: "sharp",
|
|
12849
|
+
subjectKey: "scn_negotiation_subject",
|
|
12850
|
+
},
|
|
12851
|
+
{
|
|
12852
|
+
id: "decision-fork",
|
|
12853
|
+
tagKey: "scn_decision_tag",
|
|
12854
|
+
headlineKey: "scn_decision_headline",
|
|
12855
|
+
bodyKey: "scn_decision_body",
|
|
12856
|
+
directors: ["long-horizon", "first-principles", "value-investor"],
|
|
12857
|
+
tone: "debate",
|
|
12858
|
+
intensity: "calm",
|
|
12859
|
+
subjectKey: "scn_decision_subject",
|
|
12860
|
+
},
|
|
12861
|
+
{
|
|
12862
|
+
id: "red-team",
|
|
12863
|
+
tagKey: "scn_red_team_tag",
|
|
12864
|
+
headlineKey: "scn_red_team_headline",
|
|
12865
|
+
bodyKey: "scn_red_team_body",
|
|
12866
|
+
directors: ["socrates", "value-investor", "first-principles"],
|
|
12867
|
+
tone: "critique",
|
|
12868
|
+
intensity: "sharp",
|
|
12869
|
+
subjectKey: "scn_red_team_subject",
|
|
12870
|
+
},
|
|
12871
|
+
{
|
|
12872
|
+
id: "post-mortem",
|
|
12873
|
+
tagKey: "scn_post_mortem_tag",
|
|
12874
|
+
headlineKey: "scn_post_mortem_headline",
|
|
12875
|
+
bodyKey: "scn_post_mortem_body",
|
|
12876
|
+
directors: ["socrates", "first-principles", "long-horizon"],
|
|
12877
|
+
tone: "research",
|
|
12878
|
+
intensity: "calm",
|
|
12879
|
+
subjectKey: "scn_post_mortem_subject",
|
|
12880
|
+
},
|
|
12881
|
+
{
|
|
12882
|
+
id: "career-fork",
|
|
12883
|
+
tagKey: "scn_career_tag",
|
|
12884
|
+
headlineKey: "scn_career_headline",
|
|
12885
|
+
bodyKey: "scn_career_body",
|
|
12886
|
+
directors: ["long-horizon", "value-investor", "user-empathy"],
|
|
12887
|
+
tone: "constructive",
|
|
12888
|
+
intensity: "calm",
|
|
12889
|
+
subjectKey: "scn_career_subject",
|
|
12890
|
+
},
|
|
12891
|
+
{
|
|
12892
|
+
id: "hire-evaluation",
|
|
12893
|
+
tagKey: "scn_hire_tag",
|
|
12894
|
+
headlineKey: "scn_hire_headline",
|
|
12895
|
+
bodyKey: "scn_hire_body",
|
|
12896
|
+
directors: ["first-principles", "value-investor", "user-empathy"],
|
|
12897
|
+
tone: "critique",
|
|
12898
|
+
intensity: "sharp",
|
|
12899
|
+
subjectKey: "scn_hire_subject",
|
|
12900
|
+
},
|
|
12901
|
+
{
|
|
12902
|
+
id: "roadmap-priority",
|
|
12903
|
+
tagKey: "scn_roadmap_tag",
|
|
12904
|
+
headlineKey: "scn_roadmap_headline",
|
|
12905
|
+
bodyKey: "scn_roadmap_body",
|
|
12906
|
+
directors: ["user-empathy", "first-principles", "long-horizon"],
|
|
12907
|
+
tone: "constructive",
|
|
12908
|
+
intensity: "sharp",
|
|
12909
|
+
subjectKey: "scn_roadmap_subject",
|
|
12910
|
+
},
|
|
12911
|
+
],
|
|
12486
12912
|
|
|
12487
|
-
/**
|
|
12488
|
-
*
|
|
12489
|
-
*
|
|
12490
|
-
*
|
|
12491
|
-
|
|
12492
|
-
|
|
12493
|
-
|
|
12494
|
-
|
|
12495
|
-
|
|
12496
|
-
|
|
12497
|
-
|
|
12498
|
-
|
|
12499
|
-
|
|
12500
|
-
|
|
12501
|
-
|
|
12502
|
-
|
|
12503
|
-
|
|
12504
|
-
|
|
12505
|
-
|
|
12506
|
-
|
|
12507
|
-
|
|
12508
|
-
|
|
12509
|
-
|
|
12510
|
-
id: jobId,
|
|
12511
|
-
phase: 0,
|
|
12512
|
-
label: this._t("cmp_recs_trigger_starting"),
|
|
12513
|
-
pct: 0,
|
|
12514
|
-
detail: "",
|
|
12515
|
-
es: null,
|
|
12516
|
-
error: null,
|
|
12517
|
-
};
|
|
12518
|
-
if (!this.currentRoomId) this.renderEmptyState();
|
|
12519
|
-
this._attachTopicRecJobSSE(jobId);
|
|
12520
|
-
} catch (e) {
|
|
12521
|
-
alert("Couldn't start: " + (e && e.message ? e.message : e));
|
|
12522
|
-
}
|
|
12523
|
-
},
|
|
12524
|
-
|
|
12525
|
-
/** Internal · attach the EventSource for a running job and
|
|
12526
|
-
* wire each event type to the in-memory job state. */
|
|
12527
|
-
_attachTopicRecJobSSE(jobId) {
|
|
12528
|
-
const url = `/api/topic-recs/jobs/${encodeURIComponent(jobId)}/stream`;
|
|
12529
|
-
const es = new EventSource(url);
|
|
12530
|
-
this.topicRecs.job.es = es;
|
|
12531
|
-
const updateStrip = () => {
|
|
12532
|
-
const el = document.querySelector("[data-topic-rec-progress]");
|
|
12533
|
-
if (!el || !this.topicRecs.job) return;
|
|
12534
|
-
const j = this.topicRecs.job;
|
|
12535
|
-
const labelEl = el.querySelector("[data-trec-label]");
|
|
12536
|
-
if (labelEl) labelEl.textContent = j.label || "";
|
|
12537
|
-
const detailEl = el.querySelector("[data-trec-detail]");
|
|
12538
|
-
if (detailEl) detailEl.textContent = j.detail || "";
|
|
12539
|
-
const pctEl = el.querySelector("[data-trec-pct]");
|
|
12540
|
-
if (pctEl) pctEl.textContent = j.pct ? `${j.pct}%` : "·";
|
|
12541
|
-
const bar = el.querySelector("[data-trec-bar]");
|
|
12542
|
-
if (bar) bar.style.width = `${j.pct || 0}%`;
|
|
12543
|
-
};
|
|
12544
|
-
const terminate = () => {
|
|
12545
|
-
try { es.close(); } catch { /* noop */ }
|
|
12546
|
-
this.topicRecs.job = null;
|
|
12547
|
-
if (!this.currentRoomId) this.renderEmptyState();
|
|
12548
|
-
};
|
|
12549
|
-
es.addEventListener("hello", (ev) => {
|
|
12550
|
-
try {
|
|
12551
|
-
const data = JSON.parse(ev.data);
|
|
12552
|
-
if (this.topicRecs.job) {
|
|
12553
|
-
this.topicRecs.job.phase = data.currentPhase || 0;
|
|
12554
|
-
this.topicRecs.job.pct = data.progressPct || 0;
|
|
12555
|
-
}
|
|
12556
|
-
updateStrip();
|
|
12557
|
-
} catch { /* noop */ }
|
|
12558
|
-
});
|
|
12559
|
-
es.addEventListener("topic-phase-start", (ev) => {
|
|
12560
|
-
try {
|
|
12561
|
-
const data = JSON.parse(ev.data);
|
|
12562
|
-
if (this.topicRecs.job) {
|
|
12563
|
-
this.topicRecs.job.phase = data.phase;
|
|
12564
|
-
this.topicRecs.job.label = data.label;
|
|
12565
|
-
this.topicRecs.job.detail = "";
|
|
12566
|
-
}
|
|
12567
|
-
updateStrip();
|
|
12568
|
-
} catch { /* noop */ }
|
|
12569
|
-
});
|
|
12570
|
-
es.addEventListener("topic-phase-progress", (ev) => {
|
|
12571
|
-
try {
|
|
12572
|
-
const data = JSON.parse(ev.data);
|
|
12573
|
-
if (this.topicRecs.job) {
|
|
12574
|
-
this.topicRecs.job.phase = data.phase;
|
|
12575
|
-
this.topicRecs.job.detail = data.detail || "";
|
|
12576
|
-
this.topicRecs.job.pct = data.progressPct || this.topicRecs.job.pct;
|
|
12577
|
-
}
|
|
12578
|
-
updateStrip();
|
|
12579
|
-
} catch { /* noop */ }
|
|
12580
|
-
});
|
|
12581
|
-
es.addEventListener("topic-phase-end", (ev) => {
|
|
12582
|
-
try {
|
|
12583
|
-
const data = JSON.parse(ev.data);
|
|
12584
|
-
if (this.topicRecs.job) {
|
|
12585
|
-
this.topicRecs.job.pct = data.progressPct || this.topicRecs.job.pct;
|
|
12586
|
-
}
|
|
12587
|
-
updateStrip();
|
|
12588
|
-
} catch { /* noop */ }
|
|
12589
|
-
});
|
|
12590
|
-
es.addEventListener("topic-final", () => {
|
|
12591
|
-
// Pull the freshest first page so the new cards land
|
|
12592
|
-
// immediately; closing the EventSource is on the same
|
|
12593
|
-
// tick so the next click doesn't see a stale job.
|
|
12594
|
-
terminate();
|
|
12595
|
-
void this.refreshTopicRecs();
|
|
12596
|
-
});
|
|
12597
|
-
es.addEventListener("topic-error", (ev) => {
|
|
12598
|
-
let msg = "generation failed";
|
|
12599
|
-
try { msg = (JSON.parse(ev.data).message) || msg; } catch { /* noop */ }
|
|
12600
|
-
alert("Topic generation failed: " + msg);
|
|
12601
|
-
terminate();
|
|
12602
|
-
});
|
|
12603
|
-
es.addEventListener("topic-aborted", () => {
|
|
12604
|
-
terminate();
|
|
12605
|
-
});
|
|
12606
|
-
es.onerror = () => {
|
|
12607
|
-
// Transport hiccup · treat as terminal so the UI doesn't
|
|
12608
|
-
// stay stuck on a phantom progress strip.
|
|
12609
|
-
if (this.topicRecs.job) terminate();
|
|
12610
|
-
};
|
|
12913
|
+
/** Fisher-Yates partial · pick up to n distinct scenarios from
|
|
12914
|
+
* the catalog. Re-rolled on every composer render so the user
|
|
12915
|
+
* browses a rotating set. Unlike celebrity seeds, scenarios are
|
|
12916
|
+
* not "consumed" — the same set can be replayed forever. */
|
|
12917
|
+
_pickRandomScenarios(n) {
|
|
12918
|
+
const pool = (this.SCENARIO_CARDS || []).slice();
|
|
12919
|
+
const take = Math.min(n, pool.length);
|
|
12920
|
+
for (let i = 0; i < take; i++) {
|
|
12921
|
+
const j = i + Math.floor(Math.random() * (pool.length - i));
|
|
12922
|
+
const tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
|
|
12923
|
+
}
|
|
12924
|
+
return pool.slice(0, take);
|
|
12925
|
+
},
|
|
12926
|
+
|
|
12927
|
+
/** Build the markup for the 4 visible scenario cards. Compact
|
|
12928
|
+
* 2-col grid layout — matches the celebrity seed cards on the
|
|
12929
|
+
* new-agent page (mono kicker → serif italic headline → italic
|
|
12930
|
+
* intro). No pills, no avatars, no CTA arrow — the entire card
|
|
12931
|
+
* is one click target. */
|
|
12932
|
+
scenarioAdCardsHtml() {
|
|
12933
|
+
return this._pickRandomScenarios(4)
|
|
12934
|
+
.map((c) => this.scenarioCardHtml(c))
|
|
12935
|
+
.join("");
|
|
12611
12936
|
},
|
|
12612
12937
|
|
|
12613
|
-
|
|
12614
|
-
|
|
12615
|
-
|
|
12616
|
-
|
|
12617
|
-
|
|
12618
|
-
|
|
12619
|
-
|
|
12620
|
-
|
|
12621
|
-
|
|
12622
|
-
|
|
12623
|
-
|
|
12624
|
-
|
|
12625
|
-
|
|
12626
|
-
|
|
12627
|
-
|
|
12628
|
-
|
|
12629
|
-
|
|
12630
|
-
|
|
12631
|
-
|
|
12632
|
-
|
|
12633
|
-
|
|
12634
|
-
|
|
12635
|
-
|
|
12636
|
-
|
|
12637
|
-
|
|
12638
|
-
|
|
12639
|
-
|
|
12640
|
-
|
|
12641
|
-
if (ta) {
|
|
12642
|
-
ta.focus();
|
|
12643
|
-
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
12644
|
-
this.autosizeComposerTextarea?.();
|
|
12645
|
-
}
|
|
12646
|
-
}, 50);
|
|
12647
|
-
} catch { /* noop */ }
|
|
12938
|
+
scenarioCardHtml(card) {
|
|
12939
|
+
const tag = this._t(card.tagKey);
|
|
12940
|
+
const headline = this._t(card.headlineKey);
|
|
12941
|
+
const body = this._t(card.bodyKey);
|
|
12942
|
+
// Cast strip · resolve director slugs against this.agentsById
|
|
12943
|
+
// and render up to 3 small overlap-stacked avatars at the
|
|
12944
|
+
// card foot. Each avatar carries [data-agent-profile] so a
|
|
12945
|
+
// click on the avatar opens the profile overlay (handled in
|
|
12946
|
+
// capture phase by agent-profile.js); clicks anywhere ELSE
|
|
12947
|
+
// on the card still fire the scenario action via the
|
|
12948
|
+
// [data-scenario-id] delegate.
|
|
12949
|
+
const cast = (card.directors || [])
|
|
12950
|
+
.map((id) => this.agentsById[id])
|
|
12951
|
+
.filter(Boolean)
|
|
12952
|
+
.slice(0, 3);
|
|
12953
|
+
const castHtml = cast.map((a) => `
|
|
12954
|
+
<span class="cmp-scn-av" title="${this.escape(a.name)}" data-agent-profile="${this.escape(a.id)}">
|
|
12955
|
+
<img src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}">
|
|
12956
|
+
</span>
|
|
12957
|
+
`).join("");
|
|
12958
|
+
return `
|
|
12959
|
+
<button type="button" class="cmp-scn-card" data-scenario-id="${this.escape(card.id)}">
|
|
12960
|
+
<div class="cmp-scn-tag">${this.escape(tag)}</div>
|
|
12961
|
+
<h3 class="cmp-scn-headline">${this.escape(headline)}</h3>
|
|
12962
|
+
<p class="cmp-scn-desc">${this.escape(body)}</p>
|
|
12963
|
+
${cast.length > 0 ? `<div class="cmp-scn-cast">${castHtml}</div>` : ""}
|
|
12964
|
+
</button>
|
|
12965
|
+
`;
|
|
12648
12966
|
},
|
|
12649
12967
|
|
|
12650
|
-
/**
|
|
12651
|
-
*
|
|
12652
|
-
*
|
|
12653
|
-
|
|
12654
|
-
|
|
12655
|
-
|
|
12656
|
-
|
|
12968
|
+
/** Click handler · apply a scenario preset into composer
|
|
12969
|
+
* state. Sets the director cast + tone + intensity and
|
|
12970
|
+
* replaces the textarea PLACEHOLDER (not the value) with the
|
|
12971
|
+
* scenario's starter subject — the user still types their
|
|
12972
|
+
* own real question. Submitting / leaving the composer wipes
|
|
12973
|
+
* the hint (see submitComposer / setComposerMode). */
|
|
12974
|
+
applyScenarioCard(id) {
|
|
12975
|
+
const card = (this.SCENARIO_CARDS || []).find((c) => c.id === id);
|
|
12976
|
+
if (!card) return;
|
|
12657
12977
|
const state = this.loadComposerState();
|
|
12658
|
-
|
|
12659
|
-
|
|
12660
|
-
|
|
12661
|
-
|
|
12662
|
-
|
|
12663
|
-
if (
|
|
12664
|
-
|
|
12665
|
-
|
|
12666
|
-
|
|
12978
|
+
const want = (card.directors || []).filter((aid) => this.agentsById[aid]);
|
|
12979
|
+
if (want.length) {
|
|
12980
|
+
state.directorIds = want;
|
|
12981
|
+
state.autoPickDirectors = false;
|
|
12982
|
+
}
|
|
12983
|
+
if (card.tone) state.mode = card.tone;
|
|
12984
|
+
if (card.intensity) state.intensity = card.intensity;
|
|
12985
|
+
const subject = this._t(card.subjectKey) || "";
|
|
12986
|
+
if (subject && subject !== card.subjectKey) {
|
|
12987
|
+
this._scenarioPlaceholder = subject;
|
|
12988
|
+
}
|
|
12667
12989
|
this.saveComposerState();
|
|
12668
|
-
// Re-render to reflect the new selections; keep autofocus at end of subject.
|
|
12669
12990
|
this.renderEmptyState();
|
|
12670
12991
|
setTimeout(() => {
|
|
12671
12992
|
const ta = document.querySelector("[data-composer-subject]");
|
|
12672
12993
|
if (ta) {
|
|
12673
12994
|
ta.focus();
|
|
12674
12995
|
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
12675
|
-
this.autosizeComposerTextarea();
|
|
12996
|
+
this.autosizeComposerTextarea?.();
|
|
12676
12997
|
}
|
|
12677
12998
|
}, 50);
|
|
12678
12999
|
},
|
|
12679
13000
|
|
|
12680
|
-
/**
|
|
12681
|
-
* Render one starter card. The cast + config come from the starter spec
|
|
12682
|
-
* (window.BOARDROOM_STARTERS); each avatar is clickable to open the
|
|
12683
|
-
* agent profile, the card body fires the starter action, and a
|
|
12684
|
-
* [▶ Start] button appears on hover at the right.
|
|
12685
|
-
*/
|
|
12686
|
-
starterCardHtml(q, idx) {
|
|
12687
|
-
const cast = (q.agents || [])
|
|
12688
|
-
.map((id) => this.agentsById[id])
|
|
12689
|
-
.filter(Boolean);
|
|
12690
|
-
const castHtml = cast.map((a) => `
|
|
12691
|
-
<span class="starter-agent" title="${this.escape(a.name)} · ${this.escape(a.roleTag || "")}" data-agent-profile="${this.escape(a.id)}">
|
|
12692
|
-
<img class="starter-av" src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" data-agent-profile="${this.escape(a.id)}">
|
|
12693
|
-
<span class="starter-name">${this.escape(a.name)}</span>
|
|
12694
|
-
</span>
|
|
12695
|
-
`).join("");
|
|
12696
|
-
return `
|
|
12697
|
-
<div class="starter-card" data-starter-idx="${idx}">
|
|
12698
|
-
<div class="starter-tag">${this.escape(q.tag)}</div>
|
|
12699
|
-
<div class="starter-main">
|
|
12700
|
-
<div class="starter-text">${this.escape(q.text)}</div>
|
|
12701
|
-
<div class="starter-hint">${this.escape(q.hint)}</div>
|
|
12702
|
-
<div class="starter-meta">
|
|
12703
|
-
<span class="meta-tag tag-tone"><span class="k">tone</span><span class="v">${this.escape(q.tone)}</span></span>
|
|
12704
|
-
<span class="meta-tag tag-intensity"><span class="k">intensity</span><span class="v">${this.escape(q.intensity)}</span></span>
|
|
12705
|
-
</div>
|
|
12706
|
-
</div>
|
|
12707
|
-
<div class="starter-cast" aria-label="${cast.length} directors">${castHtml}</div>
|
|
12708
|
-
<button type="button" class="starter-start" data-starter-go="${idx}" title="Start this room">
|
|
12709
|
-
<span class="starter-start-arrow">▶</span><span class="starter-start-label">Start</span>
|
|
12710
|
-
</button>
|
|
12711
|
-
</div>
|
|
12712
|
-
`;
|
|
12713
|
-
},
|
|
12714
|
-
|
|
12715
|
-
/**
|
|
12716
|
-
* Create a room from a starter spec. The cast + tone + intensity come
|
|
12717
|
-
* from `window.BOARDROOM_STARTERS` (or the explicit subject string for
|
|
12718
|
-
* legacy callers). Falls back to the default trio if a referenced
|
|
12719
|
-
* agent isn't seeded.
|
|
12720
|
-
*/
|
|
12721
|
-
async createStarterRoom(subjectOrSpec) {
|
|
12722
|
-
const starters = (typeof window !== "undefined" && Array.isArray(window.BOARDROOM_STARTERS))
|
|
12723
|
-
? window.BOARDROOM_STARTERS : [];
|
|
12724
|
-
let spec = null;
|
|
12725
|
-
if (subjectOrSpec && typeof subjectOrSpec === "object") {
|
|
12726
|
-
spec = subjectOrSpec;
|
|
12727
|
-
} else if (typeof subjectOrSpec === "string") {
|
|
12728
|
-
const text = subjectOrSpec.trim();
|
|
12729
|
-
spec = starters.find((s) => s.text === text) || { text };
|
|
12730
|
-
}
|
|
12731
|
-
if (!spec || !spec.text) return;
|
|
12732
|
-
|
|
12733
|
-
const have = new Set(this.agents.map((a) => a.id));
|
|
12734
|
-
let agentIds = (spec.agents || []).filter((id) => have.has(id));
|
|
12735
|
-
// Fallback to the canonical trio, then to any three seeded agents.
|
|
12736
|
-
if (agentIds.length < 2) {
|
|
12737
|
-
const trio = ["socrates", "first-principles", "value-investor"].filter((id) => have.has(id));
|
|
12738
|
-
agentIds = trio.length >= 2 ? trio : this.agents.slice(0, 3).map((a) => a.id);
|
|
12739
|
-
}
|
|
12740
|
-
if (agentIds.length === 0) {
|
|
12741
|
-
alert("No directors available — the agent catalog is empty.");
|
|
12742
|
-
return;
|
|
12743
|
-
}
|
|
12744
|
-
try {
|
|
12745
|
-
await this.createRoom({
|
|
12746
|
-
subject: spec.text,
|
|
12747
|
-
agentIds,
|
|
12748
|
-
mode: spec.tone || "constructive",
|
|
12749
|
-
intensity: spec.intensity || "sharp",
|
|
12750
|
-
briefStyle: spec.briefStyle || "auto",
|
|
12751
|
-
});
|
|
12752
|
-
} catch (e) {
|
|
12753
|
-
alert("Couldn't open the starter room: " + (e && e.message ? e.message : e));
|
|
12754
|
-
}
|
|
12755
|
-
},
|
|
12756
|
-
|
|
12757
13001
|
/** Tone hover copy · i18n keyed by mode, falls back to TONE_TIPS. */
|
|
12758
13002
|
toneTipFor(mode) {
|
|
12759
13003
|
const m = mode || "";
|
|
@@ -12864,7 +13108,7 @@
|
|
|
12864
13108
|
<span class="kicker-intensity">${this.escape(intensity.toUpperCase())}</span>
|
|
12865
13109
|
${statusWord ? `<span class="kicker-sep">·</span><span class="kicker-status status-${this.escape(r.status)}">${statusWord}</span>` : ""}
|
|
12866
13110
|
</div>
|
|
12867
|
-
<h1 class="room-subject" title="${this.escape(r.subject)}">${this.escape(this._truncateRoomSubject(r.subject,
|
|
13111
|
+
<h1 class="room-subject" title="${this.escape(r.subject)}">${this.escape(this._truncateRoomSubject(r.subject, 60))}</h1>
|
|
12868
13112
|
</div>
|
|
12869
13113
|
<div class="head-actions">
|
|
12870
13114
|
<div class="head-cast">${castHtml}</div>
|
|
@@ -13089,6 +13333,36 @@
|
|
|
13089
13333
|
appendMessageDom(msg) {
|
|
13090
13334
|
const chat = document.querySelector("[data-chat-messages]");
|
|
13091
13335
|
if (!chat) return;
|
|
13336
|
+
// Cross-director pipeline · MUST decide hide-on-insert BEFORE
|
|
13337
|
+
// calling messageHtml so the article's very first paint already
|
|
13338
|
+
// carries `data-prewarmed=""`. Without this pre-mark, the
|
|
13339
|
+
// article briefly paints with `.streaming` and no
|
|
13340
|
+
// `data-prewarmed`, and the corner-bracket pulse flashes for
|
|
13341
|
+
// one frame.
|
|
13342
|
+
//
|
|
13343
|
+
// Single authority · `msg.meta.preWarmed === true`. The server
|
|
13344
|
+
// stamps this flag ONLY in the runPickerThenPrewarm path
|
|
13345
|
+
// (room.ts streamSpeakerTurn with preWarmed:true). That path
|
|
13346
|
+
// by definition runs while another director's TTS is playing,
|
|
13347
|
+
// so the flag alone is sufficient. The earlier double-check
|
|
13348
|
+
// against client-side voiceQueues `hasPlaying` introduced a
|
|
13349
|
+
// race: if B's message-appended SSE arrived one frame before
|
|
13350
|
+
// A's first voice-chunk created A's queue client-side,
|
|
13351
|
+
// hasPlaying was false, B skipped the hidden set, and B's
|
|
13352
|
+
// animation flashed. Server flag is the truth — trust it.
|
|
13353
|
+
if (msg.meta && msg.meta.preWarmed === true && msg.authorKind === "agent") {
|
|
13354
|
+
this._prewarmedHidden.add(msg.id);
|
|
13355
|
+
console.log(`[pre-warm] hide msg=${msg.id} author=${msg.authorId} set-size=${this._prewarmedHidden.size}`);
|
|
13356
|
+
} else if (msg.authorKind === "agent" && msg.meta) {
|
|
13357
|
+
// Diagnostic · helps spot the case where the server didn't
|
|
13358
|
+
// stamp meta.preWarmed but the client still saw the bubble
|
|
13359
|
+
// animate during a prior speaker's TTS. If you see this log
|
|
13360
|
+
// for a director that "appeared" mid-prior-speech, the bug
|
|
13361
|
+
// is server-side: the placeholder was inserted via the fresh
|
|
13362
|
+
// path (not runPickerThenPrewarm). Inspect pumpQueue's
|
|
13363
|
+
// preWarmedHit branch for the relevant iteration.
|
|
13364
|
+
console.log(`[pre-warm] NOT hide msg=${msg.id} author=${msg.authorId} preWarmed=${String(msg.meta.preWarmed)} streaming=${String(msg.meta.streaming)}`);
|
|
13365
|
+
}
|
|
13092
13366
|
// The very first user message in the room renders as the convene block.
|
|
13093
13367
|
const isOpener = msg.id === this.firstUserMessageId();
|
|
13094
13368
|
chat.insertAdjacentHTML("beforeend", this.messageHtml(msg, isOpener));
|
|
@@ -13096,6 +13370,58 @@
|
|
|
13096
13370
|
// (single message, single map lookup) and keeps brand-new
|
|
13097
13371
|
// bubbles consistent with the rest of the chat.
|
|
13098
13372
|
this.applyNoteHighlightsForMessage(msg.id);
|
|
13373
|
+
// Safety reveal · defense in depth, NOT a hard 30s cap.
|
|
13374
|
+
//
|
|
13375
|
+
// BUG history · a flat 30s force-reveal here turned into a real
|
|
13376
|
+
// user-visible bug: if the predecessor director's TTS is longer
|
|
13377
|
+
// than 30s, the pre-warmed bubble was sitting legitimately in
|
|
13378
|
+
// "queued" state waiting its turn — the safety fired and ripped
|
|
13379
|
+
// off `data-prewarmed`, the bubble flashed into view alongside
|
|
13380
|
+
// the speaking director, and the user reported "C 出现了 和
|
|
13381
|
+
// B 重叠". A long but healthy wait is exactly what the pre-warm
|
|
13382
|
+
// pipeline is supposed to do.
|
|
13383
|
+
//
|
|
13384
|
+
// Correct semantics · only force-reveal when the pre-warm is
|
|
13385
|
+
// genuinely STUCK (no voice queue ever materialized for this
|
|
13386
|
+
// message, or its state went sideways). If the voice queue is
|
|
13387
|
+
// present and "queued", a sibling MUST be "playing" — the
|
|
13388
|
+
// hard concurrency guard enforces that invariant — so the
|
|
13389
|
+
// normal `_fireVoiceDone` promote will eventually reveal us.
|
|
13390
|
+
// Re-poll instead of giving up.
|
|
13391
|
+
if (this._prewarmedHidden.has(msg.id)) {
|
|
13392
|
+
const msgId = msg.id;
|
|
13393
|
+
const SAFETY_POLL_MS = 30_000;
|
|
13394
|
+
const safetyTick = () => {
|
|
13395
|
+
if (!this._prewarmedHidden.has(msgId)) return; // already revealed
|
|
13396
|
+
const vq = this.voiceQueues && this.voiceQueues[msgId];
|
|
13397
|
+
if (vq && vq.playState === "queued") {
|
|
13398
|
+
// Healthy wait · predecessor's TTS still playing.
|
|
13399
|
+
// Re-poll; do NOT reveal.
|
|
13400
|
+
setTimeout(safetyTick, SAFETY_POLL_MS);
|
|
13401
|
+
return;
|
|
13402
|
+
}
|
|
13403
|
+
// No queue or unexpected state · the pre-warm path went
|
|
13404
|
+
// wrong. Reveal so the user at least sees the text.
|
|
13405
|
+
console.warn(
|
|
13406
|
+
`[pre-warm] safety reveal msg=${msgId} `
|
|
13407
|
+
+ `vq=${vq ? vq.playState : "missing"}`,
|
|
13408
|
+
);
|
|
13409
|
+
this._revealPrewarmedBubble(msgId);
|
|
13410
|
+
};
|
|
13411
|
+
setTimeout(safetyTick, SAFETY_POLL_MS);
|
|
13412
|
+
}
|
|
13413
|
+
},
|
|
13414
|
+
|
|
13415
|
+
/** Reveal a previously-hidden pre-warmed chat bubble. Called from
|
|
13416
|
+
* _fireVoiceDone's promote logic and from the message-error SSE
|
|
13417
|
+
* handler. Idempotent · no-op if the bubble isn't marked. Drains
|
|
13418
|
+
* the persistent _prewarmedHidden set so subsequent renderChat
|
|
13419
|
+
* re-renders don't re-hide the bubble. */
|
|
13420
|
+
_revealPrewarmedBubble(messageId) {
|
|
13421
|
+
if (!messageId) return;
|
|
13422
|
+
this._prewarmedHidden.delete(messageId);
|
|
13423
|
+
const bubble = document.querySelector(`[data-message-id="${messageId}"]`);
|
|
13424
|
+
if (bubble && bubble.removeAttribute) bubble.removeAttribute("data-prewarmed");
|
|
13099
13425
|
},
|
|
13100
13426
|
|
|
13101
13427
|
/** "Thinking…" bouncing-dots placeholder shown before the first token. */
|
|
@@ -13120,6 +13446,12 @@
|
|
|
13120
13446
|
// chair-pending placeholder card (which is hidden in voice mode
|
|
13121
13447
|
// because the chat is hidden).
|
|
13122
13448
|
this.chairPending = true;
|
|
13449
|
+
// Phase string · drives the status-word substitution in the
|
|
13450
|
+
// round-table bubble so the user sees "Picking next speaker" /
|
|
13451
|
+
// "Considering clarify" / etc. instead of generic "Thinking".
|
|
13452
|
+
// i18n lookup happens at render time so locale changes apply
|
|
13453
|
+
// without re-firing the event.
|
|
13454
|
+
this.chairPendingPhase = String(phase || "");
|
|
13123
13455
|
// Repaint the stage so the seat picks up the thinking state.
|
|
13124
13456
|
// Cheap when stage is hidden (renderRoundTable bails fast).
|
|
13125
13457
|
this.renderRoundTable();
|
|
@@ -13173,6 +13505,7 @@
|
|
|
13173
13505
|
// ready / room-paused / etc).
|
|
13174
13506
|
const wasPending = this.chairPending === true;
|
|
13175
13507
|
this.chairPending = false;
|
|
13508
|
+
this.chairPendingPhase = "";
|
|
13176
13509
|
const chat = document.querySelector("[data-chat-messages]");
|
|
13177
13510
|
if (chat) {
|
|
13178
13511
|
const existing = chat.querySelector("[data-chair-pending]");
|
|
@@ -13210,34 +13543,42 @@
|
|
|
13210
13543
|
try { window.boardroomTypingSfx?.setThinking?.(false); } catch { /* ignore */ }
|
|
13211
13544
|
let q = this.voiceQueues[chunk.messageId];
|
|
13212
13545
|
if (!q) {
|
|
13213
|
-
//
|
|
13214
|
-
//
|
|
13215
|
-
//
|
|
13216
|
-
//
|
|
13217
|
-
//
|
|
13218
|
-
//
|
|
13219
|
-
//
|
|
13220
|
-
//
|
|
13221
|
-
//
|
|
13222
|
-
|
|
13223
|
-
|
|
13224
|
-
|
|
13225
|
-
|
|
13226
|
-
|
|
13227
|
-
|
|
13228
|
-
|
|
13229
|
-
|
|
13230
|
-
|
|
13231
|
-
|
|
13232
|
-
|
|
13233
|
-
|
|
13234
|
-
|
|
13235
|
-
|
|
13546
|
+
// Cross-director pipelining · the server now pre-warms B's
|
|
13547
|
+
// LLM/TTS while A's audio still plays on this client. When
|
|
13548
|
+
// chunks for B's messageId arrive, we MUST NOT tear down A's
|
|
13549
|
+
// queue · A still has audio buffered. Instead, create B's
|
|
13550
|
+
// queue in `playState: "queued"` so it doesn't auto-play.
|
|
13551
|
+
// _fireVoiceDone (when A finishes) promotes the oldest
|
|
13552
|
+
// "queued" sibling to "playing" + calls audio.play().
|
|
13553
|
+
// Serialisation race guard · check ONLY playState, NOT
|
|
13554
|
+
// audio.ended. Scenario the previous `!audio.ended` filter
|
|
13555
|
+
// hit: B is playing, last buffer drains → audio.ended becomes
|
|
13556
|
+
// true → audio.ended event queued in the event loop → BEFORE
|
|
13557
|
+
// _fireVoiceDone(B) runs, C's first voice-chunk arrives. With
|
|
13558
|
+
// the `!audio.ended` filter, B looks "done" → hasPlaying=false
|
|
13559
|
+
// → C marked "playing" → C.audio.play() → B AND C overlap
|
|
13560
|
+
// until _fireVoiceDone(B) finally ticks. The explicit promote
|
|
13561
|
+
// inside _fireVoiceDone is the single source of truth for
|
|
13562
|
+
// play-state transitions; until it runs, any playState===
|
|
13563
|
+
// "playing" entry in voiceQueues must block C from auto-play.
|
|
13564
|
+
const hasPlaying = Object.values(this.voiceQueues || {}).some(
|
|
13565
|
+
(other) => other && other.playState === "playing",
|
|
13566
|
+
);
|
|
13567
|
+
const playState = hasPlaying ? "queued" : "playing";
|
|
13568
|
+
q = this._createVoiceStream(roomId, chunk.messageId, playState);
|
|
13569
|
+
// Reveal a previously-hidden chat bubble · race fix · when
|
|
13570
|
+
// A's audio.ended fires BEFORE B's first voice-chunk arrives,
|
|
13571
|
+
// _fireVoiceDone(A) doesn't find B in voiceQueues yet (no
|
|
13572
|
+
// queue created), so it can't promote+reveal B. Now B's first
|
|
13573
|
+
// chunk arrives with no playing sibling left → B is created
|
|
13574
|
+
// as "playing" directly. Without this reveal, B's bubble
|
|
13575
|
+
// remains marked data-prewarmed from appendMessageDom and
|
|
13576
|
+
// never reappears in the chat. Safe to call unconditionally
|
|
13577
|
+
// when playState === "playing" · it's a no-op if the bubble
|
|
13578
|
+
// was never marked.
|
|
13579
|
+
if (playState === "playing") {
|
|
13580
|
+
this._revealPrewarmedBubble(chunk.messageId);
|
|
13236
13581
|
}
|
|
13237
|
-
// Stale queues torn down · the active queue is about to be
|
|
13238
|
-
// recreated below. Mini-player refresh runs once after the
|
|
13239
|
-
// new queue is inserted (next call site).
|
|
13240
|
-
q = this._createVoiceStream(roomId, chunk.messageId);
|
|
13241
13582
|
// Stash the speaker + current body on the queue so the
|
|
13242
13583
|
// mini-player can render speaker name / avatar / subtitle.
|
|
13243
13584
|
// Two sources to consult:
|
|
@@ -13262,6 +13603,25 @@
|
|
|
13262
13603
|
this._activeVoiceRoomId = roomId;
|
|
13263
13604
|
// Mini-player · refresh now that a new queue exists.
|
|
13264
13605
|
this.refreshMiniPlayer();
|
|
13606
|
+
// Topbar queue-head · a fresh "playing" queue (chair templated
|
|
13607
|
+
// voice that arrives outside the director queue, or the first
|
|
13608
|
+
// director on a cold start) means the audible speaker just
|
|
13609
|
+
// changed. Re-render so the head bar reflects who's actually
|
|
13610
|
+
// audible right now instead of whatever the last queue-update
|
|
13611
|
+
// SSE event said.
|
|
13612
|
+
if (playState === "playing") {
|
|
13613
|
+
this.renderQueue();
|
|
13614
|
+
}
|
|
13615
|
+
}
|
|
13616
|
+
// Watchdog feed · every arriving chunk resets the stall timer.
|
|
13617
|
+
// If we had previously surfaced a "stalled" state in the bubble,
|
|
13618
|
+
// un-flag it and repaint so the warning goes away the moment
|
|
13619
|
+
// chunks resume flowing.
|
|
13620
|
+
q.lastChunkAt = Date.now();
|
|
13621
|
+
if (q.stalled) {
|
|
13622
|
+
q.stalled = false;
|
|
13623
|
+
this.renderRoundTable();
|
|
13624
|
+
this.refreshMiniPlayer();
|
|
13265
13625
|
}
|
|
13266
13626
|
// Convert base64 to Uint8Array and append to the MSE SourceBuffer
|
|
13267
13627
|
const binary = atob(chunk.audioBase64);
|
|
@@ -13484,7 +13844,7 @@
|
|
|
13484
13844
|
},
|
|
13485
13845
|
|
|
13486
13846
|
/** Create a MediaSource-backed audio stream for one speaker's turn. */
|
|
13487
|
-
_createVoiceStream(roomId, messageId) {
|
|
13847
|
+
_createVoiceStream(roomId, messageId, playState = "playing") {
|
|
13488
13848
|
const audio = new Audio();
|
|
13489
13849
|
// Attach to the DOM · without this Chrome treats the element
|
|
13490
13850
|
// as "detached media" and the browser is free to pause it
|
|
@@ -13569,6 +13929,13 @@
|
|
|
13569
13929
|
this.refreshMiniPlayer();
|
|
13570
13930
|
// Re-assert without logging · fires ~4 Hz, would spam.
|
|
13571
13931
|
applyRate();
|
|
13932
|
+
// Heartbeat · while audio actively advances, POST
|
|
13933
|
+
// /voice-progress every ~5s so the orchestrator's
|
|
13934
|
+
// waitForVoicePlayback doesn't trip the no-heartbeat
|
|
13935
|
+
// fallback for long responses (slow TTS rate / many
|
|
13936
|
+
// sentences). The server resets its clock on each POST;
|
|
13937
|
+
// only true silence (tab killed) trips the fallback.
|
|
13938
|
+
this._maybeHeartbeatVoiceQueue(q);
|
|
13572
13939
|
});
|
|
13573
13940
|
|
|
13574
13941
|
const q = {
|
|
@@ -13581,6 +13948,14 @@
|
|
|
13581
13948
|
final: false,
|
|
13582
13949
|
doneSent: false,
|
|
13583
13950
|
ready: false,
|
|
13951
|
+
/** "playing" · audio.play() was called, browser is consuming
|
|
13952
|
+
* buffer through speaker. "queued" · pre-warmed for a later
|
|
13953
|
+
* promote; audio element exists + SourceBuffer fills + chunks
|
|
13954
|
+
* accumulate, but play() has not been called yet. _fireVoiceDone
|
|
13955
|
+
* promotes the oldest queued sibling when the playing queue
|
|
13956
|
+
* ends. enqueueVoiceChunk decides which to create based on
|
|
13957
|
+
* whether ANY existing queue is currently playing. */
|
|
13958
|
+
playState,
|
|
13584
13959
|
// Caption timing · the index of the caption whose audio bytes
|
|
13585
13960
|
// are currently being appended to the SourceBuffer. After
|
|
13586
13961
|
// `updateend` we record `buffered.end(0)` as that caption's
|
|
@@ -13588,7 +13963,83 @@
|
|
|
13588
13963
|
// finishes. Reading audio.currentTime against these ranges
|
|
13589
13964
|
// gives accurate per-chunk caption sync (no CBR assumption).
|
|
13590
13965
|
flushingIdx: -1,
|
|
13966
|
+
// Chunk-arrival watchdog · upstream TTS (MiniMax / ElevenLabs)
|
|
13967
|
+
// occasionally hangs mid-stream and the SourceBuffer just sits
|
|
13968
|
+
// there silently. lastChunkAt is bumped from enqueueVoiceChunk
|
|
13969
|
+
// on every chunk; the interval below checks "have we gone too
|
|
13970
|
+
// long without one?" and surfaces a Skip affordance in the
|
|
13971
|
+
// round-table bubble + auto-fires _fireVoiceDone after a
|
|
13972
|
+
// grace window so the server's waitForVoicePlayback resolves
|
|
13973
|
+
// and the next director starts.
|
|
13974
|
+
lastChunkAt: Date.now(),
|
|
13975
|
+
stalled: false,
|
|
13976
|
+
watchdogId: null,
|
|
13591
13977
|
};
|
|
13978
|
+
// Start the watchdog · 2s tick, warn at 8s idle, auto-skip at 15s.
|
|
13979
|
+
// Stops itself once q.final is true (audio.ended timeout in
|
|
13980
|
+
// _flushVoiceBuffer takes over) or when the queue is gone.
|
|
13981
|
+
//
|
|
13982
|
+
// Critical · "idle" means BOTH (no new chunks arriving) AND
|
|
13983
|
+
// (audio has caught up to whatever is currently buffered). If
|
|
13984
|
+
// the audio element is still playing through buffer we already
|
|
13985
|
+
// received, we're NOT stalled · we're mid-sentence, and the
|
|
13986
|
+
// server may take 5-15s of "real time" to flush the next
|
|
13987
|
+
// sentence's TTS chunks while the user keeps hearing the
|
|
13988
|
+
// previous one. Without this gate the watchdog fires
|
|
13989
|
+
// mid-utterance on perfectly healthy long-sentence playback.
|
|
13990
|
+
const VOICE_STALL_WARN_MS = 8000;
|
|
13991
|
+
const VOICE_STALL_SKIP_MS = 15000;
|
|
13992
|
+
const VOICE_AUDIO_AHEAD_GRACE_S = 0.5;
|
|
13993
|
+
q.watchdogId = setInterval(() => {
|
|
13994
|
+
const live = this.voiceQueues[q.messageId];
|
|
13995
|
+
if (!live || live !== q) { clearInterval(q.watchdogId); q.watchdogId = null; return; }
|
|
13996
|
+
if (q.final) { clearInterval(q.watchdogId); q.watchdogId = null; return; }
|
|
13997
|
+
// If audio still has buffer to consume, the user is hearing
|
|
13998
|
+
// sound · don't treat this as idle even if no new chunks
|
|
13999
|
+
// arrived recently. Three positive cases:
|
|
14000
|
+
// 1. We have undrained pendingBuffers that haven't been
|
|
14001
|
+
// appended to the SourceBuffer yet.
|
|
14002
|
+
// 2. The SourceBuffer has audio ahead of currentTime.
|
|
14003
|
+
// 3. Audio is paused with content remaining (user paused).
|
|
14004
|
+
let stillConsuming = false;
|
|
14005
|
+
try {
|
|
14006
|
+
if (q.pendingBuffers && q.pendingBuffers.length > 0) {
|
|
14007
|
+
stillConsuming = true;
|
|
14008
|
+
} else if (q.audio && !q.audio.ended) {
|
|
14009
|
+
const buf = q.audio.buffered;
|
|
14010
|
+
if (buf && buf.length > 0) {
|
|
14011
|
+
const aheadOfPlayhead = buf.end(buf.length - 1) - q.audio.currentTime;
|
|
14012
|
+
if (aheadOfPlayhead > VOICE_AUDIO_AHEAD_GRACE_S) stillConsuming = true;
|
|
14013
|
+
// Paused-with-content also counts · user explicitly held
|
|
14014
|
+
// playback, the watchdog shouldn't penalise the pause.
|
|
14015
|
+
if (q.audio.paused && aheadOfPlayhead > 0) stillConsuming = true;
|
|
14016
|
+
}
|
|
14017
|
+
}
|
|
14018
|
+
} catch (_) { /* defensive · treat as not-consuming on error */ }
|
|
14019
|
+
if (stillConsuming) {
|
|
14020
|
+
// Reset the stall warning if it was previously set ·
|
|
14021
|
+
// playback resumed naturally, no need for the amber pill.
|
|
14022
|
+
if (q.stalled) {
|
|
14023
|
+
q.stalled = false;
|
|
14024
|
+
this.renderRoundTable();
|
|
14025
|
+
this.refreshMiniPlayer();
|
|
14026
|
+
}
|
|
14027
|
+
return;
|
|
14028
|
+
}
|
|
14029
|
+
const idle = Date.now() - q.lastChunkAt;
|
|
14030
|
+
if (idle >= VOICE_STALL_SKIP_MS) {
|
|
14031
|
+
console.warn(`[voice-watchdog] auto-skip msg=${q.messageId} idle=${idle}ms (audio exhausted)`);
|
|
14032
|
+
clearInterval(q.watchdogId); q.watchdogId = null;
|
|
14033
|
+
this._fireVoiceDone(q);
|
|
14034
|
+
return;
|
|
14035
|
+
}
|
|
14036
|
+
if (idle >= VOICE_STALL_WARN_MS && !q.stalled) {
|
|
14037
|
+
q.stalled = true;
|
|
14038
|
+
console.warn(`[voice-watchdog] stall warn msg=${q.messageId} idle=${idle}ms (audio exhausted)`);
|
|
14039
|
+
this.renderRoundTable();
|
|
14040
|
+
this.refreshMiniPlayer();
|
|
14041
|
+
}
|
|
14042
|
+
}, 2000);
|
|
13592
14043
|
|
|
13593
14044
|
ms.addEventListener("sourceopen", () => {
|
|
13594
14045
|
try {
|
|
@@ -13614,12 +14065,91 @@
|
|
|
13614
14065
|
}
|
|
13615
14066
|
});
|
|
13616
14067
|
|
|
13617
|
-
// Start playing as soon as we have some data
|
|
13618
|
-
|
|
14068
|
+
// Start playing as soon as we have some data — but only if this
|
|
14069
|
+
// queue is the active "playing" one. Pre-warmed queues stay in
|
|
14070
|
+
// "queued" state and have their audio.play() deferred until
|
|
14071
|
+
// _fireVoiceDone promotes them when the prior speaker finishes.
|
|
14072
|
+
// Routed through _playVoiceQueueAudio so the hard concurrency
|
|
14073
|
+
// guard catches any cross-director overlap (audio.play() called
|
|
14074
|
+
// while another queue's audio is still mid-playback).
|
|
14075
|
+
if (q.playState === "playing") {
|
|
14076
|
+
this._playVoiceQueueAudio(q, "create-stream");
|
|
14077
|
+
}
|
|
13619
14078
|
|
|
13620
14079
|
return q;
|
|
13621
14080
|
},
|
|
13622
14081
|
|
|
14082
|
+
/** Hard concurrency guard around HTMLAudioElement.play() for voice
|
|
14083
|
+
* queues. Multiple voice elements playing concurrently is the
|
|
14084
|
+
* entire class of "B starts before A finishes" overlap bugs — no
|
|
14085
|
+
* matter what code path requests the play, the guarantee is:
|
|
14086
|
+
* AT MOST ONE voice queue's audio is unpaused at any time.
|
|
14087
|
+
*
|
|
14088
|
+
* Behaviour: before calling .play(), scan every other queue's
|
|
14089
|
+
* audio element. If any has `!paused && !ended`, refuse the new
|
|
14090
|
+
* play (keep this queue in "queued" state) and log a stack trace
|
|
14091
|
+
* so the offending call path can be identified. _fireVoiceDone
|
|
14092
|
+
* for the actually-playing queue will retry the promote loop
|
|
14093
|
+
* when its audio ends, picking this queue back up.
|
|
14094
|
+
*
|
|
14095
|
+
* Returns true when play was started, false when refused. */
|
|
14096
|
+
_playVoiceQueueAudio(q, reason) {
|
|
14097
|
+
if (!q || !q.audio) return false;
|
|
14098
|
+
const conflicts = [];
|
|
14099
|
+
for (const mid of Object.keys(this.voiceQueues)) {
|
|
14100
|
+
if (mid === q.messageId) continue;
|
|
14101
|
+
const other = this.voiceQueues[mid];
|
|
14102
|
+
if (!other || !other.audio) continue;
|
|
14103
|
+
if (other.audio.paused) continue;
|
|
14104
|
+
if (other.audio.ended) continue;
|
|
14105
|
+
conflicts.push({
|
|
14106
|
+
messageId: mid,
|
|
14107
|
+
playState: other.playState,
|
|
14108
|
+
currentTime: other.audio.currentTime,
|
|
14109
|
+
duration: other.audio.duration,
|
|
14110
|
+
});
|
|
14111
|
+
}
|
|
14112
|
+
if (conflicts.length > 0) {
|
|
14113
|
+
console.warn(
|
|
14114
|
+
`[voice-overlap-guard] refusing audio.play() msg=${q.messageId} reason=${reason} · `
|
|
14115
|
+
+ `${conflicts.length} other queue(s) still audible:`,
|
|
14116
|
+
conflicts,
|
|
14117
|
+
);
|
|
14118
|
+
// Keep this one queued · _fireVoiceDone's promote loop will
|
|
14119
|
+
// retry once the conflicting audio ends.
|
|
14120
|
+
q.playState = "queued";
|
|
14121
|
+
return false;
|
|
14122
|
+
}
|
|
14123
|
+
q.playState = "playing";
|
|
14124
|
+
try {
|
|
14125
|
+
const p = q.audio.play();
|
|
14126
|
+
if (p && typeof p.catch === "function") p.catch(() => { /* autoplay block */ });
|
|
14127
|
+
} catch (_) { /* defensive */ }
|
|
14128
|
+
return true;
|
|
14129
|
+
},
|
|
14130
|
+
|
|
14131
|
+
/** Throttled heartbeat POST · keeps the server's
|
|
14132
|
+
* waitForVoicePlayback timer alive while this queue's audio is
|
|
14133
|
+
* actively producing sound. Only the currently-playing queue
|
|
14134
|
+
* heartbeats; queued (pre-warmed) queues stay silent so the
|
|
14135
|
+
* server doesn't think they're already playing. Fires at most
|
|
14136
|
+
* once per HEARTBEAT_INTERVAL_MS · 5000 leaves plenty of buffer
|
|
14137
|
+
* inside the server's 60s no-heartbeat fallback while keeping
|
|
14138
|
+
* the network noise low (~12 reqs/min per voice queue). */
|
|
14139
|
+
_maybeHeartbeatVoiceQueue(q) {
|
|
14140
|
+
if (!q || !q.audio || !q.roomId || !q.messageId) return;
|
|
14141
|
+
if (q.playState !== "playing") return;
|
|
14142
|
+
if (q.audio.paused || q.audio.ended) return;
|
|
14143
|
+
const HEARTBEAT_INTERVAL_MS = 5000;
|
|
14144
|
+
const now = Date.now();
|
|
14145
|
+
if (q._lastHeartbeatAt && now - q._lastHeartbeatAt < HEARTBEAT_INTERVAL_MS) return;
|
|
14146
|
+
q._lastHeartbeatAt = now;
|
|
14147
|
+
fetch(
|
|
14148
|
+
`/api/rooms/${encodeURIComponent(q.roomId)}/messages/${encodeURIComponent(q.messageId)}/voice-progress`,
|
|
14149
|
+
{ method: "POST" },
|
|
14150
|
+
).catch(() => { /* best-effort */ });
|
|
14151
|
+
},
|
|
14152
|
+
|
|
13623
14153
|
/** Flush pending buffers into the SourceBuffer one at a time. */
|
|
13624
14154
|
_flushVoiceBuffer(q) {
|
|
13625
14155
|
if (!q.ready || !q.sourceBuffer || q.sourceBuffer.updating) return;
|
|
@@ -13650,24 +14180,201 @@
|
|
|
13650
14180
|
} catch (_) {}
|
|
13651
14181
|
// Wait for audio to finish playing, then fire voice-done
|
|
13652
14182
|
q.audio.addEventListener("ended", () => this._fireVoiceDone(q));
|
|
13653
|
-
// Safety timeout
|
|
13654
|
-
|
|
13655
|
-
|
|
13656
|
-
|
|
13657
|
-
|
|
13658
|
-
|
|
13659
|
-
|
|
14183
|
+
// Safety timeout · ONLY arm for queues that are actually
|
|
14184
|
+
// playing. A queued (pre-warmed) queue has audio.currentTime
|
|
14185
|
+
// === 0 because audio.play() was never called; the duration-
|
|
14186
|
+
// based remaining estimate is then "all of B's audio" and the
|
|
14187
|
+
// timer fires (duration + 2s) AFTER voice-final ─ which is
|
|
14188
|
+
// typically BEFORE A's audio.ended. _fireVoiceDone(B) then
|
|
14189
|
+
// runs prematurely · the promote loop runs while A is still
|
|
14190
|
+
// playing, promotes the next queued sibling, and B/C overlap
|
|
14191
|
+
// with A. _fireVoiceDone's promote loop re-arms the safety
|
|
14192
|
+
// timer for the newly-promoted queue.
|
|
14193
|
+
if (q.playState === "playing") {
|
|
14194
|
+
this._armVoiceFinalSafetyTimer(q);
|
|
14195
|
+
}
|
|
13660
14196
|
}
|
|
13661
14197
|
},
|
|
13662
14198
|
|
|
14199
|
+
/** Arm the safety timeout that force-fires _fireVoiceDone if the
|
|
14200
|
+
* HTMLAudioElement's `ended` event never arrives (empty stream,
|
|
14201
|
+
* browser bug, etc.). Computed at arm-time so duration / current-
|
|
14202
|
+
* Time reflect the actual playback state — never set this while
|
|
14203
|
+
* the queue is "queued" (currentTime is stuck at 0 and the timer
|
|
14204
|
+
* fires before the queue even gets to play). Idempotent · second
|
|
14205
|
+
* call is a no-op. */
|
|
14206
|
+
_armVoiceFinalSafetyTimer(q) {
|
|
14207
|
+
if (q.safetyTimerArmed) return;
|
|
14208
|
+
q.safetyTimerArmed = true;
|
|
14209
|
+
const duration = q.audio.duration;
|
|
14210
|
+
const remaining = isFinite(duration) ? (duration - q.audio.currentTime) : 0;
|
|
14211
|
+
setTimeout(() => {
|
|
14212
|
+
if (!this.voiceQueues[q.messageId]) return; // already fired
|
|
14213
|
+
// Don't kill a queue whose audio is still actively advancing.
|
|
14214
|
+
// If currentTime is still meaningfully short of duration, the
|
|
14215
|
+
// 'ended' event hasn't fired yet for legitimate reasons (slow
|
|
14216
|
+
// browser tick, paused-then-resumed, etc.). Re-arm with a
|
|
14217
|
+
// shorter grace and bail.
|
|
14218
|
+
try {
|
|
14219
|
+
if (q.audio && !q.audio.ended) {
|
|
14220
|
+
const cur = q.audio.currentTime || 0;
|
|
14221
|
+
const dur = isFinite(q.audio.duration) ? q.audio.duration : 0;
|
|
14222
|
+
if (dur > 0 && cur < dur - 0.25) {
|
|
14223
|
+
q.safetyTimerArmed = false;
|
|
14224
|
+
setTimeout(() => {
|
|
14225
|
+
if (!this.voiceQueues[q.messageId]) return;
|
|
14226
|
+
this._fireVoiceDone(q);
|
|
14227
|
+
}, Math.max(1000, (dur - cur) * 1000 + 1500));
|
|
14228
|
+
return;
|
|
14229
|
+
}
|
|
14230
|
+
}
|
|
14231
|
+
} catch (_) { /* fall through · force-fire on inspection error */ }
|
|
14232
|
+
this._fireVoiceDone(q);
|
|
14233
|
+
}, (remaining * 1000) + 2000);
|
|
14234
|
+
},
|
|
14235
|
+
|
|
13663
14236
|
_scheduleVoiceDone(q) {
|
|
13664
14237
|
// Called from voice-final handler — trigger flush check which will
|
|
13665
14238
|
// endOfStream + wait for playback to finish.
|
|
13666
14239
|
this._flushVoiceBuffer(q);
|
|
13667
14240
|
},
|
|
13668
14241
|
|
|
14242
|
+
/* ── LLM first-token watchdog ──────────────────────────────────
|
|
14243
|
+
Per-message timer that flips a streaming agent message's local
|
|
14244
|
+
phase to "llm-warming" when no token has arrived in 8s. Pairs
|
|
14245
|
+
with the server-side 60s hard cap in streamSpeakerTurn —
|
|
14246
|
+
client surfaces an early visual hint long before the server
|
|
14247
|
+
times out, so the user doesn't sit looking at a static
|
|
14248
|
+
"Thinking" bubble. */
|
|
14249
|
+
_llmFirstTokenTimers: null,
|
|
14250
|
+
_LLM_WARM_DELAY_MS: 8000,
|
|
14251
|
+
_startLlmFirstTokenWatchdog(messageId) {
|
|
14252
|
+
if (!messageId) return;
|
|
14253
|
+
if (!this._llmFirstTokenTimers) this._llmFirstTokenTimers = {};
|
|
14254
|
+
this._clearLlmFirstTokenWatchdog(messageId);
|
|
14255
|
+
this._llmFirstTokenTimers[messageId] = setTimeout(() => {
|
|
14256
|
+
const msg = this.currentMessages.find((m) => m.id === messageId);
|
|
14257
|
+
if (!msg || !msg.meta) return;
|
|
14258
|
+
if (msg.meta.streaming !== true) return;
|
|
14259
|
+
if (String(msg.body || "").trim().length > 0) return; // tokens already arrived
|
|
14260
|
+
msg.meta._localPhase = "llm-warming";
|
|
14261
|
+
this.renderRoundTable();
|
|
14262
|
+
this.renderRtSubtitle();
|
|
14263
|
+
}, this._LLM_WARM_DELAY_MS);
|
|
14264
|
+
},
|
|
14265
|
+
_clearLlmFirstTokenWatchdog(messageId) {
|
|
14266
|
+
if (!this._llmFirstTokenTimers || !this._llmFirstTokenTimers[messageId]) return;
|
|
14267
|
+
clearTimeout(this._llmFirstTokenTimers[messageId]);
|
|
14268
|
+
delete this._llmFirstTokenTimers[messageId];
|
|
14269
|
+
},
|
|
14270
|
+
|
|
14271
|
+
/* ── Auto-skip toast ───────────────────────────────────────────
|
|
14272
|
+
Server emits config-event kind=auto-skipped with
|
|
14273
|
+
payload={phase, reason, messageId?}. We render a 3s amber
|
|
14274
|
+
toast at the top of the stage so the user understands WHY the
|
|
14275
|
+
speaker jumped / why the chair didn't ask. The reason field
|
|
14276
|
+
maps to a specific i18n key (snake_case translation of the
|
|
14277
|
+
reason string), with a generic phase-based fallback. */
|
|
14278
|
+
_showAutoSkipToast(phase, reason) {
|
|
14279
|
+
const reasonKey = `auto_skip_${String(reason || "").replace(/-/g, "_")}`;
|
|
14280
|
+
const phaseKey = `auto_skip_${String(phase || "")}_timeout`;
|
|
14281
|
+
let label = this._t(reasonKey);
|
|
14282
|
+
if (label === reasonKey) label = this._t(phaseKey);
|
|
14283
|
+
if (label === phaseKey) label = this._t("rt_audio_stalled"); // last-ditch
|
|
14284
|
+
const stage = document.querySelector("[data-roundtable-stage]");
|
|
14285
|
+
const host = stage || document.body;
|
|
14286
|
+
const toast = document.createElement("div");
|
|
14287
|
+
toast.className = "auto-skip-toast";
|
|
14288
|
+
toast.textContent = label;
|
|
14289
|
+
host.appendChild(toast);
|
|
14290
|
+
// Force a layout read so the CSS animation kicks in reliably.
|
|
14291
|
+
void toast.offsetHeight;
|
|
14292
|
+
toast.classList.add("is-visible");
|
|
14293
|
+
setTimeout(() => {
|
|
14294
|
+
toast.classList.remove("is-visible");
|
|
14295
|
+
setTimeout(() => { try { toast.remove(); } catch (_) {} }, 250);
|
|
14296
|
+
}, 3000);
|
|
14297
|
+
},
|
|
14298
|
+
|
|
13669
14299
|
_fireVoiceDone(q) {
|
|
13670
14300
|
if (!this.voiceQueues[q.messageId]) return; // already fired
|
|
14301
|
+
// Stop the chunk-arrival watchdog before tearing down the queue
|
|
14302
|
+
// so a final tick can't race the delete and end up trying to
|
|
14303
|
+
// skip again.
|
|
14304
|
+
if (q.watchdogId) {
|
|
14305
|
+
clearInterval(q.watchdogId);
|
|
14306
|
+
q.watchdogId = null;
|
|
14307
|
+
}
|
|
14308
|
+
// PAUSE FIRST · before the promote loop starts the next queue's
|
|
14309
|
+
// audio. The concurrency guard (_playVoiceQueueAudio) scans all
|
|
14310
|
+
// OTHER queues for unpaused audio — if q.audio is still playing
|
|
14311
|
+
// (e.g. watchdog / safety-timer-triggered force-fire path), the
|
|
14312
|
+
// guard would refuse to promote the next queue. Pausing q here
|
|
14313
|
+
// makes the guard's conflict check correctly exclude q. Also
|
|
14314
|
+
// mark playState='ended' as belt-and-braces so callers that
|
|
14315
|
+
// check playState see "definitely not playing" even before the
|
|
14316
|
+
// delete below.
|
|
14317
|
+
try { q.audio && q.audio.pause(); } catch (_) {}
|
|
14318
|
+
q.playState = "ended";
|
|
14319
|
+
// Audio truly ended · NOW clear the chat bubble's streaming
|
|
14320
|
+
// pulse animation. message-final kept the class alive while
|
|
14321
|
+
// the voice queue existed (LLM done but TTS still playing).
|
|
14322
|
+
// Look up the message's body so updateMessageBodyDom paints
|
|
14323
|
+
// the final text; falls back to empty string when the message
|
|
14324
|
+
// was removed (message-removed race).
|
|
14325
|
+
const finishedMsg = this.currentMessages
|
|
14326
|
+
? this.currentMessages.find((m) => m.id === q.messageId)
|
|
14327
|
+
: null;
|
|
14328
|
+
this.updateMessageBodyDom(q.messageId, finishedMsg ? finishedMsg.body : "", false);
|
|
14329
|
+
// Cross-director pipeline · before deleting the just-finished
|
|
14330
|
+
// queue, find the next "queued" sibling (pre-warmed by the
|
|
14331
|
+
// server while q was playing) and promote it. The promoted
|
|
14332
|
+
// queue's audio.play() starts immediately — its chunks are
|
|
14333
|
+
// already buffered, so the user hears B with near-zero gap
|
|
14334
|
+
// after A.ended. Insertion order in voiceQueues mirrors
|
|
14335
|
+
// arrival order; iterate Object.keys to pick the OLDEST queued
|
|
14336
|
+
// (the one that has been waiting longest = closest to ready).
|
|
14337
|
+
for (const otherId of Object.keys(this.voiceQueues)) {
|
|
14338
|
+
if (otherId === q.messageId) continue;
|
|
14339
|
+
const other = this.voiceQueues[otherId];
|
|
14340
|
+
if (!other || other.playState !== "queued") continue;
|
|
14341
|
+
// Concurrency guard · q is already paused (q.audio.pause()
|
|
14342
|
+
// at top of _fireVoiceDone). promote sets playState + calls
|
|
14343
|
+
// play() through _playVoiceQueueAudio so any concurrent
|
|
14344
|
+
// audible queue gets surfaced before we start.
|
|
14345
|
+
const started = this._playVoiceQueueAudio(other, "promote-after-end");
|
|
14346
|
+
if (!started) {
|
|
14347
|
+
// Refused · some other queue still has audible audio.
|
|
14348
|
+
// Schedule a one-shot retry so the queued queue isn't
|
|
14349
|
+
// stranded forever. The conflicting queue will eventually
|
|
14350
|
+
// fire _fireVoiceDone (audio.ended / safety timer); at that
|
|
14351
|
+
// point its own promote loop will pick this one up. The
|
|
14352
|
+
// retry is just a belt-and-braces in case both signals
|
|
14353
|
+
// miss (browser glitch in audio.ended).
|
|
14354
|
+
setTimeout(() => {
|
|
14355
|
+
const liveOther = this.voiceQueues[otherId];
|
|
14356
|
+
if (!liveOther || liveOther.playState !== "queued") return;
|
|
14357
|
+
this._playVoiceQueueAudio(liveOther, "promote-retry");
|
|
14358
|
+
if (liveOther.playState === "playing") {
|
|
14359
|
+
this._revealPrewarmedBubble(otherId);
|
|
14360
|
+
if (liveOther.final && liveOther.doneSent && !liveOther.safetyTimerArmed) {
|
|
14361
|
+
this._armVoiceFinalSafetyTimer(liveOther);
|
|
14362
|
+
}
|
|
14363
|
+
}
|
|
14364
|
+
}, 500);
|
|
14365
|
+
break;
|
|
14366
|
+
}
|
|
14367
|
+
// Reveal the chat bubble for the newly-audible director ·
|
|
14368
|
+
// appendMessageDom hid it earlier because A was still playing.
|
|
14369
|
+
this._revealPrewarmedBubble(otherId);
|
|
14370
|
+
// Re-arm the safety timer if it was deferred · queued queues
|
|
14371
|
+
// skip _armVoiceFinalSafetyTimer in _flushVoiceBuffer because
|
|
14372
|
+
// currentTime=0 would make the timer fire mid-playback.
|
|
14373
|
+
if (other.final && other.doneSent && !other.safetyTimerArmed) {
|
|
14374
|
+
this._armVoiceFinalSafetyTimer(other);
|
|
14375
|
+
}
|
|
14376
|
+
break;
|
|
14377
|
+
}
|
|
13671
14378
|
delete this.voiceQueues[q.messageId];
|
|
13672
14379
|
if (this._voiceSeenSeqs) delete this._voiceSeenSeqs[q.messageId];
|
|
13673
14380
|
// Detach from the DOM host · we appended on creation in
|
|
@@ -13679,8 +14386,8 @@
|
|
|
13679
14386
|
// cross-room queue?" so hide it the moment the active queue
|
|
13680
14387
|
// drains. Subsequent queues for the same room re-trigger it.
|
|
13681
14388
|
this.refreshMiniPlayer();
|
|
13682
|
-
//
|
|
13683
|
-
|
|
14389
|
+
// q.audio.pause() already called at top · revoke the URL +
|
|
14390
|
+
// detach from DOM only.
|
|
13684
14391
|
try { URL.revokeObjectURL(q.audio.src); } catch (_) {}
|
|
13685
14392
|
fetch(`/api/rooms/${encodeURIComponent(q.roomId)}/messages/${encodeURIComponent(q.messageId)}/voice-done`, {
|
|
13686
14393
|
method: "POST",
|
|
@@ -13694,6 +14401,13 @@
|
|
|
13694
14401
|
// Subtitle · voice playback ended. If nobody else is mid-
|
|
13695
14402
|
// stream / queued the panel hides itself.
|
|
13696
14403
|
this.renderRtSubtitle();
|
|
14404
|
+
// Topbar queue-head · the audible speaker may have just
|
|
14405
|
+
// rotated (promote in the loop above) and the server's
|
|
14406
|
+
// queue-update for the new speaker hasn't landed yet.
|
|
14407
|
+
// renderQueueCollapsed prefers the voiceQueues "playing"
|
|
14408
|
+
// speaker, so a re-render here keeps the topbar in sync with
|
|
14409
|
+
// the round-table seat instead of lagging behind the SSE.
|
|
14410
|
+
this.renderQueue();
|
|
13697
14411
|
// Auto-continue countdown · canAutoContinue refuses to spin
|
|
13698
14412
|
// up the timer while the chair's voice queue is still active,
|
|
13699
14413
|
// so the round-prompt's ~5-10s read-aloud doesn't eat into
|
|
@@ -13716,6 +14430,7 @@
|
|
|
13716
14430
|
stopVoicePlayback() {
|
|
13717
14431
|
for (const messageId of Object.keys(this.voiceQueues)) {
|
|
13718
14432
|
const q = this.voiceQueues[messageId];
|
|
14433
|
+
if (q && q.watchdogId) { try { clearInterval(q.watchdogId); } catch (_) {} q.watchdogId = null; }
|
|
13719
14434
|
if (q && q.audio) {
|
|
13720
14435
|
try { q.audio.pause(); } catch (_) {}
|
|
13721
14436
|
try { URL.revokeObjectURL(q.audio.src); } catch (_) {}
|
|
@@ -13849,11 +14564,17 @@
|
|
|
13849
14564
|
if (!el) return;
|
|
13850
14565
|
this._ensureMiniPlayerWave();
|
|
13851
14566
|
let source = null;
|
|
13852
|
-
// (1) Live TTS ·
|
|
14567
|
+
// (1) Live TTS · the queue that is currently AUDIBLE. With
|
|
14568
|
+
// cross-director pipelining we may also have a "queued"
|
|
14569
|
+
// pre-warmed queue sitting in voiceQueues with audio
|
|
14570
|
+
// buffered but play() not yet called · the mini-player
|
|
14571
|
+
// reflects who the user is hearing right now, not who's
|
|
14572
|
+
// ready next.
|
|
13853
14573
|
for (const mid of Object.keys(this.voiceQueues || {})) {
|
|
13854
14574
|
const q = this.voiceQueues[mid];
|
|
13855
14575
|
if (!q || !q.audio) continue;
|
|
13856
14576
|
if (q.audio.ended) continue;
|
|
14577
|
+
if (q.playState !== "playing") continue;
|
|
13857
14578
|
source = {
|
|
13858
14579
|
kind: "live",
|
|
13859
14580
|
roomId: q.roomId,
|
|
@@ -13959,8 +14680,24 @@
|
|
|
13959
14680
|
source = null;
|
|
13960
14681
|
}
|
|
13961
14682
|
}
|
|
13962
|
-
|
|
13963
|
-
|
|
14683
|
+
// Visibility · hide only when the user is actively viewing the
|
|
14684
|
+
// same room as the playing source. "Actively viewing" means BOTH
|
|
14685
|
+
// currentRoomId matches AND the sidebar's active tab is rooms.
|
|
14686
|
+
// When the user switches to the Agents tab (or any non-rooms
|
|
14687
|
+
// tab), currentRoomId stays sticky to the last-opened room, but
|
|
14688
|
+
// the user is no longer looking at the room's stream — the
|
|
14689
|
+
// player is now their only anchor back to the live audio, so it
|
|
14690
|
+
// has to surface even though the ids match.
|
|
14691
|
+
const activeTabEl = document.querySelector(".sidebar-tab.active[data-sidebar-tab]");
|
|
14692
|
+
const activeTab = activeTabEl ? activeTabEl.dataset.sidebarTab : null;
|
|
14693
|
+
const viewingRoomsTab = !activeTab || activeTab === "rooms";
|
|
14694
|
+
const viewingSameRoom = !!(
|
|
14695
|
+
source &&
|
|
14696
|
+
source.roomId &&
|
|
14697
|
+
source.roomId === this.currentRoomId &&
|
|
14698
|
+
viewingRoomsTab
|
|
14699
|
+
);
|
|
14700
|
+
if (!source || viewingSameRoom) {
|
|
13964
14701
|
el.hidden = true;
|
|
13965
14702
|
el.removeAttribute("data-mini-player-room-id");
|
|
13966
14703
|
el.removeAttribute("data-mini-player-kind");
|
|
@@ -14998,8 +15735,19 @@
|
|
|
14998
15735
|
const authorIdAttr = (!isUser && !isChair && author?.id)
|
|
14999
15736
|
? ` data-author-id="${this.escape(author.id)}"`
|
|
15000
15737
|
: "";
|
|
15738
|
+
// Pre-warm hidden state · baked into the article tag so it
|
|
15739
|
+
// survives every renderChat re-render. Set membership is the
|
|
15740
|
+
// source of truth (appendMessageDom adds, _revealPrewarmedBubble
|
|
15741
|
+
// drains). Without this, a renderChat that fires between
|
|
15742
|
+
// message-appended and audio promote (e.g. message-updated,
|
|
15743
|
+
// brief-update, round-ended) wipes the data-prewarmed attribute
|
|
15744
|
+
// and the still-pre-warmed bubble flashes its streaming
|
|
15745
|
+
// animation behind the current speaker.
|
|
15746
|
+
const prewarmedAttr = (this._prewarmedHidden && this._prewarmedHidden.has(m.id))
|
|
15747
|
+
? " data-prewarmed=\"\""
|
|
15748
|
+
: "";
|
|
15001
15749
|
return `
|
|
15002
|
-
<article class="msg ${baseCls}${stateCls.length ? " " + stateCls.join(" ") : ""}" data-message-id="${this.escape(m.id)}" data-meta-kind="${this.escape(metaKind || "")}"${authorIdAttr}>
|
|
15750
|
+
<article class="msg ${baseCls}${stateCls.length ? " " + stateCls.join(" ") : ""}" data-message-id="${this.escape(m.id)}" data-meta-kind="${this.escape(metaKind || "")}"${authorIdAttr}${prewarmedAttr}>
|
|
15003
15751
|
${avatarHtml}
|
|
15004
15752
|
<div class="msg-content">
|
|
15005
15753
|
${chairPickKicker}
|
|
@@ -15286,15 +16034,56 @@
|
|
|
15286
16034
|
renderQueueCollapsed(items) {
|
|
15287
16035
|
const slot = document.querySelector("[data-queue-collapsed]");
|
|
15288
16036
|
if (!slot) return;
|
|
16037
|
+
// Cross-director pipeline · prefer the client's actually-audible
|
|
16038
|
+
// speaker over server's queue[0]. With pre-warm, server's queue
|
|
16039
|
+
// can lag the client briefly (between `_fireVoiceDone(A)` →
|
|
16040
|
+
// promote B → server's queue-update for B speaking) — during
|
|
16041
|
+
// that window queue[0] is still A but the user is hearing B.
|
|
16042
|
+
// The round-table seat already uses voiceQueues, so without this
|
|
16043
|
+
// sync the topbar queue-head shows a different name than the
|
|
16044
|
+
// round-table bubble.
|
|
16045
|
+
let liveAuthorId = null;
|
|
16046
|
+
const voiceQueues = this.voiceQueues || {};
|
|
16047
|
+
for (const mid of Object.keys(voiceQueues)) {
|
|
16048
|
+
const q = voiceQueues[mid];
|
|
16049
|
+
if (q && q.playState === "playing" && q.authorId) {
|
|
16050
|
+
liveAuthorId = q.authorId;
|
|
16051
|
+
break;
|
|
16052
|
+
}
|
|
16053
|
+
}
|
|
15289
16054
|
if (!items || items.length === 0) {
|
|
16055
|
+
// Even with no server queue, if a chair / director is audible
|
|
16056
|
+
// on the client (e.g. chair templated voice plays after the
|
|
16057
|
+
// round drains), surface them in the head bar instead of
|
|
16058
|
+
// sitting on "idle".
|
|
16059
|
+
if (liveAuthorId && this.agentsById[liveAuthorId]) {
|
|
16060
|
+
const liveAgent = this.agentsById[liveAuthorId];
|
|
16061
|
+
slot.innerHTML = `<span class="sum-marker">▶</span>`
|
|
16062
|
+
+ `<img class="sum-av" src="${this.escape(liveAgent.avatarPath)}" alt="" data-agent="${this.escape(liveAgent.id)}">`
|
|
16063
|
+
+ `<span class="sum-who">${this.escape(liveAgent.name)}</span>`
|
|
16064
|
+
+ `<span class="sum-state">${this.escape(this._t("q_speaking"))}</span>`;
|
|
16065
|
+
return;
|
|
16066
|
+
}
|
|
15290
16067
|
slot.innerHTML = `<span class="sum-marker">·</span><span class="sum-state">${this.escape(this._t("q_idle"))}</span>`;
|
|
15291
16068
|
return;
|
|
15292
16069
|
}
|
|
15293
16070
|
const head = items[0];
|
|
15294
|
-
|
|
16071
|
+
// If a different director is currently audible (server queue is
|
|
16072
|
+
// racing behind client promote), build the head from the live
|
|
16073
|
+
// speaker. Otherwise fall back to queue[0]. Match against items
|
|
16074
|
+
// first so the queue's own status pill is preserved when the
|
|
16075
|
+
// live speaker IS queue[0].
|
|
16076
|
+
let a = null;
|
|
16077
|
+
let displayStatus = head.status;
|
|
16078
|
+
if (liveAuthorId && this.agentsById[liveAuthorId] && liveAuthorId !== head.agentId) {
|
|
16079
|
+
a = this.agentsById[liveAuthorId];
|
|
16080
|
+
displayStatus = "speaking";
|
|
16081
|
+
} else {
|
|
16082
|
+
a = this.agentsById[head.agentId];
|
|
16083
|
+
}
|
|
15295
16084
|
if (!a) { slot.innerHTML = ""; return; }
|
|
15296
|
-
const speaking =
|
|
15297
|
-
const pending =
|
|
16085
|
+
const speaking = displayStatus === "speaking";
|
|
16086
|
+
const pending = displayStatus === "pending";
|
|
15298
16087
|
const stateLabel = speaking
|
|
15299
16088
|
? this._t("q_speaking")
|
|
15300
16089
|
: pending
|
|
@@ -15896,22 +16685,53 @@
|
|
|
15896
16685
|
replayBody = replayActive.body || "";
|
|
15897
16686
|
}
|
|
15898
16687
|
const msgs = this.currentMessages || [];
|
|
15899
|
-
|
|
16688
|
+
// (1) Audible voice queue takes precedence · with cross-director
|
|
16689
|
+
// pipelining, the most-recent streaming message is often the
|
|
16690
|
+
// PRE-WARMED next speaker (B's placeholder lands while A's
|
|
16691
|
+
// audio still plays). The seat that lights up must match
|
|
16692
|
+
// whoever the user is HEARING — not whoever's text is
|
|
16693
|
+
// streaming in the background. Scan voiceQueues for the
|
|
16694
|
+
// playing one and resolve back to its message's author.
|
|
16695
|
+
if (!speakingId && this.voiceQueues) {
|
|
15900
16696
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
15901
16697
|
const mm = msgs[i];
|
|
15902
|
-
if (mm
|
|
16698
|
+
if (!mm || mm.authorKind !== "agent") continue;
|
|
16699
|
+
const vq = this.voiceQueues[mm.id];
|
|
16700
|
+
if (vq && vq.playState === "playing") {
|
|
15903
16701
|
speakingId = mm.authorId;
|
|
15904
|
-
|
|
15905
|
-
speakerState = body.length > 0 ? "speaking" : "thinking";
|
|
16702
|
+
speakerState = "speaking";
|
|
15906
16703
|
break;
|
|
15907
16704
|
}
|
|
15908
16705
|
}
|
|
15909
16706
|
}
|
|
15910
|
-
|
|
16707
|
+
// (2) Streaming message fallback · only relevant when no audible
|
|
16708
|
+
// queue exists (text mode, OR the brief warmup between
|
|
16709
|
+
// message-appended and first voice-chunk). Picks the most
|
|
16710
|
+
// recent streaming agent message that ISN'T pre-warmed —
|
|
16711
|
+
// pre-warmed messages have a voiceQueue with playState
|
|
16712
|
+
// "queued" and should NOT light up a seat.
|
|
16713
|
+
if (!speakingId) {
|
|
16714
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
16715
|
+
const mm = msgs[i];
|
|
16716
|
+
if (!(mm && mm.meta && mm.meta.streaming === true && mm.authorKind === "agent")) continue;
|
|
16717
|
+
// Skip queued (pre-warmed) speakers · their voice waits for
|
|
16718
|
+
// the playing speaker's audio to finish.
|
|
16719
|
+
const vq = this.voiceQueues && this.voiceQueues[mm.id];
|
|
16720
|
+
if (vq && vq.playState === "queued") continue;
|
|
16721
|
+
speakingId = mm.authorId;
|
|
16722
|
+
const body = String(mm.body || "").trim();
|
|
16723
|
+
speakerState = body.length > 0 ? "speaking" : "thinking";
|
|
16724
|
+
break;
|
|
16725
|
+
}
|
|
16726
|
+
}
|
|
16727
|
+
// (2b) Dead branch removed · the old "voice queue exists at all"
|
|
16728
|
+
// path was replaced by the playing-queue priority above.
|
|
16729
|
+
if (false && !speakingId && this.voiceQueues) {
|
|
15911
16730
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
15912
16731
|
const mm = msgs[i];
|
|
15913
16732
|
if (!mm || mm.authorKind !== "agent") continue;
|
|
15914
|
-
|
|
16733
|
+
const vq = this.voiceQueues[mm.id];
|
|
16734
|
+
if (vq && vq.playState === "playing") {
|
|
15915
16735
|
speakingId = mm.authorId;
|
|
15916
16736
|
speakerState = "speaking";
|
|
15917
16737
|
break;
|
|
@@ -15945,6 +16765,24 @@
|
|
|
15945
16765
|
speakerState = "thinking";
|
|
15946
16766
|
}
|
|
15947
16767
|
|
|
16768
|
+
// Speaker's messageId · derived from the same priority chain
|
|
16769
|
+
// above. Used by the stalled-audio bubble (per-queue watchdog
|
|
16770
|
+
// surfaces on the speaker's bubble + the Skip click needs the
|
|
16771
|
+
// messageId to POST /voice-done). Null when the speaker is
|
|
16772
|
+
// thinking-only (no message id yet) or during replay.
|
|
16773
|
+
let speakingMsgId = null;
|
|
16774
|
+
if (speakingId && this.voiceQueues) {
|
|
16775
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
16776
|
+
const mm = msgs[i];
|
|
16777
|
+
if (!mm || mm.authorKind !== "agent") continue;
|
|
16778
|
+
if (mm.authorId !== speakingId) continue;
|
|
16779
|
+
if (this.voiceQueues[mm.id]) { speakingMsgId = mm.id; break; }
|
|
16780
|
+
}
|
|
16781
|
+
}
|
|
16782
|
+
const stalledQ = (speakingMsgId && this.voiceQueues && this.voiceQueues[speakingMsgId] && this.voiceQueues[speakingMsgId].stalled)
|
|
16783
|
+
? this.voiceQueues[speakingMsgId]
|
|
16784
|
+
: null;
|
|
16785
|
+
|
|
15948
16786
|
// Speaker / thinking SFX · two distinct cues:
|
|
15949
16787
|
// · `setThinking(true)` starts a looping 8-bit pulse hum
|
|
15950
16788
|
// while a seat shows the thought-bubble. Idempotent ·
|
|
@@ -16048,6 +16886,39 @@
|
|
|
16048
16886
|
})[this.conveneState.stage];
|
|
16049
16887
|
if (stageKey) statusWord = this._t(stageKey);
|
|
16050
16888
|
}
|
|
16889
|
+
// Chair-pending phase (server-driven · chair-pending payload.phase).
|
|
16890
|
+
// Maps known phase strings to i18n keys so the bubble label
|
|
16891
|
+
// reflects WHAT silent work is happening — picker LLM, clarify
|
|
16892
|
+
// gate, vote summary, brief stages, etc. Falls back to the
|
|
16893
|
+
// existing convene-stage / "Thinking" label when phase is
|
|
16894
|
+
// empty or unrecognised.
|
|
16895
|
+
if (bubbleState === "thinking" && isChair && this.chairPending && this.chairPendingPhase) {
|
|
16896
|
+
const phaseKey = ({
|
|
16897
|
+
"clarify-deciding": "rt_phase_clarify_deciding",
|
|
16898
|
+
"picker-deciding": "rt_phase_picker_deciding",
|
|
16899
|
+
"next-speaker": "rt_phase_next_speaker",
|
|
16900
|
+
"llm-warming": "rt_phase_llm_warming",
|
|
16901
|
+
"vote-summary": "rt_phase_vote_summary",
|
|
16902
|
+
"brief-extracting": "rt_phase_brief_extracting",
|
|
16903
|
+
"brief-composing": "rt_phase_brief_composing",
|
|
16904
|
+
"brief-writing": "rt_phase_brief_writing",
|
|
16905
|
+
})[this.chairPendingPhase];
|
|
16906
|
+
if (phaseKey) statusWord = this._t(phaseKey);
|
|
16907
|
+
}
|
|
16908
|
+
// Per-message LLM first-token watchdog · client-side belt for
|
|
16909
|
+
// the server's 60s hard cap. When a streaming director message
|
|
16910
|
+
// has gone >8s with no message-token arriving, _localPhase is
|
|
16911
|
+
// flipped to "llm-warming" and the bubble shows that label
|
|
16912
|
+
// until the first token lands (or the server auto-skips).
|
|
16913
|
+
if (bubbleState === "thinking" && !isChair) {
|
|
16914
|
+
const streamingMsg = (this.currentMessages || []).slice().reverse().find(
|
|
16915
|
+
(mm) => mm && mm.authorKind === "agent" && mm.authorId === m.id
|
|
16916
|
+
&& mm.meta && mm.meta.streaming === true,
|
|
16917
|
+
);
|
|
16918
|
+
if (streamingMsg && streamingMsg.meta && streamingMsg.meta._localPhase === "llm-warming") {
|
|
16919
|
+
statusWord = this._t("rt_phase_llm_warming");
|
|
16920
|
+
}
|
|
16921
|
+
}
|
|
16051
16922
|
const bubbleCls = bubbleState === "thinking"
|
|
16052
16923
|
? "rt-bubble is-thinking"
|
|
16053
16924
|
: "rt-bubble";
|
|
@@ -16083,7 +16954,25 @@
|
|
|
16083
16954
|
`<button type="button" class="rt-bubble-chair-clarify-close" data-rt-chair-bubble-close aria-label="Dismiss">✕</button>` +
|
|
16084
16955
|
`</div>`;
|
|
16085
16956
|
} else if (isSpeaking) {
|
|
16086
|
-
|
|
16957
|
+
// Stalled-audio variant · the chunk-arrival watchdog flipped
|
|
16958
|
+
// q.stalled when no new TTS chunks arrived for ~8s. Repaint
|
|
16959
|
+
// the bubble amber to signal the issue; the 15s auto-skip
|
|
16960
|
+
// inside the watchdog will recover automatically.
|
|
16961
|
+
if (stalledQ && stalledQ.messageId === (this.voiceQueues[speakingMsgId]?.messageId)) {
|
|
16962
|
+
// Audio stalled · the chunk-arrival watchdog flipped
|
|
16963
|
+
// q.stalled when no new TTS chunks arrived for ~8s. Show
|
|
16964
|
+
// an amber state on the bubble so the user understands
|
|
16965
|
+
// why playback paused. The 15s auto-skip in the watchdog
|
|
16966
|
+
// recovers automatically · no manual click affordance.
|
|
16967
|
+
const stalledText = this._t("rt_audio_stalled");
|
|
16968
|
+
bubble = `<div class="${bubbleCls} is-stalled"`
|
|
16969
|
+
+ ` title="${this.escape(stalledText)}">`
|
|
16970
|
+
+ `<span class="rt-bubble-name">${this.escape(m.name || "")}</span>`
|
|
16971
|
+
+ `<span class="rt-bubble-status">${this.escape(stalledText)}</span>`
|
|
16972
|
+
+ `</div>`;
|
|
16973
|
+
} else {
|
|
16974
|
+
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>`;
|
|
16975
|
+
}
|
|
16087
16976
|
}
|
|
16088
16977
|
const badge = (isQueued && !isSpeaking)
|
|
16089
16978
|
? `<div class="rt-badge">${String(qIdx + 1).padStart(2, "0")}</div>`
|
|
@@ -16271,35 +17160,48 @@
|
|
|
16271
17160
|
replayAudio = window.boardroomVoiceReplay.getActiveAudio() || null;
|
|
16272
17161
|
}
|
|
16273
17162
|
}
|
|
16274
|
-
// (1)
|
|
16275
|
-
//
|
|
16276
|
-
|
|
17163
|
+
// (1) Currently audible voice queue · with cross-director
|
|
17164
|
+
// pipelining, the most-recent streaming message may be the
|
|
17165
|
+
// pre-warmed next speaker whose audio is still queued. The
|
|
17166
|
+
// subtitle should reflect who the USER IS HEARING — the
|
|
17167
|
+
// "playing" queue — not whoever is streaming text in the
|
|
17168
|
+
// background. Check audible-playing queue first; only fall
|
|
17169
|
+
// back to "most recent streaming" when no queue is playing
|
|
17170
|
+
// (text mode or pre-stream warmup).
|
|
17171
|
+
if (!speakerId && this.voiceQueues) {
|
|
16277
17172
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
16278
17173
|
const m = msgs[i];
|
|
16279
|
-
if (m
|
|
17174
|
+
if (!m || m.authorKind !== "agent") continue;
|
|
17175
|
+
const vq = this.voiceQueues[m.id];
|
|
17176
|
+
if (vq && vq.playState === "playing") {
|
|
16280
17177
|
speakerId = m.authorId;
|
|
16281
17178
|
body = m.body || "";
|
|
16282
|
-
|
|
16283
|
-
|
|
16284
|
-
|
|
16285
|
-
}
|
|
17179
|
+
activeQueue = vq;
|
|
17180
|
+
// isStreaming stays false · this branch covers
|
|
17181
|
+
// playback whether the LLM stream is still going OR done.
|
|
16286
17182
|
break;
|
|
16287
17183
|
}
|
|
16288
17184
|
}
|
|
16289
17185
|
}
|
|
16290
|
-
// (
|
|
16291
|
-
//
|
|
16292
|
-
//
|
|
16293
|
-
//
|
|
16294
|
-
//
|
|
16295
|
-
if (!speakerId
|
|
17186
|
+
// (1b) Streaming-text fallback · no audible queue (text mode
|
|
17187
|
+
// OR pre-warm gap). The most-recent streaming agent
|
|
17188
|
+
// message wins, even if its voice queue is "queued" —
|
|
17189
|
+
// this captures text-mode turns and the brief moment
|
|
17190
|
+
// between message-appended and first voice-chunk.
|
|
17191
|
+
if (!speakerId) {
|
|
16296
17192
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
16297
17193
|
const m = msgs[i];
|
|
16298
|
-
if (
|
|
16299
|
-
if (this.voiceQueues[m.id]) {
|
|
17194
|
+
if (m && m.meta && m.meta.streaming === true && m.authorKind === "agent") {
|
|
16300
17195
|
speakerId = m.authorId;
|
|
16301
17196
|
body = m.body || "";
|
|
16302
|
-
|
|
17197
|
+
isStreaming = true;
|
|
17198
|
+
// Only attach a voice queue if it's the playing one ·
|
|
17199
|
+
// a queued voice queue stays in the background until
|
|
17200
|
+
// promote.
|
|
17201
|
+
if (this.voiceQueues && this.voiceQueues[m.id]
|
|
17202
|
+
&& this.voiceQueues[m.id].playState === "playing") {
|
|
17203
|
+
activeQueue = this.voiceQueues[m.id];
|
|
17204
|
+
}
|
|
16303
17205
|
break;
|
|
16304
17206
|
}
|
|
16305
17207
|
}
|
|
@@ -16556,6 +17458,11 @@
|
|
|
16556
17458
|
const isVoice = !!(this.currentRoom && this.currentRoom.deliveryMode === "voice");
|
|
16557
17459
|
const rate = this.voicePlaybackRate();
|
|
16558
17460
|
const rateLabel = (rate === 1 ? "1.0" : String(rate)) + "X";
|
|
17461
|
+
// Skip Current Speaker affordance lives inline on the queue
|
|
17462
|
+
// row's `.actions` slot (see renderQueue) · the user's eye is
|
|
17463
|
+
// already on the speaker name there, and a button on the HUD's
|
|
17464
|
+
// status panel felt detached. The HUD keeps the Rate control
|
|
17465
|
+
// (voice-mode only) as its single tab.
|
|
16559
17466
|
const rateRow = isVoice
|
|
16560
17467
|
? `
|
|
16561
17468
|
<div class="rt-hud-controls">
|
|
@@ -18099,13 +19006,31 @@
|
|
|
18099
19006
|
}
|
|
18100
19007
|
// Floating mini-player · the whole bar is the click target.
|
|
18101
19008
|
// A click anywhere on the surface (including the inner jump
|
|
18102
|
-
// button)
|
|
18103
|
-
// any descendant click bubbles up to the same handler.
|
|
19009
|
+
// button) jumps back to the live room. Match the outer aside
|
|
19010
|
+
// so any descendant click bubbles up to the same handler.
|
|
19011
|
+
//
|
|
19012
|
+
// Use `boardroomFocusRoom` (not the bare `navigateToRoom`)
|
|
19013
|
+
// because the common entry path — Agents tab with an open
|
|
19014
|
+
// agent profile — has BOTH `location.hash` already pointing
|
|
19015
|
+
// at `#/r/<id>` AND `currentRoomId === id`. Plain hash
|
|
19016
|
+
// assignment is a no-op for the browser and `handleRoute`
|
|
19017
|
+
// would early-return either way; the agent profile would
|
|
19018
|
+
// stay covering the main view and the sidebar would stay
|
|
19019
|
+
// on the Agents tab. boardroomFocusRoom closes the profile
|
|
19020
|
+
// overlay, switches the sidebar tab back to Rooms, and
|
|
19021
|
+
// re-lights the room's sidebar row via markActiveRoom.
|
|
19022
|
+
// Falls back to navigateToRoom on the rare path where the
|
|
19023
|
+
// index.html IIFE hasn't published its window globals yet.
|
|
18104
19024
|
if (e.target.closest("[data-mini-player]")) {
|
|
18105
19025
|
e.preventDefault();
|
|
18106
19026
|
const el = document.querySelector("[data-mini-player]");
|
|
18107
19027
|
const roomId = el && el.getAttribute("data-mini-player-room-id");
|
|
18108
|
-
if (roomId)
|
|
19028
|
+
if (!roomId) return;
|
|
19029
|
+
if (typeof window.boardroomFocusRoom === "function") {
|
|
19030
|
+
window.boardroomFocusRoom(roomId);
|
|
19031
|
+
} else {
|
|
19032
|
+
app.navigateToRoom(roomId);
|
|
19033
|
+
}
|
|
18109
19034
|
return;
|
|
18110
19035
|
}
|
|
18111
19036
|
if (e.target.closest("[data-divergence-close]")) {
|
|
@@ -18614,31 +19539,6 @@
|
|
|
18614
19539
|
}
|
|
18615
19540
|
// View Report — link opens /report.html?r=<id> in a new tab; let the
|
|
18616
19541
|
// anchor handle navigation. No preventDefault.
|
|
18617
|
-
// Starter card · empty-state quick-convene. Avatars opt out (their
|
|
18618
|
-
// own [data-agent-profile] handler runs via agent-profile.js capture
|
|
18619
|
-
// phase). Both the [▶ Start] button and a click anywhere else on the
|
|
18620
|
-
// card body fire the starter action.
|
|
18621
|
-
const starterAv = e.target.closest("[data-agent-profile]");
|
|
18622
|
-
if (starterAv && e.target.closest(".starter-card")) {
|
|
18623
|
-
// Let agent-profile.js handle this; don't also start the room.
|
|
18624
|
-
return;
|
|
18625
|
-
}
|
|
18626
|
-
const starterGo = e.target.closest("[data-starter-go]");
|
|
18627
|
-
if (starterGo) {
|
|
18628
|
-
e.preventDefault();
|
|
18629
|
-
const idx = parseInt(starterGo.getAttribute("data-starter-go"), 10);
|
|
18630
|
-
const list = window.BOARDROOM_STARTERS || [];
|
|
18631
|
-
if (Number.isFinite(idx) && list[idx]) app.createStarterRoom(list[idx]);
|
|
18632
|
-
return;
|
|
18633
|
-
}
|
|
18634
|
-
const starterCard = e.target.closest("[data-starter-idx]");
|
|
18635
|
-
if (starterCard) {
|
|
18636
|
-
e.preventDefault();
|
|
18637
|
-
const idx = parseInt(starterCard.getAttribute("data-starter-idx"), 10);
|
|
18638
|
-
const list = window.BOARDROOM_STARTERS || [];
|
|
18639
|
-
if (Number.isFinite(idx) && list[idx]) app.createStarterRoom(list[idx]);
|
|
18640
|
-
return;
|
|
18641
|
-
}
|
|
18642
19542
|
// ─── New room trigger (sidebar "+ New room" button, etc.) ──────
|
|
18643
19543
|
// Was bound to the overlay; now just closes any active room so the
|
|
18644
19544
|
// composer empty state shows. setComposerMode also flips the main
|
|
@@ -18988,12 +19888,15 @@
|
|
|
18988
19888
|
app.closeComposerDropdown();
|
|
18989
19889
|
return;
|
|
18990
19890
|
}
|
|
18991
|
-
// ─── Agent composer ·
|
|
18992
|
-
|
|
18993
|
-
|
|
19891
|
+
// ─── Agent composer · celebrity seed card → kick full-mode
|
|
19892
|
+
// persona build with the seed's `description`. The card
|
|
19893
|
+
// tags `personaJob.celebritySeedId` so the save-success
|
|
19894
|
+
// handler can mark the seed permanently consumed.
|
|
19895
|
+
const celebCard = e.target.closest("[data-celebrity-seed]");
|
|
19896
|
+
if (celebCard) {
|
|
18994
19897
|
e.preventDefault();
|
|
18995
|
-
const
|
|
18996
|
-
app.
|
|
19898
|
+
const id = celebCard.getAttribute("data-celebrity-seed");
|
|
19899
|
+
if (id) app.applyCelebritySeed(id);
|
|
18997
19900
|
return;
|
|
18998
19901
|
}
|
|
18999
19902
|
// Convene button → submit
|
|
@@ -19002,31 +19905,18 @@
|
|
|
19002
19905
|
app.submitComposer();
|
|
19003
19906
|
return;
|
|
19004
19907
|
}
|
|
19005
|
-
//
|
|
19006
|
-
|
|
19007
|
-
|
|
19908
|
+
// Scenario ad card → autoconfigure composer state with the
|
|
19909
|
+
// card's preset (tone + intensity + 3 directors + starter
|
|
19910
|
+
// subject), then re-render so the user lands on a working
|
|
19911
|
+
// room shape. Inner [data-agent-profile] still fires the
|
|
19912
|
+
// profile overlay via agent-profile.js's capture handler.
|
|
19913
|
+
const scenarioCard = e.target.closest("[data-scenario-id]");
|
|
19914
|
+
if (scenarioCard && !e.target.closest("[data-agent-profile]")) {
|
|
19008
19915
|
e.preventDefault();
|
|
19009
|
-
const
|
|
19010
|
-
if (
|
|
19916
|
+
const id = scenarioCard.getAttribute("data-scenario-id");
|
|
19917
|
+
if (id) app.applyScenarioCard(id);
|
|
19011
19918
|
return;
|
|
19012
19919
|
}
|
|
19013
|
-
// Topic-rec card → apply via /api/topic-recs/:id so the
|
|
19014
|
-
// full seedContext lands in composer state.
|
|
19015
|
-
const composerRec = e.target.closest("[data-cmp-rec]");
|
|
19016
|
-
if (composerRec) {
|
|
19017
|
-
e.preventDefault();
|
|
19018
|
-
const id = composerRec.getAttribute("data-cmp-rec");
|
|
19019
|
-
if (id) app.applyTopicRec(id);
|
|
19020
|
-
return;
|
|
19021
|
-
}
|
|
19022
|
-
// Topic-rec trigger button → start a fresh generation job.
|
|
19023
|
-
if (e.target.closest("[data-cmp-recs-trigger]")) {
|
|
19024
|
-
e.preventDefault();
|
|
19025
|
-
void app.startTopicRecJob();
|
|
19026
|
-
return;
|
|
19027
|
-
}
|
|
19028
|
-
// ("+ N more" pagination removed · tray now always shows
|
|
19029
|
-
// the latest 6 recs and wipes on each fresh generation.)
|
|
19030
19920
|
// Delete a room (any state): confirm, then real DELETE on the backend.
|
|
19031
19921
|
const del = e.target.closest("[data-room-delete]");
|
|
19032
19922
|
if (del) {
|
|
@@ -19328,10 +20218,19 @@
|
|
|
19328
20218
|
// queue while the tab was backgrounded (some Chrome versions
|
|
19329
20219
|
// do this even with Media Session declared), resume them on
|
|
19330
20220
|
// tab return so the user doesn't have to manually re-start.
|
|
20221
|
+
//
|
|
20222
|
+
// CRITICAL · only resume queues whose playState is "playing".
|
|
20223
|
+
// Pre-warmed queues sit in "queued" state with audio.paused=true
|
|
20224
|
+
// (audio.play() was never called); blindly calling .play() on
|
|
20225
|
+
// them starts the next speaker in parallel with the current one
|
|
20226
|
+
// and produces the A/B overlap reported on macOS workspace
|
|
20227
|
+
// switches, Cmd+Tab, Mission Control · each visibilitychange
|
|
20228
|
+
// would resume every queue indiscriminately.
|
|
19331
20229
|
if (app && app.voiceQueues) {
|
|
19332
20230
|
for (const mid of Object.keys(app.voiceQueues)) {
|
|
19333
20231
|
const q = app.voiceQueues[mid];
|
|
19334
20232
|
if (!q || !q.audio) continue;
|
|
20233
|
+
if (q.playState !== "playing") continue;
|
|
19335
20234
|
if (q.audio.paused && !q.audio.ended) {
|
|
19336
20235
|
try {
|
|
19337
20236
|
const p = q.audio.play();
|