3742 lines
161 KiB
JavaScript
3742 lines
161 KiB
JavaScript
// @ts-check
|
|
/**
|
|
* Minimal voice conversation app, talking to a Hugging Face speech-to-speech
|
|
* backend over **WebSocket** (drop-in alternative to the WebRTC variant).
|
|
*
|
|
* Click the orb -> we ask for the mic, POST a session on the LB, open a
|
|
* WebSocket on the routed compute endpoint, push session.update + mic
|
|
* audio, play back the TTS audio. The orb visually reflects the live
|
|
* state (idle, connecting, listening, user-speaking, processing,
|
|
* ai-speaking).
|
|
*
|
|
* The only meaningful difference vs. the WebRTC main.js is that the
|
|
* client owns its own AudioContext (no `attachOutputTrack`), so we hand
|
|
* it the MediaStream directly.
|
|
*
|
|
* @typedef {"idle" | "connecting" | "queued" | "your-turn" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState
|
|
*/
|
|
|
|
import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js?v=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-24 23:12 (DEBUG FETCH WRAPPER+WS SEMPRE-ATTIVO; APIPILOT: demo flag=true + PREFETCH AUTOMATICO OGNI SESSIONE WS + PREFETCH DOPO LOGIN-BY-PHONE OK (refresh=true per invalidare cache server anonima); cache client per-utente; cambio phone = invalidate automatico; export chat; fix scroll).",
|
|
"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
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 🔎 FETCH WRAPPER (SEMANTIC DEBUG LOG) — attivo SEMPRE (non solo in DEBUG)
|
|
// Ogni chiamata `fetch` lato browser viene intercettata e loggata con:
|
|
// • URL risolto, metodo, dimensioni body + preview JSON
|
|
// • status HTTP (colorato verde/giallo/rosso a seconda della classe)
|
|
// • durata (performance.now), dimensione + preview risposta JSON
|
|
// Tag semantici per filtrare veloce la Console:
|
|
// [FETCH:CONFIG] [FETCH:REASONING] [FETCH:SEARCH]
|
|
// [FETCH:PROFILE] [FETCH:REPORTS] [FETCH:APIPILOT]
|
|
// [FETCH:SESSION] [FETCH:QUEUE] [FETCH:ME]
|
|
// ---------------------------------------------------------------------------
|
|
(function _installFetchDebugTracer() {
|
|
const ORIGINAL_FETCH = window.fetch.bind(window);
|
|
// Classifica endpoint in tag leggibile (stesso ordine di @app route in server.py).
|
|
/** @param {string} url */
|
|
function _tagFor(url) {
|
|
try {
|
|
const path = url.startsWith("/") || url.startsWith("./") || url.startsWith("api/")
|
|
? "/" + url.replace(/^\.?\//, "").replace(/^\//, "")
|
|
: new URL(url, window.location.href).pathname;
|
|
if (/\/api\/apipilot\/tools\/?$/i.test(path)) return "APIPILOT:TOOLS";
|
|
if (/\/api\/apipilot\/execute\/?$/i.test(path)) return "APIPILOT:EXEC";
|
|
if (/\/api\/config\/?$/i.test(path)) return "CONFIG";
|
|
if (/\/api\/reasoning\/?$/i.test(path)) return "REASONING";
|
|
if (/\/api\/search\/?$/i.test(path)) return "SEARCH";
|
|
if (/\/api\/user\/phone\/?($|\?)/i.test(path)) return "PROFILE";
|
|
if (/\/api\/user\/reports\/?($|\?)/i.test(path)) return "REPORTS";
|
|
if (/\/api\/session\/heartbeat\/?$/i.test(path)) return "SESSION:HEARTBEAT";
|
|
if (/\/api\/session\/end\/?$/i.test(path)) return "SESSION:END";
|
|
if (/\/api\/session\/?$/i.test(path)) return "SESSION:START";
|
|
if (/\/api\/queue\/end\/?$/i.test(path)) return "QUEUE:END";
|
|
if (/\/api\/queue\/[^/?]+\/?$/i.test(path)) return "QUEUE:POLL";
|
|
if (/\/api\/me\/?$/i.test(path)) return "ACCOUNT:ME";
|
|
return "FETCH";
|
|
} catch {
|
|
return "FETCH";
|
|
}
|
|
}
|
|
/** @param {RequestInfo | URL} input */
|
|
function _stringifyUrl(input) {
|
|
if (typeof input === "string") return input;
|
|
if (input instanceof URL) return input.href;
|
|
try {
|
|
// Request-like
|
|
return /** @type {{url:string}} */ (/** @type {unknown} */ (input)).url || String(input);
|
|
} catch {
|
|
return String(input);
|
|
}
|
|
}
|
|
/**
|
|
* @param {RequestInfo | URL} input
|
|
* @param {RequestInit | undefined} init
|
|
*/
|
|
function _extractMethod(input, init) {
|
|
if (init && typeof init.method === "string") return init.method.toUpperCase();
|
|
try {
|
|
const r = /** @type {{method?:string}} */ (/** @type {unknown} */ (input));
|
|
if (r && typeof r.method === "string") return r.method.toUpperCase();
|
|
} catch { /* noop */ }
|
|
return "GET";
|
|
}
|
|
/**
|
|
* @param {RequestInfo | URL} input
|
|
* @param {RequestInit | undefined} init
|
|
* @returns {Promise<{ length: number; preview: string }>}
|
|
*/
|
|
async function _extractBodyPreview(input, init) {
|
|
/** @type {BodyInit | null | undefined} */
|
|
let body = init ? init.body : undefined;
|
|
if (body === undefined || body === null) {
|
|
// tenta Request
|
|
try {
|
|
const r = /** @type {{body?:BodyInit|null}} */ (/** @type {unknown} */ (input));
|
|
if (r && r.body != null) body = r.body;
|
|
} catch { body = null; }
|
|
}
|
|
if (body == null) return { length: 0, preview: "(nessun body)" };
|
|
try {
|
|
if (typeof body === "string") {
|
|
const s = body;
|
|
try {
|
|
const j = JSON.parse(s);
|
|
const compact = JSON.stringify(j);
|
|
return { length: s.length, preview: "JSON: " + compact.slice(0, 240) };
|
|
} catch {
|
|
return { length: s.length, preview: "TEXT: " + s.slice(0, 240) };
|
|
}
|
|
}
|
|
if (body instanceof FormData) {
|
|
const pairs = [];
|
|
for (const [k, v] of body.entries()) pairs.push(`${k}=${String(v).slice(0, 48)}`);
|
|
return { length: pairs.length, preview: "FORM: " + pairs.join(" & ").slice(0, 240) };
|
|
}
|
|
if (body instanceof URLSearchParams) {
|
|
const s = body.toString();
|
|
return { length: s.length, preview: "QS: " + s.slice(0, 240) };
|
|
}
|
|
if (body instanceof Blob) return { length: body.size, preview: `BLOB (${body.type || "unknown"}) size=${body.size}` };
|
|
if (ArrayBuffer.isView(body)) return { length: body.byteLength, preview: `ArrayBufferView len=${body.byteLength}` };
|
|
if (body instanceof ArrayBuffer) return { length: body.byteLength, preview: `ArrayBuffer len=${body.byteLength}` };
|
|
return { length: 0, preview: "(body non serializzabile nel log)" };
|
|
} catch (err) {
|
|
return { length: -1, preview: `(err preview body: ${err})` };
|
|
}
|
|
}
|
|
/**
|
|
* @param {Response} response
|
|
* @returns {Promise<{ length: number; preview: string }>}
|
|
*/
|
|
async function _extractResponsePreview(response) {
|
|
try {
|
|
const clone = response.clone();
|
|
const ct = (clone.headers.get("content-type") || "").toLowerCase();
|
|
const text = await clone.text();
|
|
if (/application\/json|application\/.*\+json/i.test(ct) || (text.trim().startsWith("{") || text.trim().startsWith("["))) {
|
|
try {
|
|
const j = JSON.parse(text);
|
|
const compact = JSON.stringify(j);
|
|
return { length: text.length, preview: "JSON: " + compact.slice(0, 260) };
|
|
} catch {
|
|
return { length: text.length, preview: "TEXT/HTML: " + text.slice(0, 260) };
|
|
}
|
|
}
|
|
return { length: text.length, preview: "TEXT: " + text.slice(0, 200) };
|
|
} catch (err) {
|
|
return { length: -1, preview: `(err clone response: ${err})` };
|
|
}
|
|
}
|
|
/** @param {number} status */
|
|
function _statusStyle(status) {
|
|
if (status >= 200 && status < 300) return "color:#16a34a;font-weight:700;";
|
|
if (status >= 300 && status < 400) return "color:#ca8a04;font-weight:700;";
|
|
if (status >= 400 && status < 500) return "color:#b45309;font-weight:700;";
|
|
return "color:#dc2626;font-weight:700;";
|
|
}
|
|
window.fetch = async function debugWrappedFetch(input, init) {
|
|
const t0 = performance.now();
|
|
const method = _extractMethod(input, init);
|
|
const rawUrl = _stringifyUrl(input);
|
|
const tag = _tagFor(rawUrl);
|
|
// Per richieste "heartbeat / end poll" (molto frequenti) logghiamo un
|
|
// singleton collapse per evitare spam: se `?quiet=true` o URL match → log
|
|
// a riga singola e non mostriamo preview.
|
|
const isPollish = /heartbeat|queue\/end|session\/end/i.test(tag) || (init && typeof init.keepalive === "boolean" && init.keepalive);
|
|
const reqPreview = isPollish ? null : await _extractBodyPreview(input, init);
|
|
if (!isPollish) {
|
|
const groupTitle = `%c→ [FETCH:${tag}] ${method} ${rawUrl}`;
|
|
const groupStyle = "color:#2563eb;font-weight:700;";
|
|
console.groupCollapsed(groupTitle, groupStyle);
|
|
console.log(`method=${method} url=${rawUrl}`);
|
|
console.log(`bodyLen=${reqPreview?.length ?? 0} preview=${reqPreview?.preview ?? "(poll/skip)"}`);
|
|
console.groupEnd();
|
|
} else {
|
|
console.debug(
|
|
`%c→ [FETCH:${tag}] ${method} ${rawUrl}`,
|
|
"color:#64748b;font-weight:600;"
|
|
);
|
|
}
|
|
/** @type {Response} */
|
|
let response;
|
|
try {
|
|
// @ts-ignore
|
|
response = await ORIGINAL_FETCH(input, init);
|
|
} catch (err) {
|
|
const dur = (performance.now() - t0).toFixed(1);
|
|
console.groupCollapsed(
|
|
`%c✗ [FETCH:${tag}] NETWORK_ERROR ${method} ${rawUrl} (${dur} ms)`,
|
|
"background:#fee2e2;color:#b91c1c;font-weight:800;padding:2px 6px;border-radius:4px;"
|
|
);
|
|
console.error(err);
|
|
console.groupEnd();
|
|
throw err;
|
|
}
|
|
const dur = (performance.now() - t0).toFixed(1);
|
|
const resPreview = isPollish ? null : await _extractResponsePreview(response);
|
|
const statusStr = `${response.status} ${response.statusText}`.trim();
|
|
if (!isPollish) {
|
|
console.groupCollapsed(
|
|
`%c← [FETCH:${tag}] %c${statusStr} %c${method} ${rawUrl} (${dur} ms)`,
|
|
"color:#0f172a;font-weight:800;",
|
|
_statusStyle(response.status),
|
|
"color:#475569;font-weight:600;"
|
|
);
|
|
console.log(`HTTP status=${response.status} statusText=${response.statusText || "-"} durationMs=${dur}`);
|
|
const finalHeaders = [];
|
|
response.headers.forEach((v, k) => finalHeaders.push(`${k}: ${String(v).slice(0, 120)}`));
|
|
console.log(`Response headers (${finalHeaders.length}):`, finalHeaders);
|
|
if (resPreview) {
|
|
console.log(`responseLen=${resPreview.length} preview=${resPreview.preview}`);
|
|
}
|
|
console.groupEnd();
|
|
} else {
|
|
const sClass = (() => {
|
|
if (response.ok) return "color:#15803d;";
|
|
if (response.status >= 500) return "color:#dc2626;";
|
|
return "color:#ca8a04;";
|
|
})();
|
|
console.debug(
|
|
`%c← [FETCH:${tag}] %c${response.status} %c${method} ${rawUrl} (${dur} ms)`,
|
|
"color:#475569;font-weight:600;",
|
|
sClass,
|
|
"color:#64748b;"
|
|
);
|
|
}
|
|
return response;
|
|
};
|
|
console.info(
|
|
"%c🔎 [FETCH-DEBUG] tracer installato. Ogni fetch è loggata con tag, method, url, status, durata e preview body.",
|
|
"background:#ecfeff;color:#0369a1;font-weight:700;padding:2px 6px;border-radius:4px;"
|
|
);
|
|
})();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 🛡️ 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 <wake> = hola vocero | ola vocero | hola vocerò | ola vocerò | olá vocero | oye vocero | hola vocerro ;";
|
|
const WAKE_WORD_RESTART_DELAY_MS = 250;
|
|
const WAKE_WORD_MAX_INTERIM_LEN = 64;
|
|
|
|
/** @typedef {any & { continuous: boolean, interimResults: boolean, lang: string, grammars: any, maxAlternatives: number, start: () => void, stop: () => void, abort: () => void, onresult: any, onerror: any, onend: any, onstart: any, onaudioend: any }} WakeRec */
|
|
/** @type {WakeRec | null} */
|
|
let wakeWordEngine = null;
|
|
let wakeWordArmed = false;
|
|
let wakeWordRestartTimer = 0;
|
|
let pendingWakeWordConfirmationText = "";
|
|
|
|
/** @param {string} s @returns {string} */
|
|
function _normalizeWakeWord(s) {
|
|
return (s || "")
|
|
.toLowerCase()
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.replace(/[^a-z0-9\s]/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
/** @param {string} transcript */
|
|
function _matchesWakeWord(transcript) {
|
|
const hay = _normalizeWakeWord(transcript);
|
|
if (!hay) return false;
|
|
// Strict: contains a whole-phrase substring match of any variant.
|
|
for (const raw of WAKE_WORD_PHRASES) {
|
|
const needle = _normalizeWakeWord(raw);
|
|
if (needle && hay.includes(needle)) return true;
|
|
}
|
|
// Loose fallback for off-by-one phonemes: allow "vocero" preceded by any
|
|
// 3-5 char greeting-like word (e.g. "ola", "hola", "oye", "uy") to catch
|
|
// accent variations the grammar misses.
|
|
if (/\b(vocero|vocerro|vocerò|vo.?zero)\b/.test(hay) &&
|
|
/\b(hola|ola|oye|uy|holi|oli)\b/.test(hay)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** @param {WakeRec} engine */
|
|
function _installWakeWordHandlers(engine) {
|
|
engine.onresult = (/** @type {any} */ event) => {
|
|
if (!wakeWordArmed) return;
|
|
let aggregate = "";
|
|
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
|
const res = event.results[i];
|
|
const alt = res[0]?.transcript || "";
|
|
aggregate += ` ${alt}`;
|
|
if (aggregate.length > WAKE_WORD_MAX_INTERIM_LEN) break;
|
|
if (_matchesWakeWord(alt) || _matchesWakeWord(aggregate)) {
|
|
if (DEBUG) console.debug(`[WAKE WORD] MATCH transcript=${JSON.stringify(alt + (aggregate !== ` ${alt}` ? ` aggregate=${JSON.stringify(aggregate)}` : ""))}`);
|
|
console.info(`[WAKE WORD] ✓ Trigger fired — starting conversation`);
|
|
// Disarm *before* click: stop sharing the mic with SpeechRecognition
|
|
// while we're about to open getUserMedia for the realtime session.
|
|
wakeWordArmed = false;
|
|
try { engine.stop(); } catch { /* ignore */ }
|
|
if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; }
|
|
// Remember the exact phrase so onClientStatus(connected) can inject it
|
|
// into the live conversation and ask the model to reply to it (instead
|
|
// of silently waiting for the next user utterance).
|
|
const spoken = (alt || "").trim() || (aggregate || "").trim() || "hola vocero";
|
|
pendingWakeWordConfirmationText = spoken;
|
|
console.info(`[WAKE WORD] 🔁 Pending confirmation text: ${JSON.stringify(pendingWakeWordConfirmationText)}`);
|
|
// Pretend the user tapped the orb. If the AudioContext fails to
|
|
// resume (iOS Safari needs a real tap), doStart() will surface the
|
|
// error with the normal "Tap to retry" affordance.
|
|
circleBtn.click();
|
|
return;
|
|
}
|
|
}
|
|
if (DEBUG) {
|
|
const cleaned = aggregate.trim();
|
|
if (cleaned) console.debug(`[WAKE WORD] interim transcript=${JSON.stringify(cleaned.slice(0, WAKE_WORD_MAX_INTERIM_LEN))}`);
|
|
}
|
|
};
|
|
|
|
engine.onerror = (/** @type {any} */ event) => {
|
|
const err = event?.error || String(event || "unknown");
|
|
// "no-speech" / "audio-capture" / "aborted" are benign.
|
|
if (["no-speech", "aborted"].includes(err)) return;
|
|
console.warn(`[WAKE WORD] engine error: ${err}${event?.message ? ` (${event.message})` : ""}`);
|
|
// "not-allowed" or "service-not-allowed": the user/OS blocked the mic for
|
|
// the recognizer — permanently disarm so we don't thrash the permission.
|
|
if (err === "not-allowed" || err === "service-not-allowed") {
|
|
console.warn("[WAKE WORD] ✋ Permission denied — permanently disarming wake word (tap the orb to start)");
|
|
wakeWordArmed = false;
|
|
try { engine.stop(); } catch { /* ignore */ }
|
|
if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; }
|
|
}
|
|
};
|
|
|
|
engine.onend = () => {
|
|
if (!wakeWordArmed) return;
|
|
// Chrome terminates continuous recognition after ~60s of silence.
|
|
// Re-arm on the next tick so a single failed start doesn't loop.
|
|
if (wakeWordRestartTimer) clearTimeout(wakeWordRestartTimer);
|
|
wakeWordRestartTimer = window.setTimeout(() => {
|
|
if (!wakeWordArmed) return;
|
|
try {
|
|
engine.start();
|
|
if (DEBUG) console.debug("[WAKE WORD] 🔁 restarted continuous listener");
|
|
} catch (err) {
|
|
// Already-started is harmless; anything else we swallow briefly.
|
|
if (DEBUG) console.debug("[WAKE WORD] restart skipped:", err && err instanceof Error ? err.message : err);
|
|
}
|
|
}, WAKE_WORD_RESTART_DELAY_MS);
|
|
};
|
|
}
|
|
|
|
function startWakeWordListener() {
|
|
if (wakeWordArmed) return;
|
|
// Only in Chromium-derived and recent Safari WebViews with SpeechRecognition.
|
|
// @ts-ignore - vendor prefixes are intentional.
|
|
const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition || window.mozSpeechRecognition;
|
|
if (!Ctor) {
|
|
if (DEBUG) console.debug("[WAKE WORD] ⚠️ Web Speech Recognition unavailable on this browser — tap the orb to start");
|
|
return;
|
|
}
|
|
try {
|
|
/** @type {WakeRec} */
|
|
// @ts-ignore
|
|
const engine = new Ctor();
|
|
engine.continuous = true;
|
|
engine.interimResults = true;
|
|
engine.lang = WAKE_WORD_LANG;
|
|
engine.maxAlternatives = 1;
|
|
// Narrow the grammar so interim results are biased toward the phrase.
|
|
try {
|
|
// @ts-ignore
|
|
const list = window.SpeechGrammarList || window.webkitSpeechGrammarList;
|
|
if (list) {
|
|
// @ts-ignore
|
|
const gl = new list();
|
|
gl.addFromString(WAKE_WORD_JSGF_GRAMMAR, 1.0);
|
|
engine.grammars = gl;
|
|
}
|
|
} catch {
|
|
// Grammar support is optional; fallback to pure transcript matching.
|
|
}
|
|
_installWakeWordHandlers(engine);
|
|
engine.start();
|
|
wakeWordEngine = engine;
|
|
wakeWordArmed = true;
|
|
console.info("[WAKE WORD] 🎯 Armed — listening for \"Hola Vocero\"");
|
|
} catch (err) {
|
|
console.warn("[WAKE WORD] failed to start arming:", err && err instanceof Error ? err.message : err);
|
|
wakeWordArmed = false;
|
|
}
|
|
}
|
|
|
|
function stopWakeWordListener() {
|
|
if (!wakeWordArmed && !wakeWordEngine && !pendingWakeWordConfirmationText) return;
|
|
wakeWordArmed = false;
|
|
if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; }
|
|
if (wakeWordEngine) {
|
|
try { wakeWordEngine.abort(); } catch { /* ignore */ }
|
|
try { wakeWordEngine.stop(); } catch { /* ignore */ }
|
|
wakeWordEngine = null;
|
|
}
|
|
if (DEBUG) console.debug("[WAKE WORD] ⏸️ Disarmed for live session");
|
|
}
|
|
|
|
// ── Noise gate ──────────────────────────────────────────────────────────────
|
|
// The Settings cursor sets the gate's open threshold in dBFS. Its leftmost
|
|
// position is an OFF detent (gate disabled, pure passthrough); the rest of the
|
|
// travel is the active threshold. The cursor shares the meter's dB axis, so the
|
|
// handle sits on the level bar — raise it until room noise stops lighting it up.
|
|
// The slider range IS the shared axis: the live meter fill and the threshold
|
|
// thumb both map across [GATE_OFF_DB, GATE_MAX_DB], so the thumb sits exactly
|
|
// where the gate cuts on the same scale as the level bar.
|
|
const GATE_OFF_DB = -66; // slider minimum = off / bottom of the meter axis
|
|
const GATE_MAX_DB = -3; // slider maximum = most aggressive / top of the meter axis
|
|
const GATE_DEFAULT_DB = -50; // first-run default: a gentle gate, enabled
|
|
|
|
/** @param {number} thresholdDb @returns {import("./ws/s2s-ws-client.js").NoiseGate} */
|
|
function gateParams(thresholdDb) {
|
|
return { enabled: thresholdDb > GATE_OFF_DB, thresholdDb };
|
|
}
|
|
|
|
// ── Tools ─────────────────────────────────────────────────────────────────
|
|
// Function tools we declare to the backend. The model decides when to call
|
|
// one; the executor below runs it and returns the result (see runTool).
|
|
/** @type {Record<string, import("./ws/s2s-ws-client.js").ToolDef>} */
|
|
const TOOL_DEFS = {
|
|
web_search: {
|
|
type: "function",
|
|
name: "web_search",
|
|
description:
|
|
"Search the web for current or factual information you don't already know " +
|
|
"(news, prices, facts, documentation). Returns the top results with titles, " +
|
|
"snippets and URLs.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { query: { type: "string", description: "The search query." } },
|
|
required: ["query"],
|
|
},
|
|
},
|
|
};
|
|
|
|
function loadSettings() {
|
|
return {
|
|
directUrl: localStorage.getItem(STORAGE_KEYS.directUrl) || "",
|
|
voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE,
|
|
instructions: localStorage.getItem(STORAGE_KEYS.instructions) || DEFAULT_INSTRUCTIONS,
|
|
noiseGate: loadGateThreshold(),
|
|
};
|
|
}
|
|
|
|
/** Stored gate threshold (dBFS), clamped to the slider range. Defaults to a
|
|
* gentle enabled gate (GATE_DEFAULT_DB) when the user hasn't set one yet. */
|
|
function loadGateThreshold() {
|
|
const stored = localStorage.getItem(STORAGE_KEYS.noiseGate);
|
|
// getItem returns null when unset, and Number(null) === 0 (finite!), so guard
|
|
// the missing/empty case explicitly before coercing — otherwise the default
|
|
// never fires and 0 clamps to the slider max.
|
|
if (stored === null || stored === "") return GATE_DEFAULT_DB;
|
|
const raw = Number(stored);
|
|
if (!Number.isFinite(raw)) return GATE_DEFAULT_DB;
|
|
return Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, Math.round(raw)));
|
|
}
|
|
|
|
/** @param {ReturnType<typeof loadSettings>} s */
|
|
function saveSettings(s) {
|
|
localStorage.setItem(STORAGE_KEYS.directUrl, s.directUrl);
|
|
localStorage.setItem(STORAGE_KEYS.voice, s.voice);
|
|
localStorage.setItem(STORAGE_KEYS.instructions, s.instructions);
|
|
localStorage.setItem(STORAGE_KEYS.noiseGate, String(s.noiseGate));
|
|
}
|
|
|
|
/** @returns {{ web_search: boolean }} */
|
|
function loadTools() {
|
|
try {
|
|
const raw = JSON.parse(localStorage.getItem(STORAGE_KEYS.tools) || "{}");
|
|
return {
|
|
web_search: raw.web_search ?? true,
|
|
};
|
|
} catch {
|
|
return { web_search: true };
|
|
}
|
|
}
|
|
|
|
function saveTools() {
|
|
localStorage.setItem(STORAGE_KEYS.tools, JSON.stringify(toolsEnabled));
|
|
}
|
|
|
|
/** @type {Record<AppState, { caption: string; disabled: boolean }>} */
|
|
const STATE_VIEWS = {
|
|
idle: { caption: "Tap or say \"Hola Vocero\"", disabled: false },
|
|
connecting: { caption: "Connecting", disabled: true },
|
|
queued: { caption: "Finding you a spot…", disabled: true },
|
|
"your-turn": { caption: "You're up! 🎉", disabled: true },
|
|
listening: { caption: "", disabled: false },
|
|
"user-speaking": { caption: "", disabled: false },
|
|
processing: { caption: "", disabled: false },
|
|
"ai-speaking": { caption: "", disabled: false },
|
|
error: { caption: "Tap to retry", disabled: false },
|
|
};
|
|
|
|
/** @type {Record<AppState, string>} */
|
|
const STATE_CLASS = {
|
|
idle: "state-idle",
|
|
connecting: "state-connecting",
|
|
queued: "state-queued",
|
|
"your-turn": "state-your-turn",
|
|
listening: "state-listening",
|
|
"user-speaking": "state-user-speaking",
|
|
processing: "state-processing",
|
|
"ai-speaking": "state-ai-speaking",
|
|
error: "state-error",
|
|
};
|
|
|
|
/** @type {ReadonlySet<AppState>} */
|
|
const LIVE_STATES = new Set(["listening", "user-speaking", "processing", "ai-speaking"]);
|
|
|
|
/** @type {HTMLButtonElement} */
|
|
const circleBtn = $("#main-circle");
|
|
/** @type {HTMLParagraphElement} */
|
|
const circleCaption = $("#circle-caption");
|
|
/** @type {HTMLParagraphElement} */
|
|
const circleSubcaption = $("#circle-subcaption");
|
|
/** @type {HTMLElement} */
|
|
const orbWrap = $(".orb-wrap");
|
|
/** @type {HTMLButtonElement} */
|
|
const micBtn = $("#mic-btn");
|
|
/** @type {HTMLButtonElement} */
|
|
const stopBtn = $("#stop-btn");
|
|
/** @type {HTMLElement} */
|
|
const queueActions = $("#queue-actions");
|
|
/** @type {HTMLButtonElement} */
|
|
const joinQueueBtn = $("#join-queue-btn");
|
|
/** @type {HTMLButtonElement} */
|
|
const leaveQueueBtn = $("#leave-queue-btn");
|
|
|
|
/** @type {HTMLButtonElement} */
|
|
const settingsBtn = $("#settings-btn");
|
|
/** @type {HTMLDialogElement} */
|
|
const settingsModal = $("#settings-modal");
|
|
|
|
/** @type {HTMLButtonElement | null} */
|
|
const aboutBtn = document.querySelector("#about-btn");
|
|
/** @type {HTMLDialogElement} */
|
|
const aboutModal = $("#about-modal");
|
|
/** @type {HTMLButtonElement} */
|
|
const aboutClose = $("#about-close");
|
|
|
|
/** @type {HTMLButtonElement} */
|
|
const toolsBtn = $("#tools-btn");
|
|
/** @type {HTMLDialogElement} */
|
|
const toolsModal = $("#tools-modal");
|
|
/** @type {HTMLButtonElement} */
|
|
const toolsClose = $("#tools-close");
|
|
|
|
/** @type {HTMLButtonElement} */
|
|
const ragBtn = $("#rag-btn");
|
|
/** @type {HTMLDialogElement} */
|
|
const ragModal = $("#rag-modal");
|
|
/** @type {HTMLButtonElement} */
|
|
const ragClose = $("#rag-close");
|
|
/** @type {HTMLTextAreaElement} */
|
|
const ragTextarea = $("#rag-textarea");
|
|
/** @type {HTMLButtonElement} */
|
|
const ragSubmit = $("#rag-submit");
|
|
/** @type {HTMLButtonElement} */
|
|
const ragClear = $("#rag-clear");
|
|
/** @type {HTMLElement} */
|
|
const ragHint = $("#rag-hint");
|
|
/** @type {HTMLInputElement} */
|
|
const toolWebSwitch = $("#tool-web");
|
|
/** @type {HTMLElement} */
|
|
const toolWebRow = $("#tool-web-row");
|
|
/** @type {HTMLElement} */
|
|
const toolWebHint = $("#tool-web-hint");
|
|
/** @type {HTMLInputElement} */
|
|
const toolCamSwitch = $("#tool-cam");
|
|
/** @type {HTMLElement} */
|
|
const toolCamHint = $("#tool-cam-hint");
|
|
/** @type {HTMLElement} */
|
|
const camPip = $("#cam-pip");
|
|
/** @type {HTMLVideoElement} */
|
|
const camVideo = $("#cam-video");
|
|
/** @type {HTMLInputElement} */
|
|
const searchKeyInput = $("#search-key");
|
|
|
|
/** @type {HTMLInputElement} */
|
|
const inputLbUrl = $("#lb-url");
|
|
/** @type {HTMLElement} */
|
|
const connField = $("#conn-field");
|
|
/** @type {HTMLElement} */
|
|
const connHint = $("#conn-hint");
|
|
/** @type {HTMLSelectElement} */
|
|
const inputVoice = $("#voice");
|
|
/** @type {HTMLTextAreaElement} */
|
|
const inputInstructions = $("#instructions");
|
|
/** @type {HTMLElement} */
|
|
const primaryPromptMeta = $("#primary-prompt-meta");
|
|
/** @type {HTMLElement} */
|
|
const identUserName = $("#ident-user-name");
|
|
/** @type {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 = "";
|
|
// Theme: mirrors S2S_THEME env on the server. Supported values: "" (default
|
|
// dark / purple theme), "white" (the light "Vocero app" mobile theme in the
|
|
// screenshot: white bg, blue mic circle, chat bubbles with avatars, blue send
|
|
// button). "dark" is an alias for "" (native dark theme — no overrides applied).
|
|
let s2sTheme = "";
|
|
|
|
// Custom RAG context entered by the user via the header "RAG" button dialog.
|
|
// Treated exactly like voceroUserContextText / injectProfile context:
|
|
// 1. appended directly to LLM instructions (effectiveReasoningInstructions)
|
|
// 2. included in the user_context block sent to /api/reasoning body
|
|
// 3. if session WS is live (client != null), also pushed live to the open
|
|
// realtime session via session.update so the model sees it immediately.
|
|
let customRagText = "";
|
|
let customRagLoaded = false;
|
|
const VOCERO_RAG_HINT = " Custom RAG context provided by the operator (treat as trusted context for this session): ";
|
|
function customRagContextText() {
|
|
if (!customRagLoaded || !customRagText) return "";
|
|
const trimmed = customRagText.trim();
|
|
if (!trimmed) return "";
|
|
const capped = trimmed.length > 12000 ? `${trimmed.slice(0, 12000)}…` : trimmed;
|
|
return `${VOCERO_RAG_HINT}<<<CUSTOM_RAG_BEGIN>>>\n${capped}\n<<<CUSTOM_RAG_END>>>`;
|
|
}
|
|
function pushCustomRagToLiveSessionIfAny() {
|
|
const ctx = customRagContextText();
|
|
if (!ctx) return;
|
|
if (typeof client === "undefined" || !client || typeof client.updateSession !== "function") return;
|
|
const currentInstructions = (pinnedPrompt || settings.instructions || "").trim();
|
|
const profileCtx = injectProfile ? (voceroUserContextText() || "") : "";
|
|
const next = [currentInstructions, profileCtx, ctx]
|
|
.map((s) => (s || "").trim())
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
console.info(
|
|
`[CUSTOM-RAG] pushed LIVE to open session via client.updateSession() contextLen=%d finalInstructionsLen=%d`,
|
|
ctx.length, next.length,
|
|
);
|
|
if (debugPrompt) {
|
|
console.info(`[DEBUG-PROMPT][CUSTOM-RAG LIVE session.update instructions] len=%d:\n%s\n`, next.length, next);
|
|
}
|
|
try {
|
|
client.updateSession({ instructions: next });
|
|
} catch (err) {
|
|
console.warn(`[CUSTOM-RAG] updateSession threw (ignored; will re-apply on next start): %s`, err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
function syncRagHintFooter() {
|
|
if (!ragHint) return;
|
|
if (!customRagLoaded || !customRagText.trim()) {
|
|
ragHint.textContent = "Nessun RAG context caricato. Premi \"Invia nella sessione\" per applicare le modifiche.";
|
|
return;
|
|
}
|
|
const lines = customRagText.split(/\r?\n/).length;
|
|
ragHint.textContent = `✅ RAG context caricato: ${customRagText.length} chars / ${lines} linee. Viene aggiunto a istruzioni LLM e user_context.`;
|
|
}
|
|
// Dedupe cache for [INJECT-PROFILE] browser logs (avoid 20 logs per click).
|
|
let _lastInjectProfileLogKey = "";
|
|
/** @param {string} key @param {() => void} logFn */
|
|
function _logOnce(key, logFn) {
|
|
if (key === _lastInjectProfileLogKey) return;
|
|
_lastInjectProfileLogKey = key;
|
|
logFn();
|
|
}
|
|
|
|
// ── Tool state ──────────────────────────────────────────────────────────────
|
|
let toolsEnabled = loadTools();
|
|
// Whether the server holds a Serper key (learned from /api/config on load).
|
|
let serverSearchKey = false;
|
|
// A user-supplied key (fallback when the deploy has none). localStorage only.
|
|
let userSearchKey = localStorage.getItem(STORAGE_KEYS.searchKey) || "";
|
|
let lastUserQuery = "";
|
|
/** @type {Record<string, { argsText: string, status: "idle" | "running" | "success" | "error", resultText: string, lastAction: "call" | "send" }>} */
|
|
let mcpToolCallState = Object.create(null);
|
|
const pageParams = new URLSearchParams(window.location.search);
|
|
// Caller identity info: loaded from /api/user/phone when the URL param
|
|
// ?phoneNumber=... is present. Purely for UI header + reasoning user_context;
|
|
// never injected into the realtime WS session (WS stays pure passthrough).
|
|
const voceroUserPhoneNumber = (pageParams.get("phoneNumber") || "").trim();
|
|
// 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<string, unknown> | null} */
|
|
let voceroUserProfile = null;
|
|
let voceroUserProfileLoaded = false;
|
|
|
|
/**
|
|
* @param {string} hypothesisId
|
|
* @param {string} location
|
|
* @param {string} msg
|
|
* @param {Record<string, unknown>} [data]
|
|
*/
|
|
function reportDebug(hypothesisId, location, msg, data = {}) {
|
|
void hypothesisId;
|
|
void location;
|
|
void msg;
|
|
void data;
|
|
}
|
|
|
|
/** Search is usable if the server has a key or the user supplied one. */
|
|
function searchAvailable() {
|
|
return serverSearchKey || !!userSearchKey;
|
|
}
|
|
|
|
/** Tool definitions for the currently-enabled (and usable) tools. */
|
|
function activeToolDefs() {
|
|
const defs = [];
|
|
if (toolsEnabled.web_search && searchAvailable()) defs.push(TOOL_DEFS.web_search);
|
|
return defs;
|
|
}
|
|
|
|
/** @returns {string} */
|
|
function voceroUserContextText() {
|
|
if (!voceroUserProfileLoaded || !voceroUserProfile || !voceroUserPhoneNumber) return "";
|
|
let serialized = JSON.stringify(voceroUserProfile);
|
|
if (serialized.length > 1600) serialized = `${serialized.slice(0, 1600)}...`;
|
|
return `${VOCERO_USER_CONTEXT_HINT}${serialized}. Use this as trusted caller context when relevant.`;
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown> | null} profile
|
|
* @returns {string}
|
|
*/
|
|
function extractVoceroUserName(profile) {
|
|
if (!profile) return "";
|
|
const directCandidates = [
|
|
profile.name,
|
|
profile.fullName,
|
|
profile.full_name,
|
|
profile.displayName,
|
|
profile.display_name,
|
|
profile.username,
|
|
];
|
|
for (const value of directCandidates) {
|
|
if (typeof value === "string" && value.trim()) return value.trim();
|
|
}
|
|
const firstName = typeof profile.firstName === "string" ? profile.firstName.trim() : "";
|
|
const lastName = typeof profile.lastName === "string" ? profile.lastName.trim() : "";
|
|
if (firstName || lastName) return `${firstName} ${lastName}`.trim();
|
|
const nestedKeys = ["user", "customer", "profile", "contact"];
|
|
for (const key of nestedKeys) {
|
|
const nested = profile[key];
|
|
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
|
const nestedName = extractVoceroUserName(/** @type {Record<string, unknown>} */ (nested));
|
|
if (nestedName) return nestedName;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
window._s2sExtractName = extractVoceroUserName;
|
|
window._s2sInitialsFor = initialsForName;
|
|
|
|
/* ─── 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<string, unknown> | 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<string, unknown>} */ (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<string, unknown> | 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<string, unknown> | 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<string, unknown>} */ (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;
|
|
}
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// 2026-09-25 INJECT API-PILOT HERAMIENTAS DINÁMICAS (PASADA 1).
|
|
// Formato IDENTICO a server.py Python: marker <<<APIPILOT_TOOLS_LIST_BEGIN>>>
|
|
// / ...lista name+description+requires_auth+required_parameters... / <<<END>>>.
|
|
// Il client ottiene la lista dal proxy GET api/apipilot/tools (cache 240 s
|
|
// per-utente in s2s-ws-client.js _apipilotClientCache) e la appende qui alle
|
|
// instructions STESSE (luogo #1 di massima priorità per l'LLM).
|
|
// Dedup: se le instructions già contengono il marker (già appese in una
|
|
// esecuzione precedente, oppure server Python ha già patchato il payload)
|
|
// → NON appendere nuovamente (no doppioni, no blob gigante).
|
|
let apipilotBlock = "";
|
|
let apipilotCount = 0;
|
|
let apipilotAppended = false;
|
|
try {
|
|
// @ts-ignore: esposto globalmente da s2s-ws-client.js (caricato prima di
|
|
// main.js in index.html). Valuta sicuro: typeof + early-skip.
|
|
const getter = typeof window !== "undefined" && window.__s2sGetApipilotPass1ContextInfo;
|
|
if (typeof getter === "function") {
|
|
const info = getter();
|
|
if (info && typeof info.block === "string" && typeof info.marker === "string") {
|
|
apipilotBlock = info.block;
|
|
apipilotCount = typeof info.count === "number" ? info.count : (apipilotBlock ? 1 : 0);
|
|
const already = typeof info.alreadyPresent === "function"
|
|
? Boolean(info.alreadyPresent(instructions))
|
|
: (typeof instructions === "string" && instructions.includes(info.marker));
|
|
if (apipilotBlock.trim() && !already) {
|
|
instructions = instructions ? `${instructions}\n\n${apipilotBlock}` : apipilotBlock;
|
|
apipilotAppended = true;
|
|
}
|
|
}
|
|
}
|
|
} catch (_) {
|
|
/* never-fail: non bloccare il resto dell'istruzione prompt. */
|
|
}
|
|
const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${apipilotAppended ? "apipilotY" : "apipilotN"}:${(base || "").length}`;
|
|
_logOnce(key, () => {
|
|
if (apipilotAppended) {
|
|
console.info(
|
|
`[INJECT-APIPILOT] ✅ BROWSER effectiveReasoningInstructions APPENDED ApiPilot tools list PASS-1 inside LLM prompt. apipilotBlockLen=%d Ntools=%d baseLen=%d profileAppended=%s ragAppended=%s -> finalLen=%d`,
|
|
apipilotBlock.length, apipilotCount, (base || "").length, profileAppended, ragAppended, instructions.length,
|
|
);
|
|
} else if (apipilotBlock) {
|
|
console.info(
|
|
`[INJECT-APIPILOT] ⏭️ BROWSER effectiveReasoningInstructions: ApiPilot tools list PASS-1 already present (dedup marker BEGIN). No append needed. blockLen=%d Ntools=%d`,
|
|
apipilotBlock.length, apipilotCount,
|
|
);
|
|
}
|
|
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"}:${apipilotAppended ? "apipilotY" : "apipilotN"}:${apipilotBlock.length}`;
|
|
_logOnce(key2, () => {
|
|
console.info(
|
|
`[DEBUG-PROMPT][BROWSER effectiveReasoningInstructions] FULL TEXT len=%d profile=%s rag=%s apipilot=%s:\n%s\n`,
|
|
instructions.length,
|
|
ctx ? `${ctx.length}c` : "none",
|
|
ragCtx ? `${ragCtx.length}c` : "none",
|
|
apipilotBlock ? `${apipilotCount}tools/${apipilotBlock.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<string>} - States where a live session is open and the user could be silent. `ai-speaking` is intentionally excluded so we never kill a reply mid-TTS. */
|
|
const IDLE_COUNT_STATES = new Set(["listening", "user-speaking", "processing"]);
|
|
|
|
function _kickIdleTimer(reason = "activity") {
|
|
idleCloseLastActivityAtMs = Date.now();
|
|
if (!IDLE_COUNT_STATES.has(currentState)) {
|
|
if (idleCloseTimer) { clearTimeout(idleCloseTimer); idleCloseTimer = 0; }
|
|
return;
|
|
}
|
|
if (idleCloseTimer) clearTimeout(idleCloseTimer);
|
|
idleCloseTimer = window.setTimeout(() => {
|
|
const staleMs = Date.now() - idleCloseLastActivityAtMs;
|
|
if (staleMs + 500 < IDLE_TIMEOUT_MS) {
|
|
// Race with a very recent kick: re-arm rather than close.
|
|
if (DEBUG) console.debug(`[IDLE] fire skipped (staleMs=${staleMs} < ${IDLE_TIMEOUT_MS}), re-arming`);
|
|
_kickIdleTimer("re-arm");
|
|
return;
|
|
}
|
|
console.info(`[IDLE] ⏰ Closing session after ${(staleMs / 1000).toFixed(1)}s of inactivity (timeout=${IDLE_TIMEOUT_MS / 1000}s) — reason=${reason}`);
|
|
idleCloseTimer = 0;
|
|
// Friendly caption flash right before teardown moves us back to the idle
|
|
// copy (so the user understands *why* the call ended).
|
|
setCaption("Chiuso per inattività", "muted");
|
|
void (async () => {
|
|
try {
|
|
await teardown();
|
|
} catch (err) {
|
|
console.warn("[IDLE] teardown failed during auto-close:", err && err instanceof Error ? err.message : err);
|
|
}
|
|
// teardown() calls setState("idle") which restores the normal idle
|
|
// caption. Leave a visible breadcrumb on the subcaption briefly so the
|
|
// user can tell what happened instead of wondering why the orb reset.
|
|
circleSubcaption.textContent = "Sessione chiusa dopo 30 secondi di silenzio. Tocca l'orb o ripeti «Hola Vocero» per iniziare di nuovo.";
|
|
circleSubcaption.hidden = false;
|
|
window.setTimeout(() => { circleSubcaption.hidden = true; }, 8000);
|
|
})();
|
|
}, IDLE_TIMEOUT_MS);
|
|
if (DEBUG) {
|
|
console.debug(`[IDLE] 🔄 reset timer (${IDLE_TIMEOUT_MS / 1000}s) reason=${reason} state=${currentState}`);
|
|
}
|
|
}
|
|
|
|
function _cancelIdleTimer() {
|
|
if (idleCloseTimer) { clearTimeout(idleCloseTimer); idleCloseTimer = 0; }
|
|
idleCloseLastActivityAtMs = 0;
|
|
if (DEBUG) console.debug(`[IDLE] ⛔ cancelled timer state=${currentState}`);
|
|
}
|
|
|
|
/** @type {S2sWsRealtimeClient | null} */
|
|
let client = null;
|
|
/** @type {MediaStream | null} */
|
|
let micStream = null;
|
|
let micMuted = false;
|
|
|
|
/** @param {AppState} next */
|
|
function setState(next) {
|
|
currentState = next;
|
|
const view = STATE_VIEWS[next];
|
|
circleBtn.disabled = view.disabled;
|
|
circleBtn.className = `circle ${STATE_CLASS[next]}`;
|
|
if (next !== "error") setCaption(view.caption);
|
|
|
|
const live = LIVE_STATES.has(next);
|
|
orbWrap.classList.toggle("live", live);
|
|
micBtn.setAttribute("aria-hidden", live ? "false" : "true");
|
|
stopBtn.setAttribute("aria-hidden", live ? "false" : "true");
|
|
micBtn.tabIndex = live ? 0 : -1;
|
|
stopBtn.tabIndex = live ? 0 : -1;
|
|
|
|
// Queue affordances: "Leave queue" whenever we're in line; "Join now" only once
|
|
// it's our turn (a slot is held for us). Both live under #queue-actions.
|
|
const yourTurn = next === "your-turn";
|
|
const inLine = next === "queued" || yourTurn;
|
|
queueActions.hidden = !inLine;
|
|
joinQueueBtn.hidden = !yourTurn;
|
|
joinQueueBtn.tabIndex = yourTurn ? 0 : -1;
|
|
leaveQueueBtn.hidden = !inLine;
|
|
leaveQueueBtn.tabIndex = inLine ? 0 : -1;
|
|
if (!yourTurn) stopJoinCountdown();
|
|
|
|
// Warm reassurance under the terse position, only while waiting in line.
|
|
if (next === "queued") {
|
|
circleSubcaption.textContent =
|
|
"Sorry, we overhugged! 🤗 Every slot is busy, so we saved you a spot. Hang tight, you're moving up.";
|
|
circleSubcaption.hidden = false;
|
|
} else {
|
|
circleSubcaption.hidden = true;
|
|
}
|
|
|
|
// Wake word: armed only when idle/error (no live mic contention with the
|
|
// realtime session), and disarmed the moment we move into any other state.
|
|
if (next === "idle" || next === "error") {
|
|
startWakeWordListener();
|
|
} else {
|
|
stopWakeWordListener();
|
|
}
|
|
|
|
// Idle auto-close: arm the countdown whenever we land in a live conversing
|
|
// state (user/assistant turn, processing), and cancel it whenever we leave
|
|
// those states (idle, queue, error, teardown). Moving *to* a live state is
|
|
// itself counted as activity so the 10s window starts fresh.
|
|
if (IDLE_COUNT_STATES.has(next)) {
|
|
_kickIdleTimer(`state→${next}`);
|
|
} else {
|
|
_cancelIdleTimer();
|
|
}
|
|
|
|
updateRestartAvailability();
|
|
}
|
|
|
|
function updateRestartAvailability() {
|
|
// Restart works from any settled state — it tears down a live call (if any)
|
|
// and reconnects with the current settings. Only block while mid-connect or
|
|
// while waiting in the queue (restarting from there would just re-queue).
|
|
restartBtn.disabled =
|
|
currentState === "connecting" || currentState === "queued" || currentState === "your-turn";
|
|
restartHint.hidden = false;
|
|
restartHint.textContent = LIVE_STATES.has(currentState)
|
|
? "Reconnects now with the settings above."
|
|
: "Starts a conversation with the settings above.";
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {"" | "error" | "muted"} [kind]
|
|
*/
|
|
function setCaption(text, kind = "") {
|
|
const trimmed = text.trim();
|
|
circleCaption.textContent = trimmed;
|
|
// .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 <dialog>, Esc closes for free; also close on the X and
|
|
// on a click in the backdrop (a click whose target is the dialog itself).
|
|
aboutBtn?.addEventListener("click", () => aboutModal.showModal());
|
|
// Mobile twin of the (i), living in the right-hand control cluster.
|
|
$("#about-btn-m").addEventListener("click", () => aboutModal.showModal());
|
|
aboutClose.addEventListener("click", () => aboutModal.close());
|
|
aboutModal.addEventListener("click", (e) => {
|
|
if (e.target === aboutModal) aboutModal.close();
|
|
});
|
|
|
|
/* ─── User Profile dialog handlers (click avatar circle) ───────────── */
|
|
// The avatar circle in the brand header: when clicked, opens a native
|
|
// <dialog> with the user's profile info. Keyboard accessible: Enter / Space
|
|
// also open the dialog. a11y note: we attach role="button" + tabindex via JS
|
|
// at load time (so even if markup changes the behavior is preserved).
|
|
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 <dialog> 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<string, unknown>} */ ({});
|
|
try { args = JSON.parse(argsJson || "{}"); } catch { /* keep {} */ }
|
|
|
|
// #region debug-point D:run-tool-entry
|
|
reportDebug("D", "main.js:runTool", "tool execution started", {
|
|
name,
|
|
callId,
|
|
args,
|
|
});
|
|
// #endregion
|
|
try {
|
|
let output = "";
|
|
let image;
|
|
if (name === "web_search") {
|
|
const q = String(args.query || "").trim();
|
|
output = await execWebSearch(q);
|
|
} else {
|
|
output = `Unknown tool: ${name}.`;
|
|
}
|
|
// #region debug-point E:run-tool-success
|
|
reportDebug("E", "main.js:runTool", "tool execution completed", {
|
|
name,
|
|
outputLen: output.length,
|
|
hasImage: !!image,
|
|
});
|
|
// #endregion
|
|
if (client) client.sendFunctionCallOutput(callId, output);
|
|
return { output, image };
|
|
} catch (err) {
|
|
const detail = truncateError(err);
|
|
// #region debug-point E:run-tool-error
|
|
reportDebug("E", "main.js:runTool", "tool execution failed", {
|
|
name,
|
|
error: detail,
|
|
});
|
|
// #endregion
|
|
if (client) client.sendFunctionCallOutput(callId, `Tool error: ${detail}`);
|
|
return { output: `Tool error: ${detail}` };
|
|
}
|
|
}
|
|
async function execWebSearch(query) {
|
|
if (!query) return "No query provided.";
|
|
/** @type {Record<string, string>} */
|
|
const body = { query };
|
|
// Only send a user key when there's no server key (server prefers its own).
|
|
if (!serverSearchKey && userSearchKey) body.key = userSearchKey;
|
|
|
|
const res = await fetch("api/search", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
let detail = String(res.status);
|
|
try { const j = await res.json(); if (j.detail) detail = j.detail; } catch {}
|
|
throw new Error(`search error (${detail})`);
|
|
}
|
|
const json = await res.json();
|
|
// Date-stamp the header so the model treats these as fresh realtime facts
|
|
// rather than its (older) training knowledge.
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
/** @type {string[]} */
|
|
const lines = [`Google search result from ${today}:`];
|
|
if (json.answer) lines.push(`Answer: ${json.answer}`);
|
|
for (const r of json.results || []) {
|
|
lines.push(`- ${r.title}: ${r.snippet} (${r.url})`);
|
|
}
|
|
return lines.length > 1 ? lines.join("\n") : `${lines[0]}\nNo results found.`;
|
|
}
|
|
|
|
async function fetchVoceroUserProfile() {
|
|
if (!voceroUserPhoneNumber) {
|
|
console.info(
|
|
"[INJECT-PROFILE] ⏭️ BROWSER fetchVoceroUserProfile SKIPPED: URL has NO ?phoneNumber=. " +
|
|
"Profile context will be empty (info-user fetch + injectProfile not possible without caller phone)."
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(`api/user/phone?phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}`);
|
|
if (!res.ok) {
|
|
// 503 = endpoint not configured on this deploy (generic deploy); skip
|
|
// silently. Any other non-2xx is an error worth reporting.
|
|
if (res.status === 503) {
|
|
console.info(
|
|
"[INJECT-PROFILE] ⏭️ BROWSER skipped (deploy did NOT configure VOCERO_USER_PHONE_AUTH_URL env). endpoint returned HTTP 503, proxy /api/user/phone unavailable. " +
|
|
"Inject-profile will have empty profile context server-side if WS/reasoning also try."
|
|
);
|
|
voceroUserProfile = null;
|
|
voceroUserProfileLoaded = false;
|
|
syncVoceroHeaderUser();
|
|
return;
|
|
}
|
|
const message = await res.text();
|
|
throw new Error(message || `HTTP ${res.status}`);
|
|
}
|
|
const json = await res.json();
|
|
voceroUserProfile = json && typeof json === "object" && json.result && typeof json.result === "object"
|
|
? json.result
|
|
: (json && typeof json === "object" && !Array.isArray(json) ? json : null);
|
|
voceroUserProfileLoaded = true;
|
|
if (voceroUserProfile) {
|
|
console.info(
|
|
`[INJECT-PROFILE] ✅ BROWSER fetchVoceroUserProfile OK. phoneLen=%d profileKeys=%s headerUser=%s`,
|
|
voceroUserPhoneNumber.length,
|
|
Object.keys(voceroUserProfile).join(","),
|
|
extractVoceroUserName(voceroUserProfile) || "(none)",
|
|
);
|
|
// ── 🚀 PREFETCH ApiPilot IMMEDIATAMENTE DOPO LOGIN-BY-PHONE RIUSCITO
|
|
// Forza refresh=true per bypassare la cache SERVER lato proxy S2S (TTL 300s)
|
|
// perché adesso abbiamo un profilo utente autenticato (non più anonimo).
|
|
// In questo modo vediamo la riga [apipilot] list_tools.start user=(non-anon)
|
|
// nel backend AquaPortal OGNI VOLTA che c'è un login phone riuscito.
|
|
try {
|
|
if (window.__s2sApipilot) {
|
|
const t0 = performance.now();
|
|
console.info(
|
|
"%c🔧 [APIPILOT-CLIENT] post-login phone → FORZO refresh api/apipilot/tools (bypass cache S2S 300s perché utente appena autenticato)",
|
|
"background:#ede9fe;color:#5b21b6;font-weight:700;padding:2px 6px;border-radius:4px;"
|
|
);
|
|
const demoToken = typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
|
|
const headers = {};
|
|
if (demoToken) headers["x-demo-session"] = demoToken;
|
|
fetch(`api/apipilot/tools?include_unauthorized=true&refresh=true&phone=${encodeURIComponent(voceroUserPhoneNumber.slice(0, 8))}`, {
|
|
method: "GET",
|
|
cache: "no-store",
|
|
headers,
|
|
}).then(async (r) => {
|
|
const dt = Math.round(performance.now() - t0);
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
const j = await r.json().catch(() => ({}));
|
|
const ok = j && j.success && j.data;
|
|
if (!ok) throw new Error(`envelope non corretto success=${Boolean(j && j.success)}`);
|
|
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
|
|
const count = typeof j.data.count === "number" ? j.data.count : tools.length;
|
|
const names = tools.slice(0, 6).map((t) => (typeof t.name === "string" ? t.name : "?")).join(", ");
|
|
// Invalida cache client WS per ogni sessione futura (in modo che
|
|
// al prossimo connect() venga fatto un refresh per ri-confermare).
|
|
try {
|
|
// @ts-ignore
|
|
if (typeof window.__s2sInvalidateApipilotCache === "function") window.__s2sInvalidateApipilotCache();
|
|
} catch (_) { /* noop */ }
|
|
console.info(
|
|
`%c🔧 [APIPILOT-CLIENT] post-login OK (dt=${dt}ms): N=${count} strumenti [primi 6: ${names || "(nessuno)"}]`,
|
|
"background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;"
|
|
);
|
|
}).catch((err) => {
|
|
const dt = Math.round(performance.now() - t0);
|
|
console.warn(
|
|
`%c🔧 [APIPILOT-CLIENT] post-login FAIL (dt=${dt}ms): ${String(err instanceof Error ? err.message : err)} → controlla [APIPILOT-DISCOVERY] nel log server S2S`,
|
|
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;"
|
|
);
|
|
});
|
|
}
|
|
} catch (_) { /* fire-and-forget silently swallow */ }
|
|
} 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;
|
|
// Feature flag: ApiPilot AquaPortal bridge attivo sul server.
|
|
// Se true = S2S_APIPILOT_TOOLS_URL è valorizzato lato backend e
|
|
// gli endpoint proxy /api/apipilot/tools e /api/apipilot/execute
|
|
// sono disponibili per il frontend.
|
|
const apipilotEnabled = !!json.apipilot;
|
|
if (typeof window.__s2sApipilot === "undefined") {
|
|
try { Object.defineProperty(window, "__s2sApipilot", { value: false, writable: true, configurable: true }); }
|
|
catch (_) { /* noop */ }
|
|
}
|
|
window.__s2sApipilot = apipilotEnabled;
|
|
// Bootstrap-start mode: empty on generic deploys (tap to start).
|
|
startSessionMode = typeof json.startSessionMode === "string"
|
|
? json.startSessionMode.trim().toUpperCase()
|
|
: "";
|
|
// Theme: "" / "dark" → native dark theme (purple orb). "white" → light
|
|
// Vocero app mobile theme (white bg, blue mic, chat bubbles w/ avatars).
|
|
const rawTheme = typeof json.theme === "string" ? json.theme.trim().toLowerCase() : "";
|
|
const resolvedTheme = rawTheme === "white" ? "white" : "dark";
|
|
s2sTheme = resolvedTheme === "white" ? "white" : "";
|
|
const root = document.documentElement;
|
|
// NOTE: always set the attribute explicitly (never remove it) so the
|
|
// anti-FOUC guard `html:not([data-theme]) [data-theme-only]` expires
|
|
// as soon as the config round-trip completes (before it, both white &
|
|
// dark parallel UI blocks are kept invisible → no flash of purple orb
|
|
// fading into blue mic on S2S_THEME=white deploys).
|
|
root.setAttribute("data-theme", resolvedTheme);
|
|
// ── Anti-FOUC veil LIFT (user fix 2026-09-10 purple flash on refresh)
|
|
// The inline <style id="s2s-anti-fouc-veil"> in <head> keeps <body>
|
|
// completely unpainted until BOTH:
|
|
// a) data-theme attribute exists on <html> ← line ↑ above
|
|
// b) class="theme-ready" exists on <html> ← line ↓ below
|
|
// Two-signal guard avoids 1-frame paint leaks where some browsers
|
|
// (WebKit especially) commit the attribute mutation a few microtasks
|
|
// before the style cascade re-evaluates against it. Also clear the
|
|
// 2500ms fail-safe timeout so it doesn't fire 2.4s later for no
|
|
// reason on a perfectly healthy config round-trip.
|
|
try {
|
|
root.classList.add("theme-ready");
|
|
if (typeof window.__s2sVeilLiftTimeout === "number") {
|
|
window.clearTimeout(window.__s2sVeilLiftTimeout);
|
|
window.__s2sVeilLiftTimeout = null;
|
|
}
|
|
} catch (_) { /* classList.add on unknown element — defensive only */ }
|
|
// #region debug-point B:config-loaded
|
|
reportDebug("B", "main.js:fetchConfig", "loaded config from api/config", {
|
|
reasoningType,
|
|
pinnedPromptLength: pinnedPrompt.length,
|
|
injectProfile,
|
|
debugPrompt,
|
|
startSessionMode,
|
|
theme: s2sTheme || "dark",
|
|
apipilotEnabled,
|
|
});
|
|
// #endregion
|
|
// ── Demo call ApiPilot (fire-and-forget, solo se flag=true sul server)
|
|
// Serve ESCLUSIVAMENTE per attivare il tracer fetch lato browser e
|
|
// mostrare in console la riga [FETCH:APIPILOT:TOOLS] con preview
|
|
// status, durata e lista strumenti. Proxy server-side verso
|
|
// S2S_APIPILOT_TOOLS_URL/tools.
|
|
if (apipilotEnabled) {
|
|
try {
|
|
const demoUrl = "api/apipilot/tools?include_unauthorized=true";
|
|
console.info(
|
|
"%c🔧 [APIPILOT-CLIENT] flag=true → DEMO CALL %s (tracer sempre-attivo logga [FETCH:APIPILOT:TOOLS] sotto)",
|
|
"background:#e0e7ff;color:#3730a3;font-weight:700;padding:2px 6px;border-radius:4px;",
|
|
demoUrl
|
|
);
|
|
fetch(demoUrl, { method: "GET", cache: "no-store" })
|
|
.then(async (r) => {
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
const j = await r.json().catch(() => ({}));
|
|
const tools = (j && j.data && Array.isArray(j.data.tools)) ? j.data.tools : [];
|
|
const count = (j && j.data && typeof j.data.count === "number") ? j.data.count : tools.length;
|
|
const names = tools.slice(0, 5).map((t) => (typeof t.name === "string" ? t.name : "?")).join(", ");
|
|
console.info(
|
|
"%c🔧 [APIPILOT-CLIENT] demo OK: %s strumenti [primi 5: %s] usa POST api/apipilot/execute {name,arguments} per eseguire",
|
|
"background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;",
|
|
count,
|
|
names || "(nessuno)"
|
|
);
|
|
})
|
|
.catch((err) => {
|
|
console.warn(
|
|
"%c🔧 [APIPILOT-CLIENT] demo FAIL: %s. Lato server logga [APIPILOT-DISCOVERY] per diagnosticare",
|
|
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;",
|
|
String(err)
|
|
);
|
|
});
|
|
} catch (_) { /* fire-and-forget: silently swallow */ }
|
|
}
|
|
// 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.
|
|
}
|
|
// ── Anti-FOUC veil LIFT (catch-all): also lift when fetchConfig took a
|
|
// non-OK / exception path so the user is never left on an invisible
|
|
// <body> if the api/config response was malformed or 500'd. Only runs
|
|
// the first time (theme-ready is idempotent). Also clears the fail-safe
|
|
// timeout (if still set) the same way the happy-path did above.
|
|
try {
|
|
const root = document.documentElement;
|
|
if (!root.hasAttribute("data-theme")) root.setAttribute("data-theme", "dark");
|
|
if (!root.classList.contains("theme-ready")) root.classList.add("theme-ready");
|
|
if (typeof window.__s2sVeilLiftTimeout === "number") {
|
|
window.clearTimeout(window.__s2sVeilLiftTimeout);
|
|
window.__s2sVeilLiftTimeout = null;
|
|
}
|
|
} catch (_) { /* defensive no-op */ }
|
|
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();
|
|
// Apply theme runtime: show/hide theme-only elements, wire up white-mic
|
|
// button forwarders, unhide chat-form in white theme, etc. Must run after
|
|
// `s2sTheme` is set and the data-theme attr has been applied to html.
|
|
_applyThemeRuntime();
|
|
}
|
|
|
|
/**
|
|
* Spanish (Castilian) label map for the "Escuchando..." status caption below
|
|
* the white-theme blue mic circle. Mirrors every client state.
|
|
*/
|
|
const WHITE_STATUS_LABELS = Object.freeze({
|
|
idle: "Toca para empezar",
|
|
connecting: "Conectando…",
|
|
queued: "En cola…",
|
|
"your-turn":"Escuchando…",
|
|
processing: "Pensando…",
|
|
"ai-speaking":"Hablando…",
|
|
error: "Algo salió mal",
|
|
stopped: "Detenido",
|
|
});
|
|
|
|
let _themeRuntimeInitialized = false;
|
|
|
|
/**
|
|
* Apply runtime wiring for the current theme:
|
|
* 1. Flip [hidden] on every [data-theme-only="white" / "dark"] element in
|
|
* the document so the correct parallel UI is shown.
|
|
* 2. Remove the hardcoded `hidden + display:none !important` on #chat-form
|
|
* in white theme (we want the text input + send button visible, just
|
|
* like the screenshot input bar).
|
|
* 3. Click forwarders: #white-mic-circle → real #main-circle,
|
|
* #white-stop-btn → real #stop-btn.
|
|
* 4. MutationObserver on #main-circle `.className` to mirror every
|
|
* `state-*` class live to #white-mic-circle so it swaps its visuals
|
|
* (mic icon / stop icon / spinner) exactly in sync with the orb.
|
|
* 5. Wrap `setCaption()` so the Spanish label `#white-mic-caption` gets
|
|
* updated at the same time as the default orb caption.
|
|
* 6. Quick action button listeners: populate chat-input / or send via
|
|
* client.sendUserText() when a session is live.
|
|
* Called by fetchConfig() AFTER s2sTheme and <html data-theme> are set.
|
|
* Re-runs safely on every config fetch (guard `_themeRuntimeInitialized` for
|
|
* the one-shot observer / listeners).
|
|
*/
|
|
function _applyThemeRuntime() {
|
|
const isWhite = s2sTheme === "white";
|
|
|
|
// 1. Toggle visibility of every element tagged data-theme-only.
|
|
// Elements with `hidden` and `[data-theme-only="white"]` should be
|
|
// UN-hidden when theme === white; `hidden + dark-only` → UN-hidden when
|
|
// theme !== white (native dark). Any element not tagged is unchanged.
|
|
const onlyWhite = document.querySelectorAll("[data-theme-only=\"white\"]");
|
|
onlyWhite.forEach(el => {
|
|
if (isWhite) {
|
|
el.removeAttribute("hidden");
|
|
if (el.style && el.style.display === "none") el.style.display = "";
|
|
} else {
|
|
el.setAttribute("hidden", "");
|
|
}
|
|
});
|
|
const onlyDark = document.querySelectorAll("[data-theme-only=\"dark\"]");
|
|
onlyDark.forEach(el => {
|
|
if (isWhite) {
|
|
el.setAttribute("hidden", "");
|
|
} else {
|
|
el.removeAttribute("hidden");
|
|
if (el.style && el.style.display === "none") el.style.display = "";
|
|
}
|
|
});
|
|
|
|
// 2. Chat form input: in WHITE theme, we need the text input visible to
|
|
// match the screenshot. The HTML ships with `hidden` + inline
|
|
// `display:none !important` because the original dark theme hid it
|
|
// (per user request 2026-09-04). Clear both here, restore when leaving.
|
|
const chatForm = document.getElementById("chat-form");
|
|
if (chatForm) {
|
|
if (isWhite) {
|
|
chatForm.removeAttribute("hidden");
|
|
chatForm.style.removeProperty("display");
|
|
} else if (!chatForm.hasAttribute("hidden")) {
|
|
// Reapply original dark-theme defaults (preserve existing user edit).
|
|
chatForm.setAttribute("hidden", "");
|
|
chatForm.style.setProperty("display", "none", "important");
|
|
}
|
|
}
|
|
|
|
// One-shot wiring (never re-add listeners/observer on subsequent calls).
|
|
if (_themeRuntimeInitialized) return;
|
|
_themeRuntimeInitialized = true;
|
|
|
|
// 3a. Click forwarder: white mic circle → the real start/stop orb handler.
|
|
const whiteMic = document.getElementById("white-mic-circle");
|
|
const realCircle = document.getElementById("main-circle");
|
|
if (whiteMic && realCircle) {
|
|
whiteMic.addEventListener("click", (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
realCircle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
}
|
|
|
|
// 3b. Click forwarder: white "Detener" pill → hidden real stop side-btn.
|
|
const whiteStop = document.getElementById("white-stop-btn");
|
|
const realStop = document.getElementById("stop-btn");
|
|
if (whiteStop && realStop) {
|
|
whiteStop.addEventListener("click", (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
realStop.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
}
|
|
|
|
// 4. Live sync of #main-circle className → #white-mic-circle className
|
|
// (states: .state-idle, .state-connecting, .state-your-turn, .state-processing,
|
|
// .state-ai-speaking, .state-error, .state-queued). White-mic styles
|
|
// use these same state-* classes (CSS prefixes with .white-mic-circle.state-*)
|
|
// so we simply shadow every class on the orb button to the new button.
|
|
if (whiteMic && realCircle && typeof MutationObserver !== "undefined") {
|
|
const syncStateClasses = () => {
|
|
// Keep the base class + data-theme-only marker but clear/re-add the rest.
|
|
const tokens = Array.from(realCircle.classList);
|
|
for (const cl of Array.from(whiteMic.classList)) {
|
|
if (cl.startsWith("state-")) whiteMic.classList.remove(cl);
|
|
}
|
|
for (const cl of tokens) {
|
|
if (cl.startsWith("state-")) whiteMic.classList.add(cl);
|
|
}
|
|
// Also show/hide the white stop pill in sync with the real stop-btn
|
|
// (which is shown when connected).
|
|
if (whiteStop) {
|
|
const visible = !realStop.hasAttribute("aria-hidden") || realStop.getAttribute("aria-hidden") === "false";
|
|
if (visible) {
|
|
whiteStop.style.removeProperty("display");
|
|
whiteStop.removeAttribute("hidden");
|
|
} else {
|
|
whiteStop.setAttribute("hidden", "");
|
|
}
|
|
}
|
|
};
|
|
syncStateClasses();
|
|
const obs = new MutationObserver(() => syncStateClasses());
|
|
obs.observe(realCircle, { attributes: true, attributeFilter: ["class", "aria-hidden"] });
|
|
if (realStop) obs.observe(realStop, { attributes: true, attributeFilter: ["aria-hidden", "class"] });
|
|
|
|
// 5. Set the initial Spanish caption for theme=white + wrap setCaption()
|
|
// so any future setCaption() call also updates the Spanish label.
|
|
const capWhite = document.getElementById("white-mic-caption");
|
|
if (capWhite) {
|
|
capWhite.textContent = WHITE_STATUS_LABELS[currentState] || WHITE_STATUS_LABELS.idle;
|
|
const origSetCaption = setCaption;
|
|
// Re-assign the global (window / module scope var holds function ref) so
|
|
// each caller of setCaption(text, kind) now also drives the Spanish label.
|
|
// We intentionally DON'T replace the signature — old code keeps working.
|
|
(function (prev) {
|
|
window.setCaption = function (text, kind) {
|
|
prev(text, kind);
|
|
// When in white theme we prefer the Spanish state-based label from
|
|
// WHITE_STATUS_LABELS whenever possible, falling back to raw text.
|
|
if (s2sTheme === "white" && capWhite) {
|
|
const fromState = WHITE_STATUS_LABELS[currentState];
|
|
capWhite.textContent = fromState || text || WHITE_STATUS_LABELS.idle;
|
|
const muted = kind === "muted";
|
|
capWhite.classList.toggle("muted", !!muted);
|
|
capWhite.classList.toggle("error", kind === "error");
|
|
}
|
|
};
|
|
})(origSetCaption);
|
|
}
|
|
}
|
|
|
|
// 6. Quick action buttons: clicking "Resumen" / "Consultar datos" /
|
|
// "Crear respuesta" populates the text input or — if a realtime
|
|
// session is live — sends it as user text straight away.
|
|
document.querySelectorAll(".quick-action-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const action = btn.getAttribute("data-action") || "";
|
|
const prompts = {
|
|
"resumen": "haz un resumen ejecutivo del último informe programado",
|
|
"consultar-datos": "consulto los datos más recientes del dashboard, dame las cifras clave",
|
|
"crear-respuesta": "redacta un borrador de respuesta profesional con tono ejecutivo",
|
|
};
|
|
const text = prompts[action] || "";
|
|
if (!text) return;
|
|
if (client && typeof client.sendUserText === "function") {
|
|
// Live session: send directly as if the user typed + pressed enter.
|
|
client.sendUserText(text);
|
|
} else {
|
|
// No session: prefill the text input so they can send manually later.
|
|
const inp = document.getElementById("chat-input");
|
|
if (inp) {
|
|
inp.value = text;
|
|
inp.dispatchEvent(new Event("input", { bubbles: true }));
|
|
inp.focus({ preventScroll: false });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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");
|
|
});
|