refactor: standardize developer text and identifiers in English

This commit is contained in:
2026-10-05 10:13:25 -03:00
parent 6b8c27fd76
commit dfc1f663c7
20 changed files with 933 additions and 1142 deletions
+5
View File
@@ -16,6 +16,11 @@ the implementation at the recorded date; current operating instructions live in
the existing document URLs as redirects.
- Removed the obsolete `index_video.html` page, which no longer matched the
elements required by the shared client script.
- Translated developer comments, diagnostics, internal model instructions, and
local identifiers into English. Sanitization accepts both the former Spanish
and new English internal tool-result wrappers.
- Renamed the reasoning engine's log-only acknowledgment field to `received`
and its debug phase to `log_only_phase1`.
## 2026-09-25
+19 -20
View File
@@ -18,8 +18,7 @@
BEFORE the <link rel="stylesheet" href="style.css"> tag below.
Problem reported by user 2026-09-10:
"quando faccio refresh per un momento si vede il theme precedente
quello purple"
"When I refresh, the previous purple theme briefly appears."
The earlier guard `html:not([data-theme]) [data-theme-only]` only
hid the two parallel widget branches, but the UNTHEMED DEFAULTS in
@@ -179,7 +178,7 @@
<a class="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">Enviar feedback</a>
<!-- Theme=white only: Status card between topbar and stage
"Listo para conversar / Informes, análisis, datos y mucho más."
Ready to converse / Reports, analysis, data and more.
with light-blue chart icon and chevron-right arrow. -->
<div class="status-card" hidden data-theme-only="white" aria-label="Estado de la sesión">
<div class="status-card-icon" aria-hidden="true">
@@ -265,8 +264,8 @@
<!-- WHITE THEME mic stage (replaces the purple orb)
- Blue mic circular button (116 px, deep blue, white mic)
- Two waveform banks left + right (audio-reactive CSS bars)
- "Escuchando..." status text
- "Detener" pill button below.
- Listening status text
- Stop pill button below.
Mirrors the screenshot center UI exactly. -->
<div class="white-mic-stage" hidden data-theme-only="white" aria-label="Control de voz">
<!-- Waveform left side: 7 bars mirror right side, heights driven by JS vars -->
@@ -311,10 +310,10 @@
<span class="w-bar wb-7" style="--bar-h: 0.4"></span>
</div>
<!-- Status caption below mic circle: "Escuchando...", "Pensando..." etc. -->
<!-- Status caption below mic circle: listening, thinking, etc. -->
<p id="white-mic-caption" class="white-mic-caption" role="status">Escuchando…</p>
<!-- Secondary pill "Detener" (Stop) below caption, with dark stop-square icon. -->
<!-- Secondary stop pill below caption, with a dark stop-square icon. -->
<button id="white-stop-btn"
class="white-stop-btn"
type="button"
@@ -338,10 +337,10 @@
</div>
</main>
<!-- Theme=white only: Quick action pills below mic area
"Resumen" · "Consultar datos" · "Crear respuesta" each with icon -->
<!-- Theme=white only: summary, data lookup and draft-response quick
actions below the mic area, each with an icon. -->
<div class="quick-actions" hidden data-theme-only="white" aria-label="Acciones rápidas">
<button class="quick-action-btn" type="button" data-action="resumen">
<button class="quick-action-btn" type="button" data-action="summary">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
@@ -350,7 +349,7 @@
</svg>
<span>Resumen</span>
</button>
<button class="quick-action-btn" type="button" data-action="consultar-datos">
<button class="quick-action-btn" type="button" data-action="query-data">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="6" y1="20" x2="6" y2="12"/>
<line x1="11" y1="20" x2="11" y2="6"/>
@@ -359,7 +358,7 @@
</svg>
<span>Consultar datos</span>
</button>
<button class="quick-action-btn" type="button" data-action="crear-respuesta">
<button class="quick-action-btn" type="button" data-action="draft-response">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="2" x2="22" y2="6"/>
<path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"/>
@@ -368,15 +367,15 @@
</button>
</div>
<!-- Chat di testo SEMPRE VISIBILE (fissa in basso, tra stage e footer).
Nota: ora è un blocco diretto del flex-col #app, non più un overlay
destro → non c'è più bisogno del bottone toggle in topbar. -->
<!-- Text chat ALWAYS VISIBLE, fixed between the stage and footer.
The panel is now a direct child of the flex-column #app, so it
no longer needs a topbar toggle for a right-side overlay. -->
<div id="chat-panel" class="chat-panel chat-panel-inline" aria-label="Chat de texto">
<div class="chat-panel-inner">
<header class="chat-panel-header">
<h3>Conversación</h3>
<div class="chat-header-actions" aria-label="Acciones de la conversación">
<!-- "En línea" status pill (green dot + text) — theme=white only.
<!-- Online status pill (green dot + text) — theme=white only.
Mirrors the screenshot chat header top-right corner. -->
<span class="chat-status-pill" hidden data-theme-only="white" role="status" aria-live="polite">
<span class="chat-status-dot" aria-hidden="true"></span>
@@ -428,14 +427,14 @@
<div id="bubble-stack" class="bubble-stack" aria-live="polite" aria-atomic="false"></div>
<!-- Webcam self-view, shown bottom-left while the camera tool is enabled.
HIDDEN BY DEFAULT TODAY: riattivare togliendo hidden + scommentando listener in main.js -->
HIDDEN BY DEFAULT TODAY: remove hidden and restore the main.js listener to reactivate. -->
<div id="cam-pip" class="cam-pip" aria-hidden="true" hidden>
<video id="cam-video" class="cam-video" autoplay playsinline muted></video>
<span class="cam-flash" aria-hidden="true"></span>
<span class="cam-label">camera</span>
</div>
<!-- Left sidebar: reports programados.
<!-- Left sidebar: scheduled reports.
Desktop: fixed left, 320px, always visible (below topbar + above sticky footer).
Mobile : off-canvas left overlay, toggled by #reports-sidebar-toggle button in topbar. -->
<aside id="reports-sidebar" class="reports-sidebar" aria-label="Reportes programados" hidden data-theme-only="dark">
@@ -615,8 +614,8 @@
</label>
<!-- Camera tool row — HIDDEN BY DEFAULT TODAY.
Per riattivarlo in futuro: togli hidden da div + small,
scommenta listener toolCamSwitch + funzioni enableCamera in main.js. -->
To reactivate later, remove hidden from the div and small,
then restore the toolCamSwitch listener and enableCamera functions in main.js. -->
<div class="tool-row tool-row-sep" id="tool-cam-row" hidden>
<div class="tool-info">
<span class="tool-name">Camera</span>
+144 -163
View File
@@ -22,36 +22,35 @@ 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.
// 🔎 DEBUG BANNER (always-on): identify the exact main.js version loaded.
// If this line is missing from the console, the browser is using an older cached file.
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).",
"%c🟢 VOCERO main.js loaded. version=2026-09-24 23:12 (ALWAYS-ON FETCH WRAPPER+WS DEBUG; APIPILOT: demo flag=true + AUTOMATIC PREFETCH FOR EVERY WS SESSION + PREFETCH AFTER PHONE LOGIN (refresh=true invalidates the anonymous server cache); per-user client cache; phone changes invalidate automatically; chat export; scroll fix).",
"background:#1e293b;color:#86efac;font-weight:700;padding:4px 8px;border-radius:4px;"
);
console.info(
" URL attuale location.href=", location.href,
" Current URL 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
// debugger; // Uncomment to pause here at startup when DevTools is open.
// ---------------------------------------------------------------------------
// 🔎 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 WRAPPER (SEMANTIC DEBUG LOG) — always active, including outside DEBUG.
// Every browser fetch is traced with:
// • resolved URL, method, body size and JSON preview
// • HTTP status (green/yellow/red according to status class)
// • duration (performance.now), response size and JSON preview
// Semantic tags for filtering the 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).
// Classify endpoints with readable tags, following the route order in server.py.
/** @param {string} url */
function _tagFor(url) {
try {
@@ -108,13 +107,13 @@ console.info(
/** @type {BodyInit | null | undefined} */
let body = init ? init.body : undefined;
if (body === undefined || body === null) {
// tenta Request
// Try the Request body.
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)" };
if (body == null) return { length: 0, preview: "(no body)" };
try {
if (typeof body === "string") {
const s = body;
@@ -138,7 +137,7 @@ console.info(
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)" };
return { length: 0, preview: "(body cannot be serialized for logging)" };
} catch (err) {
return { length: -1, preview: `(err preview body: ${err})` };
}
@@ -178,9 +177,8 @@ console.info(
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.
// Log frequent heartbeat/end polling requests on one line without a body
// preview to keep the console readable.
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) {
@@ -245,18 +243,18 @@ console.info(
return response;
};
console.info(
"%c🔎 [FETCH-DEBUG] tracer installato. Ogni fetch è loggata con tag, method, url, status, durata e preview body.",
"%c🔎 [FETCH-DEBUG] tracer installed. Every fetch logs its tag, method, URL, status, duration and body preview.",
"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"
// see index.html) — 3-layer safety:
// 1) static HTML → 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.
// 3) JS here → hidden, remove data-debug-enabled, set style.display = "none"
// Enable all debug controls only when the URL contains ?debug=true (strict string).
function applyDebugOnlyVisibilityGate(source = "bootstrap") {
const qs = new URLSearchParams(window.location.search);
const debugEnabled = qs.get("debug") === "true";
@@ -265,64 +263,61 @@ function applyDebugOnlyVisibilityGate(source = "bootstrap") {
"%c🧭 [gate-topbar-right] applyDebugOnlyVisibilityGate()",
"color:#f59e0b;font-weight:700;"
);
console.log("chiamato da:", source);
console.log("called from:", 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!)");
console.log("topbarRight element found?", !!topbarRight, topbarRight ? "see →" : "(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") : "-");
console.log("#chat-panel (inline chat ALWAYS VISIBLE at the bottom) present 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);
console.log(" hidden attr before:", topbarRight.hasAttribute("hidden"),
"| data-debug-enabled before:", topbarRight.getAttribute("data-debug-enabled"),
"| style.display before:", 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;");
console.log("%c ✅ ENABLED .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)",
console.log("%c 🚫 DISABLED .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);
console.log(" hidden attr after:", topbarRight.hasAttribute("hidden"),
"| data-debug-enabled after:", topbarRight.getAttribute("data-debug-enabled"),
"| style.display after:", 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).
// Apply the 3-layer gate to every element marked data-debug-only="true",
// including .topbar-right, #reports-sidebar-toggle and future controls.
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}`,
`Found ${debugOnlyEls.length} elements [data-debug-only="true"] to gate using 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 : "-")}`);
console.log(` [${idx + 1}/${debugOnlyEls.length}] ${tag} — hidden before? ${el.hasAttribute("hidden")} | display before: ${(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.
// Preserve elements hidden for other reasons, such as an empty reports
// sidebar. Remove only the debug-only display override.
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.log(` → debugEnabled=${debugEnabled} → hidden after? ${el.hasAttribute("hidden")} | display after: ${el.style.display || "(inherit)"}`);
});
console.groupEnd();
return debugEnabled;
}
// Esegui SUBITO al bootstrap del modulo (prima di qualsiasi listener)
// Run immediately during module bootstrap, before registering listeners.
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)
// Run again after DOMContentLoaded in case the script runs before the header is parsed.
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
applyDebugOnlyVisibilityGate("DOMContentLoaded listener");
@@ -330,7 +325,7 @@ if (document.readyState === "loading") {
} else {
applyDebugOnlyVisibilityGate("module bootstrap (DOM already parsed)");
}
// Riesegui anche a fine frame dopo un breve timeout → ultima chance.
// Run again after a short timeout as a final fallback.
setTimeout(() => applyDebugOnlyVisibilityGate("10ms timeout fallback"), 10);
window.addEventListener("hashchange", () => applyDebugOnlyVisibilityGate("hashchange"), { passive: true });
window.addEventListener("popstate", () => applyDebugOnlyVisibilityGate("popstate"), { passive: true });
@@ -339,12 +334,12 @@ 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. " +
"Respond ALWAYS and ONLY in Spanish, 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.";
"If you don't know the answer, say so in Spanish instead of guessing.";
const OPENAI_RENDER_MARKER = "[VOCERO_RENDER_REPLY]";
const OPENAI_RENDER_HINT =
@@ -1314,13 +1309,13 @@ function syncVoceroHeaderUser() {
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("mortalidad") || l.includes("riesgo") || l.includes("kpi")) return "mortality";
if (l.includes("métrica") || l.includes("metricas") || l.includes("medici")) return "metrics";
if (l.includes("diario") || l.includes("diaria") || l.includes("informe")) return "daily";
if (l.includes("producto") || l.includes("servicio")) return "products";
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";
if (l.includes("financiero") || l.includes("finanzas") || l.includes("costo") || l.includes("presupuesto") || l.includes("venta")) return "finance";
return "";
};
for (const e of entries) {
@@ -1330,7 +1325,7 @@ function syncVoceroHeaderUser() {
li.textContent = String(e.sourceScheduleLabel);
ul.appendChild(li);
}
// Build dynamic suggestion prompts (en español) based on the actual labels so
// Build dynamic suggestion prompts in Spanish 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[]} */ ([]);
@@ -1374,20 +1369,20 @@ function syncVoceroHeaderUser() {
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
// debugger; // Uncomment to pause inside every syncVoceroHeaderUser call.
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("all 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);
console.log("count (unique labels) =", count, "| wrappedLen =", totalLen);
console.log("suggestions array length (before debug gate) =", 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.log("👉 FINAL DECISION debugMode =", debugMode, " → CREATE suggestions section? ", debugMode ? "✅ YES" : "❌ NO");
console.groupEnd();
let sugSection = null;
@@ -1406,10 +1401,10 @@ function syncVoceroHeaderUser() {
}
sugSection.appendChild(sugTitle);
sugSection.appendChild(sugUl);
console.log("%c[reports-ui] sezione suggerimenti CREATA (debugMode=true)", "color:#22c55e;font-weight:700;",
console.log("%c[reports-ui] suggestions section CREATED (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)",
console.log("%c[reports-ui] suggestions section NOT CREATED (debugMode=false, no ?debug=true)",
"color:#ef4444;font-weight:700;");
}
@@ -1453,7 +1448,7 @@ function syncVoceroHeaderUser() {
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",
"%c[reports-ui] toggle reports-sidebar-toggle: hidden? BEFORE=%s | debug=%s",
"color:#f59e0b;font-weight:700;",
reportsSidebarToggle.hasAttribute("hidden"),
debugParam,
@@ -1464,7 +1459,7 @@ function syncVoceroHeaderUser() {
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)",
console.info("%c[reports-ui] 🚫 reports sidebar toggle FORCED HIDDEN (no debug=true)",
"background:#fee2e2;color:#b91c1c;font-weight:700;padding:3px 6px;border-radius:4px;");
}
}
@@ -1476,10 +1471,10 @@ function syncVoceroHeaderUser() {
// eslint-disable-next-line no-console
console.info(
"%c[reports-ui] FINE RENDER. reportsHint.querySelectorAll('.reports-suggestions').length =",
"%c[reports-ui] RENDER COMPLETE. reportsHint.querySelectorAll('.reports-suggestions').length =",
"color:#a855f7;font-weight:700;",
reportsHint.querySelectorAll(".reports-suggestions").length,
"| children nel blocco:", reportsHint.children.length,
"| children in block:", 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"
@@ -1513,21 +1508,19 @@ function effectiveReasoningInstructions() {
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).
// 2026-09-25 INJECT API-PILOT DYNAMIC TOOLS (PASS 1).
// Match the server.py format: <<<APIPILOT_TOOLS_LIST_BEGIN>>>, followed by
// name+description+requires_auth+required_parameters and the closing marker.
// Fetch tools through GET api/apipilot/tools (240 s per-user client cache in
// s2s-ws-client.js) and append them directly to the LLM instructions.
// Deduplicate by marker when a previous call or the Python server already
// injected the list, avoiding duplicate or oversized prompt blocks.
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.
// @ts-ignore: exported globally by s2s-ws-client.js, loaded before main.js.
// Check typeof before calling the optional helper.
const getter = typeof window !== "undefined" && window.__s2sGetApipilotPass1ContextInfo;
if (typeof getter === "function") {
const info = getter();
@@ -1544,7 +1537,7 @@ function effectiveReasoningInstructions() {
}
}
} catch (_) {
/* never-fail: non bloccare il resto dell'istruzione prompt. */
/* Never block the rest of the instructions prompt. */
}
const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${apipilotAppended ? "apipilotY" : "apipilotN"}:${(base || "").length}`;
_logOnce(key, () => {
@@ -1701,7 +1694,7 @@ async function fetchOpenAiReasoningAnswer(transcript) {
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.
// Log-only mode may return answer=""; that is not an error.
if (!answer && !log_only) throw new Error("reasoning returned no answer");
return { answer, log_only, raw: json };
}
@@ -1732,10 +1725,10 @@ async function maybeRunOpenAiReasoning(transcript, itemId) {
});
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.
// ── LOG-ONLY MODE (PHASE 1) ────────────────────────────────────────────
// Node-RED only logs the request and returns log_only=true.
// Preserve the backend response without injecting text or speaking.
// The native S2S backend remains responsible for the entire turn.
if (result && result.log_only) {
reportDebug("R", "main.js:maybeRunOpenAiReasoning", "external reasoning in LOG_ONLY mode — pass through to native S2S backend", {
itemId,
@@ -1749,9 +1742,9 @@ async function maybeRunOpenAiReasoning(transcript, itemId) {
);
return;
}
// ── MODE STANDARD (fasi successive) ────────────────────────────────────
// Il reasoning esterno vince: annulla la risposta nativa, inserisci il
// testo dell'assistente e parla via SpeechSynthesis.
// ── STANDARD MODE (LATER PHASES) ──────────────────────────────────────
// External reasoning takes priority: cancel the native response, insert
// the assistant text and speak through SpeechSynthesis.
const answer = (result && result.answer) || "";
if (!answer) {
console.warn(`[Vocero REASONING] empty non-log-only answer itemId=${JSON.stringify(itemId)}`);
@@ -2461,16 +2454,16 @@ async function fetchVoceroUserProfile() {
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.
// ── 🚀 PREFETCH ApiPilot IMMEDIATELY AFTER SUCCESSFUL PHONE LOGIN
// Force refresh=true to bypass the S2S proxy server cache (TTL 300 s)
// now that the caller is authenticated rather than anonymous.
// AquaPortal logs [apipilot] list_tools.start user=(non-anon) after
// every successful phone login.
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)",
"%c🔧 [APIPILOT-CLIENT] post-login phone → FORCE refresh api/apipilot/tools (bypass the 300 s S2S cache for the newly authenticated user)",
"background:#ede9fe;color:#5b21b6;font-weight:700;padding:2px 6px;border-radius:4px;"
);
const demoToken = typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
@@ -2485,24 +2478,23 @@ async function fetchVoceroUserProfile() {
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)}`);
if (!ok) throw new Error(`invalid envelope 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).
// Invalidate the WS client cache so the next session refreshes it.
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)"}]`,
`%c🔧 [APIPILOT-CLIENT] post-login OK (dt=${dt}ms): N=${count} tools [first 6: ${names || "(none)"}]`,
"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`,
`%c🔧 [APIPILOT-CLIENT] post-login FAIL (dt=${dt}ms): ${String(err instanceof Error ? err.message : err)} → check [APIPILOT-DISCOVERY] in the S2S server logs`,
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;"
);
});
@@ -2618,10 +2610,9 @@ async function fetchConfig() {
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.
// Feature flag for the server-side ApiPilot AquaPortal bridge.
// True means S2S_APIPILOT_TOOLS_URL is configured and the frontend can
// use /api/apipilot/tools and /api/apipilot/execute.
const apipilotEnabled = !!json.apipilot;
if (typeof window.__s2sApipilot === "undefined") {
try { Object.defineProperty(window, "__s2sApipilot", { value: false, writable: true, configurable: true }); }
@@ -2672,16 +2663,15 @@ async function fetchConfig() {
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
// ── ApiPilot demo call (fire-and-forget, only when the server flag is true)
// Trigger the browser fetch tracer to show [FETCH:APIPILOT:TOOLS] with
// status, duration and a tool-list preview. The server proxies to
// 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)",
"%c🔧 [APIPILOT-CLIENT] flag=true → DEMO CALL %s (always-on tracer logs [FETCH:APIPILOT:TOOLS] below)",
"background:#e0e7ff;color:#3730a3;font-weight:700;padding:2px 6px;border-radius:4px;",
demoUrl
);
@@ -2693,15 +2683,15 @@ async function fetchConfig() {
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",
"%c🔧 [APIPILOT-CLIENT] demo OK: %s tools [first 5: %s] use POST api/apipilot/execute {name,arguments} to execute",
"background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;",
count,
names || "(nessuno)"
names || "(none)"
);
})
.catch((err) => {
console.warn(
"%c🔧 [APIPILOT-CLIENT] demo FAIL: %s. Lato server logga [APIPILOT-DISCOVERY] per diagnosticare",
"%c🔧 [APIPILOT-CLIENT] demo FAIL: %s. Check [APIPILOT-DISCOVERY] server logs to diagnose",
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;",
String(err)
);
@@ -2903,16 +2893,16 @@ function _applyThemeRuntime() {
}
}
// 6. Quick action buttons: clicking "Resumen" / "Consultar datos" /
// "Crear respuesta" populates the text input or — if a realtime
// 6. Quick action buttons for summaries, data lookup and response drafts
// populate the text input or — if a realtime
// session is live — sends it as user text straight away.
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",
"summary": "haz un resumen ejecutivo del último informe programado",
"query-data": "consulto los datos más recientes del dashboard, dame las cifras clave",
"draft-response": "redacta un borrador de respuesta profesional con tono ejecutivo",
};
const text = prompts[action] || "";
if (!text) return;
@@ -3480,8 +3470,8 @@ function onClientStatus(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.
// Hide the talk unlocker immediately if it is still visible, for
// example when the user taps the orb before the 2 s warmup ends.
hideTalkUnlocker();
setState("connecting");
break;
@@ -3575,19 +3565,16 @@ function onFatalError(err) {
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.
// Schedule TALK / TALKFIRST auto-start (S2S_START_SESSION_MODE in .env):
// • "TALK" → show the unlocker after 2 s. Browser policy requires a
// gesture for getUserMedia(). A click/touch/key starts
// doStart(), then WS startWithResponse injects hola vocero
// through sendUserText+requestResponse, like a wake word.
// • "TALKFIRST" → try doStart() directly from the timer after 2 s with
// pendingWakeWordConfirmationText already set. If mic
// permission is granted, the simulated wake word triggers
// without a click. Permission/gesture failures on Safari
// or iOS fall back to the TALK unlocker.
let autoStartTalkTimerId = 0;
let _talkUnlockerShown = false;
function hideTalkUnlocker() {
@@ -3599,11 +3586,9 @@ 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.
// Show the unlocker only while idle/error, before a session starts.
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")
// Explain the microphone permission requirement when TALKFIRST falls back.
const subEl = el.querySelector(".talk-unlocker-sub");
const titleEl = el.querySelector(".talk-unlocker-title");
if (fallbackHint) {
@@ -3613,20 +3598,20 @@ function showTalkUnlocker(fallbackHint = false) {
el.removeAttribute("hidden");
_talkUnlockerShown = true;
console.info(
"%c[start-session] ▶️ talk-unlocker mostrato (%s): attende gesture utente",
"%c[start-session] ▶️ talk-unlocker shown (%s): waiting for a user gesture",
"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).
// Clear any previous timer if this function was unexpectedly called twice.
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)`,
`%c[start-session] S2S_START_SESSION_MODE=${startSessionMode}: action in 2000 ms (profile/reports/episode warmup)`,
"color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;"
);
autoStartTalkTimerId = window.setTimeout(() => {
@@ -3638,49 +3623,45 @@ function maybeScheduleAutoStart() {
}
}, 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. */
/** Attempt direct TALKFIRST auto-start without a gesture. Simulate a wake word
* by setting pendingWakeWordConfirmationText="hola vocero", as the real speech
* recognizer does, then call doStart(). Existing permissions may allow this
* without a click; otherwise fall back to the unlocker. */
async function tryTalkFirstDirect() {
if (!(currentState === "idle" || currentState === "error")) return;
if (missingServerUrl()) {
console.warn("[start-session] skip TALKFIRST: manca server URL");
console.warn("[start-session] skip TALKFIRST: missing 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.
// Prepare the simulated wake word. When onClientStatus receives "connected",
// inject it so the model responds as if the user had spoken it.
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)`,
`%c[start-session] 🎤 TALKFIRST: set simulated wake word=${JSON.stringify(pendingWakeWordConfirmationText)} and try doStart() directly (WITHOUT a 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).
// Let _ensureAudioContext create the context. If it stays suspended, the
// client resumes it on the first audio or the next user click.
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".
console.warn("[start-session] ⚠️ direct TALKFIRST doStart() FAILED → fallback unlocker", err && err instanceof Error ? err.message : err);
// Show the unlocker with the microphone permission explanation.
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).
// Preserve the simulated phrase so starting through the unlocker still
// injects it on connection and triggers the greeting.
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
// Register unlocker listeners once: click + touchstart unlock audio.
(() => {
const el = document.getElementById("talk-unlocker");
if (!el) return;
@@ -3692,28 +3673,28 @@ async function tryTalkFirstDirect() {
window.addEventListener("keydown", (ev) => {
if (startSessionMode !== "TALK") return;
if (!_talkUnlockerShown) return;
// Qualsiasi tasto = unlock (Enter/Space/anche Altri)
// Any key unlocks audio, including Enter and Space.
startTalkFromGesture();
}, { passive: true });
})();
/** Avvia doStart() dentro una gesture valida (click/touch/key).
Chiamato dal talk-unlocker o da qualsiasi altro unlocker valido. */
/** Start doStart() within a valid click/touch/key gesture.
* Called by the talk unlocker or another valid audio unlocker. */
async function startTalkFromGesture() {
hideTalkUnlocker();
if (!(currentState === "idle" || currentState === "error")) return;
if (missingServerUrl()) {
console.warn("[start-session] skip unlocker: manca server URL (missingServerUrl=true)");
console.warn("[start-session] skip unlocker: missing 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)");
// This user gesture permits AudioContext and getUserMedia to start.
console.info("[start-session] 🎤 start doStart() from unlocker gesture (TALK mode)");
const audioContext = createResumedAudioContext();
setCaption("Preparing your session…", "muted");
await doStart(audioContext);
} catch (err) {
console.error("[start-session] ⛔ doStart() da unlocker fallito", err);
console.error("[start-session] ⛔ doStart() from unlocker failed", err);
void handleStartError(err);
}
}
+508 -670
View File
File diff suppressed because it is too large Load Diff
+38 -40
View File
@@ -143,11 +143,10 @@ body::after {
}
/* ─── Overlay unlocker TALK mode (S2S_START_SESSION_MODE=TALK) ───
Cattura QUALSIASI primo tap/click dell'utente sulla pagina, perché
i browser bloccano getUserMedia() fuori da una user gesture. Senza
questo layer il doStart() del setTimeout falliva e non si sentiva
il greet. Sopra TUTTO tranne i modali <dialog> (z-index auto di dialog
vince). */
Capture the first tap/click anywhere on the page because browsers block
getUserMedia() outside a user gesture. Without this layer, doStart() from
setTimeout failed and the greeting was silent. Above all content except
<dialog> modals, whose top layer takes priority. */
.talk-unlocker {
position: fixed;
inset: 0;
@@ -466,7 +465,7 @@ a:hover {
transform: translateY(-0.5px);
}
/* ── User avatar circle (instead of old "Utente: Nome" text) ──
/* ── User avatar circle (replaces the old user-name text) ──
* - perfectly round badge, matches the 42px icon-btn size in the topbar
* - glass + brand gradient 1px border
* - two uppercase initials (Alessandro Valenti → AV), bold white
@@ -593,8 +592,8 @@ a:hover {
height: 36px;
padding: 0 12px;
}
/* ── Footer (narrow viewport): riduci tracking per non farlo andare
* a capo in malo modo; padding compatto + line-height maggiore */
/* ── Footer (narrow viewport): reduce tracking to avoid awkward wrapping;
* use compact padding and a taller line height. */
.app-footer {
padding: 8px 14px 14px;
}
@@ -2425,7 +2424,7 @@ body.cam-on .footer {
`margin-right:22px` on `.hist-msg.assistant` and `margin-left:6px` on `.hist-msg.user`
(hard rules on the bubbles themselves so nothing can hide them).
Bottom padding BUMP 2026-09-04: 96 → 160px. The screenshot showed the last
assistant line "...Necesitas más detalles o" cut clean by the footer. 160px
assistant line asking for more details cut cleanly by the footer. 160px
inside the scroll container is 5× the real footer glass height; after we
snap scrollTop to the absolute max, the last bubble terminates at the TOP
of a 160px-high transparent region → fully visible, fully above the floating
@@ -2460,7 +2459,7 @@ body.cam-on .footer {
background-clip: content-box;
}
/* ─── Chat input row (bottom of chat panel: "sobre el botón") ──────── */
/* ─── Chat input row (bottom of chat panel, above the button) ──────── */
/* NOTE: currently HIDDEN in index.html. Kept seamless here so if/when we
re-enable it the form melts visually into the page bg, no separator frame. */
.chat-form {
@@ -3474,20 +3473,20 @@ button[data-reports-sidebar-toggle] {
flex: none;
}
/* Per-chip tint cycles (keyword-based, if no data attribute → fallback brand) */
.reports-hint .reports-list li[data-tint="mortalidad"] { background: linear-gradient(180deg, color-mix(in srgb, #ff6a75 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #ff6a75 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="mortalidad"]::before { background: #ff6a75; box-shadow: 0 0 8px 0 #ff6a75aa; }
.reports-hint .reports-list li[data-tint="metricas"] { background: linear-gradient(180deg, color-mix(in srgb, #22d3ee 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #22d3ee 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="metricas"]::before { background: #22d3ee; box-shadow: 0 0 8px 0 #22d3eeaa; }
.reports-hint .reports-list li[data-tint="diario"] { background: linear-gradient(180deg, color-mix(in srgb, #8b5cf6 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #8b5cf6 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="diario"]::before { background: #8b5cf6; box-shadow: 0 0 8px 0 #8b5cf6aa; }
.reports-hint .reports-list li[data-tint="productos"] { background: linear-gradient(180deg, color-mix(in srgb, #ec4899 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #ec4899 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="productos"]::before { background: #ec4899; box-shadow: 0 0 8px 0 #ec4899aa; }
.reports-hint .reports-list li[data-tint="mortality"] { background: linear-gradient(180deg, color-mix(in srgb, #ff6a75 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #ff6a75 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="mortality"]::before { background: #ff6a75; box-shadow: 0 0 8px 0 #ff6a75aa; }
.reports-hint .reports-list li[data-tint="metrics"] { background: linear-gradient(180deg, color-mix(in srgb, #22d3ee 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #22d3ee 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="metrics"]::before { background: #22d3ee; box-shadow: 0 0 8px 0 #22d3eeaa; }
.reports-hint .reports-list li[data-tint="daily"] { background: linear-gradient(180deg, color-mix(in srgb, #8b5cf6 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #8b5cf6 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="daily"]::before { background: #8b5cf6; box-shadow: 0 0 8px 0 #8b5cf6aa; }
.reports-hint .reports-list li[data-tint="products"] { background: linear-gradient(180deg, color-mix(in srgb, #ec4899 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #ec4899 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="products"]::before { background: #ec4899; box-shadow: 0 0 8px 0 #ec4899aa; }
.reports-hint .reports-list li[data-tint="synapse"] { background: linear-gradient(180deg, color-mix(in srgb, #fbbf24 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #fbbf24 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="synapse"]::before { background: #fbbf24; box-shadow: 0 0 8px 0 #fbbf24aa; }
.reports-hint .reports-list li[data-tint="supply"] { background: linear-gradient(180deg, color-mix(in srgb, #34d399 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #34d399 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="supply"]::before { background: #34d399; box-shadow: 0 0 8px 0 #34d399aa; }
.reports-hint .reports-list li[data-tint="finanzas"] { background: linear-gradient(180deg, color-mix(in srgb, #22c55e 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #22c55e 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="finanzas"]::before { background: #22c55e; box-shadow: 0 0 8px 0 #22c55eaa; }
.reports-hint .reports-list li[data-tint="finance"] { background: linear-gradient(180deg, color-mix(in srgb, #22c55e 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #22c55e 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="finance"]::before { background: #22c55e; box-shadow: 0 0 8px 0 #22c55eaa; }
.reports-hint .reports-list li:hover {
transform: translateY(-1px) scale(1.01);
border-color: color-mix(in srgb, var(--brand) 55%, var(--border-strong));
@@ -4055,10 +4054,10 @@ button[data-reports-sidebar-toggle] {
opacity: 0.5;
}
/* ─── Webcam preview (camera tool) — HIDDEN BY DEFAULT OGGI ──────────────────
/* ─── Webcam preview (camera tool) — CURRENTLY HIDDEN BY DEFAULT ─────────────
* Floating self-view, bottom-left. Mirrored for the user; the frame sent to
* the model is drawn un-mirrored. Markup esiste per riattivazione futura.
* Per usarlo: togli hidden su cam-pip in index.html + scommenta listener in main.js. */
* the model is drawn un-mirrored. Markup is retained for future reactivation.
* To enable it, remove hidden from cam-pip in index.html and restore the main.js listener. */
.cam-pip {
position: fixed;
left: 20px;
@@ -4285,12 +4284,12 @@ html[data-theme="white"] .app-column {
margin-left: 0 !important;
}
/* ─────────────────────────────────────────────────────────────────────
USER FIX 2026-09-10 "la chat deve apparire solo nel chat box":
USER FIX 2026-09-10: show the conversation only inside the chat box.
The dark theme renders a SECOND ephemeral transcript into #bubble-stack
(floating anchored top-right — the grey/light blue bubbles the user
circled in red with an X on the last screenshot). Since the white
mobile theme shows ALL conversation exclusively inside the persistent
"Conversación" card (chat-history inside .chat-panel bounded 58dvh),
conversation card (chat-history inside .chat-panel bounded 58dvh),
we kill the parallel floating bubble stack HARD in white theme — both
the ID and the class, zero width/height/transform so it can't leave
layout artifacts. */
@@ -4543,8 +4542,8 @@ html[data-theme="white"] .topbar-right {
display: none;
}
/* ── Status card: "Listo para conversar / Informes, análisis..." ───────
User request 2026-09-10 "nascondi box listo para conversar" — hide the
/* ── Status card: ready to converse, reports and analysis ─────────────
User request 2026-09-10: hide the ready-to-converse box. Hide the
entire status card in white theme (the grey rounded banner with the
chart icon and arrow between topbar and mic stage, which the user
circled with a red X). Previously we hid only the right chevron; now
@@ -4820,7 +4819,7 @@ html[data-theme="white"] .white-stop-btn svg {
flex: none;
}
/* ── Quick actions — 3 pills (Resumen / Consultar datos / Crear respuesta).
/* ── Quick actions — 3 pills (summary / data lookup / response draft).
User request 2026-09-10 "nascondi le cose in rosso": all 3 quick action
pills are circled in red → hide the entire quick-actions container. */
html[data-theme="white"] .quick-actions {
@@ -4830,8 +4829,8 @@ html[data-theme="white"] .quick-action-btn {
display: none !important;
}
/* ── Chat panel (Conversación card): white rounded card + "En línea" pill.
CRITICAL SCROLL FIX 2026-09-10 (per "non fa lo scrolling della chatbox"):
/* ── Chat panel: white rounded conversation card + online status pill.
CRITICAL SCROLL FIX 2026-09-10: the chat box was not scrolling.
The previous white-theme override set `flex: 0 0 auto` + `max-height: none`
which removed every vertical constraint on the chat panel → it grew beyond
the viewport edge → overflow on the *page* body instead of the chat
@@ -4912,7 +4911,7 @@ html[data-theme="white"] .chat-panel-header {
border-bottom-width: 1px !important;
text-decoration: none !important;
}
/* "En línea" status pill — green dot + text */
/* Online status pill — green dot + text */
html[data-theme="white"] .chat-status-pill {
display: inline-flex;
align-items: center;
@@ -4940,10 +4939,9 @@ html[data-theme="white"] .chat-status-dot {
50% { box-shadow: 0 0 0 5px rgba(22, 163, 74, 0.08); }
}
/* ── Chat header actions row: contiene "En línea" status pill + Export button.
Aggiunto 2026-09-23: bottone download conversación.
Layout orizzontale, allineato a destra, auto-wrap con gap per evitare
overflow su schermi stretti. */
/* ── Chat header actions row: online status pill + conversation export button.
Added 2026-09-23. Horizontal, right-aligned layout with wrapping and gaps
to avoid overflow on narrow screens. */
.chat-header-actions {
display: inline-flex;
align-items: center;
@@ -4953,7 +4951,7 @@ html[data-theme="white"] .chat-status-dot {
flex-wrap: wrap;
min-width: 0;
}
/* Botón export conversación (icon button baseline: coerente con altri .icon-btn). */
/* Conversation export button, consistent with other .icon-btn controls. */
.chat-export-btn {
position: relative;
display: inline-flex;
@@ -4990,14 +4988,14 @@ html[data-theme="white"] .chat-status-dot {
pointer-events: none;
flex: 0 0 auto;
}
/* Feedback OK: verde temporáneo al exportar correctamente. */
/* Successful export feedback: temporarily green. */
.chat-export-btn.chat-export-ok {
background: rgba(22, 163, 74, 0.18) !important;
border-color: rgba(22, 163, 74, 0.34) !important;
color: #22c55e !important;
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.12);
}
/* Feedback vacía: pulse ámbar (ningún mensaje para exportar). */
/* Empty export feedback: amber pulse when there are no messages. */
.chat-export-btn.chat-export-empty {
background: rgba(245, 158, 11, 0.18) !important;
border-color: rgba(245, 158, 11, 0.34) !important;
@@ -5039,7 +5037,7 @@ html[data-theme="white"] .chat-export-btn.chat-export-empty {
}
/* ──────────────────────────────────────────────────
THE SCROLLABLE HISTORY BOX (root cause of "non scorre")
THE SCROLLABLE HISTORY BOX (root cause of the scrolling failure)
Before: max-height:none. Now it's a flex item with flex:1 1 auto inside
.chat-panel-inner, which has a bounded 100% height of its parent.
min-height:0 is MANDATORY — without it, flex column children never shrink
@@ -5213,7 +5211,7 @@ html[data-theme="white"] #bubble-stack .bubble.user::before {
}
/* 6) META label "name HH:MM" on the same single line, ONE pseudo-element.
──── USER FIX 2026-09-10 "perche mette sempre Emilio" ────
──── USER FIX 2026-09-10: remove the hardcoded user name ────
The previous iteration hardcoded the Spanish names "Vocero" (assistant)
and "Emilio" (user) plus a fake "16:14" timestamp. Instead we now read
BOTH values dynamically from data attributes written by ui/chat.js at the
+7 -7
View File
@@ -288,7 +288,7 @@ export class ChatView {
* inner .{hist|bubble}-body → data-user-name | data-asst-name + data-timestamp
*
* The .{hist|bubble}-body::before pseudo-element in style.css uses attr()
* to render the "Nombre HH:MM" label aligned to the bubble corner; the
* to render the "Name HH:MM" label aligned to the bubble corner; the
* wrapper .user::before pseudo uses attr(data-user-initials) to paint the
* circular avatar's 2-letter monogram.
*
@@ -583,8 +583,8 @@ export class ChatView {
/**
* Pretty-print the JSON conversation structure to a human-readable plain
* text log (one entry per "HH:MM Nombre: texto"). Tool calls are rendered
* as indented blocks ("TOOL: nombre" followed by Input/Output sections).
* text log (one entry per "HH:MM Name: text"). Tool calls are rendered
* as indented blocks (tool name followed by input/output sections).
* @param {ReturnType<ChatView["_serializeConversation"]>} conv
* @returns {string}
*/
@@ -648,8 +648,8 @@ export class ChatView {
* Public entry-point wired to the #chat-export-btn in the chat header.
* Serializes the current conversation and produces TWO downloads:
* 1. vocero-conversacion-YYYYMMDD-HHMMSS.json (structured data)
* 2. vocero-conversacion-YYYYMMDD-HHMMSS.txt (log legibile para humanos)
* UI feedback: briefly swaps the download icon for a ✓ "hecho" tick then
* 2. vocero-conversacion-YYYYMMDD-HHMMSS.txt (human-readable log)
* UI feedback: briefly swaps the download icon for a ✓ completion tick then
* restores the original SVG so the user gets a visual ack that the export
* completed (idempotent / errors are logged to console — never throws).
*/
@@ -677,7 +677,7 @@ export class ChatView {
setTimeout(() => this._exportBtn?.classList.remove("chat-export-empty"), 600);
}
if (typeof window !== "undefined" && typeof console !== "undefined") {
console.info("[ui/chat.js:exportConversation] No hay mensajes aún — exportación omitida.");
console.info("[ui/chat.js:exportConversation] No messages yet — export skipped.");
}
return;
}
@@ -691,7 +691,7 @@ export class ChatView {
}
} catch (err) {
if (typeof window !== "undefined" && typeof console !== "undefined") {
console.error("[ui/chat.js:exportConversation] Error exportando conversación:", err);
console.error("[ui/chat.js:exportConversation] Conversation export failed:", err);
}
}
}
+102 -119
View File
@@ -54,16 +54,14 @@ function reportWsDebug(hypothesisId, location, msg, data = {}) {
}
// ---------------------------------------------------------------------------
// 🔎 WS DEBUG TRACER (sempre attivo, 2026-09-24)
// Banner + stile etichette condiviso per tutta la famiglia di log [S2S-WS:*].
// Non è più abilitato solo via localStorage.s2s.debug: gli eventi di vita
// (connect, close, errore) e l'handshake iniziale sono loggati always-on;
// i messaggi bulk (input_audio_buffer.append) vengono solo contati per non
// spammare la Console.
// 🔎 WS DEBUG TRACER (always active, 2026-09-24)
// Shared banner and label styles for all [S2S-WS:*] logs. Lifecycle events
// (connect, close, error) and the initial handshake are always logged.
// Count bulk input_audio_buffer.append messages to avoid console spam.
// ---------------------------------------------------------------------------
(function _wsDebugBanner() {
console.info(
"%c🟡 [S2S-WS] client caricato (ws/s2s-ws-client.js v2026-09-24 23:12) — handshake, stato, messaggi chiave e tool-call loggati; PREFETCH APIPILOT strumenti AD OGNI INIZIO SESSIONE + post-login phone (cache per-utente, cambio phone = refresh=true automatico; invalid. cache globale __s2sInvalidateApipilotCache(); anti-leak token; anti-spam audio).",
"%c🟡 [S2S-WS] client loaded (ws/s2s-ws-client.js v2026-09-24 23:12) — logging handshake, status, key messages and tool calls; PREFETCH APIPILOT tools AT EVERY SESSION START + after phone login (per-user cache, phone changes trigger refresh=true; global invalidation __s2sInvalidateApipilotCache(); token masking; audio log throttling).",
"background:#fef3c7;color:#92400e;font-weight:700;padding:4px 8px;border-radius:4px;"
);
})();
@@ -79,24 +77,22 @@ function _wsLog(status, tag, msg, ...extra) {
const badge = `%c[S2S-WS:${tag}]`;
console.log(`${badge}%c ${msg}`, badgeBg, labelFg, ...extra);
}
/** Contatore per audio chunk bulk, per evitare spam. */
/** Bulk audio chunk counter for throttling logs. */
let _wsMicChunkCount = 0;
let _wsMicChunkLastLogTs = 0;
// ---------------------------------------------------------------------------
// 🔧 APIPILOT PREFETCH AD INIZIO SESSIONE (2026-09-24)
// Ogni volta che s2s-ws-client avvia una connessione Realtime (metodo connect())
// viene scaldata la cache ApiPilot lato server chiamando /api/apipilot/tools.
// Cache TTL lato client 240 s (= TTL server 300 s meno 60 s margine).
// Il prefetch è FIRE-AND-FORGET: non blocca MAI l'handshake WS, non fallisce
// connect() anche se il proxy restituisce 5xx.
// 🔧 APIPILOT PREFETCH AT SESSION START (2026-09-24)
// Each connect() warms the server-side ApiPilot cache via /api/apipilot/tools.
// Client cache TTL is 240 s (server TTL 300 s minus a 60 s margin).
// Prefetch failures, including proxy 5xx responses, do not fail connect().
// ---------------------------------------------------------------------------
/** @type {{at:number, data:any, key:string|null}|null} */
let _apipilotClientCache = null;
const _APIPILOT_CLIENT_TTL_MS = 240_000; // 4 minuti
/** Contatore sessioni totali (per log), incrementato ad ogni connect(). */
const _APIPILOT_CLIENT_TTL_MS = 240_000; // 4 minutes
/** Session counter for logging, incremented by each connect(). */
let _wsSessionCounter = 0;
/** Chiave cache per utente: ultimi 8 cifre phoneNumber o fingerprint token. */
/** Per-user cache key: last 8 phoneNumber digits or a token fingerprint. */
function _apipilotUserKey() {
try {
// @ts-ignore
@@ -108,38 +104,34 @@ function _apipilotUserKey() {
return "anon";
} catch (_) { return "anon"; }
}
/** Invalida la cache ApiPilot lato client. Esporta globalmente per main.js (dopo login phone). */
/** Invalidate the client ApiPilot cache. Exported globally for main.js after phone login. */
function _invalidateApipilotClientCache() {
_apipilotClientCache = null;
console.info(
"%c🔧 [S2S-WS:APIPILOT] Cache client invalidata (nuovo login utente o refresh=true forzato)",
"%c🔧 [S2S-WS:APIPILOT] Client cache invalidated (new user login or forced refresh=true)",
"background:#fde68a;color:#92400e;font-weight:700;padding:1px 6px;border-radius:3px;"
);
}
// Stesso identico marker e regole del lato server Python (server.py#L555-L556).
// Lo ripetiamo identico in JS per garantire:
// (1) stessa classifica dedup
// (2) stesso formato regole marker AI
// (3) dedup by marker stringa <<<APIPILOT_TOOLS_LIST_BEGIN>>> in effectiveReasoningInstructions
// (così non appendiamo due volte se sia server che client provano entrambi).
// Match the Python server's marker and rules for consistent deduplication and
// AI marker syntax. effectiveReasoningInstructions checks this marker so the
// client and server do not append the same tools list twice.
const APIPILOT_PASS1_LIST_MARKER_JS = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>";
/**
* Restituisce il testo del blocco PASSATA-1 da iniettare nel prompt instructions LLM.
* FORMATO IDENTICO al Python _resolve_apipilot_reasoning_context_block(tools=[...]).
* - Inizio con APIPILOT_PASS1_LIST_MARKER_JS
* - Fine con <<<APIPILOT_TOOLS_LIST_END>>>
* - Lingua: SPAGNOLO (stesso di profile inject e istruzioni AI)
* - Cap description a 500 chars
* - Max 100 strumenti
* @param {any[]|null|undefined} tools Lista da cache oppure null (usa cache corrente)
* @returns {string} Blocco di testo PASSATA-1 (stringa vuota se lista vuota / nessuno strumento / nessuna cache)
* Build the PASS-1 block injected into the LLM instructions prompt.
* Match Python _resolve_apipilot_reasoning_context_block(tools=[...]).
* - Start with APIPILOT_PASS1_LIST_MARKER_JS
* - End with <<<APIPILOT_TOOLS_LIST_END>>>
* - Instructions in English; assistant responses remain Spanish
* - Cap descriptions at 500 characters and tools at 100
* @param {any[]|null|undefined} tools Cached list, or null to use the current cache
* @returns {string} PASS-1 text block, empty when no tools or cache are available
*/
function buildApipilotPass1PromptBlock(tools) {
let list = Array.isArray(tools) ? tools : null;
if (!list) {
// Fallback: usa cache client locale corrente (se disponibile)
// Fall back to the current client cache, if available.
const c = _apipilotClientCache;
if (c && c.data && Array.isArray(c.data.tools)) list = c.data.tools;
}
@@ -168,25 +160,24 @@ function buildApipilotPass1PromptBlock(tools) {
console.warn("APIPILOT_PASS1_LIST_MARKER_JS", APIPILOT_PASS1_LIST_MARKER_JS);
const markers = (
`${APIPILOT_PASS1_LIST_MARKER_JS}\n` +
"### Herramientas dinámicas disponibles (ApiPilot AquaPortal)\n" +
"### Available dynamic tools (ApiPilot AquaPortal)\n" +
"\n" +
"Cada herramienta tiene un campo `name` (identificador único, NO lo modifiques, " +
"NO camelCase, NO traducir) y un campo `description` que describe el caso de uso.\n" +
"Each tool has a `name` field (a unique identifier: DO NOT change, camelCase " +
"or translate it) and a `description` field explaining its use case.\n" +
"\n" +
"REGLAS EJECUCIÓN OBLIGATORIAS:\n" +
"1. Si la petición DEL USUARIO (transcript) coincide claramente con la `description` " +
"de EXACTAMENTE una (1) herramienta de la lista inferior → DEBES emitir EXCLUSIVAMENTE " +
"una sola riga come PRIMERA línea de TU RESPUESTA, con questo formato sintattico:\n" +
' <<<APIPILOT_CALL:NAME=nome_herramienta,ARGUMENTS={"parametro1":"valor1","parametro2":"valor2"}>>>\n' +
"2. La parte ARGUMENTS={…} DEBE ser JSON válido (virgolette doppie, virgole, senza " +
"commenti). Includi SOLAMENTE i campi elencati in `required_parameters:` (se ci sono).\n" +
"3. Subito DOPO la riga marker NON aggiungere altro testo (niente spiegazioni, " +
"niente anticipazioni). Il motore eseguirà lo strumento e ti risomministrerà il " +
"risultato in una seconda chiamata.\n" +
"4. Se NESSUNA `description` corrisponde, NON emettere marker e rispondi normalmente.\n" +
"5. Se più di una descrizione corrisponde, scegli la più specifica e usa il marker.\n" +
"MANDATORY EXECUTION RULES:\n" +
"1. If the USER REQUEST (transcript) clearly matches the `description` of " +
"EXACTLY one (1) tool below, output ONLY one line as the FIRST line of YOUR " +
"RESPONSE, using this exact syntax:\n" +
' <<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1","parameter2":"value2"}>>>\n' +
"2. ARGUMENTS={…} MUST be valid JSON (double quotes, commas, no comments). " +
"Include ONLY fields listed in `required_parameters:`, if any.\n" +
"3. Do NOT add text after the marker line (no explanations or previews). " +
"The engine executes the tool and supplies its result in a second call.\n" +
"4. If NO `description` matches, do NOT emit a marker; respond normally in Spanish.\n" +
"5. If multiple descriptions match, choose the most specific and use the marker.\n" +
"\n" +
"Lista herramientas (name / description):\n" +
"Tools list (name / description):\n" +
`${entries.join("\n")}\n` +
"<<<APIPILOT_TOOLS_LIST_END>>>"
);
@@ -195,7 +186,7 @@ function buildApipilotPass1PromptBlock(tools) {
}
/**
* Controlla se le istruzioni in input contengono già il marker PASS1 (dedup).
* Check whether the input instructions already contain the PASS1 marker.
* @param {string|undefined|null} text
* @returns {boolean}
*/
@@ -204,10 +195,9 @@ function instructionsAlreadyHasApipilotPass1(text) {
}
/**
* Espone globalmente una funzione helper per:
* - main.js: costruire effectiveReasoningInstructions appendendo la lista.
* - altri moduli che volessero leggerla.
* Il check dedup è fatto automaticamente dal chiamante prima di appendere.
* Expose a global helper for main.js to append tools to
* effectiveReasoningInstructions, and for other modules to read the list.
* The caller checks for duplicates before appending.
* @returns {{block: string, count: number, marker: string, alreadyPresent:(t:string)=>boolean}}
*/
function getApipilotPass1ContextInfo() {
@@ -456,12 +446,11 @@ export class S2sWsRealtimeClient extends EventTarget {
*/
async connect() {
if (this._ws) throw new Error("Already connected");
// Contatore sessioni totali: utile nel tracer per correlare gli eventi
// di [S2S-WS:*] a un numero di sessione crescente.
// Correlate [S2S-WS:*] events using an increasing session number.
_wsSessionCounter += 1;
const sessionIdShort = `#${_wsSessionCounter}`;
this._sessionSeq = _wsSessionCounter;
_wsLog(0, "SESSION", `${sessionIdShort} handshake iniziato — status: creating-session`);
_wsLog(0, "SESSION", `${sessionIdShort} handshake started — status: creating-session`);
let connectUrl;
if (this._directUrl) {
@@ -480,7 +469,7 @@ export class S2sWsRealtimeClient extends EventTarget {
// keeps a freed slot from being spent on someone who stepped away, and the
// click is a fresh gesture (re-arms the AudioContext on iOS).
if (waited) {
_wsLog(1, "QUEUE", `${sessionIdShort} tuo turno — attesa click join per confermare slot`);
_wsLog(1, "QUEUE", `${sessionIdShort} your turn — waiting for a join click to confirm the slot`);
await this._awaitJoin(grant);
if (this._closed) throw _codedError("connect aborted", "aborted");
}
@@ -497,38 +486,38 @@ export class S2sWsRealtimeClient extends EventTarget {
}
// Spin up the AudioContext + worklets in parallel with the WS dial,
// E IN PARALLELO scaldiamo ApiPilot (strumenti dinamici AquaPortal).
// Il prefetch è non bloccante: se fallisce, non fallisce connect().
// Also warm ApiPilot (dynamic AquaPortal tools) in parallel.
// A failed prefetch does not fail connect().
const audioReady = this._setupAudio();
const wsReady = this._openWebSocket(connectUrl);
const apipilotReady = this._prefetchApipilotTools(sessionIdShort);
await Promise.all([audioReady, wsReady, apipilotReady.catch(() => void 0)]);
_wsLog(1, "SESSION", `${sessionIdShort} connessione stabilita + audio pipeline + apipilot OK`);
_wsLog(1, "SESSION", `${sessionIdShort} connection established + audio pipeline + apipilot OK`);
}
/**
* 🔧 PRE-FETCH strumenti ApiPilot ad ogni inizio di sessione Realtime.
* - Proxy sul server: GET api/apipilot/tools?include_unauthorized=true
* - Il wrapper FETCH globale traccia come [FETCH:APIPILOT:TOOLS] in Console.
* - Cache TTL locale 240 s per evitare chiamate ridondanti se l'utente
* chiude e riapre sessioni in rapida successione (server TTL=300 s).
* - SE window.__s2sApipilot=false → salta subito.
* - MAI fallisce: sempre catch interno.
* @param {string} sessionIdShort Log tag "sessione #N"
* 🔧 Pre-fetch ApiPilot tools at every Realtime session start.
* - Server proxy: GET api/apipilot/tools?include_unauthorized=true
* - The global FETCH wrapper logs [FETCH:APIPILOT:TOOLS] in the console.
* - Local TTL 240 s avoids redundant calls during rapid session restarts
* (server TTL=300 s).
* - Skip immediately when window.__s2sApipilot=false.
* - Catch failures internally.
* @param {string} sessionIdShort Log tag "session #N"
* @returns {Promise<void>}
*/
async _prefetchApipilotTools(sessionIdShort) {
try {
// @ts-ignore: __s2sApipilot è settato da main.js dopo fetchConfig()
// @ts-ignore: main.js sets __s2sApipilot after fetchConfig().
const enabled = typeof window !== "undefined" && !!window.__s2sApipilot;
if (!enabled) {
_wsLog(2, "APIPILOT", `${sessionIdShort} feature flag apipilot=false sul server — salto prefetch strumenti`);
_wsLog(2, "APIPILOT", `${sessionIdShort} server feature flag apipilot=false — skipping tools prefetch`);
return;
}
const currentUser = _apipilotUserKey();
const now = Date.now();
const cached = _apipilotClientCache;
// HIT cache solo se: (1) ancora fresca TTL (2) stesso utente key (non cambio phone/token tra sessioni)
// Use the cache only within its TTL and for the same user key.
if (cached && (now - cached.at <= _APIPILOT_CLIENT_TTL_MS) && cached.key === currentUser) {
const ageSec = Math.round((now - cached.at) / 1000);
const n =
@@ -537,10 +526,10 @@ export class S2sWsRealtimeClient extends EventTarget {
: Array.isArray(cached.data && cached.data.tools)
? cached.data.tools.length
: "?";
_wsLog(1, "APIPILOT", `${sessionIdShort} HIT cache client strumenti (user=${currentUser}, age=${ageSec}s, N=${n}, TTL=${Math.round(_APIPILOT_CLIENT_TTL_MS/1000)}s)`);
_wsLog(1, "APIPILOT", `${sessionIdShort} HIT client tools cache (user=${currentUser}, age=${ageSec}s, N=${n}, TTL=${Math.round(_APIPILOT_CLIENT_TTL_MS/1000)}s)`);
return;
}
// Propaga header x-demo-session se disponibile in window (per auth fingerprint lato server S2S).
// Forward x-demo-session when available for the S2S server auth fingerprint.
/** @type {Record<string,string>} */
const extraHeaders = {};
try {
@@ -550,11 +539,11 @@ export class S2sWsRealtimeClient extends EventTarget {
extraHeaders["x-demo-session"] = window.__s2sDemoSessionToken;
}
} catch (_) { /* noop */ }
// Usa refresh=true SE è cambiato l'utente (cache.key diversa o null) oppure la cache è stata invalidata globalmente.
// Refresh when the user key changes or the cache is missing/expired.
const needRefresh = !cached || cached.key !== currentUser || (now - cached.at > _APIPILOT_CLIENT_TTL_MS);
const qsRefresh = needRefresh ? "&refresh=true" : "";
const t0 = performance.now();
_wsLog(0, "APIPILOT", `${sessionIdShort} MISS cache (user=${currentUser}, refresh=${needRefresh ? "Y" : "N"}) → GET api/apipilot/tools (proxy a S2S_APIPILOT_TOOLS_URL server)...`);
_wsLog(0, "APIPILOT", `${sessionIdShort} MISS cache (user=${currentUser}, refresh=${needRefresh ? "Y" : "N"}) → GET api/apipilot/tools (server proxy to S2S_APIPILOT_TOOLS_URL)...`);
const res = await fetch(`api/apipilot/tools?include_unauthorized=true${qsRefresh}`, {
method: "GET",
cache: "no-store",
@@ -567,11 +556,11 @@ export class S2sWsRealtimeClient extends EventTarget {
const j = await res.json().catch(() => ({}));
const ok = j && j.success && j.data;
if (!ok) {
throw new Error(`envelope non corretto: success=${Boolean(j && j.success)}`);
throw new Error(`invalid envelope: 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;
// Aggiorna cache locale (in caso di hit la prossima sessione non chiamerà).
// Update the local cache so a subsequent session can reuse it.
_apipilotClientCache = { at: Date.now(), data: { count, tools: tools.slice(0, 100) }, key: currentUser };
const names = tools
.slice(0, 5)
@@ -580,16 +569,16 @@ export class S2sWsRealtimeClient extends EventTarget {
_wsLog(
1,
"APIPILOT",
`${sessionIdShort} READY strumenti APIPILOT caricati: N=${count}, t=${Math.round(dt)}ms, user=${currentUser} [primi 5: ${names || "(nessuno)"}]`
`${sessionIdShort} READY APIPILOT tools loaded: N=${count}, t=${Math.round(dt)}ms, user=${currentUser} [first 5: ${names || "(none)"}]`
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
_wsLog(
3,
"APIPILOT",
`${sessionIdShort} prefetch FALLITO: ${msg} — diagnostica lato server log [APIPILOT-DISCOVERY]`
`${sessionIdShort} prefetch FAILED: ${msg} — check server logs [APIPILOT-DISCOVERY]`
);
// NON rilanciare: connect() deve comunque andare a buon fine.
// Do not rethrow: connect() must still succeed.
}
}
@@ -670,21 +659,21 @@ export class S2sWsRealtimeClient extends EventTarget {
const dur = (performance.now() - t0).toFixed(1);
if (response.status === 402) {
const body = await response.json().catch(() => ({}));
_wsLog(3, "SESSION", `/session HTTP 402: limite giornaliero esaurito (tier=${body?.tier ?? "?"}) dur=${dur} ms`);
_wsLog(3, "SESSION", `/session HTTP 402: daily limit exhausted (tier=${body?.tier ?? "?"}) dur=${dur} ms`);
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
}
if (response.status === 503) {
const body = await response.json().catch(() => ({}));
if (body?.state === "at_capacity") {
_wsLog(2, "QUEUE:FULL", `/session HTTP 503 state=at_capacity (coda piena) dur=${dur} ms`);
_wsLog(2, "QUEUE:FULL", `/session HTTP 503 state=at_capacity (queue full) dur=${dur} ms`);
throw _codedError("The queue is full — try again shortly.", "queue-full");
}
_wsLog(3, "SESSION", `/session HTTP 503 (fallback sconosciuto) dur=${dur} ms`);
_wsLog(3, "SESSION", `/session HTTP 503 (unknown fallback) dur=${dur} ms`);
throw new Error("/session failed (503)");
}
if (!response.ok) {
const text = await response.text().catch(() => "");
_wsLog(3, "SESSION", `/session fallita HTTP ${response.status}: ${text.slice(0, 180)} dur=${dur} ms`);
_wsLog(3, "SESSION", `/session failed HTTP ${response.status}: ${text.slice(0, 180)} dur=${dur} ms`);
throw new Error(`/session failed (${response.status}): ${text}`);
}
const json = await response.json();
@@ -692,7 +681,7 @@ export class S2sWsRealtimeClient extends EventTarget {
_wsLog(
2,
"QUEUE:WAIT",
`/session → stato=queued queueId=${json.queue_id ?? "?"} position=${json.position ?? "?"} pollEvery=${json.poll_interval_s ?? "?"}s dur=${dur} ms`
`/session → state=queued queueId=${json.queue_id ?? "?"} position=${json.position ?? "?"} pollEvery=${json.poll_interval_s ?? "?"}s dur=${dur} ms`
);
return {
state: "queued",
@@ -724,7 +713,7 @@ export class S2sWsRealtimeClient extends EventTarget {
_wsLog(
2,
"QUEUE:POLL",
`Inizio poll coda queueId=${ticket.queueId.slice(0, 10)}… initialPos=${ticket.position} intervalMs=${intervalMs}`
`Start queue polling queueId=${ticket.queueId.slice(0, 10)}… initialPos=${ticket.position} intervalMs=${intervalMs}`
);
let pollNum = 0;
@@ -744,11 +733,11 @@ export class S2sWsRealtimeClient extends EventTarget {
if (response.status === 402) {
const body = await response.json().catch(() => ({}));
_wsLog(3, "QUEUE:LIMIT", `poll #${pollNum} HTTP 402 — limite giornaliero (tier=${body?.tier ?? "?"})`);
_wsLog(3, "QUEUE:LIMIT", `poll #${pollNum} HTTP 402 — daily limit (tier=${body?.tier ?? "?"})`);
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
}
if (response.status === 404) {
_wsLog(3, "QUEUE:EXPIRED", `poll #${pollNum} HTTP 404 — ticket scaduto queueId=${this._queueId.slice(0, 10)}…`);
_wsLog(3, "QUEUE:EXPIRED", `poll #${pollNum} HTTP 404 — ticket expired queueId=${this._queueId.slice(0, 10)}…`);
throw _codedError("Queue timed out", "queue-expired");
}
if (!response.ok) continue; // 502/503 — transient, retry
@@ -763,7 +752,7 @@ export class S2sWsRealtimeClient extends EventTarget {
continue;
}
// Reached the front and claimed a slot.
_wsLog(1, "QUEUE:FRONT", `poll #${pollNum} — RAGGIUNTA la prima posizione. Assegno slot compute.`);
_wsLog(1, "QUEUE:FRONT", `poll #${pollNum} — REACHED the front. Assigning compute slot.`);
this._queueId = "";
return this._parseGrant(json);
}
@@ -881,9 +870,8 @@ export class S2sWsRealtimeClient extends EventTarget {
/** @param {string} connectUrl */
_openWebSocket(connectUrl) {
return new Promise((resolve, reject) => {
// Non loggiamo l'URL completo (potrebbe contenere session_token in
// chiaro nella querystring). Mostriamo solo host + path + un hash della
// parte segreta per audit senza leak token.
// Mask the full URL, which may contain a plaintext session_token.
// Show host + path and secret fingerprints for auditing without leaks.
let maskedUrl = connectUrl;
try {
const u = new URL(connectUrl, window.location.href);
@@ -893,7 +881,7 @@ export class S2sWsRealtimeClient extends EventTarget {
const isSecret = /token|session|auth|jwt|secret|key/i.test(lower) || String(v).length > 16;
secretPortions.push(`${k}=${isSecret ? `✂️…${String(v).slice(-4)} (${String(v).length} chars)` : v}`);
});
maskedUrl = `${u.protocol}//${u.host}${u.pathname} ? ${secretPortions.join(" & ") || "(nessun query param)"}`;
maskedUrl = `${u.protocol}//${u.host}${u.pathname} ? ${secretPortions.join(" & ") || "(no query parameters)"}`;
} catch {
// ignore
}
@@ -905,13 +893,13 @@ export class S2sWsRealtimeClient extends EventTarget {
const onceOpen = () => {
ws.removeEventListener("open", onceOpen);
ws.removeEventListener("error", onceErr);
_wsLog(1, "CONNECT", `WebSocket APERTO su ${maskedUrl} readyState=${ws.readyState}`);
_wsLog(1, "CONNECT", `WebSocket OPEN at ${maskedUrl} readyState=${ws.readyState}`);
resolve();
};
const onceErr = (e) => {
ws.removeEventListener("open", onceOpen);
ws.removeEventListener("error", onceErr);
_wsLog(3, "CONNECT", `WebSocket IMPOSSIBILE da aprire: event.type=${e?.type ?? "error"} url=${maskedUrl}`);
_wsLog(3, "CONNECT", `WebSocket FAILED to open: event.type=${e?.type ?? "error"} url=${maskedUrl}`);
reject(new Error(`WebSocket failed to open: ${e?.type ?? "error"}`));
};
ws.addEventListener("open", onceOpen);
@@ -1037,15 +1025,11 @@ export class S2sWsRealtimeClient extends EventTarget {
`tools=${toolNames || "none"}`
);
}
// Primo ack del server: se startup mode=TALK vogliamo il comportamento
// IDENTICO a quando arriva la frase di wake word "Hola Vocero".
// Quindi NON mandiamo shaped response.create con istruzioni di greet
// hardcodate, ma invece:
// 1. injectiamo "hola vocero" nella conversazione come se l'utente
// l'avesse detto (sendUserText → conversation.item.create user)
// 2. requestResponse() VUOTO → il modello risponde esattamente come
// nel codice wake-word in main.js.
// Stesso identico risultato, nessuna istruzione custom.
// On the first server acknowledgment, TALK startup should behave
// exactly like hearing the "Hola Vocero" wake word:
// 1. Inject "hola vocero" as a user turn through sendUserText().
// 2. Call requestResponse() without custom greeting instructions,
// matching the wake-word path in main.js.
if (this._startWithResponse && !this._initialResponseSent) {
this._initialResponseSent = true;
const phrase = this._startWithResponseText.trim() || "hola vocero";
@@ -1363,7 +1347,7 @@ export class S2sWsRealtimeClient extends EventTarget {
_wsLog(
(ev.code === 1000 || ev.code === 1001) ? 1 : (ev.code ? 3 : 2),
"CLOSE",
`WebSocket CHIUSO code=${ev.code ?? "?"} reason=${String(ev.reason ?? "(nessuna)").slice(0, 160)} wasClean=${ev.wasClean}`
`WebSocket CLOSED code=${ev.code ?? "?"} reason=${String(ev.reason ?? "(none)").slice(0, 160)} wasClean=${ev.wasClean}`
);
if (this._status === "closed" || this._status === "error") return;
if (ev.code === 1000) {
@@ -1395,13 +1379,12 @@ export class S2sWsRealtimeClient extends EventTarget {
},
};
// ───────────────────────────────────────────────────────────────────────────
// 2026-09-25 INIETTA API-PILOT HERRAMIENTAS DINÁMICAS (PASADA-1) nel session.update.
// 3 strati di assicurazione (in ordine di priorità crescente, dedup by marker):
// (A) main.js effectiveReasoningInstructions() ha già appeso la lista.
// (B) NOSTRO server.py proxy realtime ri-inietta (se routing lo attraversa).
// (C) QUI s2s-ws-client: ultimo punto di catch-all prima dell'invio al
// canale, GARANTISCE che la lista sia SEMPRE presente in istruzioni
// (anche se qualcuno fa updateSession() con text sovrascritto poco dopo).
// 2026-09-25 INJECT API-PILOT DYNAMIC TOOLS (PASS 1) in session.update.
// Three safeguards, deduplicated by marker:
// (A) main.js effectiveReasoningInstructions() appends the list.
// (B) The server.py Realtime proxy injects it when routing passes there.
// (C) This client is the final safeguard before sending instructions,
// including after a live updateSession() replaces the prompt.
{
const currentInstr = (typeof session.instructions === "string") ? session.instructions : "";
if (!instructionsAlreadyHasApipilotPass1(currentInstr)) {
@@ -1453,7 +1436,7 @@ export class S2sWsRealtimeClient extends EventTarget {
const session = { type: "realtime" };
if (patch.instructions) {
let instr = patch.instructions;
// ── 2026-09-25 catch-all apipilot PASS-1 anche per patch live. ──
// ── 2026-09-25 ApiPilot PASS-1 safeguard for live patches too. ──
if (typeof instr === "string" && !instructionsAlreadyHasApipilotPass1(instr)) {
try {
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
@@ -1776,8 +1759,8 @@ export class S2sWsRealtimeClient extends EventTarget {
/** @param {Record<string, unknown>} event */
_send(event) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
// Anti-spam bulk-rate limiter per eventi audio (~25 al secondo).
// Loggiamo un resoconto ogni 250 chunk (circa 10s a 25Hz) invece di ogni singolo.
// Throttle bulk audio event logs (~25 events per second).
// Log periodic summaries rather than every chunk.
if (event.type === "input_audio_buffer.append") {
_wsMicChunkCount += 1;
const now = performance.now();
@@ -1807,7 +1790,7 @@ export class S2sWsRealtimeClient extends EventTarget {
this._ws.send(payload || JSON.stringify(event));
return;
}
// Messaggi non bulk: log sempre [S2S-WS:SEND] con anteprima del payload.
// Always log non-bulk messages as [S2S-WS:SEND] with a payload preview.
let payloadLength = 0;
let stringifyMs = 0;
let payload = "";