feat: integrate ApiPilot tools and add light-theme voice UI
This commit is contained in:
+270
-20
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user