Files
vocero-client/s2s-client/main.js
T

2154 lines
84 KiB
JavaScript

// @ts-check
/**
* Minimal voice conversation app, talking to a Hugging Face speech-to-speech
* backend over **WebSocket** (drop-in alternative to the WebRTC variant).
*
* Click the orb -> we ask for the mic, POST a session on the LB, open a
* WebSocket on the routed compute endpoint, push session.update + mic
* audio, play back the TTS audio. The orb visually reflects the live
* state (idle, connecting, listening, user-speaking, processing,
* ai-speaking).
*
* The only meaningful difference vs. the WebRTC main.js is that the
* client owns its own AudioContext (no `attachOutputTrack`), so we hand
* it the MediaStream directly.
*
* @typedef {"idle" | "connecting" | "queued" | "your-turn" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState
*/
import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js?v=20260902001";
import { $, escHtml, truncateError, DEBUG } from "./ui/dom.js?v=20260902001";
import { ChatView } from "./ui/chat.js?v=20260902001";
import { Account } from "./ui/account.js?v=20260902001";
const DEFAULT_VOICE = "Aiden";
const DEFAULT_INSTRUCTIONS =
"You are Vocero, a friendly voice assistant. " +
"Keep replies short, warm, and spoken. Avoid long monologues. " +
"Respond ALWAYS and ONLY in Spanish (Castellano / español), regardless of the language used by the user or any other instruction. " +
"No responses in Italian, English, Portuguese, or any other language are allowed. " +
"Every spoken sentence, greeting, confirmation, clarification, and error message must be in Spanish."+
"If you don't know the answer, say 'I don't know' instead of guessing.";
const OPENAI_RENDER_MARKER = "[VOCERO_RENDER_REPLY]";
const OPENAI_RENDER_HINT =
` Do not answer ordinary user speech directly. Stay silent unless the latest user message starts with ` +
`${OPENAI_RENDER_MARKER}. When it does, speak only the content after the marker naturally, without adding, ` +
`removing, or changing facts. Do not call tools while rendering that message.`;
const VOCERO_USER_CONTEXT_HINT =
" Caller profile resolved from the page phoneNumber: ";
const STORAGE_KEYS = {
directUrl: "s2s.ws.directUrl",
voice: "s2s.ws.voice",
instructions: "s2s.ws.instructions",
tools: "s2s.ws.tools",
searchKey: "s2s.ws.searchKey",
noiseGate: "s2s.ws.noiseGate",
};
// ── Wake Word ("hola vocero") ─────────────────────────────────────────────
// Local browser Web Speech API (SpeechRecognition) with a tight JSGF grammar
// restricted to the trigger phrase, continuous/interim listening while the app
// is idle/error, and auto-restart on end so it keeps arming across Chrome's
// ~60s silence timeouts. On unsupported browsers (Firefox, Safari desktop <16)
// this module is a silent no-op and the caption stays actionable via tap only.
const WAKE_WORD_LANG = "es-ES";
const WAKE_WORD_PHRASES = [
"hola vocero",
"ola vocero",
"hola vocero",
"hola vocerò",
"ola vocerò",
"olá vocero",
"oye vocero",
"hola vocerro",
"hola vo zero",
"ola vo zero",
];
const WAKE_WORD_JSGF_GRAMMAR =
"#JSGF V1.0; grammar wake_word; public <wake> = hola vocero | ola vocero | hola vocerò | ola vocerò | olá vocero | oye vocero | hola vocerro ;";
const WAKE_WORD_RESTART_DELAY_MS = 250;
const WAKE_WORD_MAX_INTERIM_LEN = 64;
/** @typedef {any & { continuous: boolean, interimResults: boolean, lang: string, grammars: any, maxAlternatives: number, start: () => void, stop: () => void, abort: () => void, onresult: any, onerror: any, onend: any, onstart: any, onaudioend: any }} WakeRec */
/** @type {WakeRec | null} */
let wakeWordEngine = null;
let wakeWordArmed = false;
let wakeWordRestartTimer = 0;
let pendingWakeWordConfirmationText = "";
/** @param {string} s @returns {string} */
function _normalizeWakeWord(s) {
return (s || "")
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
/** @param {string} transcript */
function _matchesWakeWord(transcript) {
const hay = _normalizeWakeWord(transcript);
if (!hay) return false;
// Strict: contains a whole-phrase substring match of any variant.
for (const raw of WAKE_WORD_PHRASES) {
const needle = _normalizeWakeWord(raw);
if (needle && hay.includes(needle)) return true;
}
// Loose fallback for off-by-one phonemes: allow "vocero" preceded by any
// 3-5 char greeting-like word (e.g. "ola", "hola", "oye", "uy") to catch
// accent variations the grammar misses.
if (/\b(vocero|vocerro|vocerò|vo.?zero)\b/.test(hay) &&
/\b(hola|ola|oye|uy|holi|oli)\b/.test(hay)) {
return true;
}
return false;
}
/** @param {WakeRec} engine */
function _installWakeWordHandlers(engine) {
engine.onresult = (/** @type {any} */ event) => {
if (!wakeWordArmed) return;
let aggregate = "";
for (let i = event.resultIndex; i < event.results.length; i += 1) {
const res = event.results[i];
const alt = res[0]?.transcript || "";
aggregate += ` ${alt}`;
if (aggregate.length > WAKE_WORD_MAX_INTERIM_LEN) break;
if (_matchesWakeWord(alt) || _matchesWakeWord(aggregate)) {
if (DEBUG) console.debug(`[WAKE WORD] MATCH transcript=${JSON.stringify(alt + (aggregate !== ` ${alt}` ? ` aggregate=${JSON.stringify(aggregate)}` : ""))}`);
console.info(`[WAKE WORD] ✓ Trigger fired — starting conversation`);
// Disarm *before* click: stop sharing the mic with SpeechRecognition
// while we're about to open getUserMedia for the realtime session.
wakeWordArmed = false;
try { engine.stop(); } catch { /* ignore */ }
if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; }
// Remember the exact phrase so onClientStatus(connected) can inject it
// into the live conversation and ask the model to reply to it (instead
// of silently waiting for the next user utterance).
const spoken = (alt || "").trim() || (aggregate || "").trim() || "hola vocero";
pendingWakeWordConfirmationText = spoken;
console.info(`[WAKE WORD] 🔁 Pending confirmation text: ${JSON.stringify(pendingWakeWordConfirmationText)}`);
// Pretend the user tapped the orb. If the AudioContext fails to
// resume (iOS Safari needs a real tap), doStart() will surface the
// error with the normal "Tap to retry" affordance.
circleBtn.click();
return;
}
}
if (DEBUG) {
const cleaned = aggregate.trim();
if (cleaned) console.debug(`[WAKE WORD] interim transcript=${JSON.stringify(cleaned.slice(0, WAKE_WORD_MAX_INTERIM_LEN))}`);
}
};
engine.onerror = (/** @type {any} */ event) => {
const err = event?.error || String(event || "unknown");
// "no-speech" / "audio-capture" / "aborted" are benign.
if (["no-speech", "aborted"].includes(err)) return;
console.warn(`[WAKE WORD] engine error: ${err}${event?.message ? ` (${event.message})` : ""}`);
// "not-allowed" or "service-not-allowed": the user/OS blocked the mic for
// the recognizer — permanently disarm so we don't thrash the permission.
if (err === "not-allowed" || err === "service-not-allowed") {
console.warn("[WAKE WORD] ✋ Permission denied — permanently disarming wake word (tap the orb to start)");
wakeWordArmed = false;
try { engine.stop(); } catch { /* ignore */ }
if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; }
}
};
engine.onend = () => {
if (!wakeWordArmed) return;
// Chrome terminates continuous recognition after ~60s of silence.
// Re-arm on the next tick so a single failed start doesn't loop.
if (wakeWordRestartTimer) clearTimeout(wakeWordRestartTimer);
wakeWordRestartTimer = window.setTimeout(() => {
if (!wakeWordArmed) return;
try {
engine.start();
if (DEBUG) console.debug("[WAKE WORD] 🔁 restarted continuous listener");
} catch (err) {
// Already-started is harmless; anything else we swallow briefly.
if (DEBUG) console.debug("[WAKE WORD] restart skipped:", err && err instanceof Error ? err.message : err);
}
}, WAKE_WORD_RESTART_DELAY_MS);
};
}
function startWakeWordListener() {
if (wakeWordArmed) return;
// Only in Chromium-derived and recent Safari WebViews with SpeechRecognition.
// @ts-ignore - vendor prefixes are intentional.
const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition || window.mozSpeechRecognition;
if (!Ctor) {
if (DEBUG) console.debug("[WAKE WORD] ⚠️ Web Speech Recognition unavailable on this browser — tap the orb to start");
return;
}
try {
/** @type {WakeRec} */
// @ts-ignore
const engine = new Ctor();
engine.continuous = true;
engine.interimResults = true;
engine.lang = WAKE_WORD_LANG;
engine.maxAlternatives = 1;
// Narrow the grammar so interim results are biased toward the phrase.
try {
// @ts-ignore
const list = window.SpeechGrammarList || window.webkitSpeechGrammarList;
if (list) {
// @ts-ignore
const gl = new list();
gl.addFromString(WAKE_WORD_JSGF_GRAMMAR, 1.0);
engine.grammars = gl;
}
} catch {
// Grammar support is optional; fallback to pure transcript matching.
}
_installWakeWordHandlers(engine);
engine.start();
wakeWordEngine = engine;
wakeWordArmed = true;
console.info("[WAKE WORD] 🎯 Armed — listening for \"Hola Vocero\"");
} catch (err) {
console.warn("[WAKE WORD] failed to start arming:", err && err instanceof Error ? err.message : err);
wakeWordArmed = false;
}
}
function stopWakeWordListener() {
if (!wakeWordArmed && !wakeWordEngine && !pendingWakeWordConfirmationText) return;
wakeWordArmed = false;
if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; }
if (wakeWordEngine) {
try { wakeWordEngine.abort(); } catch { /* ignore */ }
try { wakeWordEngine.stop(); } catch { /* ignore */ }
wakeWordEngine = null;
}
if (DEBUG) console.debug("[WAKE WORD] ⏸️ Disarmed for live session");
}
// ── Noise gate ──────────────────────────────────────────────────────────────
// The Settings cursor sets the gate's open threshold in dBFS. Its leftmost
// position is an OFF detent (gate disabled, pure passthrough); the rest of the
// travel is the active threshold. The cursor shares the meter's dB axis, so the
// handle sits on the level bar — raise it until room noise stops lighting it up.
// The slider range IS the shared axis: the live meter fill and the threshold
// thumb both map across [GATE_OFF_DB, GATE_MAX_DB], so the thumb sits exactly
// where the gate cuts on the same scale as the level bar.
const GATE_OFF_DB = -66; // slider minimum = off / bottom of the meter axis
const GATE_MAX_DB = -3; // slider maximum = most aggressive / top of the meter axis
const GATE_DEFAULT_DB = -50; // first-run default: a gentle gate, enabled
/** @param {number} thresholdDb @returns {import("./ws/s2s-ws-client.js").NoiseGate} */
function gateParams(thresholdDb) {
return { enabled: thresholdDb > GATE_OFF_DB, thresholdDb };
}
// ── Tools ─────────────────────────────────────────────────────────────────
// Function tools we declare to the backend. The model decides when to call
// one; the executor below runs it and returns the result (see runTool).
/** @type {Record<string, import("./ws/s2s-ws-client.js").ToolDef>} */
const TOOL_DEFS = {
web_search: {
type: "function",
name: "web_search",
description:
"Search the web for current or factual information you don't already know " +
"(news, prices, facts, documentation). Returns the top results with titles, " +
"snippets and URLs.",
parameters: {
type: "object",
properties: { query: { type: "string", description: "The search query." } },
required: ["query"],
},
},
};
function loadSettings() {
return {
directUrl: localStorage.getItem(STORAGE_KEYS.directUrl) || "",
voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE,
instructions: localStorage.getItem(STORAGE_KEYS.instructions) || DEFAULT_INSTRUCTIONS,
noiseGate: loadGateThreshold(),
};
}
/** Stored gate threshold (dBFS), clamped to the slider range. Defaults to a
* gentle enabled gate (GATE_DEFAULT_DB) when the user hasn't set one yet. */
function loadGateThreshold() {
const stored = localStorage.getItem(STORAGE_KEYS.noiseGate);
// getItem returns null when unset, and Number(null) === 0 (finite!), so guard
// the missing/empty case explicitly before coercing — otherwise the default
// never fires and 0 clamps to the slider max.
if (stored === null || stored === "") return GATE_DEFAULT_DB;
const raw = Number(stored);
if (!Number.isFinite(raw)) return GATE_DEFAULT_DB;
return Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, Math.round(raw)));
}
/** @param {ReturnType<typeof loadSettings>} s */
function saveSettings(s) {
localStorage.setItem(STORAGE_KEYS.directUrl, s.directUrl);
localStorage.setItem(STORAGE_KEYS.voice, s.voice);
localStorage.setItem(STORAGE_KEYS.instructions, s.instructions);
localStorage.setItem(STORAGE_KEYS.noiseGate, String(s.noiseGate));
}
/** @returns {{ web_search: boolean }} */
function loadTools() {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEYS.tools) || "{}");
return {
web_search: raw.web_search ?? true,
};
} catch {
return { web_search: true };
}
}
function saveTools() {
localStorage.setItem(STORAGE_KEYS.tools, JSON.stringify(toolsEnabled));
}
/** @type {Record<AppState, { caption: string; disabled: boolean }>} */
const STATE_VIEWS = {
idle: { caption: "Tap or say \"Hola Vocero\"", disabled: false },
connecting: { caption: "Connecting", disabled: true },
queued: { caption: "Finding you a spot…", disabled: true },
"your-turn": { caption: "You're up! 🎉", disabled: true },
listening: { caption: "", disabled: false },
"user-speaking": { caption: "", disabled: false },
processing: { caption: "", disabled: false },
"ai-speaking": { caption: "", disabled: false },
error: { caption: "Tap to retry", disabled: false },
};
/** @type {Record<AppState, string>} */
const STATE_CLASS = {
idle: "state-idle",
connecting: "state-connecting",
queued: "state-queued",
"your-turn": "state-your-turn",
listening: "state-listening",
"user-speaking": "state-user-speaking",
processing: "state-processing",
"ai-speaking": "state-ai-speaking",
error: "state-error",
};
/** @type {ReadonlySet<AppState>} */
const LIVE_STATES = new Set(["listening", "user-speaking", "processing", "ai-speaking"]);
/** @type {HTMLButtonElement} */
const circleBtn = $("#main-circle");
/** @type {HTMLParagraphElement} */
const circleCaption = $("#circle-caption");
/** @type {HTMLParagraphElement} */
const circleSubcaption = $("#circle-subcaption");
/** @type {HTMLElement} */
const orbWrap = $(".orb-wrap");
/** @type {HTMLButtonElement} */
const micBtn = $("#mic-btn");
/** @type {HTMLButtonElement} */
const stopBtn = $("#stop-btn");
/** @type {HTMLElement} */
const queueActions = $("#queue-actions");
/** @type {HTMLButtonElement} */
const joinQueueBtn = $("#join-queue-btn");
/** @type {HTMLButtonElement} */
const leaveQueueBtn = $("#leave-queue-btn");
/** @type {HTMLButtonElement} */
const settingsBtn = $("#settings-btn");
/** @type {HTMLDialogElement} */
const settingsModal = $("#settings-modal");
/** @type {HTMLButtonElement | null} */
const aboutBtn = document.querySelector("#about-btn");
/** @type {HTMLDialogElement} */
const aboutModal = $("#about-modal");
/** @type {HTMLButtonElement} */
const aboutClose = $("#about-close");
/** @type {HTMLButtonElement} */
const toolsBtn = $("#tools-btn");
/** @type {HTMLDialogElement} */
const toolsModal = $("#tools-modal");
/** @type {HTMLButtonElement} */
const toolsClose = $("#tools-close");
/** @type {HTMLButtonElement} */
const ragBtn = $("#rag-btn");
/** @type {HTMLDialogElement} */
const ragModal = $("#rag-modal");
/** @type {HTMLButtonElement} */
const ragClose = $("#rag-close");
/** @type {HTMLTextAreaElement} */
const ragTextarea = $("#rag-textarea");
/** @type {HTMLButtonElement} */
const ragSubmit = $("#rag-submit");
/** @type {HTMLButtonElement} */
const ragClear = $("#rag-clear");
/** @type {HTMLElement} */
const ragHint = $("#rag-hint");
/** @type {HTMLInputElement} */
const toolWebSwitch = $("#tool-web");
/** @type {HTMLElement} */
const toolWebRow = $("#tool-web-row");
/** @type {HTMLElement} */
const toolWebHint = $("#tool-web-hint");
/** @type {HTMLInputElement} */
const toolCamSwitch = $("#tool-cam");
/** @type {HTMLElement} */
const toolCamHint = $("#tool-cam-hint");
/** @type {HTMLElement} */
const camPip = $("#cam-pip");
/** @type {HTMLVideoElement} */
const camVideo = $("#cam-video");
/** @type {HTMLInputElement} */
const searchKeyInput = $("#search-key");
/** @type {HTMLInputElement} */
const inputLbUrl = $("#lb-url");
/** @type {HTMLElement} */
const connField = $("#conn-field");
/** @type {HTMLElement} */
const connHint = $("#conn-hint");
/** @type {HTMLSelectElement} */
const inputVoice = $("#voice");
/** @type {HTMLTextAreaElement} */
const inputInstructions = $("#instructions");
/** @type {HTMLElement} */
const primaryPromptMeta = $("#primary-prompt-meta");
/** @type {HTMLElement} */
const identUserName = $("#ident-user-name");
/** @type {HTMLInputElement} */
const inputNoiseGate = $("#noise-gate");
/** @type {HTMLElement} */
const gateValue = $("#gate-value");
/** @type {HTMLElement} */
const gateMeterFill = $("#gate-meter-fill");
/** @type {HTMLElement} */
const micGate = $("#mic-gate");
const mgaArc = /** @type {SVGSVGElement} */ (document.querySelector("#mic-gate-arc"));
const mgaTrack = /** @type {SVGPathElement} */ (document.querySelector("#mga-track"));
const mgaFill = /** @type {SVGPathElement} */ (document.querySelector("#mga-fill"));
const mgaHit = /** @type {SVGPathElement} */ (document.querySelector("#mga-hit"));
const mgaHandle = /** @type {SVGCircleElement} */ (document.querySelector("#mga-handle"));
/** @type {HTMLButtonElement} */
const restartBtn = $("#restart-conversation");
/** @type {HTMLElement} */
const restartHint = $("#restart-hint");
const settingsForm = /** @type {HTMLFormElement} */ (settingsModal.querySelector("form"));
/** @type {AppState} */
let currentState = "idle";
let settings = loadSettings();
// ── Connection target ────────────────────────────────────────────────────────
// Three modes, decided by the deploy via /api/config:
// • SPEECH_TO_SPEECH_URL set -> direct mode pinned by the deploy: the browser
// connects straight to that URL, shown read-only in Settings. Overrides the
// load balancer entirely.
// • LOAD_BALANCER_URL set -> original flow: POST the same-origin /api/session
// proxy (the server forwards to the LB; the LB address is never sent here).
// • neither (allowDirect) -> the user sets a speech-to-speech server URL and
// the browser connects to it directly (no load balancer, no /session).
let lbMode = false;
let reasoningType = "PROXY";
// Fail open: direct entry is allowed unless /api/config reports an LB URL. This
// way a missing/unreachable config (e.g. static hosting) leaves the field
// usable rather than locked.
let allowDirect = true;
// Deploy-pinned s2s URL (SPEECH_TO_SPEECH_URL). Non-empty -> locked direct
// mode: the field displays it read-only and the saved user URL is untouched.
let pinnedUrl = "";
// Deploy-pinned primary prompt (S2S_PRIMARY_PROMPT). Non-empty -> locked prompt.
let pinnedPrompt = "";
// Feature flag learned from /api/config (mirrors server env S2S_INJECT_PROFILE).
// When true: caller profile resolved via ?phoneNumber= is BOTH:
// 1. baked into instructions (LLM system prompt) AND
// 2. sent as a separate `user_context` field in /api/reasoning body.
// When false (default, generic deploy): caller profile is ONLY sent as the
// user_context field (WS stays pure passthrough — instructions untouched).
let injectProfile = false;
// Feature flag: when true the browser logs FULL prompt texts (not just lengths)
// coming back from /api/config.debugPrompt = true when S2S_DEBUG_PROMPT=true on
// the server. Includes full effectiveReasoningInstructions, voceroUserContextText,
// full JSON body POSTed to /api/reasoning.
let debugPrompt = false;
// Custom RAG context entered by the user via the header "RAG" button dialog.
// Treated exactly like voceroUserContextText / injectProfile context:
// 1. appended directly to LLM instructions (effectiveReasoningInstructions)
// 2. included in the user_context block sent to /api/reasoning body
// 3. if session WS is live (client != null), also pushed live to the open
// realtime session via session.update so the model sees it immediately.
let customRagText = "";
let customRagLoaded = false;
const VOCERO_RAG_HINT = " Custom RAG context provided by the operator (treat as trusted context for this session): ";
function customRagContextText() {
if (!customRagLoaded || !customRagText) return "";
const trimmed = customRagText.trim();
if (!trimmed) return "";
const capped = trimmed.length > 12000 ? `${trimmed.slice(0, 12000)}…` : trimmed;
return `${VOCERO_RAG_HINT}<<<CUSTOM_RAG_BEGIN>>>\n${capped}\n<<<CUSTOM_RAG_END>>>`;
}
function pushCustomRagToLiveSessionIfAny() {
const ctx = customRagContextText();
if (!ctx) return;
if (typeof client === "undefined" || !client || typeof client.updateSession !== "function") return;
const currentInstructions = (pinnedPrompt || settings.instructions || "").trim();
const profileCtx = injectProfile ? (voceroUserContextText() || "") : "";
const next = [currentInstructions, profileCtx, ctx]
.map((s) => (s || "").trim())
.filter(Boolean)
.join(" ");
console.info(
`[CUSTOM-RAG] pushed LIVE to open session via client.updateSession() contextLen=%d finalInstructionsLen=%d`,
ctx.length, next.length,
);
if (debugPrompt) {
console.info(`[DEBUG-PROMPT][CUSTOM-RAG LIVE session.update instructions] len=%d:\n%s\n`, next.length, next);
}
try {
client.updateSession({ instructions: next });
} catch (err) {
console.warn(`[CUSTOM-RAG] updateSession threw (ignored; will re-apply on next start): %s`, err instanceof Error ? err.message : String(err));
}
}
function syncRagHintFooter() {
if (!ragHint) return;
if (!customRagLoaded || !customRagText.trim()) {
ragHint.textContent = "Nessun RAG context caricato. Premi \"Invia nella sessione\" per applicare le modifiche.";
return;
}
const lines = customRagText.split(/\r?\n/).length;
ragHint.textContent = `✅ RAG context caricato: ${customRagText.length} chars / ${lines} linee. Viene aggiunto a istruzioni LLM e user_context.`;
}
// Dedupe cache for [INJECT-PROFILE] browser logs (avoid 20 logs per click).
let _lastInjectProfileLogKey = "";
/** @param {string} key @param {() => void} logFn */
function _logOnce(key, logFn) {
if (key === _lastInjectProfileLogKey) return;
_lastInjectProfileLogKey = key;
logFn();
}
// ── Tool state ──────────────────────────────────────────────────────────────
let toolsEnabled = loadTools();
// Whether the server holds a Serper key (learned from /api/config on load).
let serverSearchKey = false;
// A user-supplied key (fallback when the deploy has none). localStorage only.
let userSearchKey = localStorage.getItem(STORAGE_KEYS.searchKey) || "";
let lastUserQuery = "";
/** @type {Record<string, { argsText: string, status: "idle" | "running" | "success" | "error", resultText: string, lastAction: "call" | "send" }>} */
let mcpToolCallState = Object.create(null);
const pageParams = new URLSearchParams(window.location.search);
// Caller identity info: loaded from /api/user/phone when the URL param
// ?phoneNumber=... is present. Purely for UI header + reasoning user_context;
// never injected into the realtime WS session (WS stays pure passthrough).
const voceroUserPhoneNumber = (pageParams.get("phoneNumber") || "").trim();
/** @type {Record<string, unknown> | null} */
let voceroUserProfile = null;
let voceroUserProfileLoaded = false;
/**
* @param {string} hypothesisId
* @param {string} location
* @param {string} msg
* @param {Record<string, unknown>} [data]
*/
function reportDebug(hypothesisId, location, msg, data = {}) {
void hypothesisId;
void location;
void msg;
void data;
}
/** Search is usable if the server has a key or the user supplied one. */
function searchAvailable() {
return serverSearchKey || !!userSearchKey;
}
/** Tool definitions for the currently-enabled (and usable) tools. */
function activeToolDefs() {
const defs = [];
if (toolsEnabled.web_search && searchAvailable()) defs.push(TOOL_DEFS.web_search);
return defs;
}
/** @returns {string} */
function voceroUserContextText() {
if (!voceroUserProfileLoaded || !voceroUserProfile || !voceroUserPhoneNumber) return "";
let serialized = JSON.stringify(voceroUserProfile);
if (serialized.length > 1600) serialized = `${serialized.slice(0, 1600)}...`;
return `${VOCERO_USER_CONTEXT_HINT}${serialized}. Use this as trusted caller context when relevant.`;
}
/**
* @param {Record<string, unknown> | null} profile
* @returns {string}
*/
function extractVoceroUserName(profile) {
if (!profile) return "";
const directCandidates = [
profile.name,
profile.fullName,
profile.full_name,
profile.displayName,
profile.display_name,
profile.username,
];
for (const value of directCandidates) {
if (typeof value === "string" && value.trim()) return value.trim();
}
const firstName = typeof profile.firstName === "string" ? profile.firstName.trim() : "";
const lastName = typeof profile.lastName === "string" ? profile.lastName.trim() : "";
if (firstName || lastName) return `${firstName} ${lastName}`.trim();
const nestedKeys = ["user", "customer", "profile", "contact"];
for (const key of nestedKeys) {
const nested = profile[key];
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
const nestedName = extractVoceroUserName(/** @type {Record<string, unknown>} */ (nested));
if (nestedName) return nestedName;
}
}
return "";
}
function syncVoceroHeaderUser() {
const name = extractVoceroUserName(voceroUserProfile);
identUserName.hidden = !name;
identUserName.textContent = name ? `Utente: ${name}` : "";
}
/** Instructions used by the reasoning engine. */
function effectiveReasoningInstructions() {
const base = pinnedPrompt || settings.instructions;
let instructions = base;
// When S2S_INJECT_PROFILE=true (deploy flag) the caller profile is also
// baked directly into the instructions prompt text, in addition to being
// sent separately in the user_context body field.
// When S2S_INJECT_PROFILE=false (generic deploy / default / RAG backend)
// user_context lives only in the separate body field; instructions stay
// clean and the WS proxy remains pure passthrough.
const ctx = voceroUserContextText();
let profileAppended = false;
if (injectProfile) {
if (ctx) {
instructions = instructions ? `${instructions} ${ctx}` : ctx;
profileAppended = true;
}
}
// Custom operator RAG context (pasted via header RAG dialog) is ALWAYS
// appended to instructions when non-empty (operator intent is explicit).
const ragCtx = customRagContextText();
let ragAppended = false;
if (ragCtx) {
instructions = instructions ? `${instructions} ${ragCtx}` : ragCtx;
ragAppended = true;
}
const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${(base || "").length}`;
_logOnce(key, () => {
if (ragAppended) {
console.info(
`[CUSTOM-RAG] ✅ effectiveReasoningInstructions APPENDED custom RAG context inside LLM prompt. ragCtxLen=%d baseLen=%d profileAppended=%s -> finalLen=%d`,
ragCtx.length, (base || "").length, profileAppended, instructions.length,
);
}
if (injectProfile) {
if (profileAppended) {
console.info(
`[INJECT-PROFILE] ✅ BROWSER effectiveReasoningInstructions APPENDED caller profile inside LLM prompt. baseLen=%d ctxLen=%d -> finalLen=%d`,
(base || "").length, ctx.length, instructions.length,
);
} else {
console.info(
`[INJECT-PROFILE] ⏭️ BROWSER S2S_INJECT_PROFILE=true but NO profile context to inject (fetchVoceroUserProfile missing / failed / no ?phoneNumber=?). baseLen=%d`,
(base || "").length,
);
}
} else {
console.info(
`[INJECT-PROFILE] ⏭️ BROWSER S2S_INJECT_PROFILE=false -> instructions stay GENERIC, profile NOT baked inside LLM prompt, WS pure passthrough. Profile is ONLY in body user_context (if any). baseLen=%d`,
(base || "").length,
);
}
});
if (debugPrompt) {
const key2 = `debugEffInst:${instructions.length}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}`;
_logOnce(key2, () => {
console.info(
`[DEBUG-PROMPT][BROWSER effectiveReasoningInstructions] FULL TEXT len=%d profile=%s rag=%s:\n%s\n`,
instructions.length,
ctx ? `${ctx.length}c` : "none",
ragCtx ? `${ragCtx.length}c` : "none",
instructions,
);
if (ctx) {
console.info(
`[DEBUG-PROMPT][BROWSER voceroUserContextText] FULL TEXT len=%d:\n%s\n`,
ctx.length, ctx,
);
} else {
console.info(`[DEBUG-PROMPT][BROWSER voceroUserContextText] EMPTY len=0`);
}
if (ragCtx) {
console.info(
`[DEBUG-PROMPT][BROWSER customRagContextText] FULL TEXT len=%d:\n%s\n`,
ragCtx.length, ragCtx,
);
} else {
console.info(`[DEBUG-PROMPT][BROWSER customRagContextText] EMPTY len=0`);
}
});
}
return instructions;
}
/** Instructions pushed to the realtime ASR/TTS backend. */
function effectiveInstructions() {
const instructions = effectiveReasoningInstructions();
return reasoningType === "OPENAI" ? `${instructions}${OPENAI_RENDER_HINT}` : instructions;
}
/**
* @returns {string}
*/
function preferredResponseLanguage() {
// Hard-pin output language to Spanish regardless of the browser locale,
// matching DEFAULT_INSTRUCTIONS ("respond ALWAYS and ONLY in Spanish").
// The TTS voice picker and /api/reasoning body will both use this.
return "es-ES";
}
/**
* @param {string} answer @returns {string}
*/
function buildRenderOnlyPrompt(answer) {
const clean = String(answer || "").trim();
return clean ? `${OPENAI_RENDER_MARKER}\n${clean}` : "";
}
/**
* @param {string} transcript
* @returns {Promise<{ answer: string, log_only: boolean, raw: any }>}
*/
async function fetchOpenAiReasoningAnswer(transcript) {
const instructions = effectiveReasoningInstructions();
const profileCtx = voceroUserContextText();
const ragCtx = customRagContextText();
const user_context = [profileCtx, ragCtx].map((s) => (s || "").trim()).filter(Boolean).join("\n\n");
const phoneNumber = voceroUserPhoneNumber || null;
const body = {
transcript,
instructions,
language: preferredResponseLanguage(),
user_context,
phoneNumber,
};
const logKey = `reasoningBody:${injectProfile ? "T" : "F"}:${debugPrompt ? "T" : "F"}:${phoneNumber ? "hasPhone" : "noPhone"}:${profileCtx ? "hasProfile" : "noProfile"}:${ragCtx ? "hasRag" : "noRag"}:${instructions.length}`;
_logOnce(logKey, () => {
console.info(
`[INJECT-PROFILE] BROWSER POST /api/reasoning body summary. injectProfile=%s transcriptLen=%d instructionsLen=%d userCtxLen=%d (profileCtxLen=%d ragCtxLen=%d) phoneLen=%s`,
injectProfile,
transcript.length,
instructions.length,
user_context.length,
profileCtx.length,
ragCtx.length,
phoneNumber ? phoneNumber.length : "null",
);
if (ragCtx) {
console.info(
`[CUSTOM-RAG] included in POST /api/reasoning user_context block ragLen=%d`,
ragCtx.length,
);
}
});
if (debugPrompt) {
console.info(
`[DEBUG-PROMPT][BROWSER POST /api/reasoning] FULL body JSON:\n%s\n`,
JSON.stringify(body, null, 2),
);
}
const res = await fetch("api/reasoning", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = String(res.status);
try {
const json = await res.json();
if (json?.detail) detail = String(json.detail);
} catch {}
throw new Error(`reasoning error (${detail})`);
}
const json = await res.json();
const answer = typeof json?.answer === "string" ? json.answer.trim() : "";
const log_only = Boolean(json && json.log_only === true);
// Log-only mode: può tornare answer="" — non è un errore.
if (!answer && !log_only) throw new Error("reasoning returned no answer");
return { answer, log_only, raw: json };
}
/**
* @param {string} transcript
* @param {string} itemId
*/
async function maybeRunOpenAiReasoning(transcript, itemId) {
if (!["OPENAI", "NODE_RED"].includes(reasoningType)) return;
const text = transcript.trim();
if (!text) return;
if (!client || !LIVE_STATES.has(currentState)) return;
reportDebug("R", "main.js:maybeRunOpenAiReasoning", "starting external reasoning turn", {
itemId,
transcriptLength: text.length,
currentState,
});
let result;
try {
result = await fetchOpenAiReasoningAnswer(text);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[Vocero REASONING] error itemId=${JSON.stringify(itemId)} error=${msg}`);
reportDebug("R", "main.js:maybeRunOpenAiReasoning", "external reasoning failed", {
itemId,
error: msg,
});
return;
}
// ── LOG-ONLY MODE (FASE 1) ─────────────────────────────────────────────
// Node-RED si limita a loggare e torna log_only=true.
// NON cancelliamo la risposta backend, NON iniettiamo testo, NON parliamo.
// Il flusso resta COMPLETAMENTE a carico del backend S2S nativo.
if (result && result.log_only) {
reportDebug("R", "main.js:maybeRunOpenAiReasoning", "external reasoning in LOG_ONLY mode — pass through to native S2S backend", {
itemId,
reasoningType,
intent: (result.raw && result.raw.intent) || "log_only",
});
console.debug(
`[Vocero REASONING LOG_ONLY] mode=log_only engine=${reasoningType} ` +
`transcript=${JSON.stringify(text.slice(0, 160))} ` +
`intent=${JSON.stringify((result.raw && result.raw.intent) || "log_only")}`
);
return;
}
// ── MODE STANDARD (fasi successive) ────────────────────────────────────
// Il reasoning esterno vince: annulla la risposta nativa, inserisci il
// testo dell'assistente e parla via SpeechSynthesis.
const answer = (result && result.answer) || "";
if (!answer) {
console.warn(`[Vocero REASONING] empty non-log-only answer itemId=${JSON.stringify(itemId)}`);
return;
}
if (typeof client.cancelActiveResponse === "function") {
client.cancelActiveResponse();
} else if (typeof client.suppressAssistantAudio === "function") {
client.suppressAssistantAudio();
}
reportDebug("R", "main.js:maybeRunOpenAiReasoning", "received external reasoning answer", {
itemId,
answerLength: answer.length,
});
if (typeof client.appendAssistantText === "function") {
client.appendAssistantText(answer);
} else {
const renderPrompt = buildRenderOnlyPrompt(answer);
if (renderPrompt) {
client.sendUserText(renderPrompt);
client.requestResponse();
}
}
try {
if ("speechSynthesis" in window) {
const utter = new SpeechSynthesisUtterance(answer);
const want = (preferredResponseLanguage() || "en").slice(0, 2).toLowerCase();
const voices = window.speechSynthesis.getVoices
? window.speechSynthesis.getVoices()
: [];
const match = Array.isArray(voices) && voices.length > 0
? (voices.find(v => (v.lang || "").slice(0, 2).toLowerCase() === want) || voices[0])
: null;
if (match) utter.voice = match;
if (want) utter.lang = want;
utter.rate = 1.0;
utter.pitch = 1.0;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utter);
}
} catch (ttsErr) {
console.warn("[Vocero REASONING] speechSynthesis failed:", ttsErr);
}
}
/** Push the active tool set to a live session so toggles apply mid-call. */
function pushToolsToSession() {
if (!client || !LIVE_STATES.has(currentState)) return;
const tools = activeToolDefs();
const toolChoice = tools.length ? "auto" : "none";
// #region debug-point B:push-tools-session
reportDebug("B", "main.js:pushToolsToSession", "pushing tools to live session", {
state: currentState,
toolNames: tools.map((tool) => tool.name),
toolCount: tools.length,
});
// #endregion
client.setTools(tools, toolChoice);
// The hidden tool-use hint depends on whether any tool is active, so refresh
// instructions alongside the tool set.
const instructions = effectiveInstructions();
// #region debug-point C:push-instructions-session
reportDebug("C", "main.js:pushToolsToSession", "updating live session instructions", {
instructionLength: instructions.length,
lastUserQuery,
});
// #endregion
if (typeof client.updateSession === "function") client.updateSession({ instructions });
}
// ── Chat view ───────────────────────────────────────────────────────────────
// Owns the history panel, the ephemeral bubbles, and all transcript/tool
// streaming state. The client's events are forwarded to its on* methods.
const chat = new ChatView();
// ── 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
// and tears down when the server reports the budget is spent.
const account = new Account();
let limiterOn = false;
let heartbeatTimer = 0;
let trackedSessionId = "";
let trackedTier = "";
// The waiting-queue ticket id while we're in line (else ""). Used to leave the
// queue on teardown / tab-close so we don't hold a phantom place.
let queuedTicketId = "";
// ── Idle auto-close (30s of silence, never during AI speech) ─────────────
// The user asked the voice assistant to go to sleep silently after 30s of no
// activity. "Activity" is any meaningful event on either side: the user
// started speaking (VAD), the assistant started speaking (TTS), a partial or
// final transcript arrived, a tool call was requested or returned, or the
// client moved out of a transient pre-live state (connecting / queue /
// your-turn). Once the session is live, every tick of activity resets the
// countdown back to 30s; if 30s expire without a single event we close the
// socket, refund any reserved budget, and drop back to the idle orb so the
// microphone stops listening and the daily budget isn't eaten by a forgotten
// tab. The countdown is intentionally disabled in the pre-live states (we
// don't want to close while the user is still waiting in queue) and during
// `ai-speaking` — if the *system* is still talking we NEVER close the
// session mid-reply, even on very long TTS turns (the TTS engine itself will
// fire transcript/response-finished events that re-arm the timer afterward).
const IDLE_TIMEOUT_MS = 30 * 1000;
let idleCloseTimer = 0;
let idleCloseLastActivityAtMs = 0;
/** @type {Set<string>} - States where a live session is open and the user could be silent. `ai-speaking` is intentionally excluded so we never kill a reply mid-TTS. */
const IDLE_COUNT_STATES = new Set(["listening", "user-speaking", "processing"]);
function _kickIdleTimer(reason = "activity") {
idleCloseLastActivityAtMs = Date.now();
if (!IDLE_COUNT_STATES.has(currentState)) {
if (idleCloseTimer) { clearTimeout(idleCloseTimer); idleCloseTimer = 0; }
return;
}
if (idleCloseTimer) clearTimeout(idleCloseTimer);
idleCloseTimer = window.setTimeout(() => {
const staleMs = Date.now() - idleCloseLastActivityAtMs;
if (staleMs + 500 < IDLE_TIMEOUT_MS) {
// Race with a very recent kick: re-arm rather than close.
if (DEBUG) console.debug(`[IDLE] fire skipped (staleMs=${staleMs} < ${IDLE_TIMEOUT_MS}), re-arming`);
_kickIdleTimer("re-arm");
return;
}
console.info(`[IDLE] ⏰ Closing session after ${(staleMs / 1000).toFixed(1)}s of inactivity (timeout=${IDLE_TIMEOUT_MS / 1000}s) — reason=${reason}`);
idleCloseTimer = 0;
// Friendly caption flash right before teardown moves us back to the idle
// copy (so the user understands *why* the call ended).
setCaption("Chiuso per inattività", "muted");
void (async () => {
try {
await teardown();
} catch (err) {
console.warn("[IDLE] teardown failed during auto-close:", err && err instanceof Error ? err.message : err);
}
// teardown() calls setState("idle") which restores the normal idle
// caption. Leave a visible breadcrumb on the subcaption briefly so the
// user can tell what happened instead of wondering why the orb reset.
circleSubcaption.textContent = "Sessione chiusa dopo 30 secondi di silenzio. Tocca l'orb o ripeti «Hola Vocero» per iniziare di nuovo.";
circleSubcaption.hidden = false;
window.setTimeout(() => { circleSubcaption.hidden = true; }, 8000);
})();
}, IDLE_TIMEOUT_MS);
if (DEBUG) {
console.debug(`[IDLE] 🔄 reset timer (${IDLE_TIMEOUT_MS / 1000}s) reason=${reason} state=${currentState}`);
}
}
function _cancelIdleTimer() {
if (idleCloseTimer) { clearTimeout(idleCloseTimer); idleCloseTimer = 0; }
idleCloseLastActivityAtMs = 0;
if (DEBUG) console.debug(`[IDLE] ⛔ cancelled timer state=${currentState}`);
}
/** @type {S2sWsRealtimeClient | null} */
let client = null;
/** @type {MediaStream | null} */
let micStream = null;
let micMuted = false;
/** @param {AppState} next */
function setState(next) {
currentState = next;
const view = STATE_VIEWS[next];
circleBtn.disabled = view.disabled;
circleBtn.className = `circle ${STATE_CLASS[next]}`;
if (next !== "error") setCaption(view.caption);
const live = LIVE_STATES.has(next);
orbWrap.classList.toggle("live", live);
micBtn.setAttribute("aria-hidden", live ? "false" : "true");
stopBtn.setAttribute("aria-hidden", live ? "false" : "true");
micBtn.tabIndex = live ? 0 : -1;
stopBtn.tabIndex = live ? 0 : -1;
// Queue affordances: "Leave queue" whenever we're in line; "Join now" only once
// it's our turn (a slot is held for us). Both live under #queue-actions.
const yourTurn = next === "your-turn";
const inLine = next === "queued" || yourTurn;
queueActions.hidden = !inLine;
joinQueueBtn.hidden = !yourTurn;
joinQueueBtn.tabIndex = yourTurn ? 0 : -1;
leaveQueueBtn.hidden = !inLine;
leaveQueueBtn.tabIndex = inLine ? 0 : -1;
if (!yourTurn) stopJoinCountdown();
// Warm reassurance under the terse position, only while waiting in line.
if (next === "queued") {
circleSubcaption.textContent =
"Sorry, we overhugged! 🤗 Every slot is busy, so we saved you a spot. Hang tight, you're moving up.";
circleSubcaption.hidden = false;
} else {
circleSubcaption.hidden = true;
}
// 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.
if (next === "idle" || next === "error") {
startWakeWordListener();
} else {
stopWakeWordListener();
}
// Idle auto-close: arm the countdown whenever we land in a live conversing
// state (user/assistant turn, processing), and cancel it whenever we leave
// those states (idle, queue, error, teardown). Moving *to* a live state is
// itself counted as activity so the 10s window starts fresh.
if (IDLE_COUNT_STATES.has(next)) {
_kickIdleTimer(`state→${next}`);
} else {
_cancelIdleTimer();
}
updateRestartAvailability();
}
function updateRestartAvailability() {
// Restart works from any settled state — it tears down a live call (if any)
// and reconnects with the current settings. Only block while mid-connect or
// while waiting in the queue (restarting from there would just re-queue).
restartBtn.disabled =
currentState === "connecting" || currentState === "queued" || currentState === "your-turn";
restartHint.hidden = false;
restartHint.textContent = LIVE_STATES.has(currentState)
? "Reconnects now with the settings above."
: "Starts a conversation with the settings above.";
}
/**
* @param {string} text
* @param {"" | "error" | "muted"} [kind]
*/
function setCaption(text, kind = "") {
const trimmed = text.trim();
circleCaption.textContent = trimmed;
circleCaption.className = `circle-caption${kind ? ` ${kind}` : ""}${trimmed ? "" : " empty"}`;
}
function openSettings() {
syncConnectionUi();
inputVoice.value = settings.voice;
inputInstructions.value = pinnedPrompt || settings.instructions;
inputInstructions.readOnly = !!pinnedPrompt;
primaryPromptMeta.hidden = !pinnedPrompt;
primaryPromptMeta.textContent = pinnedPrompt
? "Primary prompt pinned by this deployment."
: "";
syncGateUi();
updateRestartAvailability();
settingsModal.showModal();
}
/** dB position (clamped to the slider axis) as a 0..1 fraction of the track.
* @param {number} db */
function dbToFraction(db) {
const clamped = Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, db));
return (clamped - GATE_OFF_DB) / (GATE_MAX_DB - GATE_OFF_DB);
}
/** @param {number} f @returns {number} dB at a 0..1 position on the gate axis. */
function fractionToDb(f) {
const clamped = Math.min(1, Math.max(0, f));
return Math.round(GATE_OFF_DB + clamped * (GATE_MAX_DB - GATE_OFF_DB));
}
// ── Radial gate arc (around the mic button, live during a call) ─────────────
// A 270° arc with the gap facing the orb (right). Fraction 0 (=Off) sits at the
// bottom-ish start; 1 (=max) at the top-ish end. The level fill and the
// threshold handle ride this same axis, mirroring the Settings widget.
const ARC_R = 40;
// A ~200° arc centred on the left (180°) so the wide gap faces the orb (right).
const ARC_SPAN_DEG = 200;
const ARC_START_DEG = 180 - ARC_SPAN_DEG / 2; // lower-left start; Off end
/** Point at fraction f (0..1) and radius r, in the 0..100 viewBox.
* @param {number} f @param {number} [r] */
function arcPoint(f, r = ARC_R) {
const deg = ARC_START_DEG + f * ARC_SPAN_DEG;
const rad = (deg * Math.PI) / 180;
return { x: 50 + r * Math.cos(rad), y: 50 + r * Math.sin(rad) };
}
/** SVG path `d` for the full 0..1 arc (clockwise). */
function fullArcD() {
const a = arcPoint(0);
const b = arcPoint(1);
const largeArc = ARC_SPAN_DEG > 180 ? 1 : 0;
return `M ${a.x} ${a.y} A ${ARC_R} ${ARC_R} 0 ${largeArc} 1 ${b.x} ${b.y}`;
}
/** One-time geometry: track, fill (dash-revealed) and the transparent hit band. */
function initGateArc() {
const d = fullArcD();
mgaTrack.setAttribute("d", d);
mgaFill.setAttribute("d", d);
mgaHit.setAttribute("d", d);
// pathLength 100 lets us reveal the fill by fraction via dashoffset.
mgaFill.setAttribute("pathLength", "100");
mgaFill.style.strokeDasharray = "100 100";
mgaFill.style.strokeDashoffset = "100"; // empty until levels arrive
renderGateHandle();
}
/** Place the threshold bead on the arc at the stored threshold; flag off state. */
function renderGateHandle() {
const off = settings.noiseGate <= GATE_OFF_DB;
const p = arcPoint(dbToFraction(settings.noiseGate));
mgaHandle.setAttribute("cx", String(p.x));
mgaHandle.setAttribute("cy", String(p.y));
micGate.classList.toggle("gate-off", off);
}
/** Paint a 0..1 live level onto the arc fill (and the Settings meter if open).
* Brightens the tick when the level crosses the threshold — i.e. the gate is
* actually open — but only when gating is enabled.
* @param {number} rms */
function paintInputLevel(rms) {
const db = rms > 0 ? 20 * Math.log10(rms) : GATE_OFF_DB;
const f = dbToFraction(db);
mgaFill.style.strokeDashoffset = String(100 * (1 - f));
if (settingsModal.open) gateMeterFill.style.width = `${f * 100}%`;
const enabled = settings.noiseGate > GATE_OFF_DB;
micGate.classList.toggle("gate-open", enabled && f >= dbToFraction(settings.noiseGate));
}
/** The single place that commits a new gate threshold: updates both controls,
* persists, and applies live to the running session.
* @param {number} db */
function setGateThreshold(db) {
settings.noiseGate = Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, Math.round(db)));
const off = settings.noiseGate <= GATE_OFF_DB;
inputNoiseGate.value = String(settings.noiseGate);
gateValue.textContent = off ? "Off" : `${settings.noiseGate} dB`;
renderGateHandle();
localStorage.setItem(STORAGE_KEYS.noiseGate, String(settings.noiseGate));
if (client && LIVE_STATES.has(currentState)) {
client.setNoiseGate(gateParams(settings.noiseGate));
}
}
/** Reflect the stored gate threshold into the slider, label and arc handle. */
function syncGateUi() {
inputNoiseGate.value = String(settings.noiseGate);
const off = settings.noiseGate <= GATE_OFF_DB;
gateValue.textContent = off ? "Off" : `${settings.noiseGate} dB`;
renderGateHandle();
}
// Drag along the arc band to set the threshold (a tap on the glyph still mutes).
let gateDragging = false;
/** @param {PointerEvent} e */
function gatePointerToDb(e) {
const rect = mgaArc.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
let deg = (Math.atan2(e.clientY - cy, e.clientX - cx) * 180) / Math.PI;
if (deg < 0) deg += 360;
// Map the on-arc angle to a fraction; angles in the right-side gap fall
// outside [0,1] and fractionToDb clamps them to the nearest end (just-below
// start -> Off, just-past end -> max).
const f = (deg - ARC_START_DEG) / ARC_SPAN_DEG;
return fractionToDb(f);
}
mgaHit.addEventListener("pointerdown", (e) => {
gateDragging = true;
mgaHit.setPointerCapture(e.pointerId);
setGateThreshold(gatePointerToDb(e));
});
mgaHit.addEventListener("pointermove", (e) => {
if (gateDragging) setGateThreshold(gatePointerToDb(e));
});
const endGateDrag = (/** @type {PointerEvent} */ e) => {
if (!gateDragging) return;
gateDragging = false;
try { mgaHit.releasePointerCapture(e.pointerId); } catch {}
};
mgaHit.addEventListener("pointerup", endGateDrag);
mgaHit.addEventListener("pointercancel", endGateDrag);
settingsBtn.addEventListener("click", openSettings);
// About panel: native <dialog>, Esc closes for free; also close on the X and
// on a click in the backdrop (a click whose target is the dialog itself).
aboutBtn?.addEventListener("click", () => aboutModal.showModal());
// Mobile twin of the (i), living in the right-hand control cluster.
$("#about-btn-m").addEventListener("click", () => aboutModal.showModal());
aboutClose.addEventListener("click", () => aboutModal.close());
aboutModal.addEventListener("click", (e) => {
if (e.target === aboutModal) aboutModal.close();
});
function syncToolsUi() {}
/**
* @param {string} _query
* @param {any} [_opts]
*/
function updateMcpToolSuggestions(_query, _opts) {}
toolsBtn.addEventListener("click", () => { toolsModal.showModal(); });
toolsClose.addEventListener("click", () => toolsModal.close());
toolsModal.addEventListener("click", (e) => {
if (e.target === toolsModal) toolsModal.close();
});
ragBtn.addEventListener("click", () => {
// Pre-fill the textarea with current value (if any) so user can edit.
if (customRagLoaded && ragTextarea && !ragTextarea.value.trim()) {
ragTextarea.value = customRagText;
} else if (ragTextarea && !ragTextarea.value.trim() && customRagText.trim()) {
ragTextarea.value = customRagText;
}
syncRagHintFooter();
ragModal.showModal();
});
ragClose.addEventListener("click", () => ragModal.close());
ragModal.addEventListener("click", (e) => {
if (e.target === ragModal) ragModal.close();
});
ragClear.addEventListener("click", () => {
if (ragTextarea) ragTextarea.value = "";
syncRagHintFooter();
if (ragTextarea) ragTextarea.focus();
});
ragSubmit.addEventListener("click", () => {
const next = (ragTextarea ? ragTextarea.value : "").trim();
if (!next) {
customRagText = "";
customRagLoaded = true;
syncRagHintFooter();
console.info(`[CUSTOM-RAG] submit: textarea empty -> clearing any prior custom RAG context.`);
ragModal.close();
return;
}
customRagText = next;
customRagLoaded = true;
syncRagHintFooter();
const ctx = customRagContextText();
const lines = next.split(/\r?\n/).length;
console.info(
`[CUSTOM-RAG] submit OK: rawLen=%d lines=%d wrappedCtxLen=%d -> will appear in instructions + user_context body.`,
next.length, lines, ctx.length,
);
if (debugPrompt) {
console.info(
`[DEBUG-PROMPT][CUSTOM-RAG submitted raw text] len=%d lines=%d:\n%s\n`,
next.length, lines, next,
);
if (ctx) {
console.info(
`[DEBUG-PROMPT][CUSTOM-RAG wrapped context (with CUSTOM_RAG_BEGIN/END markers)] len=%d:\n%s\n`,
ctx.length, ctx,
);
}
}
// If a realtime session is already live (e.g. user already clicked the orb)
// we push the new context LIVE into the session so it takes effect
// immediately without having to restart.
pushCustomRagToLiveSessionIfAny();
ragModal.close();
});
toolWebSwitch.addEventListener("change", () => {
if (toolWebSwitch.checked && !searchAvailable()) {
toolWebSwitch.checked = false;
return;
}
toolsEnabled.web_search = toolWebSwitch.checked;
saveTools();
pushToolsToSession();
});
searchKeyInput.addEventListener("input", () => {
if (serverSearchKey) return;
userSearchKey = searchKeyInput.value.trim();
if (userSearchKey) localStorage.setItem(STORAGE_KEYS.searchKey, userSearchKey);
else localStorage.removeItem(STORAGE_KEYS.searchKey);
const avail = searchAvailable();
toolWebSwitch.disabled = !avail;
toolWebRow.classList.toggle("disabled", !avail);
if (!avail && toolsEnabled.web_search) {
toolsEnabled.web_search = false;
toolWebSwitch.checked = false;
saveTools();
pushToolsToSession();
}
toolWebHint.textContent = userSearchKey
? "Using your key — stored in this browser only."
: "No server key configured. Add your own Serper key to enable web search.";
});
async function runTool(name, argsJson, callId) {
if (!client) return { output: "" };
let args = /** @type {Record<string, unknown>} */ ({});
try { args = JSON.parse(argsJson || "{}"); } catch { /* keep {} */ }
// #region debug-point D:run-tool-entry
reportDebug("D", "main.js:runTool", "tool execution started", {
name,
callId,
args,
});
// #endregion
try {
let output = "";
let image;
if (name === "web_search") {
const q = String(args.query || "").trim();
output = await execWebSearch(q);
} else {
output = `Unknown tool: ${name}.`;
}
// #region debug-point E:run-tool-success
reportDebug("E", "main.js:runTool", "tool execution completed", {
name,
outputLen: output.length,
hasImage: !!image,
});
// #endregion
if (client) client.sendFunctionCallOutput(callId, output);
return { output, image };
} catch (err) {
const detail = truncateError(err);
// #region debug-point E:run-tool-error
reportDebug("E", "main.js:runTool", "tool execution failed", {
name,
error: detail,
});
// #endregion
if (client) client.sendFunctionCallOutput(callId, `Tool error: ${detail}`);
return { output: `Tool error: ${detail}` };
}
}
async function execWebSearch(query) {
if (!query) return "No query provided.";
/** @type {Record<string, string>} */
const body = { query };
// Only send a user key when there's no server key (server prefers its own).
if (!serverSearchKey && userSearchKey) body.key = userSearchKey;
const res = await fetch("api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = String(res.status);
try { const j = await res.json(); if (j.detail) detail = j.detail; } catch {}
throw new Error(`search error (${detail})`);
}
const json = await res.json();
// Date-stamp the header so the model treats these as fresh realtime facts
// rather than its (older) training knowledge.
const today = new Date().toISOString().slice(0, 10);
/** @type {string[]} */
const lines = [`Google search result from ${today}:`];
if (json.answer) lines.push(`Answer: ${json.answer}`);
for (const r of json.results || []) {
lines.push(`- ${r.title}: ${r.snippet} (${r.url})`);
}
return lines.length > 1 ? lines.join("\n") : `${lines[0]}\nNo results found.`;
}
async function fetchVoceroUserProfile() {
if (!voceroUserPhoneNumber) {
console.info(
"[INJECT-PROFILE] ⏭️ BROWSER fetchVoceroUserProfile SKIPPED: URL has NO ?phoneNumber=. " +
"Profile context will be empty (info-user fetch + injectProfile not possible without caller phone)."
);
return;
}
try {
const res = await fetch(`api/user/phone?phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}`);
if (!res.ok) {
// 503 = endpoint not configured on this deploy (generic deploy); skip
// silently. Any other non-2xx is an error worth reporting.
if (res.status === 503) {
console.info(
"[INJECT-PROFILE] ⏭️ BROWSER skipped (deploy did NOT configure VOCERO_USER_PHONE_AUTH_URL env). endpoint returned HTTP 503, proxy /api/user/phone unavailable. " +
"Inject-profile will have empty profile context server-side if WS/reasoning also try."
);
voceroUserProfile = null;
voceroUserProfileLoaded = false;
syncVoceroHeaderUser();
return;
}
const message = await res.text();
throw new Error(message || `HTTP ${res.status}`);
}
const json = await res.json();
voceroUserProfile = json && typeof json === "object" && json.result && typeof json.result === "object"
? json.result
: (json && typeof json === "object" && !Array.isArray(json) ? json : null);
voceroUserProfileLoaded = true;
if (voceroUserProfile) {
console.info(
`[INJECT-PROFILE] ✅ BROWSER fetchVoceroUserProfile OK. phoneLen=%d profileKeys=%s headerUser=%s`,
voceroUserPhoneNumber.length,
Object.keys(voceroUserProfile).join(","),
extractVoceroUserName(voceroUserProfile) || "(none)",
);
} else {
console.info(
`[INJECT-PROFILE] ⏭️ BROWSER fetchVoceroUserProfile HTTP 200 but empty/non-object JSON. profileIsNull=%s keys=none`,
voceroUserProfile === null,
);
}
syncVoceroHeaderUser();
} catch (err) {
voceroUserProfile = null;
voceroUserProfileLoaded = false;
syncVoceroHeaderUser();
const message = err instanceof Error ? err.message : String(err);
console.warn(
`[INJECT-PROFILE] ⛔ BROWSER fetchVoceroUserProfile FAILED phone=%s error=%s`,
JSON.stringify(voceroUserPhoneNumber), message,
);
}
}
/** Learn server config (search key + connection target), then refresh the UI. */
async function fetchConfig() {
try {
const res = await fetch("api/config");
if (res.ok) {
const json = await res.json();
serverSearchKey = !!json.search;
lbMode = !!json.lb;
reasoningType = ["OPENAI","NODE_RED"].includes((json.reasoningType || "").toUpperCase())
? (json.reasoningType || "").toUpperCase()
: "PROXY";
// Lock to LB mode only when the deploy reports a load balancer.
allowDirect = json.allowDirect ?? !lbMode;
// Deploy-pinned direct URL (overrides the LB server-side already).
pinnedUrl = (json.s2sUrl || "").trim();
pinnedPrompt = (json.primaryPrompt || "").trim();
injectProfile = !!json.injectProfile;
debugPrompt = !!json.debugPrompt;
// #region debug-point B:config-loaded
reportDebug("B", "main.js:fetchConfig", "loaded config from api/config", {
reasoningType,
pinnedPromptLength: pinnedPrompt.length,
injectProfile,
debugPrompt,
});
// #endregion
// The conversation-time limiter rides on the LB being present.
limiterOn = lbMode;
}
// Non-OK response: leave the fail-open default (allowDirect = true).
} catch {
// Config endpoint unreachable (e.g. static hosting): keep direct entry.
}
if (DEBUG) console.debug(`[ui] config: allowDirect=${allowDirect} lbMode=${lbMode}`);
// Login chip + remaining-budget (no-op / hidden when the limiter is off).
void account.refresh();
syncToolsUi();
syncConnectionUi();
}
/**
* Resolve where to connect, per the deploy's mode:
* • LB mode -> `{ sessionUrl }`, the client POSTs the same-origin /api/session
* proxy and the server forwards to the LB (its address stays server-side).
* • direct -> `{ directUrl }`, connect straight to the s2s WebSocket.
* Throws a user-facing error if direct mode is on but no URL was entered.
* @returns {{ sessionUrl: string } | { directUrl: string }}
*/
function connectionTarget() {
if (!allowDirect) {
return { sessionUrl: "api/session" };
}
const directUrl = buildDirectWsUrl(pinnedUrl || settings.directUrl);
if (!directUrl) {
throw new Error("Enter a speech-to-speech server URL in Settings.");
}
return { directUrl };
}
/**
* Normalise a user-typed server address into a realtime WebSocket URL.
* Accepts bare hosts (`localhost:8080`), http(s) URLs, or ws(s) URLs, and adds
* the `/v1/realtime` path when none is given. A full connect URL (with path
* and/or query) is preserved as-is.
* @param {string} raw @returns {string}
*/
function buildDirectWsUrl(raw) {
let s = (raw || "").trim();
if (!s) return "";
if (!/^wss?:\/\//i.test(s)) {
if (/^https?:\/\//i.test(s)) {
s = s.replace(/^http/i, "ws"); // http→ws, https→wss
} else {
const isLocal = /^(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(s);
s = (isLocal ? "ws://" : "wss://") + s;
}
}
try {
const u = new URL(s);
if (u.pathname === "" || u.pathname === "/") u.pathname = "/v1/realtime";
if (voceroUserPhoneNumber) {
u.searchParams.set("phoneNumber", voceroUserPhoneNumber);
}
return u.toString();
} catch {
if (voceroUserPhoneNumber) {
const sep = s.includes("?") ? "&" : "?";
return `${s}${sep}phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}`;
}
return s;
}
}
/** Create + resume an AudioContext synchronously (must run inside the user
* gesture so iOS lets it start). Returns null if construction fails. */
function createResumedAudioContext() {
try {
const Ctx = window.AudioContext || /** @type {any} */ (window).webkitAudioContext;
const ctx = new Ctx({ latencyHint: "interactive" });
if (ctx.state === "suspended") void ctx.resume().catch(() => {});
return /** @type {AudioContext} */ (ctx);
} catch (err) {
console.warn("[main] AudioContext init failed:", err);
return null;
}
}
/** Read the editable settings out of the form. The URL field is only honoured
* in free direct mode — in LB mode it's hidden, and when the deploy pins a
* URL it's read-only, so the user's saved URL survives either way. */
function readSettingsFromForm() {
return {
directUrl: allowDirect && !pinnedUrl ? inputLbUrl.value.trim() : settings.directUrl,
voice: inputVoice.value || DEFAULT_VOICE,
instructions: pinnedPrompt ? settings.instructions : inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS,
noiseGate: readGateThreshold(),
};
}
/** Gate threshold (dBFS) currently shown on the slider, clamped to range. */
function readGateThreshold() {
const v = Math.round(Number(inputNoiseGate.value));
if (!Number.isFinite(v)) return GATE_OFF_DB;
return Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, v));
}
/** Adapt the connection field to the mode learned from /api/config. */
function syncConnectionUi() {
if (pinnedUrl) {
// Deploy-pinned URL: show it, but locked — the deployment owns it.
connField.hidden = false;
inputLbUrl.value = pinnedUrl;
inputLbUrl.readOnly = true;
connHint.classList.remove("error");
connHint.textContent = "Speech-to-speech server URL pinned by this deployment.";
} else if (allowDirect) {
// Direct mode: the user sets their own s2s server URL.
connField.hidden = false;
inputLbUrl.value = settings.directUrl;
inputLbUrl.readOnly = false;
inputLbUrl.placeholder = "http://localhost:port";
connHint.classList.remove("error");
connHint.textContent =
"URL of your speech-to-speech server, e.g. http://localhost:8080 (the app adds /v1/realtime).";
} else {
// LB mode: the load balancer URL is deployment-owned — hide it entirely so
// its address is never exposed in Settings.
connField.hidden = true;
}
}
/** True when the user must supply a server URL before connecting (direct mode
* with nothing set). */
function missingServerUrl() {
return allowDirect && !pinnedUrl && !buildDirectWsUrl(settings.directUrl);
}
/** Open Settings and point the user at the empty server-URL field. */
function promptServerUrl() {
if (settingsModal.open) syncConnectionUi();
else openSettings();
connHint.textContent = "Set the speech-to-speech server URL to start.";
connHint.classList.add("error");
inputLbUrl.focus();
}
settingsForm.addEventListener("submit", (event) => {
const submitter = /** @type {HTMLButtonElement | null} */ ((/** @type {SubmitEvent} */ (event)).submitter);
if (submitter?.value !== "save") return;
settings = readSettingsFromForm();
saveSettings(settings);
// Voice + instructions can apply to a live session without reconnecting; a
// changed connection URL only takes effect on the next restart.
if (client && LIVE_STATES.has(currentState)) {
client.updateSession({ voice: settings.voice, instructions: effectiveInstructions() });
}
});
// The noise gate applies live (worklet param), so tune it without a restart:
// update the label/marker, persist, and push straight to the running client.
inputNoiseGate.addEventListener("input", () => {
setGateThreshold(readGateThreshold());
});
restartBtn.addEventListener("click", async () => {
if (currentState === "connecting") return; // a connect is already underway
settings = readSettingsFromForm();
saveSettings(settings);
if (missingServerUrl()) { promptServerUrl(); return; } // keep settings open
settingsModal.close();
// Grab the AudioContext NOW, inside the click gesture — teardown() awaits, and
// creating it afterwards would fall outside the gesture (silent on iOS).
const audioContext = createResumedAudioContext();
try {
if (client) await teardown();
await doStart(audioContext);
} catch (err) {
await handleStartError(err);
}
});
circleBtn.addEventListener("click", async () => {
try {
if (currentState === "idle" || currentState === "error") {
if (missingServerUrl()) { promptServerUrl(); return; }
await doStart();
}
} catch (err) {
await handleStartError(err);
}
});
/** A failed start is either the daily limit (show the modal, return to idle) or
* a real fault (surface it). doStart already closed any orphan AudioContext.
* @param {any} err */
async function handleStartError(err) {
if (err && err.code === "limit") {
await teardown();
account.showLimit(err.tier);
return;
}
// The user left the queue (close() aborted the wait): teardown already reset
// the UI to idle, so there's nothing to report.
if (err && err.code === "aborted") return;
// The whole waiting line is full: a warm, reassuring modal rather than an error.
if (err && err.code === "queue-full") {
await teardown();
account.showBusy();
return;
}
// Our place lapsed (ticket reaped, or the join window ran out). Recoverable, not
// a fault: land on the retry state with a kind, plain-language reason.
if (err && (err.code === "queue-expired" || err.code === "join-expired")) {
await teardown();
setState("error");
setCaption(
err.code === "join-expired"
? "Your spot expired. Tap to rejoin."
: "That took a while. Tap to rejoin.",
"error",
);
return;
}
onFatalError(err);
}
micBtn.addEventListener("click", () => {
if (!micStream || !client) return;
micMuted = !micMuted;
for (const track of micStream.getAudioTracks()) {
track.enabled = !micMuted;
}
client.setMuted(micMuted);
micBtn.classList.toggle("muted", micMuted);
micBtn.setAttribute("aria-label", micMuted ? "Unmute" : "Mute");
micBtn.title = micMuted ? "Unmute" : "Mute";
});
stopBtn.addEventListener("click", async () => {
await teardown();
});
// "Leave queue": tear down the pending connect (aborts the poll wait) and drop
// our place in line. Same teardown path as stopping a live call.
leaveQueueBtn.addEventListener("click", async () => {
await teardown();
});
// "Join now": accept the held slot. The click is a user gesture, so the client
// re-resumes the AudioContext here (iOS) before dialing.
joinQueueBtn.addEventListener("click", () => {
stopJoinCountdown();
if (client) client.join();
});
const MIC_CONSTRAINTS = {
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
};
/** Prompt for mic permission up front, then immediately release the tracks so no
* recording indicator lingers during a queue wait. Throws a friendly error if the
* user denies. */
async function primeMicPermission() {
try {
const s = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
for (const track of s.getTracks()) track.stop();
} catch (err) {
throw new Error(
`Microphone access denied${err instanceof Error ? `: ${err.message}` : ""}`,
);
}
}
/** Acquire the live capture stream once a slot is granted. Permission was primed
* in the tap gesture, so this is silent. Stored module-side for mute + teardown. */
async function acquireMicStream() {
micStream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
return micStream;
}
/** @param {number} position Update the queued caption ("You're #N in line"). */
function onQueuePosition(position) {
const n = Number(position) || 0;
setCaption(n > 0 ? `You're #${n} in line` : "Finding you a spot…", "muted");
}
// ── "Your turn" join countdown ──────────────────────────────────────────────
// While a slot is held for us, show how long is left to accept it. The client's
// join gate expires just before the load balancer reclaims the slot.
let joinCountdownTimer = 0;
/** @param {number} sec */
function startJoinCountdown(sec) {
stopJoinCountdown();
let left = Math.max(0, Math.floor(sec));
const paint = () => {
joinQueueBtn.textContent = left > 0 ? `Join now (${left}s)` : "Join now";
};
paint();
joinCountdownTimer = window.setInterval(() => {
left -= 1;
if (left <= 0) {
stopJoinCountdown();
joinQueueBtn.textContent = "Join now";
return;
}
paint();
}, 1000);
}
function stopJoinCountdown() {
if (joinCountdownTimer) {
clearInterval(joinCountdownTimer);
joinCountdownTimer = 0;
}
}
/**
* Start a conversation. Pass a pre-created AudioContext when the caller already
* made one inside the tap/click gesture (required on iOS); otherwise one is
* created here, which is still inside the gesture for a direct orb tap.
* @param {AudioContext | null} [audioContext]
*/
async function doStart(audioContext = null) {
// Resolve the target before touching mic/audio so a misconfiguration (e.g.
// direct mode with no URL) fails fast with a clear message.
const target = connectionTarget();
chat.clear();
chat.reset();
setState("connecting");
setCaption("Asking for mic…", "muted");
// Create + resume the AudioContext SYNCHRONOUSLY, still inside the gesture.
// iOS Safari only starts an AudioContext from a user gesture; if we waited
// until after the getUserMedia / session-creation awaits below, it would stay
// suspended and the whole pipeline would be silent.
if (!audioContext) audioContext = createResumedAudioContext();
// Prime the mic permission now (get the prompt out of the way up front), then
// release it. The real capture stream is acquired only once a slot is granted
// (see acquireMicStream), so the mic 'in use' indicator never lights while we
// sit in the queue. Permission persists, so the later acquire is silent.
try {
await primeMicPermission();
} catch (err) {
if (audioContext) void audioContext.close().catch(() => {});
throw err;
}
const startInstructions = effectiveInstructions();
const startTools = activeToolDefs();
// #region debug-point B:client-construction
reportDebug("B", "main.js:doStart", "constructing realtime client", {
toolNames: startTools.map((tool) => tool.name),
toolCount: startTools.length,
instructionLength: startInstructions.length,
});
// #endregion
const c = new S2sWsRealtimeClient({
...target,
voice: settings.voice,
instructions: startInstructions,
acquireMic: acquireMicStream,
tools: startTools,
noiseGate: gateParams(settings.noiseGate),
...(audioContext ? { audioContext } : {}),
});
client = c;
c.addEventListener("queue", (e) => {
const { position, queueId } = /** @type {CustomEvent<{ position: number; queueId: string }>} */ (e).detail;
if (queueId) queuedTicketId = queueId;
onQueuePosition(position);
});
c.addEventListener("ready-to-join", (e) => {
const { info, expiresSec } = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo; expiresSec: number }>} */ (e).detail;
// A slot is held for us. We're out of the queue now, so drop the ticket ref.
// Track the granted session id already so that leaving (or letting the timer
// lapse) refunds the budget the server reserved at claim, even before we dial.
queuedTicketId = "";
if (info?.sessionId) {
trackedSessionId = info.sessionId;
trackedTier = info.tier || "anon";
}
startJoinCountdown(expiresSec);
});
c.addEventListener("status", (e) => {
const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
_kickIdleTimer(`client-status:${detail.status}`);
onClientStatus(detail.status);
});
c.addEventListener("transcript", (e) => {
const d = /** @type {CustomEvent<{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }>} */ (e).detail;
_kickIdleTimer(`transcript-${d.role}${d.partial ? ":partial" : ""}`);
chat.onTranscript(d);
if (d.role === "user") {
updateMcpToolSuggestions(d.text || "");
if (!d.partial) void maybeRunOpenAiReasoning(d.text || "", d.itemId || "");
}
});
c.addEventListener("response-finished", (e) => {
const detail = /** @type {CustomEvent<{ responseId: string; status: string; audible?: boolean; transcript?: string }>} */ (e).detail;
_kickIdleTimer(`response-finished:${detail.status || "done"}`);
chat.onResponseFinished(detail);
});
c.addEventListener("toolcall", (e) => {
const { name, arguments: args, callId } = /** @type {CustomEvent<{ name: string; arguments: string; callId: string }>} */ (e).detail;
_kickIdleTimer(`toolcall:${name}`);
// #region debug-point D:toolcall-event
reportDebug("D", "main.js:client.toolcall", "toolcall event received by main app", {
name,
callId,
args,
});
// #endregion
chat.onToolCall(name);
// Execute the tool, then push it to the conversation once the result is in,
// so the toggle shows both the call input and its output together.
void runTool(name, args, callId).then(({ output, image }) => {
_kickIdleTimer(`tool-result:${name}`);
chat.onToolResult(name, args, output, image);
});
});
c.addEventListener("error", (e) => {
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).
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);
});
c.addEventListener("session", (e) => {
const info = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo }>} */ (e).detail.info;
console.log("[ws] session created:", info.sessionId);
// A slot was granted — we're out of the queue; drop the ticket reference so
// teardown doesn't try to leave a line we already left.
queuedTicketId = "";
// A metered tier (anon / free): heartbeat so the server can extend the
// reservation and tell us when the daily budget runs out. PRO isn't limited.
if (info.limited && info.sessionId) {
trackedSessionId = info.sessionId;
trackedTier = info.tier || "anon";
startHeartbeat(info.heartbeatSec || 5);
}
});
c.addEventListener("input-level", (e) => {
const { rms } = /** @type {CustomEvent<{ rms: number }>} */ (e).detail;
paintInputLevel(rms);
// Proxy for "user is speaking" before VAD/STT emits the first transcript
// delta. RMS > ~-52 dBFS (≈ 0.0025 linear) consistently means actual
// speech or close enough to a real utterance that the 10s idle window
// should restart. Threshold is generous (≈ GATE_DEFAULT_DB - 2dB) so even
// quiet speakers keep the session alive; linear fast path avoids log10
// on the hot 50 Hz level path.
if (rms > 0.0025) _kickIdleTimer("input-level");
});
try {
await c.connect();
} catch (err) {
// The grant can be refused (402 → limit) or the dial can fail. In LB mode
// the AudioContext hasn't been adopted by the client yet (the session POST
// runs first), so close the one we created here to avoid leaking it.
if (audioContext) void audioContext.close().catch(() => {});
throw err;
}
}
// ── Conversation-time heartbeat ─────────────────────────────────────────────
/** Ping the server every `sec` seconds so it can meter the live session; when
* it reports the daily budget is spent, cut the call and show the limit modal.
* @param {number} sec */
function startHeartbeat(sec) {
stopHeartbeat();
heartbeatTimer = window.setInterval(async () => {
if (!trackedSessionId) return;
try {
const res = await fetch("api/session/heartbeat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: trackedSessionId }),
keepalive: true,
});
const json = await res.json().catch(() => ({}));
if (json.expired) await onLimitReached();
} catch (err) {
// A transient network blip shouldn't kill the call; the next tick retries.
if (DEBUG) console.debug("[ui] heartbeat failed:", err);
}
}, Math.max(1, sec) * 1000);
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = 0;
}
}
/** The server cut the live session: tear down and explain why. */
async function onLimitReached() {
const tier = trackedTier;
stopHeartbeat();
await teardown();
account.showLimit(tier);
}
/** Tell the server a session ended so it reconciles + refunds the unused chunk.
* Uses sendBeacon so it still fires when the tab is closing. */
function endTrackedSession() {
if (!trackedSessionId) return;
const body = JSON.stringify({ sessionId: trackedSessionId });
try {
const blob = new Blob([body], { type: "application/json" });
if (!navigator.sendBeacon("api/session/end", blob)) {
void fetch("api/session/end", {
method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true,
}).catch(() => {});
}
} catch {
// Best-effort; the server sweep reaps the session anyway.
}
trackedSessionId = "";
trackedTier = "";
}
/** Leave the waiting queue so the LB frees our place. sendBeacon so it still
* fires on tab close; the LB also reaps the ticket on TTL as a backstop. */
function endQueueTicket() {
if (!queuedTicketId) return;
const body = JSON.stringify({ queueId: queuedTicketId });
try {
const blob = new Blob([body], { type: "application/json" });
if (!navigator.sendBeacon("api/queue/end", blob)) {
void fetch("api/queue/end", {
method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true,
}).catch(() => {});
}
} catch {
// Best-effort; the LB reaps the ticket on TTL anyway.
}
queuedTicketId = "";
}
/** @param {string} status */
function onClientStatus(status) {
switch (status) {
case "creating-session":
case "connecting":
setState("connecting");
break;
case "queued":
setState("queued");
break;
case "your-turn":
setState("your-turn");
break;
case "connected":
setState("listening");
// Wake word confirmation: if this session was triggered by the "Hola
// Vocero" hot-word, inject the phrase into the conversation exactly as
// though the user had spoken it, then ask the model to reply so the
// assistant greets back instead of silently waiting for the next turn.
if (pendingWakeWordConfirmationText && client) {
const phrase = pendingWakeWordConfirmationText.trim() || "hola vocero";
pendingWakeWordConfirmationText = "";
console.info(`[WAKE WORD] 📤 Injecting confirmation phrase to ai-server: ${JSON.stringify(phrase)}`);
try {
client.sendUserText(phrase);
client.requestResponse();
} catch (err) {
console.warn("[WAKE WORD] failed to inject confirmation phrase:", err && err instanceof Error ? err.message : err);
}
}
break;
case "user-speaking":
setState("user-speaking");
break;
case "processing":
setState("processing");
break;
case "ai-speaking":
setState("ai-speaking");
break;
case "closed":
// teardown() will move us to idle
break;
case "error":
setState("error");
break;
}
}
async function teardown() {
stopHeartbeat();
stopJoinCountdown();
endTrackedSession();
endQueueTicket();
chat.reset({ dismiss: true });
updateMcpToolSuggestions("");
// If teardown fires *before* the first "connected" status (e.g. failed dial,
// 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) {
try {
await client.close();
} catch (err) {
console.warn("[main] error closing client:", err);
}
client = null;
}
if (micStream) {
for (const track of micStream.getTracks()) track.stop();
micStream = null;
}
micMuted = false;
micBtn.classList.remove("muted");
setState("idle");
// Refresh the chip's remaining-today after the budget moved.
if (limiterOn) void account.refresh();
}
/** @param {unknown} err */
function onFatalError(err) {
console.error("[main] fatal:", err);
setState("error");
const message = err instanceof Error ? err.message : String(err);
setCaption(truncateError(message), "error");
void teardown().catch(() => {
setState("error");
setCaption(truncateError(message), "error");
});
}
setState("idle");
chat.renderEmptyState();
initGateArc();
void fetchConfig();
void fetchVoceroUserProfile();
syncRagHintFooter();
(() => {
const toolCamRow = document.getElementById("tool-cam-row");
if (toolCamRow) toolCamRow.setAttribute("hidden", "");
if (toolCamHint) toolCamHint.setAttribute("hidden", "");
if (camPip) camPip.setAttribute("hidden", "");
if (camVideo) camVideo.setAttribute("hidden", "");
})();
// Reconcile a live session if the tab is closed/hidden mid-call (no teardown).
window.addEventListener("pagehide", () => { endTrackedSession(); endQueueTicket(); });
requestAnimationFrame(() => {
document.body.classList.remove("booting");
});