fix(ui): simplify Vocero controls and correct session feedback
This commit is contained in:
+117
-214
@@ -628,13 +628,13 @@ function saveTools() {
|
||||
/** @type {Record<AppState, { caption: string; disabled: boolean }>} */
|
||||
const STATE_VIEWS = {
|
||||
idle: { caption: "Toca o di \"Hola Vocero\"", disabled: false },
|
||||
connecting: { caption: "Conectando", disabled: true },
|
||||
connecting: { caption: "Conectando…", disabled: true },
|
||||
queued: { caption: "Buscando un lugar para ti…", disabled: true },
|
||||
"your-turn": { caption: "¡Es tu turno! 🎉", disabled: true },
|
||||
listening: { caption: "", disabled: false },
|
||||
"user-speaking": { caption: "", disabled: false },
|
||||
processing: { caption: "", disabled: false },
|
||||
"ai-speaking": { caption: "", disabled: false },
|
||||
listening: { caption: "Escuchando…", disabled: true },
|
||||
"user-speaking": { caption: "Te escucho…", disabled: true },
|
||||
processing: { caption: "Pensando…", disabled: true },
|
||||
"ai-speaking": { caption: "Vocero está hablando…", disabled: true },
|
||||
error: { caption: "Toca para reintentar", disabled: false },
|
||||
};
|
||||
|
||||
@@ -666,6 +666,18 @@ const orbWrap = $(".orb-wrap");
|
||||
const micBtn = $("#mic-btn");
|
||||
/** @type {HTMLButtonElement} */
|
||||
const stopBtn = $("#stop-btn");
|
||||
/** @type {HTMLButtonElement} */
|
||||
const whiteMicBtn = $("#white-mic-circle");
|
||||
/** @type {HTMLButtonElement} */
|
||||
const whiteStopBtn = $("#white-stop-btn");
|
||||
/** @type {HTMLParagraphElement} */
|
||||
const whiteMicCaption = $("#white-mic-caption");
|
||||
/** @type {HTMLParagraphElement} */
|
||||
const whiteMicSubcaption = $("#white-mic-subcaption");
|
||||
/** @type {HTMLElement} */
|
||||
const chatStatus = $("#chat-status");
|
||||
/** @type {HTMLElement} */
|
||||
const chatStatusLabel = $("#chat-status-label");
|
||||
/** @type {HTMLElement} */
|
||||
const queueActions = $("#queue-actions");
|
||||
/** @type {HTMLButtonElement} */
|
||||
@@ -735,7 +747,7 @@ const inputVoice = $("#voice");
|
||||
const inputInstructions = $("#instructions");
|
||||
/** @type {HTMLElement} */
|
||||
const primaryPromptMeta = $("#primary-prompt-meta");
|
||||
/** @type {HTMLElement} */
|
||||
/** @type {HTMLButtonElement} */
|
||||
const identUserName = $("#ident-user-name");
|
||||
/** @type {HTMLElement} */
|
||||
// NOTE: #reports-hint now lives inside #reports-sidebar (left sidebar), not
|
||||
@@ -766,14 +778,6 @@ const userProfileSubtitle = $("#user-profile-subtitle");
|
||||
/** @type {HTMLElement} */
|
||||
const userProfileMetadata = $("#user-profile-metadata");
|
||||
|
||||
/* ─── Inline chat form (always-visible panel at the bottom of #app) ──── */
|
||||
/** @type {HTMLFormElement | null} */
|
||||
const chatForm = /** @type {HTMLFormElement | null} */ ($("#chat-form"));
|
||||
/** @type {HTMLInputElement | null} */
|
||||
const chatInput = /** @type {HTMLInputElement | null} */ ($("#chat-input"));
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
const chatSendBtn = /** @type {HTMLButtonElement | null} */ ($("#chat-send"));
|
||||
|
||||
/** @type {null | { count:number, wrappedLen:number, hints:string[], entries:Array<{sourceScheduleLabel?:string, date?:any}> }} */
|
||||
let voceroReportsList = null;
|
||||
let voceroReportsLoaded = false;
|
||||
@@ -1220,7 +1224,7 @@ function populateUserProfileDialog(profile) {
|
||||
* and optionally restore focus to the avatar when it closes (a11y).
|
||||
*/
|
||||
function openUserProfileDialog() {
|
||||
if (!userProfileDialog) return;
|
||||
if (!userProfileDialog || !extractVoceroUserName(voceroUserProfile)) return;
|
||||
populateUserProfileDialog(/** @type {Record<string, unknown>} */ (voceroUserProfile));
|
||||
try {
|
||||
userProfileDialog.showModal();
|
||||
@@ -1234,17 +1238,18 @@ function openUserProfileDialog() {
|
||||
function syncVoceroHeaderUser() {
|
||||
const name = extractVoceroUserName(voceroUserProfile);
|
||||
const initials = initialsForName(name);
|
||||
identUserName.hidden = !name;
|
||||
identUserName.hidden = s2sTheme !== "white" || !name;
|
||||
identUserName.disabled = !name;
|
||||
if (name) {
|
||||
// Show avatar with the 2-letter initials. Full name is reachable via hover
|
||||
// tooltip (title) and is used by screen readers via aria-label.
|
||||
identUserName.textContent = initials;
|
||||
identUserName.title = `Usuario: ${name}`;
|
||||
identUserName.setAttribute("aria-label", `Usuario: ${name}`);
|
||||
identUserName.setAttribute("aria-label", `Ver datos de ${name}`);
|
||||
} else {
|
||||
identUserName.textContent = "";
|
||||
identUserName.removeAttribute("title");
|
||||
identUserName.removeAttribute("aria-label");
|
||||
identUserName.setAttribute("aria-label", "Ver datos del usuario");
|
||||
}
|
||||
|
||||
if (!reportsHint) return;
|
||||
@@ -1820,40 +1825,6 @@ function pushToolsToSession() {
|
||||
// streaming state. The client's events are forwarded to its on* methods.
|
||||
const chat = new ChatView();
|
||||
|
||||
// ── Inline chat form submit (always-visible panel) ─────────────────────────
|
||||
// User can send free-text messages by typing + pressing Enter (form submit) or
|
||||
// tapping the gradient paper-plane send button. The text flows into the
|
||||
// existing realtime client sendUserText() so it behaves exactly the same as a
|
||||
// voice-transcribed phrase (onUserTranscriptFinished → server → assistant
|
||||
// response). ChatView already renders bubbles/history via its on* hooks so we
|
||||
// just forward the text to the socket.
|
||||
if (chatForm && chatInput) {
|
||||
chatForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const raw = chatInput.value;
|
||||
if (!raw) return;
|
||||
const text = raw.trim();
|
||||
if (!text) {
|
||||
chatInput.value = "";
|
||||
return;
|
||||
}
|
||||
// 1. Send to Vocero realtime server (identical to voice phrase submission).
|
||||
try {
|
||||
if (client && typeof client.sendUserText === "function") {
|
||||
client.sendUserText(text);
|
||||
}
|
||||
} catch (err) {
|
||||
// Never crash the UI if the realtime client rejected; swallow to console.
|
||||
console.warn("chat-form: sendUserText threw", err);
|
||||
}
|
||||
// 2. Reset input. ChatView.onUserTranscriptStarted / onUserTranscriptFinished
|
||||
// (already wired to client events) will render the ephemeral bubble +
|
||||
// persistent history row + scroll to bottom automatically.
|
||||
chatInput.value = "";
|
||||
chatInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Account / limiter ─────────────────────────────────────────────────────
|
||||
// Login chip + daily-limit modal (inert unless the deploy is in LB mode). The
|
||||
// server meters conversation time; the client just heartbeats a live session
|
||||
@@ -1919,7 +1890,12 @@ function _kickIdleTimer(reason = "activity") {
|
||||
// user can tell what happened instead of wondering why the orb reset.
|
||||
circleSubcaption.textContent = "Sesión cerrada tras 30 segundos de silencio. Toca el círculo o repite «Hola Vocero» para empezar de nuevo.";
|
||||
circleSubcaption.hidden = false;
|
||||
window.setTimeout(() => { circleSubcaption.hidden = true; }, 8000);
|
||||
whiteMicSubcaption.textContent = circleSubcaption.textContent;
|
||||
whiteMicSubcaption.hidden = false;
|
||||
window.setTimeout(() => {
|
||||
circleSubcaption.hidden = true;
|
||||
whiteMicSubcaption.hidden = true;
|
||||
}, 8000);
|
||||
})();
|
||||
}, IDLE_TIMEOUT_MS);
|
||||
if (DEBUG) {
|
||||
@@ -1938,14 +1914,41 @@ let client = null;
|
||||
/** @type {MediaStream | null} */
|
||||
let micStream = null;
|
||||
let micMuted = false;
|
||||
/** @type {Promise<void> | null} */
|
||||
let teardownPromise = null;
|
||||
|
||||
function syncVoiceControls() {
|
||||
const view = STATE_VIEWS[currentState];
|
||||
const live = LIVE_STATES.has(currentState);
|
||||
const disabled = view.disabled || !!teardownPromise;
|
||||
const label = currentState === "error" ? "Reintentar conversación de voz"
|
||||
: currentState === "idle" ? "Iniciar conversación de voz" : view.caption;
|
||||
circleBtn.disabled = disabled;
|
||||
circleBtn.setAttribute("aria-label", label);
|
||||
whiteMicBtn.disabled = disabled;
|
||||
whiteMicBtn.setAttribute("aria-label", label);
|
||||
whiteMicBtn.className = `white-mic-circle ${STATE_CLASS[currentState]}`;
|
||||
whiteStopBtn.hidden = !live || s2sTheme !== "white";
|
||||
whiteStopBtn.disabled = !live || !!teardownPromise;
|
||||
|
||||
const waiting = currentState === "queued" || currentState === "your-turn";
|
||||
chatStatus.dataset.status = live ? "online" : currentState === "error" ? "error"
|
||||
: currentState === "connecting" || waiting ? "waiting" : "idle";
|
||||
chatStatusLabel.textContent = live ? "En línea"
|
||||
: currentState === "connecting" ? "Conectando…"
|
||||
: currentState === "queued" ? "En cola"
|
||||
: currentState === "your-turn" ? "Tu turno"
|
||||
: currentState === "error" ? "Sin conexión" : "Sin sesión";
|
||||
}
|
||||
|
||||
/** @param {AppState} next */
|
||||
function setState(next) {
|
||||
const previousState = currentState;
|
||||
currentState = next;
|
||||
const view = STATE_VIEWS[next];
|
||||
circleBtn.disabled = view.disabled;
|
||||
circleBtn.className = `circle ${STATE_CLASS[next]}`;
|
||||
if (next !== "error") setCaption(view.caption);
|
||||
syncVoiceControls();
|
||||
if (next !== "error" || previousState !== "error") setCaption(view.caption);
|
||||
|
||||
const live = LIVE_STATES.has(next);
|
||||
orbWrap.classList.toggle("live", live);
|
||||
@@ -1973,6 +1976,8 @@ function setState(next) {
|
||||
} else {
|
||||
circleSubcaption.hidden = true;
|
||||
}
|
||||
whiteMicSubcaption.textContent = circleSubcaption.textContent;
|
||||
whiteMicSubcaption.hidden = circleSubcaption.hidden;
|
||||
|
||||
// Wake word: armed only when idle/error (no live mic contention with the
|
||||
// realtime session), and disarmed the moment we move into any other state.
|
||||
@@ -2022,6 +2027,9 @@ function setCaption(text, kind = "") {
|
||||
if (!trimmed) parts.push("empty");
|
||||
if (addIdleCta) parts.push("idle-cta");
|
||||
circleCaption.className = parts.join(" ");
|
||||
whiteMicCaption.textContent = trimmed;
|
||||
whiteMicCaption.classList.toggle("muted", kind === "muted");
|
||||
whiteMicCaption.classList.toggle("error", kind === "error");
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
@@ -2177,22 +2185,9 @@ aboutModal.addEventListener("click", (e) => {
|
||||
if (e.target === aboutModal) aboutModal.close();
|
||||
});
|
||||
|
||||
/* ─── User Profile dialog handlers (click avatar circle) ───────────── */
|
||||
// The avatar circle in the brand header: when clicked, opens a native
|
||||
// <dialog> with the user's profile info. Keyboard accessible: Enter / Space
|
||||
// also open the dialog. a11y note: we attach role="button" + tabindex via JS
|
||||
// at load time (so even if markup changes the behavior is preserved).
|
||||
/* ─── User profile: native button and dialog keyboard behavior ─────── */
|
||||
if (identUserName) {
|
||||
identUserName.setAttribute("role", "button");
|
||||
identUserName.setAttribute("tabindex", "0");
|
||||
identUserName.style.cursor = "pointer";
|
||||
identUserName.addEventListener("click", (e) => { e.preventDefault(); openUserProfileDialog(); });
|
||||
identUserName.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
openUserProfileDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Close via the explicit X button (top-right of the card).
|
||||
if (userProfileClose) {
|
||||
@@ -2200,6 +2195,9 @@ if (userProfileClose) {
|
||||
}
|
||||
// Click in the backdrop = click whose target is the <dialog> itself.
|
||||
if (userProfileDialog) {
|
||||
userProfileDialog.addEventListener("close", () => {
|
||||
if (!identUserName.hidden) identUserName.focus();
|
||||
});
|
||||
userProfileDialog.addEventListener("click", (e) => {
|
||||
if (e.target === userProfileDialog) userProfileDialog.close();
|
||||
});
|
||||
@@ -2724,48 +2722,15 @@ async function fetchConfig() {
|
||||
void account.refresh();
|
||||
syncToolsUi();
|
||||
syncConnectionUi();
|
||||
// Apply theme runtime: show/hide theme-only elements, wire up white-mic
|
||||
// button forwarders, unhide chat-form in white theme, etc. Must run after
|
||||
// `s2sTheme` is set and the data-theme attr has been applied to html.
|
||||
// Apply the theme and refresh state-dependent controls after config resolves.
|
||||
_applyThemeRuntime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spanish (Castilian) label map for the "Escuchando..." status caption below
|
||||
* the white-theme blue mic circle. Mirrors every client state.
|
||||
*/
|
||||
const WHITE_STATUS_LABELS = Object.freeze({
|
||||
idle: "Toca para empezar",
|
||||
connecting: "Conectando…",
|
||||
queued: "En cola…",
|
||||
"your-turn":"Escuchando…",
|
||||
processing: "Pensando…",
|
||||
"ai-speaking":"Hablando…",
|
||||
error: "Algo salió mal",
|
||||
stopped: "Detenido",
|
||||
});
|
||||
|
||||
let _themeRuntimeInitialized = false;
|
||||
|
||||
/**
|
||||
* Apply runtime wiring for the current theme:
|
||||
* 1. Flip [hidden] on every [data-theme-only="white" / "dark"] element in
|
||||
* the document so the correct parallel UI is shown.
|
||||
* 2. Remove the hardcoded `hidden + display:none !important` on #chat-form
|
||||
* in white theme (we want the text input + send button visible, just
|
||||
* like the screenshot input bar).
|
||||
* 3. Click forwarders: #white-mic-circle → real #main-circle,
|
||||
* #white-stop-btn → real #stop-btn.
|
||||
* 4. MutationObserver on #main-circle `.className` to mirror every
|
||||
* `state-*` class live to #white-mic-circle so it swaps its visuals
|
||||
* (mic icon / stop icon / spinner) exactly in sync with the orb.
|
||||
* 5. Wrap `setCaption()` so the Spanish label `#white-mic-caption` gets
|
||||
* updated at the same time as the default orb caption.
|
||||
* 6. Quick action button listeners: populate chat-input / or send via
|
||||
* client.sendUserText() when a session is live.
|
||||
* Called by fetchConfig() AFTER s2sTheme and <html data-theme> are set.
|
||||
* Re-runs safely on every config fetch (guard `_themeRuntimeInitialized` for
|
||||
* the one-shot observer / listeners).
|
||||
* Apply theme visibility, then restore the avatar and session visibility.
|
||||
* Register theme-specific actions once; setState/setCaption update both views.
|
||||
*/
|
||||
function _applyThemeRuntime() {
|
||||
const isWhite = s2sTheme === "white";
|
||||
@@ -2793,109 +2758,29 @@ function _applyThemeRuntime() {
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Chat form input: in WHITE theme, we need the text input visible to
|
||||
// match the screenshot. The HTML ships with `hidden` + inline
|
||||
// `display:none !important` because the original dark theme hid it
|
||||
// (per user request 2026-09-04). Clear both here, restore when leaving.
|
||||
const chatForm = document.getElementById("chat-form");
|
||||
if (chatForm) {
|
||||
if (isWhite) {
|
||||
chatForm.removeAttribute("hidden");
|
||||
chatForm.style.removeProperty("display");
|
||||
} else if (!chatForm.hasAttribute("hidden")) {
|
||||
// Reapply original dark-theme defaults (preserve existing user edit).
|
||||
chatForm.setAttribute("hidden", "");
|
||||
chatForm.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
syncVoceroHeaderUser();
|
||||
syncVoiceControls();
|
||||
|
||||
// One-shot wiring (never re-add listeners/observer on subsequent calls).
|
||||
// One-shot wiring (never re-add listeners on subsequent calls).
|
||||
if (_themeRuntimeInitialized) return;
|
||||
_themeRuntimeInitialized = true;
|
||||
|
||||
// 3a. Click forwarder: white mic circle → the real start/stop orb handler.
|
||||
// Start through the same handler as the dark-theme orb.
|
||||
const whiteMic = document.getElementById("white-mic-circle");
|
||||
const realCircle = document.getElementById("main-circle");
|
||||
if (whiteMic && realCircle) {
|
||||
whiteMic.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
realCircle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
if (!whiteMic.disabled) realCircle.click();
|
||||
});
|
||||
}
|
||||
|
||||
// 3b. Click forwarder: white "Detener" pill → hidden real stop side-btn.
|
||||
const whiteStop = document.getElementById("white-stop-btn");
|
||||
const realStop = document.getElementById("stop-btn");
|
||||
if (whiteStop && realStop) {
|
||||
whiteStop.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
realStop.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
}
|
||||
whiteStopBtn.addEventListener("click", async () => {
|
||||
if (LIVE_STATES.has(currentState)) await teardown();
|
||||
});
|
||||
|
||||
// 4. Live sync of #main-circle className → #white-mic-circle className
|
||||
// (states: .state-idle, .state-connecting, .state-your-turn, .state-processing,
|
||||
// .state-ai-speaking, .state-error, .state-queued). White-mic styles
|
||||
// use these same state-* classes (CSS prefixes with .white-mic-circle.state-*)
|
||||
// so we simply shadow every class on the orb button to the new button.
|
||||
if (whiteMic && realCircle && typeof MutationObserver !== "undefined") {
|
||||
const syncStateClasses = () => {
|
||||
// Keep the base class + data-theme-only marker but clear/re-add the rest.
|
||||
const tokens = Array.from(realCircle.classList);
|
||||
for (const cl of Array.from(whiteMic.classList)) {
|
||||
if (cl.startsWith("state-")) whiteMic.classList.remove(cl);
|
||||
}
|
||||
for (const cl of tokens) {
|
||||
if (cl.startsWith("state-")) whiteMic.classList.add(cl);
|
||||
}
|
||||
// Also show/hide the white stop pill in sync with the real stop-btn
|
||||
// (which is shown when connected).
|
||||
if (whiteStop) {
|
||||
const visible = !realStop.hasAttribute("aria-hidden") || realStop.getAttribute("aria-hidden") === "false";
|
||||
if (visible) {
|
||||
whiteStop.style.removeProperty("display");
|
||||
whiteStop.removeAttribute("hidden");
|
||||
} else {
|
||||
whiteStop.setAttribute("hidden", "");
|
||||
}
|
||||
}
|
||||
};
|
||||
syncStateClasses();
|
||||
const obs = new MutationObserver(() => syncStateClasses());
|
||||
obs.observe(realCircle, { attributes: true, attributeFilter: ["class", "aria-hidden"] });
|
||||
if (realStop) obs.observe(realStop, { attributes: true, attributeFilter: ["aria-hidden", "class"] });
|
||||
|
||||
// 5. Set the initial Spanish caption for theme=white + wrap setCaption()
|
||||
// so any future setCaption() call also updates the Spanish label.
|
||||
const capWhite = document.getElementById("white-mic-caption");
|
||||
if (capWhite) {
|
||||
capWhite.textContent = WHITE_STATUS_LABELS[currentState] || WHITE_STATUS_LABELS.idle;
|
||||
const origSetCaption = setCaption;
|
||||
// Re-assign the global (window / module scope var holds function ref) so
|
||||
// each caller of setCaption(text, kind) now also drives the Spanish label.
|
||||
// We intentionally DON'T replace the signature — old code keeps working.
|
||||
(function (prev) {
|
||||
window.setCaption = function (text, kind) {
|
||||
prev(text, kind);
|
||||
// When in white theme we prefer the Spanish state-based label from
|
||||
// WHITE_STATUS_LABELS whenever possible, falling back to raw text.
|
||||
if (s2sTheme === "white" && capWhite) {
|
||||
const fromState = WHITE_STATUS_LABELS[currentState];
|
||||
capWhite.textContent = fromState || text || WHITE_STATUS_LABELS.idle;
|
||||
const muted = kind === "muted";
|
||||
capWhite.classList.toggle("muted", !!muted);
|
||||
capWhite.classList.toggle("error", kind === "error");
|
||||
}
|
||||
};
|
||||
})(origSetCaption);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Quick action buttons for summaries, data lookup and response drafts
|
||||
// populate the text input or — if a realtime
|
||||
// session is live — sends it as user text straight away.
|
||||
// Retained quick actions are only available to an active realtime session.
|
||||
document.querySelectorAll(".quick-action-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const action = btn.getAttribute("data-action") || "";
|
||||
@@ -2907,16 +2792,7 @@ function _applyThemeRuntime() {
|
||||
const text = prompts[action] || "";
|
||||
if (!text) return;
|
||||
if (client && typeof client.sendUserText === "function") {
|
||||
// Live session: send directly as if the user typed + pressed enter.
|
||||
client.sendUserText(text);
|
||||
} else {
|
||||
// No session: prefill the text input so they can send manually later.
|
||||
const inp = document.getElementById("chat-input");
|
||||
if (inp) {
|
||||
inp.value = text;
|
||||
inp.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
inp.focus({ preventScroll: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3301,6 +3177,7 @@ async function doStart(audioContext = null) {
|
||||
});
|
||||
|
||||
c.addEventListener("status", (e) => {
|
||||
if (client !== c) return;
|
||||
const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
|
||||
_kickIdleTimer(`client-status:${detail.status}`);
|
||||
onClientStatus(detail.status);
|
||||
@@ -3340,15 +3217,19 @@ async function doStart(audioContext = null) {
|
||||
});
|
||||
});
|
||||
c.addEventListener("error", (e) => {
|
||||
if (client !== c) return;
|
||||
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
|
||||
onFatalError(detail.error);
|
||||
});
|
||||
c.addEventListener("server-error", (e) => {
|
||||
// Non-fatal: the backend reported an error mid-session. Log it, keep the
|
||||
// socket and the conversation alive (the model can recover on its own).
|
||||
// socket and the conversation alive while showing an actionable message.
|
||||
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
|
||||
const msg = detail.error instanceof Error ? detail.error.message : String(detail.error);
|
||||
console.warn("[main] server error (non-fatal):", msg);
|
||||
if (client === c) {
|
||||
setCaption("Vocero no pudo completar la respuesta. Intenta hablar de nuevo.", "error");
|
||||
}
|
||||
});
|
||||
c.addEventListener("session", (e) => {
|
||||
const info = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo }>} */ (e).detail.info;
|
||||
@@ -3512,7 +3393,9 @@ function onClientStatus(status) {
|
||||
setState("ai-speaking");
|
||||
break;
|
||||
case "closed":
|
||||
// teardown() will move us to idle
|
||||
// An intentional close clears client before closing the socket. A remote
|
||||
// close still needs to release the mic and clear the online UI.
|
||||
if (client) void teardown();
|
||||
break;
|
||||
case "error":
|
||||
setState("error");
|
||||
@@ -3520,7 +3403,21 @@ function onClientStatus(status) {
|
||||
}
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
/** @param {AppState} [finalState] @returns {Promise<void>} */
|
||||
function teardown(finalState = "idle") {
|
||||
if (teardownPromise) return teardownPromise;
|
||||
const closingClient = client;
|
||||
client = null;
|
||||
teardownPromise = finishTeardown(closingClient, finalState).finally(() => {
|
||||
teardownPromise = null;
|
||||
syncVoiceControls();
|
||||
});
|
||||
syncVoiceControls();
|
||||
return teardownPromise;
|
||||
}
|
||||
|
||||
/** @param {S2sWsRealtimeClient | null} closingClient @param {AppState} finalState */
|
||||
async function finishTeardown(closingClient, finalState) {
|
||||
stopHeartbeat();
|
||||
stopJoinCountdown();
|
||||
endTrackedSession();
|
||||
@@ -3531,13 +3428,12 @@ async function teardown() {
|
||||
// user left queue, limit exceeded) the pending wake-word confirmation would
|
||||
// be re-injected on the next session. Clear it so we never double-fire.
|
||||
pendingWakeWordConfirmationText = "";
|
||||
if (client) {
|
||||
if (closingClient) {
|
||||
try {
|
||||
await client.close();
|
||||
await closingClient.close();
|
||||
} catch (err) {
|
||||
console.warn("[main] error closing client:", err);
|
||||
}
|
||||
client = null;
|
||||
}
|
||||
if (micStream) {
|
||||
for (const track of micStream.getTracks()) track.stop();
|
||||
@@ -3545,7 +3441,7 @@ async function teardown() {
|
||||
}
|
||||
micMuted = false;
|
||||
micBtn.classList.remove("muted");
|
||||
setState("idle");
|
||||
setState(finalState);
|
||||
// Refresh the chip's remaining-today after the budget moved.
|
||||
if (limiterOn) void account.refresh();
|
||||
}
|
||||
@@ -3553,13 +3449,20 @@ async function teardown() {
|
||||
/** @param {unknown} err */
|
||||
function onFatalError(err) {
|
||||
console.error("[main] fatal:", err);
|
||||
const wasLive = LIVE_STATES.has(currentState);
|
||||
setState("error");
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setCaption(truncateError(message), "error");
|
||||
void teardown().catch(() => {
|
||||
const caption = message.includes("micrófono")
|
||||
? `${truncateError(message)} Toca para reintentar.`
|
||||
: wasLive
|
||||
? "Se interrumpió la conexión con Vocero. Revisa tu conexión y toca para reintentar."
|
||||
: "No se pudo conectar con Vocero. Revisa tu conexión y toca para reintentar.";
|
||||
setCaption(caption, "error");
|
||||
const showError = () => {
|
||||
setState("error");
|
||||
setCaption(truncateError(message), "error");
|
||||
});
|
||||
setCaption(caption, "error");
|
||||
};
|
||||
void teardown("error").then(showError, showError);
|
||||
}
|
||||
|
||||
setState("idle");
|
||||
|
||||
Reference in New Issue
Block a user