feat: integrate ApiPilot tools and add light-theme voice UI

This commit is contained in:
2026-10-05 08:25:29 -04:00
parent 413ba08c21
commit 4d7c7a23df
9 changed files with 6930 additions and 171 deletions
+270 -20
View File
@@ -13,12 +13,100 @@
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css?v=202609041120" />
<!-- ═══════════════════════════════════════════════════════════════════
ANTI-FOUC VEIL (S2S_THEME flash fix) — MUST stay INLINE and
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"
The earlier guard `html:not([data-theme]) [data-theme-only]` only
hid the two parallel widget branches, but the UNTHEMED DEFAULTS in
style.css (:root purple palette, body background #07080d, topbar
glass, #app deep-space colours, purple orb border gradients) were
still rendering for the ~1-2 frames between body paint commit and
fetchConfig() returning api/config.theme → setAttribute("data-theme").
The user saw a dark-purple flash before the white Vocero theme
applied — exactly the symptom described.
FIX (3 layers, fail-safe):
1. While html has NO data-theme attribute (pre-fetchConfig),
render <body> COMPLETELY INVISIBLE + background:white +
border:none (the two most visible paint leaks fixed first).
Display:none would block CSS transitions from being recorded;
visibility:hidden keeps layout but forbids painting entirely,
which is exactly what we want.
2. Safety timeout: if fetchConfig() fails to resolve (e.g.
static hosting / no server / offline) the veil auto-lifts
after 2500 ms so the user is NOT stuck on a blank page.
3. Immediately after root.setAttribute("data-theme", …) in
fetchConfig() we also add class="theme-ready" which the veil
additionally checks so the user sees a 1-frame lift, not a
delayed paint. The visibility guard is a double-OR so it
expires on EITHER signal.
═══════════════════════════════════════════════════════════════════ -->
<style id="s2s-anti-fouc-veil">
html:not([data-theme]) body,
html:not(.theme-ready) body {
visibility: hidden !important;
background: #ffffff !important;
color: #0f172a !important;
opacity: 0 !important;
transition: none !important;
animation: none !important;
}
html:not([data-theme]),
html:not(.theme-ready) {
background: #ffffff !important;
}
/* Also kill the purple orb + any glass chrome during the veil window,
because even when <body> is visibility:hidden some browsers do a
first compositing pass that leaks a few pixels of background. */
html:not([data-theme]) #main-circle,
html:not([data-theme]) .orb-wrap,
html:not([data-theme]) .stage,
html:not([data-theme]) .topbar,
html:not([data-theme]) #app,
html:not(.theme-ready) #main-circle,
html:not(.theme-ready) .orb-wrap,
html:not(.theme-ready) .stage,
html:not(.theme-ready) .topbar,
html:not(.theme-ready) #app {
background: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
border: none !important;
color: transparent !important;
}
</style>
<script>
// Fail-safe veil lift: if fetchConfig() fails to reply (static hosting,
// offline, api/config 500) the user never sees <body> stuck invisible.
// setTimeout (not rAF) because we want a real wall-clock timeout.
window.__s2sVeilLiftTimeout = window.setTimeout(function () {
var r = document.documentElement;
if (!r.hasAttribute("data-theme")) r.setAttribute("data-theme", "dark");
r.classList.add("theme-ready");
}, 2500);
// Also try to clear the fail-safe as soon as DOMContentLoaded fires so
// we don't accidentally fire the lift AFTER a normal-config path.
document.addEventListener("DOMContentLoaded", function () {
var r = document.documentElement;
if (r.hasAttribute("data-theme") && !r.classList.contains("theme-ready")) {
r.classList.add("theme-ready");
if (window.__s2sVeilLiftTimeout) window.clearTimeout(window.__s2sVeilLiftTimeout);
}
});
</script>
<link rel="stylesheet" href="style.css?v=202609291430" />
<script>
document.addEventListener("DOMContentLoaded", () => {
const l = document.querySelector('link[rel="stylesheet"]');
if (l && !l.href.includes("v=202609041120")) {
l.href = l.href.replace(/v=\d+/, "v=202609041120");
if (l && !l.href.includes("v=202609291430")) {
l.href = l.href.replace(/v=\d+/, "v=202609291430");
}
});
</script>
@@ -39,19 +127,33 @@
</div>
<div id="app">
<header class="topbar">
<!-- Hamburger (theme=white only): icon button 3 horizontal lines, sits left of brand cluster, top-left corner, square icon, dark text, no visible borders.
Mirrors the 3-line menu icon in the screenshot header. -->
<button id="menu-btn" class="icon-btn menu-btn" type="button" title="Menú" aria-label="Abrir menú" hidden data-theme-only="white">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="brand">
<div class="ident">
<div class="ident-head">
<a class="ident-title">Vocero</a>
<!-- Subtitle shown below Vocero title (theme=white only) -->
<p class="ident-subtitle" hidden data-theme-only="white">Agente conversacional</p>
</div>
<div id="ident-user-name" class="ident-user-name" hidden></div>
</div>
</div>
<!-- User avatar circle (theme=white only): grid item next to the brand, not
absolute-positioned → eliminates overlap with title/subtitle entirely.
Same element id so main.js syncVoceroHeaderUser() still fills the text. -->
<div id="ident-user-name" class="ident-user-name" hidden data-theme-only="white" aria-hidden="true">AV</div>
<!-- Left sidebar (reports) toggle: always visible, pinned left side of the
topbar control cluster. Clicking slides in the offcanvas sidebar on
mobile; it's a no-op hover hint on desktop where sidebar is always
shown. -->
<button id="reports-sidebar-toggle" class="icon-btn" title="Reports programados" aria-label="Abrir panel de reportes" aria-expanded="false" aria-controls="reports-sidebar" hidden data-reports-sidebar-toggle data-debug-only="true">
<button id="reports-sidebar-toggle" class="icon-btn" title="Reports programados" aria-label="Abrir panel de reportes" aria-expanded="false" aria-controls="reports-sidebar" hidden data-reports-sidebar-toggle data-debug-only="true" data-theme-only="dark">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z" fill="none"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<span id="reports-sidebar-badge" class="chat-badge" aria-hidden="true"></span>
</button>
@@ -74,8 +176,34 @@
</div>
</header>
<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."
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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<line x1="6" y1="20" x2="6" y2="12"/>
<line x1="11" y1="20" x2="11" y2="6"/>
<line x1="16" y1="20" x2="16" y2="15"/>
<line x1="21" y1="20" x2="21" y2="10"/>
</svg>
</div>
<div class="status-card-text">
<h2 class="status-card-title">Listo para conversar</h2>
<p class="status-card-sub">Informes, análisis, datos y mucho más.</p>
</div>
<button class="status-card-arrow icon-btn" type="button" aria-label="Ver más detalles" title="Detalles">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<main class="stage">
<div class="orb-wrap">
<!-- DARK THEME orb (purple gradient + purple icons): shown only on native dark theme, hidden in white -->
<div class="orb-wrap" data-theme-only="dark">
<div id="mic-gate" class="mic-gate">
<svg id="mic-gate-arc" class="mic-gate-arc" viewBox="0 0 100 100" aria-hidden="true">
<path id="mga-track" class="mga-track" fill="none" />
@@ -134,6 +262,69 @@
</button>
</div>
<!-- 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.
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 -->
<div class="white-waveform white-waveform-left" aria-hidden="true">
<span class="w-bar wb-1" style="--bar-h: 0.4"></span>
<span class="w-bar wb-2" style="--bar-h: 0.65"></span>
<span class="w-bar wb-3" style="--bar-h: 0.9"></span>
<span class="w-bar wb-4" style="--bar-h: 1"></span>
<span class="w-bar wb-5" style="--bar-h: 0.7"></span>
<span class="w-bar wb-6" style="--bar-h: 0.55"></span>
<span class="w-bar wb-7" style="--bar-h: 0.3"></span>
</div>
<!-- Big circular mic button (theme=white). Click = start/stop voice session.
Forwards all its clicks to the real #main-circle so the entire WS
pipeline still works. -->
<button id="white-mic-circle"
class="white-mic-circle state-idle"
type="button"
aria-label="Iniciar conversación de voz">
<span class="wmc-halo" aria-hidden="true"></span>
<span class="wmc-ring" aria-hidden="true"></span>
<span class="wmc-core" aria-hidden="true"></span>
<svg class="wmc-icon wmc-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="12" rx="3" fill="currentColor" stroke="none"/>
<path d="M5 10a7 7 0 0 0 14 0"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="8" y1="22" x2="16" y2="22"/>
</svg>
<svg class="wmc-icon wmc-stop" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
<span class="wmc-icon wmc-spinner" aria-hidden="true"></span>
</button>
<!-- Waveform right side: 7 bars mirroring left -->
<div class="white-waveform white-waveform-right" aria-hidden="true">
<span class="w-bar wb-1" style="--bar-h: 0.3"></span>
<span class="w-bar wb-2" style="--bar-h: 0.55"></span>
<span class="w-bar wb-3" style="--bar-h: 0.7"></span>
<span class="w-bar wb-4" style="--bar-h: 1"></span>
<span class="w-bar wb-5" style="--bar-h: 0.9"></span>
<span class="w-bar wb-6" style="--bar-h: 0.65"></span>
<span class="w-bar wb-7" style="--bar-h: 0.4"></span>
</div>
<!-- Status caption below mic circle: "Escuchando...", "Pensando..." 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. -->
<button id="white-stop-btn"
class="white-stop-btn"
type="button"
aria-label="Detener"
title="Detener sesión de voz">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
<span>Detener</span>
</button>
</div>
<p id="circle-caption" class="circle-caption" role="status">Tap to start</p>
<p id="circle-subcaption" class="circle-subcaption" hidden></p>
@@ -147,31 +338,90 @@
</div>
</main>
<!-- Theme=white only: Quick action pills below mic area
"Resumen" · "Consultar datos" · "Crear respuesta" each with 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">
<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"/>
<line x1="8" y1="13" x2="16" y2="13"/>
<line x1="8" y1="17" x2="14" y2="17"/>
</svg>
<span>Resumen</span>
</button>
<button class="quick-action-btn" type="button" data-action="consultar-datos">
<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"/>
<line x1="16" y1="20" x2="16" y2="15"/>
<line x1="21" y1="20" x2="21" y2="10"/>
</svg>
<span>Consultar datos</span>
</button>
<button class="quick-action-btn" type="button" data-action="crear-respuesta">
<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"/>
</svg>
<span>Crear respuesta</span>
</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. -->
<section id="chat-panel" class="chat-panel chat-panel-inline" aria-label="Chat de texto">
<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.
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>
<span>En línea</span>
</span>
<button id="chat-export-btn" class="chat-export-btn icon-btn"
type="button"
aria-label="Descargar conversación"
title="Descargar conversación (JSON + TXT)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
</div>
</header>
<div id="chat-history" class="chat-history" role="log" aria-live="polite" aria-atomic="false"></div>
<!-- Text input row HIDDEN per user request 2026-09-04 (restore removing hidden tag below) -->
<!-- Text input row — HIDDEN in dark theme per user request 2026-09-04.
In white theme VISIBLE with a new design (clip button + rounded input + blue circular send),
which mirrors the screenshot footer input bar.
Restore the dark-theme input by removing the `hidden` attr + style below. -->
<form id="chat-form" class="chat-form" autocomplete="off" spellcheck="false" hidden style="display:none!important">
<button id="chat-attach-btn" class="chat-attach-btn icon-btn" type="button" aria-label="Adjuntar archivo" title="Adjuntar">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<input id="chat-input"
class="chat-input"
type="text"
name="message"
placeholder="Escribe aquí tu mensaje a Vocero…"
placeholder="Escribe un mensaje…"
aria-label="Enviar mensaje de texto a Vocero"
maxlength="500"
/>
<button id="chat-send" type="submit" class="chat-send-btn icon-btn" aria-label="Enviar mensaje">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
<button id="chat-send" type="submit" class="chat-send-btn-round" aria-label="Enviar mensaje">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</form>
</div>
</section>
</div>
</div>
<!-- Ephemeral chat bubbles anchored to the top-right -->
@@ -188,7 +438,7 @@
<!-- Left sidebar: reports programados.
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>
<aside id="reports-sidebar" class="reports-sidebar" aria-label="Reportes programados" hidden data-theme-only="dark">
<div id="reports-sidebar-backdrop" class="reports-sidebar-backdrop" data-close-reports-sidebar></div>
<div class="reports-sidebar-inner">
<header class="reports-sidebar-header">
@@ -199,7 +449,7 @@
</header>
<!-- MOVED from inside .ident in the topbar — same #id so main.js syncVoceroHeaderUser()
continues to work with zero JS changes to reports rendering logic. -->
<div id="reports-hint" class="reports-hint" hidden></div>
<div id="reports-hint" class="reports-hint" hidden data-theme-only="dark"></div>
<!-- Debug-only: Spanish suggestions panel (gated behind ?debug=true).
Shown as the bottom block inside the left sidebar. -->
<div id="reports-suggestions" class="reports-suggestions" hidden data-debug-only="true"></div>
@@ -259,7 +509,7 @@
</header>
<div class="tab-panels">
<section class="tab-panel active" role="tabpanel">
<div class="tab-panel active" role="tabpanel">
<label class="field" id="conn-field">
<span id="conn-label">Speech-to-speech server URL</span>
<input id="lb-url" type="text" autocomplete="off" spellcheck="false" placeholder="http://localhost:port" />
@@ -327,7 +577,7 @@
</button>
<small id="restart-hint">Connect first, then come back here to apply live changes.</small>
</div>
</section>
</div>
</div>
<footer class="modal-footer">
@@ -456,16 +706,16 @@
</button>
<!-- Hero: big glass avatar circle + title + subtitle (email fallback). -->
<section class="user-profile-hero">
<div class="user-profile-hero">
<div id="user-profile-avatar" class="user-profile-avatar" aria-hidden="true">AV</div>
<h2 id="user-profile-title" class="user-profile-title"></h2>
<p id="user-profile-subtitle" class="user-profile-subtitle"></p>
</section>
</div>
<!-- Metadata: a 1-col definition-list-like grid. Empty rows are hidden
automatically via JS when the field has no value, so we never show
blank labels. -->
<section id="user-profile-metadata" class="user-profile-metadata" aria-label="Informazioni utente"></section>
<div id="user-profile-metadata" class="user-profile-metadata" aria-label="Informazioni utente"></div>
</div>
</dialog>
@@ -473,6 +723,6 @@
<span class="app-footer-text">Azurian Vocero realtime ver.Beta 2.1</span>
</footer>
<script type="module" src="main.js?v=202609041120"></script>
<script type="module" src="main.js?v=202609102207"></script>
</body>
</html>
+603 -4
View File
@@ -26,7 +26,7 @@ import { Account } from "./ui/account.js?v=202609041120";
// ESATTAMENTE quale versione di main.js è caricata nel browser.
// Se NON vedi questa riga in Console = stai ancora usando il file CACHATO VECCHIO.
console.info(
"%c🟢 VOCERO main.js loaded. version=2026-09-04 11:20 (FIX HEADER TRONCO: body safe-area-inset top/right/left 10px min + topbar padding-top 16 + margin-top safe. FIX SCROLL INSUFFICIENTE: chat-history padding-bottom 96→160px; chat-panel clamp 40-96→60-160; #app padding-bottom 56→120px+safe; footer safe-area bottom; step 4 retry +250ms (5 step tot); MAX_SAFE_INTEGER invece di 9M)",
"%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(
@@ -38,6 +38,218 @@ console.info(
// 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:
@@ -630,6 +842,11 @@ let debugPrompt = false;
// 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:
@@ -799,6 +1016,8 @@ function initialsForName(rawName) {
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) ─── */
/**
@@ -1293,8 +1512,53 @@ function effectiveReasoningInstructions() {
instructions = instructions ? `${instructions} ${ragCtx}` : ragCtx;
ragAppended = true;
}
const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${(base || "").length}`;
// ───────────────────────────────────────────────────────────────────────────
// 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`,
@@ -1321,13 +1585,14 @@ function effectiveReasoningInstructions() {
}
});
if (debugPrompt) {
const key2 = `debugEffInst:${instructions.length}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}`;
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:\n%s\n`,
`[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) {
@@ -2196,6 +2461,53 @@ 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.
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`,
@@ -2306,10 +2618,49 @@ 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.
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,
@@ -2317,8 +2668,46 @@ async function fetchConfig() {
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;
}
@@ -2326,11 +2715,221 @@ async function fetchConfig() {
} 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 });
}
}
});
});
}
/**
+3822 -121
View File
File diff suppressed because it is too large Load Diff
+1312
View File
File diff suppressed because it is too large Load Diff
+279
View File
@@ -35,6 +35,19 @@ function reportChatDebug(hypothesisId, location, msg, data = {}) {
const WRENCH_PATH = `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>`;
const CHAT_BUBBLE_SVG = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_SVG}<span class="chat-empty-title">No messages yet</span><span class="chat-empty-hint">Tap the orb and start talking</span></div>`;
/**
* @param {string} prefix
* @param {string} [sep]
* @returns {string} safe filename prefix (a-z 0-9 _ - no spaces / slashes / unicode),
* sanitised so any OS accepts it.
*/
function _safeName(prefix, sep = "-") {
const base = String(prefix || "").toLowerCase().trim()
.normalize("NFKD").replace(/[^\x20-\x7E]/g, "")
.replace(/[^a-z0-9]+/gi, sep || "-")
.replace(new RegExp(`^${sep}+|${sep}+$`, "g"), "");
return base || "vocero";
}
export class ChatView {
constructor() {
@@ -58,6 +71,11 @@ export class ChatView {
this._chatHistory = $("#chat-history");
/** @type {HTMLElement} */
this._bubbleStack = $("#bubble-stack");
/** @type {HTMLButtonElement | null} Export conversation download button in header. */
this._exportBtn = /** @type {HTMLButtonElement | null} */ (document.getElementById("chat-export-btn"));
if (this._exportBtn) {
this._exportBtn.addEventListener("click", () => this.exportConversation());
}
// Inline chat is always "open" (permanently visible on screen).
this._panelOpen = true;
@@ -103,6 +121,55 @@ export class ChatView {
});
}
// ── User name / initials helpers ────────────────────────────────────────
/** @returns {string} display name for the current user (e.g. "Alessandro").
* Reuses extractVoceroUserName + initialsForName exposed on window by main.js
* so we don't duplicate the nested-object lookup logic (profile/user/customer
* keys, first+last name fallback, etc.). Falls back to "Usuario" (Spanish)
* when no profile has been resolved yet — never returns "" because the
* white-theme chat label is rendered into a pseudo-element that would show
* a lonely timestamp otherwise.
*/
_resolveUserName() {
const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null;
const profile =
/** @type {Record<string, unknown> | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null;
const full = ex ? ex(profile) : "";
if (!full) return "Usuario";
// Return ONLY the first token so the narrow chat label line fits:
// "Alessandro Valenti" → "Alessandro", matches the screenshot style.
const first = String(full).trim().split(/\s+/)[0] || full;
return first;
}
/** @returns {string} 2-letter uppercase initials, e.g. "Alessandro Valenti" → "AV". */
_resolveUserInitials() {
const mk = typeof window._s2sInitialsFor === "function" ? window._s2sInitialsFor : null;
const profile =
/** @type {Record<string, unknown> | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null;
const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null;
const full = ex ? ex(profile) : "";
if (mk && full) {
const init = mk(full);
if (init) return init;
}
return "AV";
}
/** @returns {string} "HH:MM" 24h using the current locale, no seconds. */
_nowStamp() {
try {
return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
} catch (_) {
const d = new Date();
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
}
/** Assistant display name — fixed Spanish label, same as the topbar brand. */
get _assistantName() {
return "Vocero";
}
// ── Panel ───────────────────────────────────────────────────────────────
_openPanel() {
@@ -213,6 +280,18 @@ export class ChatView {
* Build a role-labelled message element. Ephemeral bubbles and persistent
* history rows share the same shape and differ only in their class prefix
* (`bubble`/`bubble-*` vs `hist-msg`/`hist-*`).
*
* White-theme label + avatar data attributes (populated on both the outer
* wrapper and the inner body):
* wrapper .{hist-msg|bubble}.user → data-user-name / data-user-initials / data-timestamp
* wrapper .{hist-msg|bubble}.assistant → data-asst-name / data-timestamp
* 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
* wrapper .user::before pseudo uses attr(data-user-initials) to paint the
* circular avatar's 2-letter monogram.
*
* @param {{ container: string, prefix: string, role: "user"|"assistant", text: string, partial?: boolean }} o
* @returns {HTMLElement}
*/
@@ -220,7 +299,27 @@ export class ChatView {
const el = document.createElement("div");
el.className = `${container} ${role}`;
const label = role === "user" ? "You" : "Assistant";
const stamp = this._nowStamp();
if (role === "user") {
const uName = this._resolveUserName();
const uInit = this._resolveUserInitials();
el.setAttribute("data-user-name", uName);
el.setAttribute("data-user-initials", uInit);
el.setAttribute("data-timestamp", stamp);
} else {
el.setAttribute("data-asst-name", this._assistantName);
el.setAttribute("data-timestamp", stamp);
}
el.innerHTML = `<div class="${prefix}-role">${label}</div><div class="${prefix}-body${partial ? " partial" : ""}">${escHtml(text)}</div>`;
const body = /** @type {HTMLDivElement} */ (el.lastElementChild);
if (body) {
if (role === "user") {
body.setAttribute("data-user-name", el.getAttribute("data-user-name") || "");
} else {
body.setAttribute("data-asst-name", this._assistantName);
}
body.setAttribute("data-timestamp", stamp);
}
return el;
}
@@ -417,6 +516,186 @@ export class ChatView {
this._asstByResp.clear();
}
// ── Export / download conversation ───────────────────────────────────────
/**
* Serialize the current persistent chat history (.hist-msg rows inside
* #chat-history) into a structured JSON object. Covers:
* - user / assistant text messages (role, text, timestamp, name, initials)
* - tool-call expandable rows (role=tool, name, args, output)
* - webcam / snapshot images (role=image, dataUrl)
* Empty state rows (.chat-empty) and ephemeral orb bubbles are ignored.
* @returns {{
* exportedAt: string,
* messageCount: number,
* messages: Array<{ type: string; timestamp?: string; [k: string]: unknown }>,
* }}
*/
_serializeConversation() {
/** @type {Array<{ type: string; timestamp?: string; [k: string]: unknown }>} */
const messages = [];
if (!this._chatHistory) return { exportedAt: new Date().toISOString(), messageCount: 0, messages };
const rows = /** @type {HTMLElement[]} */ ([...this._chatHistory.querySelectorAll(":scope > .hist-msg")]);
for (const row of rows) {
const ts = row.getAttribute("data-timestamp") || undefined;
if (row.classList.contains("user") || row.classList.contains("assistant")) {
const body = /** @type {HTMLElement | null} */ (row.querySelector(".hist-body"));
if (!body) continue;
const role = row.classList.contains("user") ? "user" : "assistant";
const out = /** @type {{ type: string; text: string; timestamp?: string; name?: string; initials?: string; interrupted?: boolean }} */ ({
type: role,
text: body.textContent || "",
});
if (ts) out.timestamp = ts;
if (role === "user") {
const n = row.getAttribute("data-user-name");
const i = row.getAttribute("data-user-initials");
if (n) out.name = n;
if (i) out.initials = i;
}
if (row.classList.contains("interrupted")) out.interrupted = true;
messages.push(out);
} else if (row.classList.contains("tool")) {
const img = /** @type {HTMLImageElement | null} */ (row.querySelector("img.hist-image"));
if (img) {
messages.push({ type: "image", timestamp: ts, dataUrl: img.src || "" });
} else {
const nameEl = /** @type {HTMLElement | null} */ (row.querySelector(".hist-tool-name"));
const blocks = /** @type {HTMLElement[]} */ ([...row.querySelectorAll(".hist-tool-block")]);
const args = blocks[0]?.textContent || "";
const output = blocks[1]?.textContent || "";
messages.push({
type: "tool",
timestamp: ts,
name: nameEl?.textContent || "",
args,
output,
});
}
}
}
return {
exportedAt: new Date().toISOString(),
messageCount: messages.length,
messages,
};
}
/**
* 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).
* @param {ReturnType<ChatView["_serializeConversation"]>} conv
* @returns {string}
*/
_conversationToText(conv) {
const lines = [];
const date = (() => { try { return new Date(conv.exportedAt).toLocaleString(); } catch { return conv.exportedAt; } })();
lines.push(`Conversación Vocero — ${date}`);
lines.push(`Mensajes totales: ${conv.messageCount}`);
lines.push("—".repeat(52));
lines.push("");
for (const m of conv.messages) {
if (m.type === "user" || m.type === "assistant") {
const label = m.type === "user" ? (m.name ? String(m.name) : "Usuario") : "Vocero";
const head = [m.timestamp, label].filter(Boolean).join(" ");
lines.push(`${head}${m.interrupted ? " [interrumpida]" : ""}:`);
lines.push(String(m.text || "").trim());
lines.push("");
} else if (m.type === "tool") {
const head = [m.timestamp, `HERRAMIENTA: ${String(m.name || "").trim()}`].filter(Boolean).join(" ");
lines.push(head);
const args = String(m.args || "").trim();
const output = String(m.output || "").trim();
if (args) { lines.push(" Input:"); lines.push(" " + args.split("\n").join("\n ")); }
if (output) { lines.push(" Output:"); lines.push(" " + output.split("\n").join("\n ")); }
lines.push("");
} else if (m.type === "image") {
const head = [m.timestamp, "SNAPSHOT (imagen web-cámara enviada al modelo)"].filter(Boolean).join(" ");
lines.push(head);
lines.push(" " + String(m.dataUrl || "").slice(0, 80) + (String(m.dataUrl || "").length > 80 ? "…" : ""));
lines.push("");
}
}
return lines.join("\n");
}
/**
* Trigger a browser "download" for the given string content. Uses the
* classic Blob+URL+a.click pattern — no backend call, fully client-side,
* works offline and never leaks the conversation content anywhere.
* @param {string} content
* @param {string} filename
* @param {string} mime
*/
_downloadBlob(content, filename, mime) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
setTimeout(() => a.remove(), 0);
} finally {
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
}
/**
* 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
* restores the original SVG so the user gets a visual ack that the export
* completed (idempotent / errors are logged to console — never throws).
*/
exportConversation() {
try {
const conv = this._serializeConversation();
const profile = /** @type {Record<string, unknown> | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null;
const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null;
const userName = ex && profile ? String(ex(profile) || "").trim() : "";
const stampBase = (() => {
const d = new Date();
const pad = (n, w = 2) => String(n).padStart(w, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
})();
const prefix = _safeName(`vocero-conversacion${userName ? "-" + userName : ""}`, "-");
const jsonName = `${prefix}-${stampBase}.json`;
const txtName = `${prefix}-${stampBase}.txt`;
const jsonText = JSON.stringify(conv, null, 2);
const plainText = this._conversationToText(conv);
if (conv.messageCount === 0) {
// Don't offer downloads of empty logs; flash the button briefly to
// signal "pressed" without spamming the user with empty files.
if (this._exportBtn) {
this._exportBtn.classList.add("chat-export-empty");
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.");
}
return;
}
this._downloadBlob(jsonText, jsonName, "application/json;charset=utf-8");
this._downloadBlob(plainText, txtName, "text/plain;charset=utf-8");
// Quick visual OK: paint the button green for 650ms. Avoids modifying
// innerHTML (we'd otherwise lose the SVG on mobile fast-clicks).
if (this._exportBtn) {
this._exportBtn.classList.add("chat-export-ok");
setTimeout(() => this._exportBtn?.classList.remove("chat-export-ok"), 650);
}
} catch (err) {
if (typeof window !== "undefined" && typeof console !== "undefined") {
console.error("[ui/chat.js:exportConversation] Error exportando conversación:", err);
}
}
}
// ── Client event handlers ─────────────────────────────────────────────────
/**
+467 -18
View File
@@ -53,6 +53,187 @@ function reportWsDebug(hypothesisId, location, msg, data = {}) {
void 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.
// ---------------------------------------------------------------------------
(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).",
"background:#fef3c7;color:#92400e;font-weight:700;padding:4px 8px;border-radius:4px;"
);
})();
/** @param {number} status 0=connect,1=ok,2=warn,3=err @param {string} tag @param {string} msg @param {unknown[]} extra */
function _wsLog(status, tag, msg, ...extra) {
const palette = [
["color:#1d4ed8;font-weight:700;", "background:#dbeafe;color:#1e3a8a;font-weight:800;padding:1px 6px;border-radius:3px;"], // CONNECT info
["color:#047857;font-weight:700;", "background:#d1fae5;color:#065f46;font-weight:800;padding:1px 6px;border-radius:3px;"], // OK
["color:#b45309;font-weight:700;", "background:#fef3c7;color:#92400e;font-weight:800;padding:1px 6px;border-radius:3px;"], // WARN
["color:#b91c1c;font-weight:700;", "background:#fee2e2;color:#7f1d1d;font-weight:800;padding:1px 6px;border-radius:3px;"], // ERR
];
const [labelFg, badgeBg] = palette[Math.max(0, Math.min(3, status))];
const badge = `%c[S2S-WS:${tag}]`;
console.log(`${badge}%c ${msg}`, badgeBg, labelFg, ...extra);
}
/** Contatore per audio chunk bulk, per evitare spam. */
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.
// ---------------------------------------------------------------------------
/** @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(). */
let _wsSessionCounter = 0;
/** Chiave cache per utente: ultimi 8 cifre phoneNumber o fingerprint token. */
function _apipilotUserKey() {
try {
// @ts-ignore
const phone = (typeof window !== "undefined" && window.voceroUserPhoneNumber) || "";
if (phone && phone.length >= 8) return `P:${phone.slice(-8)}`;
// @ts-ignore
const tk = typeof window !== "undefined" && typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
if (tk && tk.length >= 6) return `T:${tk.slice(-6)}`;
return "anon";
} catch (_) { return "anon"; }
}
/** Invalida la cache ApiPilot lato client. Esporta globalmente per main.js (dopo login phone). */
function _invalidateApipilotClientCache() {
_apipilotClientCache = null;
console.info(
"%c🔧 [S2S-WS:APIPILOT] Cache client invalidata (nuovo login utente o refresh=true forzato)",
"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).
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)
*/
function buildApipilotPass1PromptBlock(tools) {
let list = Array.isArray(tools) ? tools : null;
if (!list) {
// Fallback: usa cache client locale corrente (se disponibile)
const c = _apipilotClientCache;
if (c && c.data && Array.isArray(c.data.tools)) list = c.data.tools;
}
if (!Array.isArray(list) || list.length === 0) return "";
const capped = list.slice(0, 100);
/** @type {string[]} */
const entries = [];
for (const t of capped) {
if (!t || typeof t !== "object") continue;
const name = typeof t.name === "string" ? t.name : "";
if (!name) continue;
const desc = typeof t.description === "string" ? t.description : "";
const params = (t.parameters && typeof t.parameters === "object") ? t.parameters : null;
const requiresAuth = Boolean(t.requires_auth);
const req = (params && Array.isArray(params.required))
? params.required.filter((x) => typeof x === "string")
: [];
let descCut = desc.slice(0, 500);
if (desc.length > 500) descCut += "…";
let snippet = `- name: ${name}\n requires_auth: ${requiresAuth ? "true" : "false"}`;
if (req.length) snippet += `\n required_parameters: [${req.slice(0, 40).join(", ")}]`;
if (descCut) snippet += `\n description: ${descCut}`;
entries.push(snippet);
}
if (!entries.length) return "";
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" +
"\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" +
"\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" +
"\n" +
"Lista herramientas (name / description):\n" +
`${entries.join("\n")}\n` +
"<<<APIPILOT_TOOLS_LIST_END>>>"
);
console.warn("markers", markers);
return markers;
}
/**
* Controlla se le istruzioni in input contengono già il marker PASS1 (dedup).
* @param {string|undefined|null} text
* @returns {boolean}
*/
function instructionsAlreadyHasApipilotPass1(text) {
return typeof text === "string" && text.includes(APIPILOT_PASS1_LIST_MARKER_JS);
}
/**
* 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.
* @returns {{block: string, count: number, marker: string, alreadyPresent:(t:string)=>boolean}}
*/
function getApipilotPass1ContextInfo() {
const c = _apipilotClientCache;
const tools = (c && c.data && Array.isArray(c.data.tools)) ? c.data.tools : [];
const block = buildApipilotPass1PromptBlock(tools);
return {
block,
count: tools.length,
marker: APIPILOT_PASS1_LIST_MARKER_JS,
alreadyPresent: instructionsAlreadyHasApipilotPass1,
};
}
try {
if (typeof window !== "undefined") {
Object.defineProperty(window, "__s2sInvalidateApipilotCache", {
value: _invalidateApipilotClientCache,
writable: true, configurable: true,
});
Object.defineProperty(window, "__s2sGetApipilotPass1ContextInfo", {
value: getApipilotPass1ContextInfo,
writable: true, configurable: true,
});
}
} catch (_) { /* noop */ }
/**
* @typedef {Object} WsClientOptions
* @property {string} [sessionUrl] URL to POST for the session handshake (returns
@@ -275,6 +456,12 @@ 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.
_wsSessionCounter += 1;
const sessionIdShort = `#${_wsSessionCounter}`;
this._sessionSeq = _wsSessionCounter;
_wsLog(0, "SESSION", `${sessionIdShort} handshake iniziato — status: creating-session`);
let connectUrl;
if (this._directUrl) {
@@ -293,6 +480,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`);
await this._awaitJoin(grant);
if (this._closed) throw _codedError("connect aborted", "aborted");
}
@@ -308,10 +496,101 @@ export class S2sWsRealtimeClient extends EventTarget {
this.options.micStream = await this._acquireMic();
}
// Spin up the AudioContext + worklets in parallel with the WS dial.
// 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().
const audioReady = this._setupAudio();
const wsReady = this._openWebSocket(connectUrl);
await Promise.all([audioReady, wsReady]);
const apipilotReady = this._prefetchApipilotTools(sessionIdShort);
await Promise.all([audioReady, wsReady, apipilotReady.catch(() => void 0)]);
_wsLog(1, "SESSION", `${sessionIdShort} connessione stabilita + 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"
* @returns {Promise<void>}
*/
async _prefetchApipilotTools(sessionIdShort) {
try {
// @ts-ignore: __s2sApipilot è settato da main.js dopo fetchConfig()
const enabled = typeof window !== "undefined" && !!window.__s2sApipilot;
if (!enabled) {
_wsLog(2, "APIPILOT", `${sessionIdShort} feature flag apipilot=false sul server — salto prefetch strumenti`);
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)
if (cached && (now - cached.at <= _APIPILOT_CLIENT_TTL_MS) && cached.key === currentUser) {
const ageSec = Math.round((now - cached.at) / 1000);
const n =
cached.data && typeof cached.data.count === "number"
? cached.data.count
: 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)`);
return;
}
// Propaga header x-demo-session se disponibile in window (per auth fingerprint lato server S2S).
/** @type {Record<string,string>} */
const extraHeaders = {};
try {
// @ts-ignore
if (typeof window.__s2sDemoSessionToken === "string" && window.__s2sDemoSessionToken.length > 4) {
// @ts-ignore
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.
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)...`);
const res = await fetch(`api/apipilot/tools?include_unauthorized=true${qsRefresh}`, {
method: "GET",
cache: "no-store",
headers: Object.keys(extraHeaders).length ? extraHeaders : undefined,
});
const dt = performance.now() - t0;
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
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)}`);
}
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à).
_apipilotClientCache = { at: Date.now(), data: { count, tools: tools.slice(0, 100) }, key: currentUser };
const names = tools
.slice(0, 5)
.map((t) => (typeof t.name === "string" ? t.name : "?"))
.join(", ");
_wsLog(
1,
"APIPILOT",
`${sessionIdShort} READY strumenti APIPILOT caricati: N=${count}, t=${Math.round(dt)}ms, user=${currentUser} [primi 5: ${names || "(nessuno)"}]`
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
_wsLog(
3,
"APIPILOT",
`${sessionIdShort} prefetch FALLITO: ${msg} — diagnostica lato server log [APIPILOT-DISCOVERY]`
);
// NON rilanciare: connect() deve comunque andare a buon fine.
}
}
/**
@@ -381,30 +660,40 @@ export class S2sWsRealtimeClient extends EventTarget {
async _postSession() {
const url = this._sessionUrl;
console.log("[ws] POST", url);
_wsLog(0, "SESSION", `POST /session → ${url}`);
const t0 = performance.now();
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const dur = (performance.now() - t0).toFixed(1);
if (response.status === 402) {
// The session proxy refused: today's per-tier time budget is spent. Surface
// it as a typed error so the UI shows the limit modal, not a crash.
const body = await response.json().catch(() => ({}));
_wsLog(3, "SESSION", `/session HTTP 402: limite giornaliero esaurito (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`);
throw _codedError("The queue is full — try again shortly.", "queue-full");
}
_wsLog(3, "SESSION", `/session HTTP 503 (fallback sconosciuto) 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`);
throw new Error(`/session failed (${response.status}): ${text}`);
}
const json = await response.json();
if (json.state === "queued") {
_wsLog(
2,
"QUEUE:WAIT",
`/session → stato=queued queueId=${json.queue_id ?? "?"} position=${json.position ?? "?"} pollEvery=${json.poll_interval_s ?? "?"}s dur=${dur} ms`
);
return {
state: "queued",
queueId: json.queue_id,
@@ -412,7 +701,13 @@ export class S2sWsRealtimeClient extends EventTarget {
pollIntervalS: json.poll_interval_s,
};
}
return { state: "granted", grant: this._parseGrant(json) };
const grant = this._parseGrant(json);
_wsLog(
1,
"SESSION",
`/session OK → GRANT sessionId=${grant.sessionId?.slice(0, 10)}… pendingTimeout=${grant.pendingTimeoutS}s dur=${dur} ms`
);
return { state: "granted", grant };
}
/**
@@ -426,8 +721,15 @@ export class S2sWsRealtimeClient extends EventTarget {
const intervalMs = Math.max(1, ticket.pollIntervalS || 2) * 1000;
this._queueId = ticket.queueId;
this._emitQueue(ticket.position);
_wsLog(
2,
"QUEUE:POLL",
`Inizio poll coda queueId=${ticket.queueId.slice(0, 10)}… initialPos=${ticket.position} intervalMs=${intervalMs}`
);
let pollNum = 0;
while (true) {
pollNum += 1;
await this._queueSleep(intervalMs);
if (this._closed) throw _codedError("queue wait aborted", "aborted");
@@ -442,9 +744,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 ?? "?"})`);
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)}…`);
throw _codedError("Queue timed out", "queue-expired");
}
if (!response.ok) continue; // 502/503 — transient, retry
@@ -452,10 +756,14 @@ export class S2sWsRealtimeClient extends EventTarget {
const json = await response.json().catch(() => null);
if (!json) continue;
if (json.state === "queued") {
if (this._debug || pollNum === 1 || pollNum % 10 === 0) {
_wsLog(2, "QUEUE:POLL", `poll #${pollNum} position=${json.position ?? "?"}`);
}
this._emitQueue(json.position);
continue;
}
// Reached the front and claimed a slot.
_wsLog(1, "QUEUE:FRONT", `poll #${pollNum} — RAGGIUNTA la prima posizione. Assegno slot compute.`);
this._queueId = "";
return this._parseGrant(json);
}
@@ -573,6 +881,23 @@ 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.
let maskedUrl = connectUrl;
try {
const u = new URL(connectUrl, window.location.href);
const secretPortions = [];
u.searchParams.forEach((v, k) => {
const lower = k.toLowerCase();
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)"}`;
} catch {
// ignore
}
_wsLog(0, "CONNECT", `new WebSocket() → ${maskedUrl}`);
const ws = new WebSocket(connectUrl);
ws.binaryType = "arraybuffer";
this._ws = ws;
@@ -580,11 +905,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}`);
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}`);
reject(new Error(`WebSocket failed to open: ${e?.type ?? "error"}`));
};
ws.addEventListener("open", onceOpen);
@@ -594,6 +921,7 @@ export class S2sWsRealtimeClient extends EventTarget {
ws.addEventListener("close", (e) => this._onWsClose(e));
ws.addEventListener("error", (e) => {
console.error("[ws] socket error", e);
_wsLog(3, "ERR", `WebSocket error: event.type=${e?.type ?? "error"} wasClean=${"wasClean" in e ? String(/** @type {CloseEvent} */ (e).wasClean) : "?"}`, e);
});
});
}
@@ -647,9 +975,33 @@ export class S2sWsRealtimeClient extends EventTarget {
const type = event?.type;
if (typeof type !== "string") return;
// Always-on tracing for high-signal events (lifecycle, transcript/tool calls,
// errors). For bulk streams (audio/text deltas) we log only count / first
// + last so the console doesn't scroll into uselessness.
const isHighSignal = /^(session\.|error$|conversation\.item\.created|conversation\.item\.input_audio_transcription\.completed|response\.created|response\.output_item\.done|response\.audio_transcript\.done|response\.output_audio_transcript\.done|response\.done|response\.function_call_arguments\.done|rate_limits\.updated)/i.test(
type,
);
if (isHighSignal) {
const isError = type === "error";
const extra = type.startsWith("conversation.item.input_audio_transcription")
? ` item=${event.item_id} ci=${event.content_index} — ${String(event.delta ?? event.transcript ?? "").slice(0, 180)}`
: type.startsWith("response.")
? ` resp=${String(event.response_id ?? event.response?.id ?? "").slice(0, 10)} status=${event.response?.status ?? "-"} ${String(event.transcript ?? event.text ?? event.delta ?? "").slice(0, 200)}`
: type === "response.function_call_arguments.done"
? ` tool=${event.name} callId=${String(event.call_id ?? "").slice(0, 10)} argsPreview=${String(event.arguments ?? "").slice(0, 220)}`
: type === "error"
? ` code=${String(event.code ?? "-")} message=${String(event.message ?? "").slice(0, 200)}`
: type === "rate_limits.updated"
? ` ${String(JSON.stringify(event.rate_limits ?? [])).slice(0, 220)}`
: "";
_wsLog(isError ? 3 : (type.startsWith("response.") && /(text_done|audio_transcript\.done|output_item\.done|output_audio_transcript\.done|function_call_arguments)/i.test(type) ? 1 : 0),
"MSG",
`← ${type}${extra}`
);
}
// Opt-in event tracing for diagnosing turn/transcript issues. Enable with
// `localStorage.setItem("s2s.debug", "1")` in the browser console.
if (this._debug) {
if (this._debug && !isHighSignal) {
const extra = type.startsWith("conversation.item.input_audio_transcription")
? ` item=${event.item_id} ci=${event.content_index} ${event.delta ?? event.transcript ?? ""}`
: type.startsWith("response.")
@@ -1008,6 +1360,11 @@ export class S2sWsRealtimeClient extends EventTarget {
/** @param {CloseEvent} ev */
_onWsClose(ev) {
console.log("[ws] socket closed:", ev.code, ev.reason);
_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}`
);
if (this._status === "closed" || this._status === "error") return;
if (ev.code === 1000) {
this._setStatus("closed");
@@ -1037,6 +1394,38 @@ export class S2sWsRealtimeClient extends EventTarget {
output: { voice: this.options.voice },
},
};
// ───────────────────────────────────────────────────────────────────────────
// 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).
{
const currentInstr = (typeof session.instructions === "string") ? session.instructions : "";
if (!instructionsAlreadyHasApipilotPass1(currentInstr)) {
try {
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
? window.__s2sGetApipilotPass1ContextInfo()
: null;
const blk = (info && typeof info.block === "string") ? info.block : buildApipilotPass1PromptBlock(null);
if (blk && blk.trim()) {
session.instructions = currentInstr
? `${currentInstr}\n\n${blk}`
: blk;
const lenBlk = blk.length;
const cntTools = (info && typeof info.count === "number") ? info.count : "?";
_wsLog(
1, "APIPILOT",
`_sendSessionUpdate: APPENDED PASS-1 instructions (N=${cntTools}, +${lenBlk} chars). origLen=${currentInstr.length} newLen=${session.instructions.length}`,
);
}
} catch (_) {
/* never-fail */
}
}
}
// Tools are declared here; the backend already accepts them in
// session.update and emits response.function_call_arguments.done when the
// model decides to call one. Only include the keys when we actually have
@@ -1049,8 +1438,9 @@ export class S2sWsRealtimeClient extends EventTarget {
reportWsDebug("B", "s2s-ws-client.js:_sendSessionUpdate", "sending initial session.update", {
toolCount: this._tools.length,
toolNames: this._tools.map((tool) => tool.name),
hasInstructions: !!this.options.instructions,
instructionLength: (this.options.instructions || "").length,
hasInstructions: !!session.instructions,
instructionLength: (session.instructions || "").length,
apipilotPass1Present: instructionsAlreadyHasApipilotPass1(session.instructions),
});
// #endregion
this._send({ type: "session.update", session });
@@ -1061,14 +1451,35 @@ export class S2sWsRealtimeClient extends EventTarget {
updateSession(patch) {
/** @type {Record<string, any>} */
const session = { type: "realtime" };
if (patch.instructions) session.instructions = patch.instructions;
if (patch.instructions) {
let instr = patch.instructions;
// ── 2026-09-25 catch-all apipilot PASS-1 anche per patch live. ──
if (typeof instr === "string" && !instructionsAlreadyHasApipilotPass1(instr)) {
try {
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
? window.__s2sGetApipilotPass1ContextInfo()
: null;
const blk = (info && typeof info.block === "string") ? info.block : buildApipilotPass1PromptBlock(null);
if (blk && blk.trim()) {
const origLen = instr.length;
instr = `${instr}\n\n${blk}`;
_wsLog(
1, "APIPILOT",
`updateSession: APPENDED PASS-1 on live patch (N=${(info && typeof info.count === "number") ? info.count : "?"}, +${blk.length} chars). origLen=${origLen} newLen=${instr.length}`,
);
}
} catch (_) { /* never-fail */ }
}
session.instructions = instr;
}
if (patch.voice) session.audio = { output: { voice: patch.voice } };
if (Object.keys(session).length > 1) {
// #region debug-point C:update-session
reportWsDebug("C", "s2s-ws-client.js:updateSession", "sending patch session.update", {
hasInstructions: !!patch.instructions,
instructionLength: (patch.instructions || "").length,
hasInstructions: !!session.instructions,
instructionLength: (session.instructions || "").length,
hasVoice: !!patch.voice,
apipilotPass1Present: typeof session.instructions === "string" && instructionsAlreadyHasApipilotPass1(session.instructions),
});
// #endregion
this._send({ type: "session.update", session });
@@ -1365,14 +1776,46 @@ 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.
if (event.type === "input_audio_buffer.append") {
_wsMicChunkCount += 1;
const now = performance.now();
if (_wsMicChunkCount === 1 || now - _wsMicChunkLastLogTs >= 2500 || _wsMicChunkCount % 250 === 0) {
_wsMicChunkLastLogTs = now;
_wsLog(0, "SEND:BULK", `→ input_audio_buffer.append chunk #${_wsMicChunkCount} audio_len=${String(event.audio ?? "").length} chars (base64)`);
}
let payloadLength = 0;
let stringifyMs = 0;
let payload = "";
if (event.type === "conversation.item.create") {
const startedAt = performance.now();
payload = JSON.stringify(event);
stringifyMs = performance.now() - startedAt;
payloadLength = payload.length;
// #region debug-point A:conversation-item-create
reportWsDebug("A", "s2s-ws-client.js:_send", "sending conversation.item.create payload", {
payloadLength,
stringifyMs: Math.round(stringifyMs),
});
// #endregion
}
if (this._debug && (event.type === "session.update" || event.type === "conversation.item.create")) {
const preview = (payload || JSON.stringify(event)).slice(0, 400);
console.debug(`[Vocero WS OUT] type=${event.type} payload=${preview}`);
}
this._ws.send(payload || JSON.stringify(event));
return;
}
// Messaggi non bulk: log sempre [S2S-WS:SEND] con anteprima del payload.
let payloadLength = 0;
let stringifyMs = 0;
let payload = "";
const startedAt = performance.now();
payload = JSON.stringify(event);
stringifyMs = performance.now() - startedAt;
payloadLength = payload.length;
if (event.type === "conversation.item.create") {
const startedAt = performance.now();
payload = JSON.stringify(event);
stringifyMs = performance.now() - startedAt;
payloadLength = payload.length;
// #region debug-point A:conversation-item-create
reportWsDebug("A", "s2s-ws-client.js:_send", "sending conversation.item.create payload", {
payloadLength,
@@ -1380,11 +1823,17 @@ export class S2sWsRealtimeClient extends EventTarget {
});
// #endregion
}
const isToolish = /tool|function|response\.create/i.test(event.type || "");
const preview = payload.slice(0, isToolish ? 600 : 260);
_wsLog(
isToolish ? 1 : 0,
"SEND",
`→ ${String(event.type ?? "?")} payload=${payloadLength} bytes stringify=${Math.round(stringifyMs)} ms — ${preview}${payload.length > (isToolish ? 600 : 260) ? "…" : ""}`
);
if (this._debug && (event.type === "session.update" || event.type === "conversation.item.create")) {
const preview = (payload || JSON.stringify(event)).slice(0, 400);
console.debug(`[Vocero WS OUT] type=${event.type} payload=${preview}`);
console.debug(`[Vocero WS OUT] type=${event.type} payload=${payload.slice(0, 400)}`);
}
this._ws.send(payload || JSON.stringify(event));
this._ws.send(payload);
}
async close() {
+90 -6
View File
@@ -4,16 +4,80 @@ LLM Output Processor
Intercepts LLM output to:
1. Extract tool calls and send them via text_output_queue
2. Forward clean text to TTS pipeline
3. 2026-09-25: SANITIZE APIPILOT internal markers before clients / TTS (anti-leak tecnico)
"""
from __future__ import annotations
import logging
import re
from collections.abc import Iterator
from queue import Queue
from speech_to_speech.baseHandler import BaseHandler
from speech_to_speech.pipeline.events import AssistantTextEvent, ResponseFailedEvent, TokenUsageEvent
# 2026-09-25 SANITIZE APIPILOT CALL/RESULT markers in LLM output BEFORE:
# (a) AssistantTextEvent viene mandato ai client WS (e loggato come <<< TEXT:/ASSISTANT:)
# (b) TTSInput viene mandato a Qwen3-TTS (che lo pronuncerebbe ad alta voce).
# Formati osservati reali oggi (upstream Vocero start_pipeline_rag):
# 1) <<<APIPILOT_CALL:NAME=tool,ARGUMENTS=json>>> standard 3x
# 2) <APIPILOT_CALL:NAME=get_user_briefing,ARGUMENTS={"format":"voice"}
# singolo '<' '>' + args inventati da AI (format=voice)
# 3) <<<APIPILOT_CALL:...}> \n>> spezzato multi-riga
# 4) >>>APIPILOT_CALL:...<<< invertito (errore sintassi AI)
# 5) <<<APIPILOT_RESULT:PASS2>>> + blocco result.
_APIPILOT_CALL_NORM = re.compile(
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
re.IGNORECASE | re.DOTALL,
)
_APIPILOT_CALL_INV = re.compile(
r"[>]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[<]{1,3}",
re.IGNORECASE | re.DOTALL,
)
_APIPILOT_RESULT = re.compile(
r"[<]{1,3}\s*APIPILOT_RESULT(?::PASS2)?[^<>\n]{0,4000}[>]{1,3}",
re.IGNORECASE | re.DOTALL,
)
_APIPILOT_SPANISH_PLACEHOLDER = ("Voy a consultar tus datos, un momento por favor.")
def _sanitize_apipilot_before_client_and_tts(text: str) -> tuple[str, bool, str]:
if not isinstance(text, str) or not text.strip():
return text, False, ""
found_call = False
found_call_tool = ""
def _repl_call(m: re.Match) -> str:
nonlocal found_call, found_call_tool
found_call = True
try:
nm = m.group(1)
if isinstance(nm, str) and nm.strip():
found_call_tool = nm.strip()
except Exception:
pass
return f" {_APIPILOT_SPANISH_PLACEHOLDER} "
new_text = _APIPILOT_CALL_NORM.sub(_repl_call, text)
if not found_call:
new_text = _APIPILOT_CALL_INV.sub(_repl_call, new_text)
found_result = False
if _APIPILOT_RESULT.search(new_text):
new_text = _APIPILOT_RESULT.sub(" ", new_text)
found_result = True
# safety catchall frammenti marker senza wrapper (se marker troncato mid-stream)
low = new_text.lower()
if "apipilot" in low and ("call" in low or "result" in low or "name=" in low or "arguments=" in low):
new_text = re.sub(r"(?i)\bAPIPILOT(?:_CALL|_RESULT)?\b", " ", new_text)
new_text = re.sub(r"(?i)\b(?:NAME|ARGUMENTS|STATUS|PASS2)\s*[:=]", " ", new_text)
found_call = True
if found_call or found_result:
new_text = re.sub(r"\s{2,}", " ", new_text).strip()
tag = ""
if found_call:
tag = f"CALL({found_call_tool or '?'})"
if found_result:
tag = (tag + "+" if tag else "") + "RESULT"
return new_text, (found_call or found_result), tag
from speech_to_speech.pipeline.handler_types import LLMOut, TTSIn
from speech_to_speech.pipeline.messages import EndOfResponse, LLMResponseChunk, TokenUsage, TTSInput
from speech_to_speech.pipeline.queue_types import TextEventItem
@@ -120,24 +184,44 @@ class LMOutputProcessor(BaseHandler[LLMOut, TTSIn]):
logger.debug(f"LM processor: text='{lm_output.text}', tools={lm_output.tools}")
# 2026-09-25 SANITIZE SINGOLO PUNTO CENTRALE: PRIMA di AssistantTextEvent (client/log)
# E PRIMA di TTSInput (Qwen3-TTS che pronuncerebbe marker APIPILOT CALL).
clean_text = lm_output.text
any_sanitized = False
sanitize_tag = ""
if clean_text and clean_text.strip():
clean_text, any_sanitized, sanitize_tag = _sanitize_apipilot_before_client_and_tts(clean_text)
if any_sanitized:
logger.warning(
"[LM-OUTPUT-APIPILOT-SANITIZE] %s — tool=%s rawLen=%d cleanLen=%d (rimosso / sostituito con placeholder spagnolo PRIMA di client + TTS).",
sanitize_tag or "-",
(sanitize_tag if sanitize_tag else "marker"),
len(lm_output.text or ""),
len(clean_text or ""),
)
# evita che lo stesso chunk (marker solo) arrivi a TTS come "" → Hello.
# Se dopo sanitize testo vuoto MA avevamo trovato marker → placeholder.
if not clean_text.strip():
clean_text = _APIPILOT_SPANISH_PLACEHOLDER
if self.text_output_queue is not None:
event = AssistantTextEvent(
text=lm_output.text,
text=clean_text,
turn_id=lm_output.turn_id,
turn_revision=lm_output.turn_revision,
cancel_generation=lm_output.cancel_generation,
)
if lm_output.tools:
event.tools = lm_output.tools
logger.info(f"Sending to clients: text='{lm_output.text}', tools={[t.name for t in lm_output.tools]}")
logger.info(f"Sending to clients: text='{clean_text}', tools={[t.name for t in lm_output.tools]}")
else:
logger.debug(f"Sending to clients: text='{lm_output.text}' (no tools)")
logger.debug(f"Sending to clients: text='{clean_text}' (no tools)")
self.text_output_queue.put(event)
if lm_output.text and response_wants_audio(lm_output.response):
logger.debug(f"Forwarding to TTS: '{lm_output.text}'")
if clean_text and response_wants_audio(lm_output.response):
logger.debug(f"Forwarding to TTS: '{clean_text}' (original marker was: {sanitize_tag if any_sanitized else 'none'})")
yield TTSInput(
text=lm_output.text,
text=clean_text,
language_code=lm_output.language_code,
runtime_config=lm_output.runtime_config,
response=lm_output.response,
+86 -1
View File
@@ -12,6 +12,78 @@ import math
import re
import tempfile
import unicodedata
# 2026-09-25 SANITIZE APIPILOT MARKERS PRIMA DI TTS (anti-leak pronunciato da Qwen3).
# Lato server S2S Python abbiamo gia' gate + sanitize ma come DOPPIA RETE
# di sicurezza sanitizziamo DIRETTAMENTE il testo che entra in Qwen3-TTS.
# Formati osservati reali oggi:
# (a) <<<APIPILOT_CALL:NAME=tool,ARGUMENTS=json>>> standard 3 parentesi
# (b) <APIPILOT_CALL:NAME=tool,ARGUMENTS={"format":"voice"}
# singolo < > (caso user oggi)
# (c) <<<APIPILOT_CALL:...}> \n>> spezzato piu' righe
# (d) >>>APIPILOT_CALL:...<<< invertito
# Regola: CALL -> placeholder spagnolo (frase naturale utente), RESULT -> strip.
_APIPILOT_CALL_NORMAL_RE = re.compile(
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
re.IGNORECASE | re.DOTALL,
)
_APIPILOT_CALL_INVERTED_RE = re.compile(
r"[>]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[<]{1,3}",
re.IGNORECASE | re.DOTALL,
)
_APIPILOT_RESULT_RE = re.compile(
r"[<]{1,3}\s*APIPILOT_RESULT(?::PASS2)?[^<>\n]{0,2000}[>]{1,3}",
re.IGNORECASE | re.DOTALL,
)
_APIPILOT_SPANISH_PLACEHOLDER = "Voy a consultar tus datos, un momento por favor."
def _tts_sanitize_apipilot_markers(text: str) -> tuple[str, bool, str]:
"""Rimuove marker APIPILOT dal testo TTS.
Returns (sanitized_text, found_any, log_tag).
"""
if not isinstance(text, str) or not text.strip():
return text, False, "empty"
found_call = False
found_result = False
tool_name = ""
# 1) marker CALL standard + invertito -> placeholder spagnolo
def _repl_call(m: re.Match) -> str:
nonlocal found_call, tool_name
found_call = True
try:
nm = m.group(1)
if isinstance(nm, str) and nm.strip():
tool_name = nm.strip()
except Exception:
pass
return f" {_APIPILOT_SPANISH_PLACEHOLDER} "
text2 = _APIPILOT_CALL_NORMAL_RE.sub(_repl_call, text)
if not found_call:
text2 = _APIPILOT_CALL_INVERTED_RE.sub(_repl_call, text2)
# 2) marker RESULT -> strip silenzioso (non sono pronunciabili)
if _APIPILOT_RESULT_RE.search(text2):
found_result = True
text2 = _APIPILOT_RESULT_RE.sub(" ", text2)
# 3) safety: FRAMMENTI marker singoli che non hanno wrapper completo
low = text2.lower()
if "apipilot" in low and ("call" in low or "result" in low):
import re as _re_sf
text2 = _re_sf.sub(r"(?i)\bAPIPILOT(?:_CALL|_RESULT)?\b", " ", text2)
text2 = _re_sf.sub(r"(?i)\b(?:NAME|ARGUMENTS|STATUS|PASS2)\s*[:=]", " ", text2)
found_call = True
if found_call or found_result:
text2 = re.sub(r"\s{2,}", " ", text2).strip()
tag = "-"
if found_call and found_result:
tag = f"CALL({tool_name or '?'})+RESULT"
elif found_call:
tag = f"CALL({tool_name or '?'})"
elif found_result:
tag = "RESULT"
return text2, (found_call or found_result), tag
from collections.abc import Callable
from pathlib import Path
from sys import platform
@@ -709,7 +781,20 @@ class Qwen3TTSHandler(BaseHandler[TTSIn, TTSOut]):
coalesced_text, _language_code, _saw_end_of_response = self._coalesce_pending_tts_input(tts_input)
text = coalesced_text or "Hello."
raw_text = coalesced_text or "Hello."
# 2026-09-25 SANITIZE APIPILOT PRIMA DI TTS (doppia rete sicurezza):
# Anche se lato server S2s Python abbiamo gia' gate audio drop + sanitize,
# Qwen3-TTS puo' ricevere marker per race / nuovi formati. Quindi QUI
# eliminiamo / sostituiamo con placeholder PRIMA di chiamare generate().
text, found_marker, sanitize_tag = _tts_sanitize_apipilot_markers(raw_text)
if found_marker:
logger.warning(
"[TTS-APIPILOT-SANITIZE] marker=%s rawLen=%d sanitizedLen=%d → sostituito con placeholder spagnolo / strip.",
sanitize_tag, len(raw_text or ""), len(text or ""),
)
if not text.strip():
# Safety se il marker era l'unica cosa: ripetiamo placeholder invece di Hello.
text = _APIPILOT_SPANISH_PLACEHOLDER
model_type = self._model_type()
self._apply_session_voice_override(model_type, runtime_config, response)
+1 -1
View File
@@ -38,7 +38,7 @@ export S2S_PROXY_TARGET
echo "💻 Avvio dell'interfaccia S2S Client UI (Porta $UI_PORT)..."
echo "🔌 Proxy realtime verso $S2S_PROXY_TARGET"
echo "💡 Premi Ctrl+C in questo terminale per fermarla."
echo " Premi Ctrl+C in questo terminale per fermarla."
echo "----------------------------------------------------"
cd "$ROOT_DIR"