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" 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" 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> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const l = document.querySelector('link[rel="stylesheet"]'); const l = document.querySelector('link[rel="stylesheet"]');
if (l && !l.href.includes("v=202609041120")) { if (l && !l.href.includes("v=202609291430")) {
l.href = l.href.replace(/v=\d+/, "v=202609041120"); l.href = l.href.replace(/v=\d+/, "v=202609291430");
} }
}); });
</script> </script>
@@ -39,19 +127,33 @@
</div> </div>
<div id="app"> <div id="app">
<header class="topbar"> <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="brand">
<div class="ident"> <div class="ident">
<div class="ident-head"> <div class="ident-head">
<a class="ident-title">Vocero</a> <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>
<div id="ident-user-name" class="ident-user-name" hidden></div>
</div> </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 <!-- Left sidebar (reports) toggle: always visible, pinned left side of the
topbar control cluster. Clicking slides in the offcanvas sidebar on topbar control cluster. Clicking slides in the offcanvas sidebar on
mobile; it's a no-op hover hint on desktop where sidebar is always mobile; it's a no-op hover hint on desktop where sidebar is always
shown. --> 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> <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> <span id="reports-sidebar-badge" class="chat-badge" aria-hidden="true"></span>
</button> </button>
@@ -74,8 +176,34 @@
</div> </div>
</header> </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"> <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"> <div id="mic-gate" class="mic-gate">
<svg id="mic-gate-arc" class="mic-gate-arc" viewBox="0 0 100 100" aria-hidden="true"> <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" /> <path id="mga-track" class="mga-track" fill="none" />
@@ -134,6 +262,69 @@
</button> </button>
</div> </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-caption" class="circle-caption" role="status">Tap to start</p>
<p id="circle-subcaption" class="circle-subcaption" hidden></p> <p id="circle-subcaption" class="circle-subcaption" hidden></p>
@@ -147,31 +338,90 @@
</div> </div>
</main> </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). <!-- 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 Nota: ora è un blocco diretto del flex-col #app, non più un overlay
destro → non c'è più bisogno del bottone toggle in topbar. --> 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"> <div class="chat-panel-inner">
<header class="chat-panel-header"> <header class="chat-panel-header">
<h3>Conversación</h3> <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> </header>
<div id="chat-history" class="chat-history" role="log" aria-live="polite" aria-atomic="false"></div> <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"> <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" <input id="chat-input"
class="chat-input" class="chat-input"
type="text" type="text"
name="message" name="message"
placeholder="Escribe aquí tu mensaje a Vocero…" placeholder="Escribe un mensaje…"
aria-label="Enviar mensaje de texto a Vocero" aria-label="Enviar mensaje de texto a Vocero"
maxlength="500" maxlength="500"
/> />
<button id="chat-send" type="submit" class="chat-send-btn icon-btn" aria-label="Enviar mensaje"> <button id="chat-send" type="submit" class="chat-send-btn-round" 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> <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> </button>
</form> </form>
</div> </div>
</section> </div>
</div> </div>
<!-- Ephemeral chat bubbles anchored to the top-right --> <!-- Ephemeral chat bubbles anchored to the top-right -->
@@ -188,7 +438,7 @@
<!-- Left sidebar: reports programados. <!-- Left sidebar: reports programados.
Desktop: fixed left, 320px, always visible (below topbar + above sticky footer). Desktop: fixed left, 320px, always visible (below topbar + above sticky footer).
Mobile : off-canvas left overlay, toggled by #reports-sidebar-toggle button in topbar. --> 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 id="reports-sidebar-backdrop" class="reports-sidebar-backdrop" data-close-reports-sidebar></div>
<div class="reports-sidebar-inner"> <div class="reports-sidebar-inner">
<header class="reports-sidebar-header"> <header class="reports-sidebar-header">
@@ -199,7 +449,7 @@
</header> </header>
<!-- MOVED from inside .ident in the topbar — same #id so main.js syncVoceroHeaderUser() <!-- MOVED from inside .ident in the topbar — same #id so main.js syncVoceroHeaderUser()
continues to work with zero JS changes to reports rendering logic. --> 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). <!-- Debug-only: Spanish suggestions panel (gated behind ?debug=true).
Shown as the bottom block inside the left sidebar. --> Shown as the bottom block inside the left sidebar. -->
<div id="reports-suggestions" class="reports-suggestions" hidden data-debug-only="true"></div> <div id="reports-suggestions" class="reports-suggestions" hidden data-debug-only="true"></div>
@@ -259,7 +509,7 @@
</header> </header>
<div class="tab-panels"> <div class="tab-panels">
<section class="tab-panel active" role="tabpanel"> <div class="tab-panel active" role="tabpanel">
<label class="field" id="conn-field"> <label class="field" id="conn-field">
<span id="conn-label">Speech-to-speech server URL</span> <span id="conn-label">Speech-to-speech server URL</span>
<input id="lb-url" type="text" autocomplete="off" spellcheck="false" placeholder="http://localhost:port" /> <input id="lb-url" type="text" autocomplete="off" spellcheck="false" placeholder="http://localhost:port" />
@@ -327,7 +577,7 @@
</button> </button>
<small id="restart-hint">Connect first, then come back here to apply live changes.</small> <small id="restart-hint">Connect first, then come back here to apply live changes.</small>
</div> </div>
</section> </div>
</div> </div>
<footer class="modal-footer"> <footer class="modal-footer">
@@ -456,16 +706,16 @@
</button> </button>
<!-- Hero: big glass avatar circle + title + subtitle (email fallback). --> <!-- 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> <div id="user-profile-avatar" class="user-profile-avatar" aria-hidden="true">AV</div>
<h2 id="user-profile-title" class="user-profile-title"></h2> <h2 id="user-profile-title" class="user-profile-title"></h2>
<p id="user-profile-subtitle" class="user-profile-subtitle"></p> <p id="user-profile-subtitle" class="user-profile-subtitle"></p>
</section> </div>
<!-- Metadata: a 1-col definition-list-like grid. Empty rows are hidden <!-- 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 automatically via JS when the field has no value, so we never show
blank labels. --> 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> </div>
</dialog> </dialog>
@@ -473,6 +723,6 @@
<span class="app-footer-text">Azurian Vocero realtime ver.Beta 2.1</span> <span class="app-footer-text">Azurian Vocero realtime ver.Beta 2.1</span>
</footer> </footer>
<script type="module" src="main.js?v=202609041120"></script> <script type="module" src="main.js?v=202609102207"></script>
</body> </body>
</html> </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. // ESATTAMENTE quale versione di main.js è caricata nel browser.
// Se NON vedi questa riga in Console = stai ancora usando il file CACHATO VECCHIO. // Se NON vedi questa riga in Console = stai ancora usando il file CACHATO VECCHIO.
console.info( 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;" "background:#1e293b;color:#86efac;font-weight:700;padding:4px 8px;border-radius:4px;"
); );
console.info( console.info(
@@ -38,6 +38,218 @@ console.info(
// eslint-disable-next-line no-debugger // eslint-disable-next-line no-debugger
// debugger; // ← scommenta per fermarti QUI all'avvio se DevTools è aperto // 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, // 🛡️ GATE .topbar-right (about / tools / rag / chat / settings buttons,
// vedi index.html L36-L56) — 3-layer safety: // 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 // waiting for the user to say anything). Mirrors the server env
// S2S_START_SESSION_MODE. Empty / anything else = manual (tap the start). // S2S_START_SESSION_MODE. Empty / anything else = manual (tap the start).
let startSessionMode = ""; 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. // Custom RAG context entered by the user via the header "RAG" button dialog.
// Treated exactly like voceroUserContextText / injectProfile context: // Treated exactly like voceroUserContextText / injectProfile context:
@@ -799,6 +1016,8 @@ function initialsForName(rawName) {
const last = parts[parts.length - 1][0] ?? ""; const last = parts[parts.length - 1][0] ?? "";
return (first + last).toUpperCase(); return (first + last).toUpperCase();
} }
window._s2sExtractName = extractVoceroUserName;
window._s2sInitialsFor = initialsForName;
/* ─── User profile dialog (opened by clicking avatar in the header) ─── */ /* ─── User profile dialog (opened by clicking avatar in the header) ─── */
/** /**
@@ -1293,8 +1512,53 @@ function effectiveReasoningInstructions() {
instructions = instructions ? `${instructions} ${ragCtx}` : ragCtx; instructions = instructions ? `${instructions} ${ragCtx}` : ragCtx;
ragAppended = true; 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, () => { _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) { if (ragAppended) {
console.info( console.info(
`[CUSTOM-RAG] ✅ effectiveReasoningInstructions APPENDED custom RAG context inside LLM prompt. ragCtxLen=%d baseLen=%d profileAppended=%s -> finalLen=%d`, `[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) { 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, () => { _logOnce(key2, () => {
console.info( 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, instructions.length,
ctx ? `${ctx.length}c` : "none", ctx ? `${ctx.length}c` : "none",
ragCtx ? `${ragCtx.length}c` : "none", ragCtx ? `${ragCtx.length}c` : "none",
apipilotBlock ? `${apipilotCount}tools/${apipilotBlock.length}c` : "none",
instructions, instructions,
); );
if (ctx) { if (ctx) {
@@ -2196,6 +2461,53 @@ async function fetchVoceroUserProfile() {
Object.keys(voceroUserProfile).join(","), Object.keys(voceroUserProfile).join(","),
extractVoceroUserName(voceroUserProfile) || "(none)", 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 { } else {
console.info( console.info(
`[INJECT-PROFILE] ⏭️ BROWSER fetchVoceroUserProfile HTTP 200 but empty/non-object JSON. profileIsNull=%s keys=none`, `[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(); pinnedPrompt = (json.primaryPrompt || "").trim();
injectProfile = !!json.injectProfile; injectProfile = !!json.injectProfile;
debugPrompt = !!json.debugPrompt; 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). // Bootstrap-start mode: empty on generic deploys (tap to start).
startSessionMode = typeof json.startSessionMode === "string" startSessionMode = typeof json.startSessionMode === "string"
? json.startSessionMode.trim().toUpperCase() ? 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 // #region debug-point B:config-loaded
reportDebug("B", "main.js:fetchConfig", "loaded config from api/config", { reportDebug("B", "main.js:fetchConfig", "loaded config from api/config", {
reasoningType, reasoningType,
@@ -2317,8 +2668,46 @@ async function fetchConfig() {
injectProfile, injectProfile,
debugPrompt, debugPrompt,
startSessionMode, startSessionMode,
theme: s2sTheme || "dark",
apipilotEnabled,
}); });
// #endregion // #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. // The conversation-time limiter rides on the LB being present.
limiterOn = lbMode; limiterOn = lbMode;
} }
@@ -2326,11 +2715,221 @@ async function fetchConfig() {
} catch { } catch {
// Config endpoint unreachable (e.g. static hosting): keep direct entry. // 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}`); if (DEBUG) console.debug(`[ui] config: allowDirect=${allowDirect} lbMode=${lbMode}`);
// Login chip + remaining-budget (no-op / hidden when the limiter is off). // Login chip + remaining-budget (no-op / hidden when the limiter is off).
void account.refresh(); void account.refresh();
syncToolsUi(); syncToolsUi();
syncConnectionUi(); 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 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 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>`; 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 { export class ChatView {
constructor() { constructor() {
@@ -58,6 +71,11 @@ export class ChatView {
this._chatHistory = $("#chat-history"); this._chatHistory = $("#chat-history");
/** @type {HTMLElement} */ /** @type {HTMLElement} */
this._bubbleStack = $("#bubble-stack"); 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). // Inline chat is always "open" (permanently visible on screen).
this._panelOpen = true; 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 ─────────────────────────────────────────────────────────────── // ── Panel ───────────────────────────────────────────────────────────────
_openPanel() { _openPanel() {
@@ -213,6 +280,18 @@ export class ChatView {
* Build a role-labelled message element. Ephemeral bubbles and persistent * Build a role-labelled message element. Ephemeral bubbles and persistent
* history rows share the same shape and differ only in their class prefix * history rows share the same shape and differ only in their class prefix
* (`bubble`/`bubble-*` vs `hist-msg`/`hist-*`). * (`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 * @param {{ container: string, prefix: string, role: "user"|"assistant", text: string, partial?: boolean }} o
* @returns {HTMLElement} * @returns {HTMLElement}
*/ */
@@ -220,7 +299,27 @@ export class ChatView {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = `${container} ${role}`; el.className = `${container} ${role}`;
const label = role === "user" ? "You" : "Assistant"; 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>`; 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; return el;
} }
@@ -417,6 +516,186 @@ export class ChatView {
this._asstByResp.clear(); 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 ───────────────────────────────────────────────── // ── Client event handlers ─────────────────────────────────────────────────
/** /**
+467 -18
View File
@@ -53,6 +53,187 @@ function reportWsDebug(hypothesisId, location, msg, data = {}) {
void 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 * @typedef {Object} WsClientOptions
* @property {string} [sessionUrl] URL to POST for the session handshake (returns * @property {string} [sessionUrl] URL to POST for the session handshake (returns
@@ -275,6 +456,12 @@ export class S2sWsRealtimeClient extends EventTarget {
*/ */
async connect() { async connect() {
if (this._ws) throw new Error("Already connected"); 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; let connectUrl;
if (this._directUrl) { 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 // 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). // click is a fresh gesture (re-arms the AudioContext on iOS).
if (waited) { if (waited) {
_wsLog(1, "QUEUE", `${sessionIdShort} tuo turno — attesa click join per confermare slot`);
await this._awaitJoin(grant); await this._awaitJoin(grant);
if (this._closed) throw _codedError("connect aborted", "aborted"); if (this._closed) throw _codedError("connect aborted", "aborted");
} }
@@ -308,10 +496,101 @@ export class S2sWsRealtimeClient extends EventTarget {
this.options.micStream = await this._acquireMic(); 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 audioReady = this._setupAudio();
const wsReady = this._openWebSocket(connectUrl); 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() { async _postSession() {
const url = this._sessionUrl; const url = this._sessionUrl;
console.log("[ws] POST", url); console.log("[ws] POST", url);
_wsLog(0, "SESSION", `POST /session → ${url}`);
const t0 = performance.now();
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: "{}", body: "{}",
}); });
const dur = (performance.now() - t0).toFixed(1);
if (response.status === 402) { 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(() => ({})); 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 }); throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
} }
if (response.status === 503) { if (response.status === 503) {
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
if (body?.state === "at_capacity") { 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"); 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)"); throw new Error("/session failed (503)");
} }
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ""); 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}`); throw new Error(`/session failed (${response.status}): ${text}`);
} }
const json = await response.json(); const json = await response.json();
if (json.state === "queued") { 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 { return {
state: "queued", state: "queued",
queueId: json.queue_id, queueId: json.queue_id,
@@ -412,7 +701,13 @@ export class S2sWsRealtimeClient extends EventTarget {
pollIntervalS: json.poll_interval_s, 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; const intervalMs = Math.max(1, ticket.pollIntervalS || 2) * 1000;
this._queueId = ticket.queueId; this._queueId = ticket.queueId;
this._emitQueue(ticket.position); 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) { while (true) {
pollNum += 1;
await this._queueSleep(intervalMs); await this._queueSleep(intervalMs);
if (this._closed) throw _codedError("queue wait aborted", "aborted"); if (this._closed) throw _codedError("queue wait aborted", "aborted");
@@ -442,9 +744,11 @@ export class S2sWsRealtimeClient extends EventTarget {
if (response.status === 402) { if (response.status === 402) {
const body = await response.json().catch(() => ({})); 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 }); throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
} }
if (response.status === 404) { 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"); throw _codedError("Queue timed out", "queue-expired");
} }
if (!response.ok) continue; // 502/503 — transient, retry if (!response.ok) continue; // 502/503 — transient, retry
@@ -452,10 +756,14 @@ export class S2sWsRealtimeClient extends EventTarget {
const json = await response.json().catch(() => null); const json = await response.json().catch(() => null);
if (!json) continue; if (!json) continue;
if (json.state === "queued") { 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); this._emitQueue(json.position);
continue; continue;
} }
// Reached the front and claimed a slot. // Reached the front and claimed a slot.
_wsLog(1, "QUEUE:FRONT", `poll #${pollNum} — RAGGIUNTA la prima posizione. Assegno slot compute.`);
this._queueId = ""; this._queueId = "";
return this._parseGrant(json); return this._parseGrant(json);
} }
@@ -573,6 +881,23 @@ export class S2sWsRealtimeClient extends EventTarget {
/** @param {string} connectUrl */ /** @param {string} connectUrl */
_openWebSocket(connectUrl) { _openWebSocket(connectUrl) {
return new Promise((resolve, reject) => { 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); const ws = new WebSocket(connectUrl);
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
this._ws = ws; this._ws = ws;
@@ -580,11 +905,13 @@ export class S2sWsRealtimeClient extends EventTarget {
const onceOpen = () => { const onceOpen = () => {
ws.removeEventListener("open", onceOpen); ws.removeEventListener("open", onceOpen);
ws.removeEventListener("error", onceErr); ws.removeEventListener("error", onceErr);
_wsLog(1, "CONNECT", `WebSocket APERTO su ${maskedUrl} readyState=${ws.readyState}`);
resolve(); resolve();
}; };
const onceErr = (e) => { const onceErr = (e) => {
ws.removeEventListener("open", onceOpen); ws.removeEventListener("open", onceOpen);
ws.removeEventListener("error", onceErr); 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"}`)); reject(new Error(`WebSocket failed to open: ${e?.type ?? "error"}`));
}; };
ws.addEventListener("open", onceOpen); ws.addEventListener("open", onceOpen);
@@ -594,6 +921,7 @@ export class S2sWsRealtimeClient extends EventTarget {
ws.addEventListener("close", (e) => this._onWsClose(e)); ws.addEventListener("close", (e) => this._onWsClose(e));
ws.addEventListener("error", (e) => { ws.addEventListener("error", (e) => {
console.error("[ws] socket 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; const type = event?.type;
if (typeof type !== "string") return; 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 // Opt-in event tracing for diagnosing turn/transcript issues. Enable with
// `localStorage.setItem("s2s.debug", "1")` in the browser console. // `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") const extra = type.startsWith("conversation.item.input_audio_transcription")
? ` item=${event.item_id} ci=${event.content_index} ${event.delta ?? event.transcript ?? ""}` ? ` item=${event.item_id} ci=${event.content_index} ${event.delta ?? event.transcript ?? ""}`
: type.startsWith("response.") : type.startsWith("response.")
@@ -1008,6 +1360,11 @@ export class S2sWsRealtimeClient extends EventTarget {
/** @param {CloseEvent} ev */ /** @param {CloseEvent} ev */
_onWsClose(ev) { _onWsClose(ev) {
console.log("[ws] socket closed:", ev.code, ev.reason); 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 (this._status === "closed" || this._status === "error") return;
if (ev.code === 1000) { if (ev.code === 1000) {
this._setStatus("closed"); this._setStatus("closed");
@@ -1037,6 +1394,38 @@ export class S2sWsRealtimeClient extends EventTarget {
output: { voice: this.options.voice }, 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 // Tools are declared here; the backend already accepts them in
// session.update and emits response.function_call_arguments.done when the // session.update and emits response.function_call_arguments.done when the
// model decides to call one. Only include the keys when we actually have // 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", { reportWsDebug("B", "s2s-ws-client.js:_sendSessionUpdate", "sending initial session.update", {
toolCount: this._tools.length, toolCount: this._tools.length,
toolNames: this._tools.map((tool) => tool.name), toolNames: this._tools.map((tool) => tool.name),
hasInstructions: !!this.options.instructions, hasInstructions: !!session.instructions,
instructionLength: (this.options.instructions || "").length, instructionLength: (session.instructions || "").length,
apipilotPass1Present: instructionsAlreadyHasApipilotPass1(session.instructions),
}); });
// #endregion // #endregion
this._send({ type: "session.update", session }); this._send({ type: "session.update", session });
@@ -1061,14 +1451,35 @@ export class S2sWsRealtimeClient extends EventTarget {
updateSession(patch) { updateSession(patch) {
/** @type {Record<string, any>} */ /** @type {Record<string, any>} */
const session = { type: "realtime" }; 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 (patch.voice) session.audio = { output: { voice: patch.voice } };
if (Object.keys(session).length > 1) { if (Object.keys(session).length > 1) {
// #region debug-point C:update-session // #region debug-point C:update-session
reportWsDebug("C", "s2s-ws-client.js:updateSession", "sending patch session.update", { reportWsDebug("C", "s2s-ws-client.js:updateSession", "sending patch session.update", {
hasInstructions: !!patch.instructions, hasInstructions: !!session.instructions,
instructionLength: (patch.instructions || "").length, instructionLength: (session.instructions || "").length,
hasVoice: !!patch.voice, hasVoice: !!patch.voice,
apipilotPass1Present: typeof session.instructions === "string" && instructionsAlreadyHasApipilotPass1(session.instructions),
}); });
// #endregion // #endregion
this._send({ type: "session.update", session }); this._send({ type: "session.update", session });
@@ -1365,14 +1776,46 @@ export class S2sWsRealtimeClient extends EventTarget {
/** @param {Record<string, unknown>} event */ /** @param {Record<string, unknown>} event */
_send(event) { _send(event) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return; 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 payloadLength = 0;
let stringifyMs = 0; let stringifyMs = 0;
let payload = ""; let payload = "";
const startedAt = performance.now();
payload = JSON.stringify(event);
stringifyMs = performance.now() - startedAt;
payloadLength = payload.length;
if (event.type === "conversation.item.create") { 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 // #region debug-point A:conversation-item-create
reportWsDebug("A", "s2s-ws-client.js:_send", "sending conversation.item.create payload", { reportWsDebug("A", "s2s-ws-client.js:_send", "sending conversation.item.create payload", {
payloadLength, payloadLength,
@@ -1380,11 +1823,17 @@ export class S2sWsRealtimeClient extends EventTarget {
}); });
// #endregion // #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")) { 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=${payload.slice(0, 400)}`);
console.debug(`[Vocero WS OUT] type=${event.type} payload=${preview}`);
} }
this._ws.send(payload || JSON.stringify(event)); this._ws.send(payload);
} }
async close() { async close() {
+90 -6
View File
@@ -4,16 +4,80 @@ LLM Output Processor
Intercepts LLM output to: Intercepts LLM output to:
1. Extract tool calls and send them via text_output_queue 1. Extract tool calls and send them via text_output_queue
2. Forward clean text to TTS pipeline 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 from __future__ import annotations
import logging import logging
import re
from collections.abc import Iterator from collections.abc import Iterator
from queue import Queue from queue import Queue
from speech_to_speech.baseHandler import BaseHandler from speech_to_speech.baseHandler import BaseHandler
from speech_to_speech.pipeline.events import AssistantTextEvent, ResponseFailedEvent, TokenUsageEvent 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.handler_types import LLMOut, TTSIn
from speech_to_speech.pipeline.messages import EndOfResponse, LLMResponseChunk, TokenUsage, TTSInput from speech_to_speech.pipeline.messages import EndOfResponse, LLMResponseChunk, TokenUsage, TTSInput
from speech_to_speech.pipeline.queue_types import TextEventItem 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}") 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: if self.text_output_queue is not None:
event = AssistantTextEvent( event = AssistantTextEvent(
text=lm_output.text, text=clean_text,
turn_id=lm_output.turn_id, turn_id=lm_output.turn_id,
turn_revision=lm_output.turn_revision, turn_revision=lm_output.turn_revision,
cancel_generation=lm_output.cancel_generation, cancel_generation=lm_output.cancel_generation,
) )
if lm_output.tools: if lm_output.tools:
event.tools = 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: 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) self.text_output_queue.put(event)
if lm_output.text and response_wants_audio(lm_output.response): if clean_text and response_wants_audio(lm_output.response):
logger.debug(f"Forwarding to TTS: '{lm_output.text}'") logger.debug(f"Forwarding to TTS: '{clean_text}' (original marker was: {sanitize_tag if any_sanitized else 'none'})")
yield TTSInput( yield TTSInput(
text=lm_output.text, text=clean_text,
language_code=lm_output.language_code, language_code=lm_output.language_code,
runtime_config=lm_output.runtime_config, runtime_config=lm_output.runtime_config,
response=lm_output.response, response=lm_output.response,
+86 -1
View File
@@ -12,6 +12,78 @@ import math
import re import re
import tempfile import tempfile
import unicodedata 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 collections.abc import Callable
from pathlib import Path from pathlib import Path
from sys import platform 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) 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() model_type = self._model_type()
self._apply_session_voice_override(model_type, runtime_config, response) 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 "💻 Avvio dell'interfaccia S2S Client UI (Porta $UI_PORT)..."
echo "🔌 Proxy realtime verso $S2S_PROXY_TARGET" 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 "----------------------------------------------------" echo "----------------------------------------------------"
cd "$ROOT_DIR" cd "$ROOT_DIR"