// @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=202609041120"; import { $, escHtml, truncateError, DEBUG } from "./ui/dom.js?v=202609041120"; import { ChatView } from "./ui/chat.js?v=202609041120"; import { Account } from "./ui/account.js?v=202609041120"; // --------------------------------------------------------------------------- // πŸ”Ž DEBUG BANNER (always-on): emissione incondizionata in console per capire // ESATTAMENTE quale versione di main.js Γ¨ caricata nel browser. // Se NON vedi questa riga in Console = stai ancora usando il file CACHATO VECCHIO. console.info( "%c🟒 VOCERO main.js loaded. version=2026-09-04 11:20 (FIX HEADER TRONCO: body safe-area-inset top/right/left 10px min + topbar padding-top 16 + margin-top safe. FIX SCROLL INSUFFICIENTE: chat-history padding-bottom 96β†’160px; chat-panel clamp 40-96β†’60-160; #app padding-bottom 56β†’120px+safe; footer safe-area bottom; step 4 retry +250ms (5 step tot); MAX_SAFE_INTEGER invece di 9M)", "background:#1e293b;color:#86efac;font-weight:700;padding:4px 8px;border-radius:4px;" ); console.info( " URL attuale location.href=", location.href, "\n location.search=", location.search || "(empty)", "\n DEBUG (imported from dom.js) =", DEBUG, "\n typeof window.DEBUG =", typeof window.DEBUG ); // eslint-disable-next-line no-debugger // debugger; // ← scommenta per fermarti QUI all'avvio se DevTools Γ¨ aperto // --------------------------------------------------------------------------- // πŸ›‘οΈ GATE .topbar-right (about / tools / rag / chat / settings buttons, // vedi index.html L36-L56) β€” 3-layer safety: // 1) HTML statico β†’ hidden + data-debug-only="true" // 2) CSS β†’ [data-debug-only="true"]:not([data-debug-enabled="true"]) display:none !important // 3) JS qui β†’ hidden, removeAttribute data-debug-enabled, anche style.display = "none" // Solo se URL query-string ha ?debug=true (strict string) abilitiamo TUTTO. function applyDebugOnlyVisibilityGate(source = "bootstrap") { const qs = new URLSearchParams(window.location.search); const debugEnabled = qs.get("debug") === "true"; const topbarRight = document.querySelector(".topbar-right"); console.groupCollapsed( "%c🧭 [gate-topbar-right] applyDebugOnlyVisibilityGate()", "color:#f59e0b;font-weight:700;" ); console.log("chiamato da:", source); console.log("location.search =", JSON.stringify(window.location.search)); console.log("query params =", Object.fromEntries(qs.entries())); console.log("debugEnabled (strict === 'true') =", debugEnabled); console.log("topbarRight element trovato?", !!topbarRight, topbarRight ? "vedi β†’" : "(null!)"); const chatInline = document.getElementById("chat-panel"); console.log("#chat-panel (chat inline SEMPRE VISIBILE in basso) presente in DOM =", !!chatInline, chatInline ? "hidden?" : "", chatInline ? chatInline.hasAttribute("hidden") : "-"); if (topbarRight) { console.log(" hidden attr prima:", topbarRight.hasAttribute("hidden"), "| data-debug-enabled prima:", topbarRight.getAttribute("data-debug-enabled"), "| style.display prima:", topbarRight.style.display); if (debugEnabled) { topbarRight.setAttribute("data-debug-enabled", "true"); topbarRight.removeAttribute("hidden"); topbarRight.style.display = ""; console.log("%c βœ… ABILITATO .topbar-right (debug=true)", "color:#22c55e;font-weight:700;"); } else { topbarRight.removeAttribute("data-debug-enabled"); topbarRight.setAttribute("hidden", ""); topbarRight.style.setProperty("display", "none", "important"); console.log("%c 🚫 DISABILITATO .topbar-right (no debug=true)", "background:#fee2e2;color:#b91c1c;font-weight:700;padding:3px 6px;border-radius:4px;"); } console.log(" hidden attr dopo:", topbarRight.hasAttribute("hidden"), "| data-debug-enabled dopo:", topbarRight.getAttribute("data-debug-enabled"), "| style.display dopo:", topbarRight.style.display); } // 3-layer gate UNIVERSALE su TUTTI gli elementi marcati con data-debug-only="true" // (non solo .topbar-right, ma anche #reports-sidebar-toggle e qualsiasi // altro elemento futuro marcato con lo stesso attributo). const debugOnlyEls = document.querySelectorAll("[data-debug-only='true']"); console.log( `Trovati ${debugOnlyEls.length} elementi [data-debug-only="true"] da gate-are in base a debugEnabled=${debugEnabled}`, ); debugOnlyEls.forEach((el, idx) => { const tag = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${el.className && typeof el.className === "string" ? `.${el.className.split(/\s+/).filter(Boolean).slice(0, 2).join(".")}` : ""}`; console.log(` [${idx + 1}/${debugOnlyEls.length}] ${tag} β€” hidden prima? ${el.hasAttribute("hidden")} | display prima: ${(el instanceof HTMLElement ? el.style.display : "-")}`); if (!(el instanceof HTMLElement)) return; if (debugEnabled) { el.setAttribute("data-debug-enabled", "true"); // Non forziamo hidden=false su elementi che sono logicamente hidden per // altri motivi (es. #reports-sidebar-toggle Γ¨ hidden se non ci sono // reports). Rimuoviamo lo "spegnimento debug-only" e basta. el.style.removeProperty("display"); } else { el.removeAttribute("data-debug-enabled"); el.setAttribute("hidden", ""); el.style.setProperty("display", "none", "important"); } console.log(` β†’ debugEnabled=${debugEnabled} β†’ hidden dopo? ${el.hasAttribute("hidden")} | display dopo: ${el.style.display || "(inherit)"}`); }); console.groupEnd(); return debugEnabled; } // Esegui SUBITO al bootstrap del modulo (prima di qualsiasi listener) const GLOBAL_DEBUG_ENABLED__TOPBAR = applyDebugOnlyVisibilityGate("main.js module bootstrap (top)"); // Riesegui dopo DOMContentLoaded per sicurezza (nel caso query script sia posizionato // in testa e l'HTML header non sia ancora parse-ato quando girano queste righe) if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { applyDebugOnlyVisibilityGate("DOMContentLoaded listener"); }, { once: true }); } else { applyDebugOnlyVisibilityGate("module bootstrap (DOM already parsed)"); } // Riesegui anche a fine frame dopo un breve timeout β†’ ultima chance. setTimeout(() => applyDebugOnlyVisibilityGate("10ms timeout fallback"), 10); window.addEventListener("hashchange", () => applyDebugOnlyVisibilityGate("hashchange"), { passive: true }); window.addEventListener("popstate", () => applyDebugOnlyVisibilityGate("popstate"), { passive: true }); 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."+ "the comma sign ',' in numbers it's used to separate thousands."+ "the point sign '.' in numbers it's used to separate decimals."+ "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 = 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} */ 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} 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} */ 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} */ 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} */ 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 {HTMLElement} */ // NOTE: #reports-hint now lives inside #reports-sidebar (left sidebar), not // inside .ident in the topbar. getElementById still returns the unique node. const reportsHint = $("#reports-hint"); /** @type {HTMLElement} */ const reportsSuggestions = $("#reports-suggestions"); /** @type {HTMLElement} */ const reportsSidebar = $("#reports-sidebar"); /** @type {HTMLButtonElement | null} */ const reportsSidebarToggle = /** @type {HTMLButtonElement | null} */ ($("#reports-sidebar-toggle")); /** @type {HTMLButtonElement | null} */ const reportsSidebarClose = /** @type {HTMLButtonElement | null} */ ($("#reports-sidebar-close")); /** @type {HTMLElement} */ const reportsSidebarBadge = $("#reports-sidebar-badge"); /* ─── User profile dialog refs (opened by clicking the avatar circle) ── */ /** @type {HTMLDialogElement} */ const userProfileDialog = $("#user-profile-dialog"); /** @type {HTMLButtonElement} */ const userProfileClose = $("#user-profile-close"); /** @type {HTMLElement} */ const userProfileAvatar = $("#user-profile-avatar"); /** @type {HTMLElement} */ const userProfileTitle = $("#user-profile-title"); /** @type {HTMLElement} */ 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; /** @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; // Feature flag: "TALK" means the browser auto-starts the mic + realtime session // 2 seconds after page load AND asks the model greets the user first (without // waiting for the user to say anything). Mirrors the server env // S2S_START_SESSION_MODE. Empty / anything else = manual (tap the start). let startSessionMode = ""; // 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}<<>>\n${capped}\n<<>>`; } 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} */ 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(); // Podcast episode ID: loaded from URL ?episode_id=... Used server-side to fetch // the episode details via Aquachile AzurianLab API and inject it as PRIMARY // trusted context into the LLM prompt (both WS session.update instructions // and /api/reasoning body user_context + instructions). const voceroEpisodeId = (pageParams.get("episode_id") || "").trim(); /** @type {Record | null} */ let voceroUserProfile = null; let voceroUserProfileLoaded = false; /** * @param {string} hypothesisId * @param {string} location * @param {string} msg * @param {Record} [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 | 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} */ (nested)); if (nestedName) return nestedName; } } return ""; } /** * Builds 2-letter uppercase initials for a user name ("Alessandro Valenti" β†’ "AV"). * Edge cases: * - single word name β†’ first 2 chars (upper) * - empty or whitespace-only β†’ "" (caller hides the avatar) * - accented chars kept (e.g. JosΓ© MarΓ­a β†’ JM) * @param {string} rawName * @returns {string} */ function initialsForName(rawName) { const trimmed = String(rawName || "").trim(); if (!trimmed) return ""; // Split on any whitespace run (also handles multiple spaces). const parts = trimmed.split(/\s+/).filter(Boolean); if (!parts.length) return ""; if (parts.length === 1) { return parts[0].slice(0, 2).toUpperCase(); } // Take first letter of first token + first letter of LAST token, so // "JosΓ© MarΓ­a GarcΓ­a LΓ³pez" β†’ "JL" and "Anna di Stefano" β†’ "AS". const first = parts[0][0] ?? ""; const last = parts[parts.length - 1][0] ?? ""; return (first + last).toUpperCase(); } /* ─── User profile dialog (opened by clicking avatar in the header) ─── */ /** * Returns the first truthy non-blank string at any of the given keys on `obj`, * also recursing into the common nested containers user/customer/profile/contact * (same keys as extractVoceroUserName uses) so we find data whether the profile * is flat or wrapped in a sub-object. * @param {Record | null} obj * @param {string[]} keys * @param {string[]} [nestedKeys] * @returns {string} */ function firstProfileField(obj, keys, nestedKeys = ["user", "customer", "profile", "contact"]) { if (!obj || typeof obj !== "object") return ""; for (const k of keys) { const v = /** @type {unknown} */ (obj)[k]; if (typeof v === "string" && v.trim()) return v.trim(); if (typeof v === "number" && !Number.isNaN(v)) return String(v); } if (Array.isArray(nestedKeys)) { for (const nk of nestedKeys) { const nested = /** @type {unknown} */ (obj[nk]); if (nested && typeof nested === "object" && !Array.isArray(nested)) { const hit = firstProfileField(/** @type {Record} */ (nested), keys, []); if (hit) return hit; } } } return ""; } /** * Returns true if `v` is likely a valid non-empty profile value (strings of * length >=1 or numbers or dates). Objects/arrays are excluded because the * metadata grid is a flat label+value UI. * @param {unknown} v * @returns {v is string | number | boolean | bigint | symbol} */ function isUsefulProfileValue(v) { if (typeof v === "string") return v.trim().length > 0; if (typeof v === "number" && !Number.isNaN(v)) return true; if (typeof v === "boolean") return true; if (v instanceof Date) return true; return false; } /** * Builds an array of {label, value, mono, href, phone, email} rows for the * user profile dialog. Fields that are not present or empty are skipped so we * never show a blank row. * @param {Record | null} profile * @returns {Array<{label:string, value:string, mono?:boolean, href?:string, isEmail?:boolean, isPhone?:boolean, isDate?:boolean}>} */ function buildProfileRows(profile) { /** @type {Array<{label:string, value:string, mono?:boolean, href?:string, isEmail?:boolean, isPhone?:boolean, isDate?:boolean}>} */ const rows = []; // Email (linkable mailto) const email = firstProfileField(profile, [ "email", "emailAddress", "email_address", "eMail", "mail", "contactEmail", "user_email", "customer_email", "loginEmail", "username_email", ]); if (email) rows.push({ label: "Email", value: email, isEmail: true, href: `mailto:${email}` }); // Phone (linkable tel) const phone = firstProfileField(profile, [ "phone", "phoneNumber", "phone_number", "mobile", "mobileNumber", "telephone", "tel", "cellphone", "contactPhone", "user_phone", "whatsapp", ]); if (phone) rows.push({ label: "TelΓ©fono", value: phone, isPhone: true, href: `tel:${phone.replace(/[^\d+]/g, "")}` }); // Username (handle / handle moniker) const username = firstProfileField(profile, [ "username", "userName", "user_name", "login", "handle", "nickname", "screenName", "accountName", ]); if (username) rows.push({ label: "Username", value: username, mono: true }); // Role / Plan const role = firstProfileField(profile, [ "role", "roleName", "userRole", "plan", "planName", "tier", "subscription", "membership", "accessLevel", ]); if (role) rows.push({ label: "Ruolo / Piano", value: role }); // Company / Organization const company = firstProfileField(profile, [ "company", "companyName", "company_name", "organization", "org", "organizationName", "business", ]); if (company) rows.push({ label: "Azienda", value: company }); // User/Account ID (mono for monospace copy-paste) const userId = firstProfileField(profile, [ "userId", "user_id", "uid", "accountId", "account_id", "sub", "customerId", "customer_id", "id", "userPk", "profileId", "profile_id", "identityId", ]); if (userId) rows.push({ label: "ID Utente", value: userId, mono: true }); // Locale / Country const country = firstProfileField(profile, [ "country", "countryName", "country_code", "countryCode", "locale", "language", "lang", "region", "timezone", "tz", ]); if (country) rows.push({ label: "Nazione / Lingua", value: country }); // Created at / signed up date β€” try common date fields. const createdAtKeys = [ "createdAt", "created_at", "created", "createdDate", "created_date", "signUpDate", "signup_date", "joined", "joinDate", "join_date", "registrationDate", ]; let createdAt = firstProfileField(profile, createdAtKeys); if (!createdAt && profile) { // Date objects / number timestamps for (const k of createdAtKeys) { const v = /** @type {unknown} */ (profile[k]); if (v instanceof Date) { createdAt = v.toLocaleString(); break; } if (typeof v === "number" && !Number.isNaN(v) && v > 946681200) { // ~2000-01-01 try { createdAt = new Date(v * (v < 1e12 ? 1000 : 1)).toLocaleString(); break; } catch { /* noop */ } } } } if (createdAt) rows.push({ label: "Iscritto il", value: createdAt, isDate: true }); // Last sign in / active date (last_login etc) const lastActiveKeys = [ "lastLogin", "last_login", "lastLoginAt", "lastActive", "last_active", "lastActiveAt", "updatedAt", "updated_at", ]; let lastActive = firstProfileField(profile, lastActiveKeys); if (!lastActive && profile) { for (const k of lastActiveKeys) { const v = /** @type {unknown} */ (profile[k]); if (v instanceof Date) { lastActive = v.toLocaleString(); break; } if (typeof v === "number" && !Number.isNaN(v) && v > 946681200) { try { lastActive = new Date(v * (v < 1e12 ? 1000 : 1)).toLocaleString(); break; } catch { /* noop */ } } } } if (lastActive) rows.push({ label: "Ultimo accesso", value: lastActive, isDate: true }); return rows; } /** * Fills the dialog hero + metadata grid with values from the user profile. * If profile is null/empty we show a friendly empty state. * @param {Record | null} profile */ function populateUserProfileDialog(profile) { const name = extractVoceroUserName(profile); const initials = initialsForName(name); if (userProfileAvatar) { userProfileAvatar.textContent = initials || "??"; userProfileAvatar.setAttribute("aria-label", name ? `Avatar: ${name}` : "Utente non impostato"); } if (userProfileTitle) { userProfileTitle.textContent = name || "Utente non impostato"; } if (userProfileSubtitle) { const email = firstProfileField(profile, ["email", "emailAddress", "email_address", "eMail", "mail"]); const phone = firstProfileField(profile, ["phone", "phoneNumber", "phone_number", "mobile", "telephone", "tel"]); const parts = [email, phone].filter(Boolean); userProfileSubtitle.textContent = parts.length ? parts.join(" Β· ") : (name ? "Nessuna informazione di contatto disponibile." : "Connettersi a Vocero per vedere i dati utente."); } if (userProfileMetadata) { userProfileMetadata.innerHTML = ""; const rows = buildProfileRows(profile); if (!rows.length) { userProfileMetadata.hidden = true; return; } userProfileMetadata.hidden = false; for (const row of rows) { const wrapper = document.createElement("div"); wrapper.className = "user-profile-row"; const label = document.createElement("div"); label.className = "user-profile-row-label"; label.textContent = row.label; const value = document.createElement("div"); value.className = "user-profile-row-value"; /** @type {Node} */ let node; if (row.href) { const a = document.createElement("a"); a.href = row.href; if (row.isEmail) { a.rel = "noopener"; a.target = "_blank"; } if (row.isPhone) a.rel = "nofollow noopener"; if (row.mono) { const m = document.createElement("code"); m.textContent = row.value; a.appendChild(m); } else a.textContent = row.value; node = a; } else if (row.mono) { const m = document.createElement("code"); m.textContent = row.value; node = m; } else { node = document.createTextNode(row.value); } value.appendChild(node); wrapper.appendChild(label); wrapper.appendChild(value); userProfileMetadata.appendChild(wrapper); } } } /** * Open the profile dialog, populate it from the current voceroUserProfile, * and optionally restore focus to the avatar when it closes (a11y). */ function openUserProfileDialog() { if (!userProfileDialog) return; populateUserProfileDialog(/** @type {Record} */ (voceroUserProfile)); try { userProfileDialog.showModal(); } catch { // If dialog is already open / unsupported we fall back to an inline style // (shouldn't happen on any modern browser). userProfileDialog.setAttribute("open", ""); } } function syncVoceroHeaderUser() { const name = extractVoceroUserName(voceroUserProfile); const initials = initialsForName(name); identUserName.hidden = !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 = `Utente: ${name}`; identUserName.setAttribute("aria-label", `Utente: ${name}`); } else { identUserName.textContent = ""; identUserName.removeAttribute("title"); identUserName.removeAttribute("aria-label"); } if (!reportsHint) return; // Keep the topbar-right in sync every time we re-render reports (another // safety net alongside the bootstrap / DOMContentLoaded / 10ms fallback). if (typeof applyDebugOnlyVisibilityGate === "function") { applyDebugOnlyVisibilityGate("syncVoceroHeaderUser pre-check"); } const hasList = voceroReportsLoaded && Array.isArray(voceroReportsList?.entries) && voceroReportsList.entries.some((e) => e && typeof e === "object" && e.sourceScheduleLabel); if (!hasList) { reportsHint.hidden = true; reportsHint.innerHTML = ""; return; } const rawEntries = /** @type {Array<{sourceScheduleLabel:string}>} */ ( voceroReportsList.entries.filter((e) => e && typeof e === "object" && e.sourceScheduleLabel) ); // Deduplicate by label (preserve insertion order = first occurrence = most recent // since the server pre-sorts DESC). Empty or null labels already filtered above. const seen = new Set(); /** @type {Array<{sourceScheduleLabel:string}>} */ const entries = []; for (const e of rawEntries) { const key = String(e.sourceScheduleLabel).trim(); if (!key) continue; if (seen.has(key)) continue; seen.add(key); entries.push({ sourceScheduleLabel: key }); } if (!entries.length) { reportsHint.hidden = true; reportsHint.innerHTML = ""; if (reportsSuggestions) { reportsSuggestions.innerHTML = ""; reportsSuggestions.hidden = true; } if (reportsSidebar) reportsSidebar.hidden = true; if (reportsSidebarToggle) reportsSidebarToggle.hidden = true; if (reportsSidebarBadge) { reportsSidebarBadge.removeAttribute("data-count"); reportsSidebarBadge.textContent = ""; } return; } // Total count reflects the unique labels shown in the UI (not the raw server // repeated list). Use unique count unless caller explicitly set count. const count = entries.length; const totalLen = voceroReportsList.wrappedLen ?? 0; const titleRow = document.createElement("div"); titleRow.className = "reports-summary"; const titleText = document.createElement("span"); titleText.className = "reports-summary-text"; titleText.textContent = "Reportes cargados"; const badge1 = document.createElement("span"); badge1.className = "reports-summary-badge"; badge1.textContent = `${count} programados`; const badge2 = document.createElement("span"); badge2.className = "reports-summary-badge"; badge2.textContent = `Bloque LLM ${totalLen.toLocaleString()} chars`; titleRow.appendChild(titleText); titleRow.appendChild(badge1); titleRow.appendChild(badge2); const ul = document.createElement("ul"); ul.className = "reports-list"; const labelTintFor = (rawLabel) => { const l = String(rawLabel || "").toLowerCase(); if (l.includes("mortalidad") || l.includes("riesgo") || l.includes("kpi")) return "mortalidad"; if (l.includes("mΓ©trica") || l.includes("metricas") || l.includes("medici")) return "metricas"; if (l.includes("diario") || l.includes("diaria") || l.includes("informe")) return "diario"; if (l.includes("producto") || l.includes("servicio")) return "productos"; if (l.includes("synapse") || l.includes("ejecucion") || l.includes("calendario") || l.includes(" ti ") || l.endsWith("ti")) return "synapse"; if (l.includes("supply") || l.includes("cadena") || l.includes("log") || l.includes("envΓ­o") || l.includes("envio") || l.includes("chile")) return "supply"; if (l.includes("financiero") || l.includes("finanzas") || l.includes("costo") || l.includes("presupuesto") || l.includes("venta")) return "finanzas"; return ""; }; for (const e of entries) { const li = document.createElement("li"); const tint = labelTintFor(e.sourceScheduleLabel); if (tint) li.setAttribute("data-tint", tint); li.textContent = String(e.sourceScheduleLabel); ul.appendChild(li); } // Build dynamic suggestion prompts (en espaΓ±ol) based on the actual labels so // the user knows what they can ask the assistant about. Max 4 suggestions; // pick first N entries by priority. const suggestions = /** @type {string[]} */ ([]); const labelPool = entries.map((e) => String(e.sourceScheduleLabel)); // Try to match contextual templates by keyword first (intent-based) const hasKeyword = (kws) => labelPool.some((l) => kws.some((k) => l.toLowerCase().includes(k.toLowerCase()))); const pickAnyLabel = (kws) => kws ? (labelPool.find((l) => kws.some((k) => l.toLowerCase().includes(k.toLowerCase()))) || null) : null; const firstLabel = labelPool[0]; const secondLabel = labelPool[1] || firstLabel; const thirdLabel = labelPool[2] || firstLabel; // Generic base templates always valid; keep intent- & keyword-driven ones higher priority const intentTemplates = [ { when: hasKeyword(["riesgo", "riesgos", "mortalidad", "porcentaje", "kpi"]), text: `Dame un resumen ejecutivo de los riesgos operativos y KPIs del reporte de ${pickAnyLabel(["riesgo", "riesgos", "mortalidad", "porcentaje", "kpi"]) || firstLabel}.` }, { when: hasKeyword(["diario", "diaria", "informe"]), text: `CuΓ©ntame los puntos clave del informe diario mΓ‘s reciente (${pickAnyLabel(["diario", "diaria", "informe"]) || firstLabel}).` }, { when: hasKeyword(["mΓ©trica", "mΓ©tricas", "metricas", "mediciΓ³n", "medicion"]), text: `Compara las mΓ©tricas de la ΓΊltima semana del reporte ${pickAnyLabel(["mΓ©trica", "mΓ©tricas", "metricas", "mediciΓ³n", "medicion"]) || secondLabel}.` }, { when: hasKeyword(["producto", "productos", "servicio", "servicios"]), text: `QuΓ© hay de nuevo en productos y servicios segΓΊn el reporte ${pickAnyLabel(["producto", "productos", "servicio", "servicios"]) || thirdLabel}?` }, { when: hasKeyword(["supply", "supply chain", "cadena", "logΓ­stica", "logistica", "envΓ­o", "envio", "chile", "latin"]), text: `Hay novedades en la cadena de suministro o logΓ­stica en el reporte ${pickAnyLabel(["supply", "cadena", "logΓ­stica", "logistica", "envΓ­o", "envio", "chile", "latin"]) || thirdLabel}?` }, { when: hasKeyword(["synapse", "ejecucion", "ejecuciones", "calendario", "ti", "it"]), text: `ΒΏQuΓ© ejecuciones del calendario Synapse TI estΓ‘n reportadas? (${pickAnyLabel(["synapse", "ejecucion", "ejecuciones", "calendario", "ti", "it"]) || thirdLabel}).` }, { when: hasKeyword(["podcast", "briefing", "maΓ±ana", "matutino", "morning"]), text: `Lee el podcast briefing matutino de hoy (${pickAnyLabel(["podcast", "briefing", "maΓ±ana", "matutino", "morning"]) || firstLabel}).` }, { when: hasKeyword(["financiero", "finanzas", "costo", "costos", "presupuesto", "venta", "ventas"]), text: `AnΓ‘lisis financiero: costos, presupuesto y ventas del reporte ${pickAnyLabel(["financiero", "finanzas", "costo", "costos", "presupuesto", "venta", "ventas"]) || secondLabel}.` }, { when: hasKeyword(["ejecutivo", "ceo", "reuniΓ³n", "reunion", "semanal"]), text: `Puntos de agenda para la reuniΓ³n ejecutiva semanal segΓΊn el reporte ${pickAnyLabel(["ejecutivo", "ceo", "reuniΓ³n", "reunion", "semanal"]) || secondLabel}.` }, { when: true, text: `ΒΏQuΓ© highlights hay en el reporte mΓ‘s reciente Β«${firstLabel}Β»?` }, { when: true, text: `Resume los 3 hallazgos principales de los reportes de hoy (${count} disponibles).` }, { when: count >= 2, text: `Compara ${firstLabel} vs ${secondLabel} y dime cuΓ‘l tiene mayor prioridad.` }, { when: count >= 3, text: `Plan de acciΓ³n sugerido con base en ${firstLabel}, ${secondLabel} y ${thirdLabel}.` }, ]; for (const t of intentTemplates) { if (t.when && suggestions.length < 4) suggestions.push(t.text); if (suggestions.length >= 4) break; } const qs = new URLSearchParams(window.location.search); const debugParamRaw = qs.get("debug"); // eslint-disable-next-line no-debugger // debugger; // ← scommenta per fermarti dentro syncVoceroHeaderUser ogni volta console.groupCollapsed("%cπŸ” [reports-ui] syncVoceroHeaderUser() render details", "color:#60a5fa;font-weight:700;"); console.log("location.href =", location.href); console.log("location.search =", JSON.stringify(window.location.search)); console.log("tutti i query params =", Object.fromEntries(qs.entries())); console.log("pageParams (legacy module-load) .get('debug') =", JSON.stringify(pageParams.get("debug"))); console.log("fresh qs .get('debug') =", JSON.stringify(debugParamRaw), "| typeof =", typeof debugParamRaw); console.log("strict === 'true' ? =", debugParamRaw === "true"); console.log("count (labels uniche) =", count, "| wrappedLen =", totalLen); console.log("suggerimenti array length (prima del gate debug) =", suggestions.length); suggestions.forEach((s, i) => console.log(` sug[${i}] = ${s}`)); const currentDebug = debugParamRaw === "true"; const debugMode = currentDebug; console.log("πŸ‘‰ DECISIONE FINALE debugMode =", debugMode, " β†’ sezione suggerimenti DA CREARE? ", debugMode ? "βœ… SÌ" : "❌ NO"); console.groupEnd(); let sugSection = null; if (debugMode) { sugSection = document.createElement("div"); sugSection.className = "reports-suggestions"; const sugTitle = document.createElement("span"); sugTitle.className = "reports-suggestions-title"; sugTitle.textContent = "Sugerencias de preguntas para el asistente:"; const sugUl = document.createElement("ul"); sugUl.className = "reports-suggestions-list"; for (const s of suggestions) { const li = document.createElement("li"); li.textContent = s; sugUl.appendChild(li); } sugSection.appendChild(sugTitle); sugSection.appendChild(sugUl); console.log("%c[reports-ui] sezione suggerimenti CREATA (debugMode=true)", "color:#22c55e;font-weight:700;", "nItems=", sugUl.children.length, "innerHTML=", sugSection.outerHTML.slice(0, 300)); } else { console.log("%c[reports-ui] sezione suggerimenti NON CREATA (debugMode=false, non c'Γ¨ ?debug=true)", "color:#ef4444;font-weight:700;"); } reportsHint.innerHTML = ""; reportsHint.appendChild(titleRow); reportsHint.appendChild(ul); // Suggestions section lives inside the sidebar as a separate static element // (#reports-suggestions), NOT inside the .reports-hint card β€” keeps chips // card compact and shows suggestions below it when debug mode is on. if (reportsSuggestions) { reportsSuggestions.innerHTML = ""; // the #reports-suggestions element itself carries the `data-debug-only` // attribute, so applyDebugOnlyVisibilityGate will hide/show it automatically // based on ?debug=true (3-layer gate + html hidden attr + CSS fallback). reportsSuggestions.hidden = true; if (sugSection) { // Keep inner children (titleText + ul) β€” but drop outer wrapper so the // static element's CSS (reports-suggestions class already on the el) // is the one styling the block. while (sugSection.firstChild) reportsSuggestions.appendChild(sugSection.firstChild); sugSection = null; // After populating, run the gate again to make sure static element // stays visible only if debug=true (3-layer gate will re-apply rules). if (typeof applyDebugOnlyVisibilityGate === "function") applyDebugOnlyVisibilityGate("post syncVoceroHeaderUser sug"); } } else if (sugSection) { reportsHint.appendChild(sugSection); } reportsHint.hidden = false; // Unhide & wire the sidebar now that we have reports to show. // The sidebar starts with [hidden] to avoid empty flashes on boot. if (reportsSidebar) { reportsSidebar.hidden = false; // Ensure sidebar is painted into the DOM before reading layout. // On desktop >= 1024px CSS keeps it always shown; on mobile we don't // auto-open (that's user-driven via the toggle button). } // Topbar toggle button: shown only when we have reports AND debug=true. if (reportsSidebarToggle) { const qs = new URLSearchParams(window.location.search); const debugParam = qs.get("debug") === "true"; console.info( "%c[reports-ui] toggle reports-sidebar-toggle: hidden? PRIMA=%s | debug=%s", "color:#f59e0b;font-weight:700;", reportsSidebarToggle.hasAttribute("hidden"), debugParam, ); if (debugParam) { reportsSidebarToggle.hidden = false; } else { reportsSidebarToggle.setAttribute("hidden", ""); reportsSidebarToggle.style.setProperty("display", "none", "important"); reportsSidebarToggle.removeAttribute("data-debug-enabled"); console.info("%c[reports-ui] 🚫 reports sidebar toggle FORZATO HIDDEN (no debug=true)", "background:#fee2e2;color:#b91c1c;font-weight:700;padding:3px 6px;border-radius:4px;"); } } // Update badge count on the toggle button (CSS shows it only if != 0). if (reportsSidebarBadge) { reportsSidebarBadge.setAttribute("data-count", String(count)); reportsSidebarBadge.textContent = count > 99 ? "99+" : String(count); } // eslint-disable-next-line no-console console.info( "%c[reports-ui] FINE RENDER. reportsHint.querySelectorAll('.reports-suggestions').length =", "color:#a855f7;font-weight:700;", reportsHint.querySelectorAll(".reports-suggestions").length, "| children nel blocco:", reportsHint.children.length, "| reportsHint.hidden=", reportsHint.hidden, "| #reports-sidebar hidden=", reportsSidebar ? reportsSidebar.hidden : "no-sidebar-el", "| badge count:", reportsSidebarBadge ? reportsSidebarBadge.getAttribute("data-count") : "no-badge" ); } /** 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 episode_id = voceroEpisodeId || null; const body = { transcript, instructions, language: preferredResponseLanguage(), user_context, phoneNumber, episode_id, }; const logKey = `reasoningBody:${injectProfile ? "T" : "F"}:${debugPrompt ? "T" : "F"}:${phoneNumber ? "hasPhone" : "noPhone"}:${episode_id ? "hasEpisode" : "noEpisode"}:${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 episodeLen=%s`, injectProfile, transcript.length, instructions.length, user_context.length, profileCtx.length, ragCtx.length, phoneNumber ? phoneNumber.length : "null", episode_id ? episode_id.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(); // ── 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 // 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} - 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; // .idle-cta is the JS-based fallback (adds gradient CTA style) for browsers // that don't support :has(). The primary selector is .stage:has(.state-idle) .circle-caption. const addIdleCta = currentState === "idle" && !kind && trimmed; const parts = ["circle-caption"]; if (kind) parts.push(kind); if (!trimmed) parts.push("empty"); if (addIdleCta) parts.push("idle-cta"); circleCaption.className = parts.join(" "); } 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 , 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(); }); /* ─── User Profile dialog handlers (click avatar circle) ───────────── */ // The avatar circle in the brand header: when clicked, opens a native // 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). 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) { userProfileClose.addEventListener("click", (e) => { e.preventDefault(); userProfileDialog?.close(); }); } // Click in the backdrop = click whose target is the itself. if (userProfileDialog) { userProfileDialog.addEventListener("click", (e) => { if (e.target === userProfileDialog) userProfileDialog.close(); }); } /* ─── Reports sidebar (LEFT) handlers ────────────────────────────────────── */ /** * Toggle open / close reports sidebar (off-canvas mobile overlay OR no-op desktop). * The actual visibility on desktop is handled entirely via CSS min-width:1024px rule (sidebar * always rendered inline); the JS code here is only for the mobile "slidein * @param {boolean} [forceOpen] */ function openReportsSidebar(forceOpen) { if (!reportsSidebar) return; const open = typeof forceOpen === "boolean" ? forceOpen : !reportsSidebar.classList.contains("open"); reportsSidebar.classList.toggle("open", open); if (reportsSidebarToggle) { reportsSidebarToggle.setAttribute("aria-expanded", open ? "true" : "false"); } } if (reportsSidebarToggle) { reportsSidebarToggle.addEventListener("click", (e) => { e.preventDefault(); // toggle: if already open close else open openReportsSidebar(!reportsSidebar.classList.contains("open")); }); } if (reportsSidebarClose) { reportsSidebarClose.addEventListener("click", (e) => { e.preventDefault(); openReportsSidebar(false); }); } // Close on backdrop click OR any element marked [data-close-reports-sidebar]. if (reportsSidebar) { reportsSidebar.addEventListener("click", (e) => { const target = /** @type {HTMLElement} */ (e.target); if (!target) return; if (target.closest("[data-close-reports-sidebar]")) openReportsSidebar(false); }); } // Close with ESC (same pattern as chat panel). document.addEventListener("keydown", (e) => { if (e.key === "Escape" && reportsSidebar?.classList.contains("open")) openReportsSidebar(false); }, { passive: true }); 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} */ ({}); 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} */ 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, ); } } async function fetchVoceroReportsList() { // NOTE: the server allows reports list without phone (global scheduled reports // for the company may be valid too). If a phone is present on the page URL we // pass it along anyway so the upstream can filter per-caller if configured. try { const qs = voceroUserPhoneNumber ? `?phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}` : ""; const res = await fetch(`api/user/reports${qs}`); if (!res.ok) { // 503 = S2S_REPORT_LIST not configured on this deploy β†’ silent skip. if (res.status === 503) { console.info( "[INJECT-REPORTS] ⏭️ BROWSER skipped (deploy did NOT configure S2S_REPORT_LIST env). endpoint returned HTTP 503, reports UI not shown.", ); voceroReportsList = null; voceroReportsLoaded = true; syncVoceroHeaderUser(); return; } const message = await res.text(); throw new Error(message || `HTTP ${res.status}`); } const json = await res.json(); const valid = json && typeof json === "object" && Array.isArray(json.hints); if (!valid) { console.info( `[INJECT-REPORTS] ⏭️ BROWSER fetchVoceroReportsList HTTP 200 but payload missing hints array. keys=%s`, json && typeof json === "object" ? Object.keys(json).join(",") : "none", ); voceroReportsList = null; voceroReportsLoaded = true; syncVoceroHeaderUser(); return; } voceroReportsList = json; voceroReportsLoaded = true; if (json.skipped || !json.hints.length) { console.info( `[INJECT-REPORTS] ⏭️ BROWSER reports list empty after normalization. reason=%s count=%d`, json.reason || "empty hints", json.count ?? 0, ); } else { console.info( `[INJECT-REPORTS] βœ… BROWSER fetchVoceroReportsList OK. phoneLen=%s count=%d wrappedLen=%d firstHint=%.120s`, voceroUserPhoneNumber ? voceroUserPhoneNumber.length : 0, json.count ?? json.hints.length, json.wrappedLen ?? 0, json.hints[0] || "", ); if (window.S2S_DEBUG_PROMPT) { console.debug( "[DEBUG-REPORTS][BROWSER reports hints] FULL list (%d items) β†’ %o", json.hints.length, json.hints, ); } } syncVoceroHeaderUser(); } catch (err) { voceroReportsList = null; voceroReportsLoaded = true; syncVoceroHeaderUser(); const message = err instanceof Error ? err.message : String(err); console.warn( `[INJECT-REPORTS] β›” BROWSER fetchVoceroReportsList 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; // Bootstrap-start mode: empty on generic deploys (tap to start). startSessionMode = typeof json.startSessionMode === "string" ? json.startSessionMode.trim().toUpperCase() : ""; // #region debug-point B:config-loaded reportDebug("B", "main.js:fetchConfig", "loaded config from api/config", { reasoningType, pinnedPromptLength: pinnedPrompt.length, injectProfile, debugPrompt, startSessionMode, }); // #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); } if (voceroEpisodeId) { u.searchParams.set("episode_id", voceroEpisodeId); } return u.toString(); } catch { if (voceroUserPhoneNumber || voceroEpisodeId) { const sep = s.includes("?") ? "&" : "?"; const parts = []; if (voceroUserPhoneNumber) parts.push(`phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}`); if (voceroEpisodeId) parts.push(`episode_id=${encodeURIComponent(voceroEpisodeId)}`); return `${s}${sep}${parts.join("&")}`; } 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), startWithResponse: startSessionMode === "TALK", ...(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": // Qualsiasi inizio connessione (manuale da Orb / restart o unlocker): // se il talk-unlocker Γ¨ ancora visibile (utente ha cliccato l'Orb prima // del 2s warmup, ad esempio) lo nascondiamo subito. hideTalkUnlocker(); setState("connecting"); break; case "queued": hideTalkUnlocker(); setState("queued"); break; case "your-turn": hideTalkUnlocker(); setState("your-turn"); break; case "connected": hideTalkUnlocker(); 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(); // Schedulo auto-start TALK / TALKFIRST (S2S_START_SESSION_MODE nel .env): // β€’ "TALK" β†’ dopo 2s mostra overlay unlocker (policy browser: // getUserMedia() richiede gesture). Click/touch/tasto = // doStart() dentro la gesture, poi ws startWithResponse // inject hola vocero via sendUserText+requestResponse nel // ws client (stesso flow identico a wake-word). // β€’ "TALKFIRST" β†’ dopo 2s TENTA direttamete doStart() dal timer, con // pendingWakeWordConfirmationText preimpostato. Se i // permessi del mic sono GRANTED (giΓ  consentiti in passato // o Chrome policy permissive), la wake-word simulata scatta // subito senza nessun click. Se invece fallisce per // permesso / gesture (Safari/iOS), fallback sullo stesso // unlocker di TALK. let autoStartTalkTimerId = 0; let _talkUnlockerShown = false; function hideTalkUnlocker() { const el = document.getElementById("talk-unlocker"); if (el && !el.hasAttribute("hidden")) el.setAttribute("hidden", ""); _talkUnlockerShown = false; } function showTalkUnlocker(fallbackHint = false) { if (!(startSessionMode === "TALK" || startSessionMode === "TALKFIRST")) return; const el = document.getElementById("talk-unlocker"); if (!el) return; // L'unlocker Γ¨ utile solo a stato idle/error: se l'utente ha giΓ  cliccato l'Orb // o Γ¨ connecting/connected non mostriamo niente. if (!(currentState === "idle" || currentState === "error")) return; // Se Γ¨ fallback da TALKFIRST cambiamo il testo in modo che spieghi perchΓ© // Γ¨ comparso ("tocca per consentire il microfono", invece di "preparando") const subEl = el.querySelector(".talk-unlocker-sub"); const titleEl = el.querySelector(".talk-unlocker-title"); if (fallbackHint) { if (titleEl) titleEl.textContent = "Se requiere permiso de micrΓ³fono"; if (subEl) subEl.textContent = "Toca en cualquier lugar para permitir el audio y empezar"; } el.removeAttribute("hidden"); _talkUnlockerShown = true; console.info( "%c[start-session] ▢️ talk-unlocker mostrato (%s): attende gesture utente", "color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;", fallbackHint ? "fallback TALKFIRST" : "TALK mode" ); } function maybeScheduleAutoStart() { // Pulisco un eventuale timer precedente (caso di doppia chiamata non attesa). if (autoStartTalkTimerId) { clearTimeout(autoStartTalkTimerId); autoStartTalkTimerId = 0; } if (startSessionMode !== "TALK" && startSessionMode !== "TALKFIRST") return; console.info( `%c[start-session] S2S_START_SESSION_MODE=${startSessionMode}: azione tra 2000 ms (warmup profilo/reports/episode)`, "color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;" ); autoStartTalkTimerId = window.setTimeout(() => { autoStartTalkTimerId = 0; if (startSessionMode === "TALKFIRST") { void tryTalkFirstDirect(); } else { showTalkUnlocker(false); } }, 2000); } /** Tenta l'auto-start diretto (TALKFIRST) SENZA gesture. Simula la wake word * impostando pendingWakeWordConfirmationText="hola vocero" (stessa variabile * del riconoscitore vocale reale), poi chiama doStart() β€” se i permessi sono * giΓ  grantiti (Chrome/Android dopo "allow always"), tutto funziona senza * click. Altrimenti fallback sull'unlocker. */ async function tryTalkFirstDirect() { if (!(currentState === "idle" || currentState === "error")) return; if (missingServerUrl()) { console.warn("[start-session] skip TALKFIRST: manca server URL"); promptServerUrl(); return; } // Step 1: pre-carico la wake-word simulata. Quando onClientStatus riceve // "connected", la frase viene injectata e il modello risponde esattamente // come quando la hot-word Γ¨ stata pronunciata dal microfono. pendingWakeWordConfirmationText = pendingWakeWordConfirmationText?.trim() ? pendingWakeWordConfirmationText : "hola vocero"; console.info( `%c[start-session] 🎀 TALKFIRST: imposto wake-word simulata=${JSON.stringify(pendingWakeWordConfirmationText)} e provo doStart() diretto (SENZA gesture)`, "color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;" ); try { setCaption("Preparing your session…", "muted"); // Non forzo AudioContext da fuori (non c'Γ¨ gesture): lo crea internamente // _ensureAudioContext; se rimane suspended il client lo resume() quando // arriva il primo audio (o sul prossimo click utente che lo unlocka). await doStart(); } catch (err) { console.warn("[start-session] ⚠️ TALKFIRST doStart() diretto FALLITO β†’ fallback unlocker", err && err instanceof Error ? err.message : err); // Fallback: mostro unlocker con la frase esplicativa "permesso mic". try { await teardown(); } catch (_) { /* no-op */ } setState("idle"); chat.renderEmptyState(); // Ripristino pendingWakeWordConfirmationText: nel caso l'utente usi // l'unlocker per avviare, NON vogliamo perdere la frase simulata (la // useremo di nuovo al connected, scattando quindi sempre la greet). pendingWakeWordConfirmationText = pendingWakeWordConfirmationText || "hola vocero"; showTalkUnlocker(true); void handleStartError(err instanceof Error ? err : new Error(String(err || "mic permission required"))); } } // Attacca una sola volta i listener sull'unlocker: click + touchstart = unlock (() => { const el = document.getElementById("talk-unlocker"); if (!el) return; function _unlockViaGesture() { startTalkFromGesture(); } el.addEventListener("click", _unlockViaGesture, { passive: true, once: false }); el.addEventListener("touchstart", _unlockViaGesture, { passive: true, once: false }); window.addEventListener("keydown", (ev) => { if (startSessionMode !== "TALK") return; if (!_talkUnlockerShown) return; // Qualsiasi tasto = unlock (Enter/Space/anche Altri) startTalkFromGesture(); }, { passive: true }); })(); /** Avvia doStart() dentro una gesture valida (click/touch/key). Chiamato dal talk-unlocker o da qualsiasi altro unlocker valido. */ async function startTalkFromGesture() { hideTalkUnlocker(); if (!(currentState === "idle" || currentState === "error")) return; if (missingServerUrl()) { console.warn("[start-session] skip unlocker: manca server URL (missingServerUrl=true)"); promptServerUrl(); return; } try { // Qui SIAMO dentro la user gesture: AudioContext + getUserMedia funzionano. console.info("[start-session] 🎀 avvio doStart() da gesture unlocker (TALK mode)"); const audioContext = createResumedAudioContext(); setCaption("Preparing your session…", "muted"); await doStart(audioContext); } catch (err) { console.error("[start-session] β›” doStart() da unlocker fallito", err); void handleStartError(err); } } void (async function bootstrapWithAutoStart() { await fetchConfig(); maybeScheduleAutoStart(); })(); void fetchVoceroUserProfile(); void fetchVoceroReportsList(); 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"); });