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"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="style.css?v=202609041120" />
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
ANTI-FOUC VEIL (S2S_THEME flash fix) — MUST stay INLINE and
|
||||
BEFORE the <link rel="stylesheet" href="style.css"> tag below.
|
||||
|
||||
Problem reported by user 2026-09-10:
|
||||
"quando faccio refresh per un momento si vede il theme precedente
|
||||
quello purple"
|
||||
|
||||
The earlier guard `html:not([data-theme]) [data-theme-only]` only
|
||||
hid the two parallel widget branches, but the UNTHEMED DEFAULTS in
|
||||
style.css (:root purple palette, body background #07080d, topbar
|
||||
glass, #app deep-space colours, purple orb border gradients) were
|
||||
still rendering for the ~1-2 frames between body paint commit and
|
||||
fetchConfig() returning api/config.theme → setAttribute("data-theme").
|
||||
The user saw a dark-purple flash before the white Vocero theme
|
||||
applied — exactly the symptom described.
|
||||
|
||||
FIX (3 layers, fail-safe):
|
||||
1. While html has NO data-theme attribute (pre-fetchConfig),
|
||||
render <body> COMPLETELY INVISIBLE + background:white +
|
||||
border:none (the two most visible paint leaks fixed first).
|
||||
Display:none would block CSS transitions from being recorded;
|
||||
visibility:hidden keeps layout but forbids painting entirely,
|
||||
which is exactly what we want.
|
||||
2. Safety timeout: if fetchConfig() fails to resolve (e.g.
|
||||
static hosting / no server / offline) the veil auto-lifts
|
||||
after 2500 ms so the user is NOT stuck on a blank page.
|
||||
3. Immediately after root.setAttribute("data-theme", …) in
|
||||
fetchConfig() we also add class="theme-ready" which the veil
|
||||
additionally checks so the user sees a 1-frame lift, not a
|
||||
delayed paint. The visibility guard is a double-OR so it
|
||||
expires on EITHER signal.
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<style id="s2s-anti-fouc-veil">
|
||||
html:not([data-theme]) body,
|
||||
html:not(.theme-ready) body {
|
||||
visibility: hidden !important;
|
||||
background: #ffffff !important;
|
||||
color: #0f172a !important;
|
||||
opacity: 0 !important;
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
html:not([data-theme]),
|
||||
html:not(.theme-ready) {
|
||||
background: #ffffff !important;
|
||||
}
|
||||
/* Also kill the purple orb + any glass chrome during the veil window,
|
||||
because even when <body> is visibility:hidden some browsers do a
|
||||
first compositing pass that leaks a few pixels of background. */
|
||||
html:not([data-theme]) #main-circle,
|
||||
html:not([data-theme]) .orb-wrap,
|
||||
html:not([data-theme]) .stage,
|
||||
html:not([data-theme]) .topbar,
|
||||
html:not([data-theme]) #app,
|
||||
html:not(.theme-ready) #main-circle,
|
||||
html:not(.theme-ready) .orb-wrap,
|
||||
html:not(.theme-ready) .stage,
|
||||
html:not(.theme-ready) .topbar,
|
||||
html:not(.theme-ready) #app {
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Fail-safe veil lift: if fetchConfig() fails to reply (static hosting,
|
||||
// offline, api/config 500) the user never sees <body> stuck invisible.
|
||||
// setTimeout (not rAF) because we want a real wall-clock timeout.
|
||||
window.__s2sVeilLiftTimeout = window.setTimeout(function () {
|
||||
var r = document.documentElement;
|
||||
if (!r.hasAttribute("data-theme")) r.setAttribute("data-theme", "dark");
|
||||
r.classList.add("theme-ready");
|
||||
}, 2500);
|
||||
// Also try to clear the fail-safe as soon as DOMContentLoaded fires so
|
||||
// we don't accidentally fire the lift AFTER a normal-config path.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var r = document.documentElement;
|
||||
if (r.hasAttribute("data-theme") && !r.classList.contains("theme-ready")) {
|
||||
r.classList.add("theme-ready");
|
||||
if (window.__s2sVeilLiftTimeout) window.clearTimeout(window.__s2sVeilLiftTimeout);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css?v=202609291430" />
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const l = document.querySelector('link[rel="stylesheet"]');
|
||||
if (l && !l.href.includes("v=202609041120")) {
|
||||
l.href = l.href.replace(/v=\d+/, "v=202609041120");
|
||||
if (l && !l.href.includes("v=202609291430")) {
|
||||
l.href = l.href.replace(/v=\d+/, "v=202609291430");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -39,19 +127,33 @@
|
||||
</div>
|
||||
<div id="app">
|
||||
<header class="topbar">
|
||||
<!-- Hamburger (theme=white only): icon button 3 horizontal lines, sits left of brand cluster, top-left corner, square icon, dark text, no visible borders.
|
||||
Mirrors the 3-line menu icon in the screenshot header. -->
|
||||
<button id="menu-btn" class="icon-btn menu-btn" type="button" title="Menú" aria-label="Abrir menú" hidden data-theme-only="white">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="ident">
|
||||
<div class="ident-head">
|
||||
<a class="ident-title">Vocero</a>
|
||||
<!-- Subtitle shown below Vocero title (theme=white only) -->
|
||||
<p class="ident-subtitle" hidden data-theme-only="white">Agente conversacional</p>
|
||||
</div>
|
||||
<div id="ident-user-name" class="ident-user-name" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User avatar circle (theme=white only): grid item next to the brand, not
|
||||
absolute-positioned → eliminates overlap with title/subtitle entirely.
|
||||
Same element id so main.js syncVoceroHeaderUser() still fills the text. -->
|
||||
<div id="ident-user-name" class="ident-user-name" hidden data-theme-only="white" aria-hidden="true">AV</div>
|
||||
<!-- Left sidebar (reports) toggle: always visible, pinned left side of the
|
||||
topbar control cluster. Clicking slides in the offcanvas sidebar on
|
||||
mobile; it's a no-op hover hint on desktop where sidebar is always
|
||||
shown. -->
|
||||
<button id="reports-sidebar-toggle" class="icon-btn" title="Reports programados" aria-label="Abrir panel de reportes" aria-expanded="false" aria-controls="reports-sidebar" hidden data-reports-sidebar-toggle data-debug-only="true">
|
||||
<button id="reports-sidebar-toggle" class="icon-btn" title="Reports programados" aria-label="Abrir panel de reportes" aria-expanded="false" aria-controls="reports-sidebar" hidden data-reports-sidebar-toggle data-debug-only="true" data-theme-only="dark">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z" fill="none"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
||||
<span id="reports-sidebar-badge" class="chat-badge" aria-hidden="true"></span>
|
||||
</button>
|
||||
@@ -74,8 +176,34 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<a class="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">Enviar feedback</a>
|
||||
|
||||
<!-- Theme=white only: Status card between topbar and stage
|
||||
"Listo para conversar / Informes, análisis, datos y mucho más."
|
||||
with light-blue chart icon and chevron-right arrow. -->
|
||||
<div class="status-card" hidden data-theme-only="white" aria-label="Estado de la sesión">
|
||||
<div class="status-card-icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="6" y1="20" x2="6" y2="12"/>
|
||||
<line x1="11" y1="20" x2="11" y2="6"/>
|
||||
<line x1="16" y1="20" x2="16" y2="15"/>
|
||||
<line x1="21" y1="20" x2="21" y2="10"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="status-card-text">
|
||||
<h2 class="status-card-title">Listo para conversar</h2>
|
||||
<p class="status-card-sub">Informes, análisis, datos y mucho más.</p>
|
||||
</div>
|
||||
<button class="status-card-arrow icon-btn" type="button" aria-label="Ver más detalles" title="Detalles">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main class="stage">
|
||||
<div class="orb-wrap">
|
||||
<!-- DARK THEME orb (purple gradient + purple icons): shown only on native dark theme, hidden in white -->
|
||||
<div class="orb-wrap" data-theme-only="dark">
|
||||
<div id="mic-gate" class="mic-gate">
|
||||
<svg id="mic-gate-arc" class="mic-gate-arc" viewBox="0 0 100 100" aria-hidden="true">
|
||||
<path id="mga-track" class="mga-track" fill="none" />
|
||||
@@ -134,6 +262,69 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- WHITE THEME mic stage (replaces the purple orb)
|
||||
- Blue mic circular button (116 px, deep blue, white mic)
|
||||
- Two waveform banks left + right (audio-reactive CSS bars)
|
||||
- "Escuchando..." status text
|
||||
- "Detener" pill button below.
|
||||
Mirrors the screenshot center UI exactly. -->
|
||||
<div class="white-mic-stage" hidden data-theme-only="white" aria-label="Control de voz">
|
||||
<!-- Waveform left side: 7 bars mirror right side, heights driven by JS vars -->
|
||||
<div class="white-waveform white-waveform-left" aria-hidden="true">
|
||||
<span class="w-bar wb-1" style="--bar-h: 0.4"></span>
|
||||
<span class="w-bar wb-2" style="--bar-h: 0.65"></span>
|
||||
<span class="w-bar wb-3" style="--bar-h: 0.9"></span>
|
||||
<span class="w-bar wb-4" style="--bar-h: 1"></span>
|
||||
<span class="w-bar wb-5" style="--bar-h: 0.7"></span>
|
||||
<span class="w-bar wb-6" style="--bar-h: 0.55"></span>
|
||||
<span class="w-bar wb-7" style="--bar-h: 0.3"></span>
|
||||
</div>
|
||||
|
||||
<!-- Big circular mic button (theme=white). Click = start/stop voice session.
|
||||
Forwards all its clicks to the real #main-circle so the entire WS
|
||||
pipeline still works. -->
|
||||
<button id="white-mic-circle"
|
||||
class="white-mic-circle state-idle"
|
||||
type="button"
|
||||
aria-label="Iniciar conversación de voz">
|
||||
<span class="wmc-halo" aria-hidden="true"></span>
|
||||
<span class="wmc-ring" aria-hidden="true"></span>
|
||||
<span class="wmc-core" aria-hidden="true"></span>
|
||||
<svg class="wmc-icon wmc-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2" width="6" height="12" rx="3" fill="currentColor" stroke="none"/>
|
||||
<path d="M5 10a7 7 0 0 0 14 0"/>
|
||||
<line x1="12" y1="19" x2="12" y2="22"/>
|
||||
<line x1="8" y1="22" x2="16" y2="22"/>
|
||||
</svg>
|
||||
<svg class="wmc-icon wmc-stop" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
<span class="wmc-icon wmc-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<!-- Waveform right side: 7 bars mirroring left -->
|
||||
<div class="white-waveform white-waveform-right" aria-hidden="true">
|
||||
<span class="w-bar wb-1" style="--bar-h: 0.3"></span>
|
||||
<span class="w-bar wb-2" style="--bar-h: 0.55"></span>
|
||||
<span class="w-bar wb-3" style="--bar-h: 0.7"></span>
|
||||
<span class="w-bar wb-4" style="--bar-h: 1"></span>
|
||||
<span class="w-bar wb-5" style="--bar-h: 0.9"></span>
|
||||
<span class="w-bar wb-6" style="--bar-h: 0.65"></span>
|
||||
<span class="w-bar wb-7" style="--bar-h: 0.4"></span>
|
||||
</div>
|
||||
|
||||
<!-- Status caption below mic circle: "Escuchando...", "Pensando..." etc. -->
|
||||
<p id="white-mic-caption" class="white-mic-caption" role="status">Escuchando…</p>
|
||||
|
||||
<!-- Secondary pill "Detener" (Stop) below caption, with dark stop-square icon. -->
|
||||
<button id="white-stop-btn"
|
||||
class="white-stop-btn"
|
||||
type="button"
|
||||
aria-label="Detener"
|
||||
title="Detener sesión de voz">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
<span>Detener</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="circle-caption" class="circle-caption" role="status">Tap to start</p>
|
||||
<p id="circle-subcaption" class="circle-subcaption" hidden></p>
|
||||
|
||||
@@ -147,31 +338,90 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Theme=white only: Quick action pills below mic area
|
||||
"Resumen" · "Consultar datos" · "Crear respuesta" each with icon -->
|
||||
<div class="quick-actions" hidden data-theme-only="white" aria-label="Acciones rápidas">
|
||||
<button class="quick-action-btn" type="button" data-action="resumen">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13"/>
|
||||
<line x1="8" y1="17" x2="14" y2="17"/>
|
||||
</svg>
|
||||
<span>Resumen</span>
|
||||
</button>
|
||||
<button class="quick-action-btn" type="button" data-action="consultar-datos">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="6" y1="20" x2="6" y2="12"/>
|
||||
<line x1="11" y1="20" x2="11" y2="6"/>
|
||||
<line x1="16" y1="20" x2="16" y2="15"/>
|
||||
<line x1="21" y1="20" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<span>Consultar datos</span>
|
||||
</button>
|
||||
<button class="quick-action-btn" type="button" data-action="crear-respuesta">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="18" y1="2" x2="22" y2="6"/>
|
||||
<path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"/>
|
||||
</svg>
|
||||
<span>Crear respuesta</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat di testo SEMPRE VISIBILE (fissa in basso, tra stage e footer).
|
||||
Nota: ora è un blocco diretto del flex-col #app, non più un overlay
|
||||
destro → non c'è più bisogno del bottone toggle in topbar. -->
|
||||
<section id="chat-panel" class="chat-panel chat-panel-inline" aria-label="Chat de texto">
|
||||
<div id="chat-panel" class="chat-panel chat-panel-inline" aria-label="Chat de texto">
|
||||
<div class="chat-panel-inner">
|
||||
<header class="chat-panel-header">
|
||||
<h3>Conversación</h3>
|
||||
<div class="chat-header-actions" aria-label="Acciones de la conversación">
|
||||
<!-- "En línea" status pill (green dot + text) — theme=white only.
|
||||
Mirrors the screenshot chat header top-right corner. -->
|
||||
<span class="chat-status-pill" hidden data-theme-only="white" role="status" aria-live="polite">
|
||||
<span class="chat-status-dot" aria-hidden="true"></span>
|
||||
<span>En línea</span>
|
||||
</span>
|
||||
<button id="chat-export-btn" class="chat-export-btn icon-btn"
|
||||
type="button"
|
||||
aria-label="Descargar conversación"
|
||||
title="Descargar conversación (JSON + TXT)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="chat-history" class="chat-history" role="log" aria-live="polite" aria-atomic="false"></div>
|
||||
<!-- Text input row HIDDEN per user request 2026-09-04 (restore removing hidden tag below) -->
|
||||
<!-- Text input row — HIDDEN in dark theme per user request 2026-09-04.
|
||||
In white theme VISIBLE with a new design (clip button + rounded input + blue circular send),
|
||||
which mirrors the screenshot footer input bar.
|
||||
Restore the dark-theme input by removing the `hidden` attr + style below. -->
|
||||
<form id="chat-form" class="chat-form" autocomplete="off" spellcheck="false" hidden style="display:none!important">
|
||||
<button id="chat-attach-btn" class="chat-attach-btn icon-btn" type="button" aria-label="Adjuntar archivo" title="Adjuntar">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input id="chat-input"
|
||||
class="chat-input"
|
||||
type="text"
|
||||
name="message"
|
||||
placeholder="Escribe aquí tu mensaje a Vocero…"
|
||||
placeholder="Escribe un mensaje…"
|
||||
aria-label="Enviar mensaje de texto a Vocero"
|
||||
maxlength="500"
|
||||
/>
|
||||
<button id="chat-send" type="submit" class="chat-send-btn icon-btn" aria-label="Enviar mensaje">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
<button id="chat-send" type="submit" class="chat-send-btn-round" aria-label="Enviar mensaje">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ephemeral chat bubbles anchored to the top-right -->
|
||||
@@ -188,7 +438,7 @@
|
||||
<!-- Left sidebar: reports programados.
|
||||
Desktop: fixed left, 320px, always visible (below topbar + above sticky footer).
|
||||
Mobile : off-canvas left overlay, toggled by #reports-sidebar-toggle button in topbar. -->
|
||||
<aside id="reports-sidebar" class="reports-sidebar" aria-label="Reportes programados" hidden>
|
||||
<aside id="reports-sidebar" class="reports-sidebar" aria-label="Reportes programados" hidden data-theme-only="dark">
|
||||
<div id="reports-sidebar-backdrop" class="reports-sidebar-backdrop" data-close-reports-sidebar></div>
|
||||
<div class="reports-sidebar-inner">
|
||||
<header class="reports-sidebar-header">
|
||||
@@ -199,7 +449,7 @@
|
||||
</header>
|
||||
<!-- MOVED from inside .ident in the topbar — same #id so main.js syncVoceroHeaderUser()
|
||||
continues to work with zero JS changes to reports rendering logic. -->
|
||||
<div id="reports-hint" class="reports-hint" hidden></div>
|
||||
<div id="reports-hint" class="reports-hint" hidden data-theme-only="dark"></div>
|
||||
<!-- Debug-only: Spanish suggestions panel (gated behind ?debug=true).
|
||||
Shown as the bottom block inside the left sidebar. -->
|
||||
<div id="reports-suggestions" class="reports-suggestions" hidden data-debug-only="true"></div>
|
||||
@@ -259,7 +509,7 @@
|
||||
</header>
|
||||
|
||||
<div class="tab-panels">
|
||||
<section class="tab-panel active" role="tabpanel">
|
||||
<div class="tab-panel active" role="tabpanel">
|
||||
<label class="field" id="conn-field">
|
||||
<span id="conn-label">Speech-to-speech server URL</span>
|
||||
<input id="lb-url" type="text" autocomplete="off" spellcheck="false" placeholder="http://localhost:port" />
|
||||
@@ -327,7 +577,7 @@
|
||||
</button>
|
||||
<small id="restart-hint">Connect first, then come back here to apply live changes.</small>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="modal-footer">
|
||||
@@ -456,16 +706,16 @@
|
||||
</button>
|
||||
|
||||
<!-- Hero: big glass avatar circle + title + subtitle (email fallback). -->
|
||||
<section class="user-profile-hero">
|
||||
<div class="user-profile-hero">
|
||||
<div id="user-profile-avatar" class="user-profile-avatar" aria-hidden="true">AV</div>
|
||||
<h2 id="user-profile-title" class="user-profile-title"></h2>
|
||||
<p id="user-profile-subtitle" class="user-profile-subtitle"></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Metadata: a 1-col definition-list-like grid. Empty rows are hidden
|
||||
automatically via JS when the field has no value, so we never show
|
||||
blank labels. -->
|
||||
<section id="user-profile-metadata" class="user-profile-metadata" aria-label="Informazioni utente"></section>
|
||||
<div id="user-profile-metadata" class="user-profile-metadata" aria-label="Informazioni utente"></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -473,6 +723,6 @@
|
||||
<span class="app-footer-text">Azurian Vocero realtime ver.Beta 2.1</span>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="main.js?v=202609041120"></script>
|
||||
<script type="module" src="main.js?v=202609102207"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+603
-4
@@ -26,7 +26,7 @@ import { Account } from "./ui/account.js?v=202609041120";
|
||||
// ESATTAMENTE quale versione di main.js è caricata nel browser.
|
||||
// Se NON vedi questa riga in Console = stai ancora usando il file CACHATO VECCHIO.
|
||||
console.info(
|
||||
"%c🟢 VOCERO main.js loaded. version=2026-09-04 11:20 (FIX HEADER TRONCO: body safe-area-inset top/right/left 10px min + topbar padding-top 16 + margin-top safe. FIX SCROLL INSUFFICIENTE: chat-history padding-bottom 96→160px; chat-panel clamp 40-96→60-160; #app padding-bottom 56→120px+safe; footer safe-area bottom; step 4 retry +250ms (5 step tot); MAX_SAFE_INTEGER invece di 9M)",
|
||||
"%c🟢 VOCERO main.js loaded. version=2026-09-24 23:12 (DEBUG FETCH WRAPPER+WS SEMPRE-ATTIVO; APIPILOT: demo flag=true + PREFETCH AUTOMATICO OGNI SESSIONE WS + PREFETCH DOPO LOGIN-BY-PHONE OK (refresh=true per invalidare cache server anonima); cache client per-utente; cambio phone = invalidate automatico; export chat; fix scroll).",
|
||||
"background:#1e293b;color:#86efac;font-weight:700;padding:4px 8px;border-radius:4px;"
|
||||
);
|
||||
console.info(
|
||||
@@ -38,6 +38,218 @@ console.info(
|
||||
// eslint-disable-next-line no-debugger
|
||||
// debugger; // ← scommenta per fermarti QUI all'avvio se DevTools è aperto
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🔎 FETCH WRAPPER (SEMANTIC DEBUG LOG) — attivo SEMPRE (non solo in DEBUG)
|
||||
// Ogni chiamata `fetch` lato browser viene intercettata e loggata con:
|
||||
// • URL risolto, metodo, dimensioni body + preview JSON
|
||||
// • status HTTP (colorato verde/giallo/rosso a seconda della classe)
|
||||
// • durata (performance.now), dimensione + preview risposta JSON
|
||||
// Tag semantici per filtrare veloce la Console:
|
||||
// [FETCH:CONFIG] [FETCH:REASONING] [FETCH:SEARCH]
|
||||
// [FETCH:PROFILE] [FETCH:REPORTS] [FETCH:APIPILOT]
|
||||
// [FETCH:SESSION] [FETCH:QUEUE] [FETCH:ME]
|
||||
// ---------------------------------------------------------------------------
|
||||
(function _installFetchDebugTracer() {
|
||||
const ORIGINAL_FETCH = window.fetch.bind(window);
|
||||
// Classifica endpoint in tag leggibile (stesso ordine di @app route in server.py).
|
||||
/** @param {string} url */
|
||||
function _tagFor(url) {
|
||||
try {
|
||||
const path = url.startsWith("/") || url.startsWith("./") || url.startsWith("api/")
|
||||
? "/" + url.replace(/^\.?\//, "").replace(/^\//, "")
|
||||
: new URL(url, window.location.href).pathname;
|
||||
if (/\/api\/apipilot\/tools\/?$/i.test(path)) return "APIPILOT:TOOLS";
|
||||
if (/\/api\/apipilot\/execute\/?$/i.test(path)) return "APIPILOT:EXEC";
|
||||
if (/\/api\/config\/?$/i.test(path)) return "CONFIG";
|
||||
if (/\/api\/reasoning\/?$/i.test(path)) return "REASONING";
|
||||
if (/\/api\/search\/?$/i.test(path)) return "SEARCH";
|
||||
if (/\/api\/user\/phone\/?($|\?)/i.test(path)) return "PROFILE";
|
||||
if (/\/api\/user\/reports\/?($|\?)/i.test(path)) return "REPORTS";
|
||||
if (/\/api\/session\/heartbeat\/?$/i.test(path)) return "SESSION:HEARTBEAT";
|
||||
if (/\/api\/session\/end\/?$/i.test(path)) return "SESSION:END";
|
||||
if (/\/api\/session\/?$/i.test(path)) return "SESSION:START";
|
||||
if (/\/api\/queue\/end\/?$/i.test(path)) return "QUEUE:END";
|
||||
if (/\/api\/queue\/[^/?]+\/?$/i.test(path)) return "QUEUE:POLL";
|
||||
if (/\/api\/me\/?$/i.test(path)) return "ACCOUNT:ME";
|
||||
return "FETCH";
|
||||
} catch {
|
||||
return "FETCH";
|
||||
}
|
||||
}
|
||||
/** @param {RequestInfo | URL} input */
|
||||
function _stringifyUrl(input) {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.href;
|
||||
try {
|
||||
// Request-like
|
||||
return /** @type {{url:string}} */ (/** @type {unknown} */ (input)).url || String(input);
|
||||
} catch {
|
||||
return String(input);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {RequestInfo | URL} input
|
||||
* @param {RequestInit | undefined} init
|
||||
*/
|
||||
function _extractMethod(input, init) {
|
||||
if (init && typeof init.method === "string") return init.method.toUpperCase();
|
||||
try {
|
||||
const r = /** @type {{method?:string}} */ (/** @type {unknown} */ (input));
|
||||
if (r && typeof r.method === "string") return r.method.toUpperCase();
|
||||
} catch { /* noop */ }
|
||||
return "GET";
|
||||
}
|
||||
/**
|
||||
* @param {RequestInfo | URL} input
|
||||
* @param {RequestInit | undefined} init
|
||||
* @returns {Promise<{ length: number; preview: string }>}
|
||||
*/
|
||||
async function _extractBodyPreview(input, init) {
|
||||
/** @type {BodyInit | null | undefined} */
|
||||
let body = init ? init.body : undefined;
|
||||
if (body === undefined || body === null) {
|
||||
// tenta Request
|
||||
try {
|
||||
const r = /** @type {{body?:BodyInit|null}} */ (/** @type {unknown} */ (input));
|
||||
if (r && r.body != null) body = r.body;
|
||||
} catch { body = null; }
|
||||
}
|
||||
if (body == null) return { length: 0, preview: "(nessun body)" };
|
||||
try {
|
||||
if (typeof body === "string") {
|
||||
const s = body;
|
||||
try {
|
||||
const j = JSON.parse(s);
|
||||
const compact = JSON.stringify(j);
|
||||
return { length: s.length, preview: "JSON: " + compact.slice(0, 240) };
|
||||
} catch {
|
||||
return { length: s.length, preview: "TEXT: " + s.slice(0, 240) };
|
||||
}
|
||||
}
|
||||
if (body instanceof FormData) {
|
||||
const pairs = [];
|
||||
for (const [k, v] of body.entries()) pairs.push(`${k}=${String(v).slice(0, 48)}`);
|
||||
return { length: pairs.length, preview: "FORM: " + pairs.join(" & ").slice(0, 240) };
|
||||
}
|
||||
if (body instanceof URLSearchParams) {
|
||||
const s = body.toString();
|
||||
return { length: s.length, preview: "QS: " + s.slice(0, 240) };
|
||||
}
|
||||
if (body instanceof Blob) return { length: body.size, preview: `BLOB (${body.type || "unknown"}) size=${body.size}` };
|
||||
if (ArrayBuffer.isView(body)) return { length: body.byteLength, preview: `ArrayBufferView len=${body.byteLength}` };
|
||||
if (body instanceof ArrayBuffer) return { length: body.byteLength, preview: `ArrayBuffer len=${body.byteLength}` };
|
||||
return { length: 0, preview: "(body non serializzabile nel log)" };
|
||||
} catch (err) {
|
||||
return { length: -1, preview: `(err preview body: ${err})` };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Promise<{ length: number; preview: string }>}
|
||||
*/
|
||||
async function _extractResponsePreview(response) {
|
||||
try {
|
||||
const clone = response.clone();
|
||||
const ct = (clone.headers.get("content-type") || "").toLowerCase();
|
||||
const text = await clone.text();
|
||||
if (/application\/json|application\/.*\+json/i.test(ct) || (text.trim().startsWith("{") || text.trim().startsWith("["))) {
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
const compact = JSON.stringify(j);
|
||||
return { length: text.length, preview: "JSON: " + compact.slice(0, 260) };
|
||||
} catch {
|
||||
return { length: text.length, preview: "TEXT/HTML: " + text.slice(0, 260) };
|
||||
}
|
||||
}
|
||||
return { length: text.length, preview: "TEXT: " + text.slice(0, 200) };
|
||||
} catch (err) {
|
||||
return { length: -1, preview: `(err clone response: ${err})` };
|
||||
}
|
||||
}
|
||||
/** @param {number} status */
|
||||
function _statusStyle(status) {
|
||||
if (status >= 200 && status < 300) return "color:#16a34a;font-weight:700;";
|
||||
if (status >= 300 && status < 400) return "color:#ca8a04;font-weight:700;";
|
||||
if (status >= 400 && status < 500) return "color:#b45309;font-weight:700;";
|
||||
return "color:#dc2626;font-weight:700;";
|
||||
}
|
||||
window.fetch = async function debugWrappedFetch(input, init) {
|
||||
const t0 = performance.now();
|
||||
const method = _extractMethod(input, init);
|
||||
const rawUrl = _stringifyUrl(input);
|
||||
const tag = _tagFor(rawUrl);
|
||||
// Per richieste "heartbeat / end poll" (molto frequenti) logghiamo un
|
||||
// singleton collapse per evitare spam: se `?quiet=true` o URL match → log
|
||||
// a riga singola e non mostriamo preview.
|
||||
const isPollish = /heartbeat|queue\/end|session\/end/i.test(tag) || (init && typeof init.keepalive === "boolean" && init.keepalive);
|
||||
const reqPreview = isPollish ? null : await _extractBodyPreview(input, init);
|
||||
if (!isPollish) {
|
||||
const groupTitle = `%c→ [FETCH:${tag}] ${method} ${rawUrl}`;
|
||||
const groupStyle = "color:#2563eb;font-weight:700;";
|
||||
console.groupCollapsed(groupTitle, groupStyle);
|
||||
console.log(`method=${method} url=${rawUrl}`);
|
||||
console.log(`bodyLen=${reqPreview?.length ?? 0} preview=${reqPreview?.preview ?? "(poll/skip)"}`);
|
||||
console.groupEnd();
|
||||
} else {
|
||||
console.debug(
|
||||
`%c→ [FETCH:${tag}] ${method} ${rawUrl}`,
|
||||
"color:#64748b;font-weight:600;"
|
||||
);
|
||||
}
|
||||
/** @type {Response} */
|
||||
let response;
|
||||
try {
|
||||
// @ts-ignore
|
||||
response = await ORIGINAL_FETCH(input, init);
|
||||
} catch (err) {
|
||||
const dur = (performance.now() - t0).toFixed(1);
|
||||
console.groupCollapsed(
|
||||
`%c✗ [FETCH:${tag}] NETWORK_ERROR ${method} ${rawUrl} (${dur} ms)`,
|
||||
"background:#fee2e2;color:#b91c1c;font-weight:800;padding:2px 6px;border-radius:4px;"
|
||||
);
|
||||
console.error(err);
|
||||
console.groupEnd();
|
||||
throw err;
|
||||
}
|
||||
const dur = (performance.now() - t0).toFixed(1);
|
||||
const resPreview = isPollish ? null : await _extractResponsePreview(response);
|
||||
const statusStr = `${response.status} ${response.statusText}`.trim();
|
||||
if (!isPollish) {
|
||||
console.groupCollapsed(
|
||||
`%c← [FETCH:${tag}] %c${statusStr} %c${method} ${rawUrl} (${dur} ms)`,
|
||||
"color:#0f172a;font-weight:800;",
|
||||
_statusStyle(response.status),
|
||||
"color:#475569;font-weight:600;"
|
||||
);
|
||||
console.log(`HTTP status=${response.status} statusText=${response.statusText || "-"} durationMs=${dur}`);
|
||||
const finalHeaders = [];
|
||||
response.headers.forEach((v, k) => finalHeaders.push(`${k}: ${String(v).slice(0, 120)}`));
|
||||
console.log(`Response headers (${finalHeaders.length}):`, finalHeaders);
|
||||
if (resPreview) {
|
||||
console.log(`responseLen=${resPreview.length} preview=${resPreview.preview}`);
|
||||
}
|
||||
console.groupEnd();
|
||||
} else {
|
||||
const sClass = (() => {
|
||||
if (response.ok) return "color:#15803d;";
|
||||
if (response.status >= 500) return "color:#dc2626;";
|
||||
return "color:#ca8a04;";
|
||||
})();
|
||||
console.debug(
|
||||
`%c← [FETCH:${tag}] %c${response.status} %c${method} ${rawUrl} (${dur} ms)`,
|
||||
"color:#475569;font-weight:600;",
|
||||
sClass,
|
||||
"color:#64748b;"
|
||||
);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
console.info(
|
||||
"%c🔎 [FETCH-DEBUG] tracer installato. Ogni fetch è loggata con tag, method, url, status, durata e preview body.",
|
||||
"background:#ecfeff;color:#0369a1;font-weight:700;padding:2px 6px;border-radius:4px;"
|
||||
);
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🛡️ GATE .topbar-right (about / tools / rag / chat / settings buttons,
|
||||
// vedi index.html L36-L56) — 3-layer safety:
|
||||
@@ -630,6 +842,11 @@ let debugPrompt = false;
|
||||
// waiting for the user to say anything). Mirrors the server env
|
||||
// S2S_START_SESSION_MODE. Empty / anything else = manual (tap the start).
|
||||
let startSessionMode = "";
|
||||
// Theme: mirrors S2S_THEME env on the server. Supported values: "" (default
|
||||
// dark / purple theme), "white" (the light "Vocero app" mobile theme in the
|
||||
// screenshot: white bg, blue mic circle, chat bubbles with avatars, blue send
|
||||
// button). "dark" is an alias for "" (native dark theme — no overrides applied).
|
||||
let s2sTheme = "";
|
||||
|
||||
// Custom RAG context entered by the user via the header "RAG" button dialog.
|
||||
// Treated exactly like voceroUserContextText / injectProfile context:
|
||||
@@ -799,6 +1016,8 @@ function initialsForName(rawName) {
|
||||
const last = parts[parts.length - 1][0] ?? "";
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
window._s2sExtractName = extractVoceroUserName;
|
||||
window._s2sInitialsFor = initialsForName;
|
||||
|
||||
/* ─── User profile dialog (opened by clicking avatar in the header) ─── */
|
||||
/**
|
||||
@@ -1293,8 +1512,53 @@ function effectiveReasoningInstructions() {
|
||||
instructions = instructions ? `${instructions} ${ragCtx}` : ragCtx;
|
||||
ragAppended = true;
|
||||
}
|
||||
const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${(base || "").length}`;
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2026-09-25 INJECT API-PILOT HERAMIENTAS DINÁMICAS (PASADA 1).
|
||||
// Formato IDENTICO a server.py Python: marker <<<APIPILOT_TOOLS_LIST_BEGIN>>>
|
||||
// / ...lista name+description+requires_auth+required_parameters... / <<<END>>>.
|
||||
// Il client ottiene la lista dal proxy GET api/apipilot/tools (cache 240 s
|
||||
// per-utente in s2s-ws-client.js _apipilotClientCache) e la appende qui alle
|
||||
// instructions STESSE (luogo #1 di massima priorità per l'LLM).
|
||||
// Dedup: se le instructions già contengono il marker (già appese in una
|
||||
// esecuzione precedente, oppure server Python ha già patchato il payload)
|
||||
// → NON appendere nuovamente (no doppioni, no blob gigante).
|
||||
let apipilotBlock = "";
|
||||
let apipilotCount = 0;
|
||||
let apipilotAppended = false;
|
||||
try {
|
||||
// @ts-ignore: esposto globalmente da s2s-ws-client.js (caricato prima di
|
||||
// main.js in index.html). Valuta sicuro: typeof + early-skip.
|
||||
const getter = typeof window !== "undefined" && window.__s2sGetApipilotPass1ContextInfo;
|
||||
if (typeof getter === "function") {
|
||||
const info = getter();
|
||||
if (info && typeof info.block === "string" && typeof info.marker === "string") {
|
||||
apipilotBlock = info.block;
|
||||
apipilotCount = typeof info.count === "number" ? info.count : (apipilotBlock ? 1 : 0);
|
||||
const already = typeof info.alreadyPresent === "function"
|
||||
? Boolean(info.alreadyPresent(instructions))
|
||||
: (typeof instructions === "string" && instructions.includes(info.marker));
|
||||
if (apipilotBlock.trim() && !already) {
|
||||
instructions = instructions ? `${instructions}\n\n${apipilotBlock}` : apipilotBlock;
|
||||
apipilotAppended = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* never-fail: non bloccare il resto dell'istruzione prompt. */
|
||||
}
|
||||
const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${apipilotAppended ? "apipilotY" : "apipilotN"}:${(base || "").length}`;
|
||||
_logOnce(key, () => {
|
||||
if (apipilotAppended) {
|
||||
console.info(
|
||||
`[INJECT-APIPILOT] ✅ BROWSER effectiveReasoningInstructions APPENDED ApiPilot tools list PASS-1 inside LLM prompt. apipilotBlockLen=%d Ntools=%d baseLen=%d profileAppended=%s ragAppended=%s -> finalLen=%d`,
|
||||
apipilotBlock.length, apipilotCount, (base || "").length, profileAppended, ragAppended, instructions.length,
|
||||
);
|
||||
} else if (apipilotBlock) {
|
||||
console.info(
|
||||
`[INJECT-APIPILOT] ⏭️ BROWSER effectiveReasoningInstructions: ApiPilot tools list PASS-1 already present (dedup marker BEGIN). No append needed. blockLen=%d Ntools=%d`,
|
||||
apipilotBlock.length, apipilotCount,
|
||||
);
|
||||
}
|
||||
if (ragAppended) {
|
||||
console.info(
|
||||
`[CUSTOM-RAG] ✅ effectiveReasoningInstructions APPENDED custom RAG context inside LLM prompt. ragCtxLen=%d baseLen=%d profileAppended=%s -> finalLen=%d`,
|
||||
@@ -1321,13 +1585,14 @@ function effectiveReasoningInstructions() {
|
||||
}
|
||||
});
|
||||
if (debugPrompt) {
|
||||
const key2 = `debugEffInst:${instructions.length}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}`;
|
||||
const key2 = `debugEffInst:${instructions.length}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${apipilotAppended ? "apipilotY" : "apipilotN"}:${apipilotBlock.length}`;
|
||||
_logOnce(key2, () => {
|
||||
console.info(
|
||||
`[DEBUG-PROMPT][BROWSER effectiveReasoningInstructions] FULL TEXT len=%d profile=%s rag=%s:\n%s\n`,
|
||||
`[DEBUG-PROMPT][BROWSER effectiveReasoningInstructions] FULL TEXT len=%d profile=%s rag=%s apipilot=%s:\n%s\n`,
|
||||
instructions.length,
|
||||
ctx ? `${ctx.length}c` : "none",
|
||||
ragCtx ? `${ragCtx.length}c` : "none",
|
||||
apipilotBlock ? `${apipilotCount}tools/${apipilotBlock.length}c` : "none",
|
||||
instructions,
|
||||
);
|
||||
if (ctx) {
|
||||
@@ -2196,6 +2461,53 @@ async function fetchVoceroUserProfile() {
|
||||
Object.keys(voceroUserProfile).join(","),
|
||||
extractVoceroUserName(voceroUserProfile) || "(none)",
|
||||
);
|
||||
// ── 🚀 PREFETCH ApiPilot IMMEDIATAMENTE DOPO LOGIN-BY-PHONE RIUSCITO
|
||||
// Forza refresh=true per bypassare la cache SERVER lato proxy S2S (TTL 300s)
|
||||
// perché adesso abbiamo un profilo utente autenticato (non più anonimo).
|
||||
// In questo modo vediamo la riga [apipilot] list_tools.start user=(non-anon)
|
||||
// nel backend AquaPortal OGNI VOLTA che c'è un login phone riuscito.
|
||||
try {
|
||||
if (window.__s2sApipilot) {
|
||||
const t0 = performance.now();
|
||||
console.info(
|
||||
"%c🔧 [APIPILOT-CLIENT] post-login phone → FORZO refresh api/apipilot/tools (bypass cache S2S 300s perché utente appena autenticato)",
|
||||
"background:#ede9fe;color:#5b21b6;font-weight:700;padding:2px 6px;border-radius:4px;"
|
||||
);
|
||||
const demoToken = typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
|
||||
const headers = {};
|
||||
if (demoToken) headers["x-demo-session"] = demoToken;
|
||||
fetch(`api/apipilot/tools?include_unauthorized=true&refresh=true&phone=${encodeURIComponent(voceroUserPhoneNumber.slice(0, 8))}`, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
headers,
|
||||
}).then(async (r) => {
|
||||
const dt = Math.round(performance.now() - t0);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const ok = j && j.success && j.data;
|
||||
if (!ok) throw new Error(`envelope non corretto success=${Boolean(j && j.success)}`);
|
||||
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
|
||||
const count = typeof j.data.count === "number" ? j.data.count : tools.length;
|
||||
const names = tools.slice(0, 6).map((t) => (typeof t.name === "string" ? t.name : "?")).join(", ");
|
||||
// Invalida cache client WS per ogni sessione futura (in modo che
|
||||
// al prossimo connect() venga fatto un refresh per ri-confermare).
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (typeof window.__s2sInvalidateApipilotCache === "function") window.__s2sInvalidateApipilotCache();
|
||||
} catch (_) { /* noop */ }
|
||||
console.info(
|
||||
`%c🔧 [APIPILOT-CLIENT] post-login OK (dt=${dt}ms): N=${count} strumenti [primi 6: ${names || "(nessuno)"}]`,
|
||||
"background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;"
|
||||
);
|
||||
}).catch((err) => {
|
||||
const dt = Math.round(performance.now() - t0);
|
||||
console.warn(
|
||||
`%c🔧 [APIPILOT-CLIENT] post-login FAIL (dt=${dt}ms): ${String(err instanceof Error ? err.message : err)} → controlla [APIPILOT-DISCOVERY] nel log server S2S`,
|
||||
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;"
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (_) { /* fire-and-forget silently swallow */ }
|
||||
} else {
|
||||
console.info(
|
||||
`[INJECT-PROFILE] ⏭️ BROWSER fetchVoceroUserProfile HTTP 200 but empty/non-object JSON. profileIsNull=%s keys=none`,
|
||||
@@ -2306,10 +2618,49 @@ async function fetchConfig() {
|
||||
pinnedPrompt = (json.primaryPrompt || "").trim();
|
||||
injectProfile = !!json.injectProfile;
|
||||
debugPrompt = !!json.debugPrompt;
|
||||
// Feature flag: ApiPilot AquaPortal bridge attivo sul server.
|
||||
// Se true = S2S_APIPILOT_TOOLS_URL è valorizzato lato backend e
|
||||
// gli endpoint proxy /api/apipilot/tools e /api/apipilot/execute
|
||||
// sono disponibili per il frontend.
|
||||
const apipilotEnabled = !!json.apipilot;
|
||||
if (typeof window.__s2sApipilot === "undefined") {
|
||||
try { Object.defineProperty(window, "__s2sApipilot", { value: false, writable: true, configurable: true }); }
|
||||
catch (_) { /* noop */ }
|
||||
}
|
||||
window.__s2sApipilot = apipilotEnabled;
|
||||
// Bootstrap-start mode: empty on generic deploys (tap to start).
|
||||
startSessionMode = typeof json.startSessionMode === "string"
|
||||
? json.startSessionMode.trim().toUpperCase()
|
||||
: "";
|
||||
// Theme: "" / "dark" → native dark theme (purple orb). "white" → light
|
||||
// Vocero app mobile theme (white bg, blue mic, chat bubbles w/ avatars).
|
||||
const rawTheme = typeof json.theme === "string" ? json.theme.trim().toLowerCase() : "";
|
||||
const resolvedTheme = rawTheme === "white" ? "white" : "dark";
|
||||
s2sTheme = resolvedTheme === "white" ? "white" : "";
|
||||
const root = document.documentElement;
|
||||
// NOTE: always set the attribute explicitly (never remove it) so the
|
||||
// anti-FOUC guard `html:not([data-theme]) [data-theme-only]` expires
|
||||
// as soon as the config round-trip completes (before it, both white &
|
||||
// dark parallel UI blocks are kept invisible → no flash of purple orb
|
||||
// fading into blue mic on S2S_THEME=white deploys).
|
||||
root.setAttribute("data-theme", resolvedTheme);
|
||||
// ── Anti-FOUC veil LIFT (user fix 2026-09-10 purple flash on refresh)
|
||||
// The inline <style id="s2s-anti-fouc-veil"> in <head> keeps <body>
|
||||
// completely unpainted until BOTH:
|
||||
// a) data-theme attribute exists on <html> ← line ↑ above
|
||||
// b) class="theme-ready" exists on <html> ← line ↓ below
|
||||
// Two-signal guard avoids 1-frame paint leaks where some browsers
|
||||
// (WebKit especially) commit the attribute mutation a few microtasks
|
||||
// before the style cascade re-evaluates against it. Also clear the
|
||||
// 2500ms fail-safe timeout so it doesn't fire 2.4s later for no
|
||||
// reason on a perfectly healthy config round-trip.
|
||||
try {
|
||||
root.classList.add("theme-ready");
|
||||
if (typeof window.__s2sVeilLiftTimeout === "number") {
|
||||
window.clearTimeout(window.__s2sVeilLiftTimeout);
|
||||
window.__s2sVeilLiftTimeout = null;
|
||||
}
|
||||
} catch (_) { /* classList.add on unknown element — defensive only */ }
|
||||
// #region debug-point B:config-loaded
|
||||
reportDebug("B", "main.js:fetchConfig", "loaded config from api/config", {
|
||||
reasoningType,
|
||||
@@ -2317,8 +2668,46 @@ async function fetchConfig() {
|
||||
injectProfile,
|
||||
debugPrompt,
|
||||
startSessionMode,
|
||||
theme: s2sTheme || "dark",
|
||||
apipilotEnabled,
|
||||
});
|
||||
// #endregion
|
||||
// ── Demo call ApiPilot (fire-and-forget, solo se flag=true sul server)
|
||||
// Serve ESCLUSIVAMENTE per attivare il tracer fetch lato browser e
|
||||
// mostrare in console la riga [FETCH:APIPILOT:TOOLS] con preview
|
||||
// status, durata e lista strumenti. Proxy server-side verso
|
||||
// S2S_APIPILOT_TOOLS_URL/tools.
|
||||
if (apipilotEnabled) {
|
||||
try {
|
||||
const demoUrl = "api/apipilot/tools?include_unauthorized=true";
|
||||
console.info(
|
||||
"%c🔧 [APIPILOT-CLIENT] flag=true → DEMO CALL %s (tracer sempre-attivo logga [FETCH:APIPILOT:TOOLS] sotto)",
|
||||
"background:#e0e7ff;color:#3730a3;font-weight:700;padding:2px 6px;border-radius:4px;",
|
||||
demoUrl
|
||||
);
|
||||
fetch(demoUrl, { method: "GET", cache: "no-store" })
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const tools = (j && j.data && Array.isArray(j.data.tools)) ? j.data.tools : [];
|
||||
const count = (j && j.data && typeof j.data.count === "number") ? j.data.count : tools.length;
|
||||
const names = tools.slice(0, 5).map((t) => (typeof t.name === "string" ? t.name : "?")).join(", ");
|
||||
console.info(
|
||||
"%c🔧 [APIPILOT-CLIENT] demo OK: %s strumenti [primi 5: %s] usa POST api/apipilot/execute {name,arguments} per eseguire",
|
||||
"background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;",
|
||||
count,
|
||||
names || "(nessuno)"
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
"%c🔧 [APIPILOT-CLIENT] demo FAIL: %s. Lato server logga [APIPILOT-DISCOVERY] per diagnosticare",
|
||||
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;",
|
||||
String(err)
|
||||
);
|
||||
});
|
||||
} catch (_) { /* fire-and-forget: silently swallow */ }
|
||||
}
|
||||
// The conversation-time limiter rides on the LB being present.
|
||||
limiterOn = lbMode;
|
||||
}
|
||||
@@ -2326,11 +2715,221 @@ async function fetchConfig() {
|
||||
} catch {
|
||||
// Config endpoint unreachable (e.g. static hosting): keep direct entry.
|
||||
}
|
||||
// ── Anti-FOUC veil LIFT (catch-all): also lift when fetchConfig took a
|
||||
// non-OK / exception path so the user is never left on an invisible
|
||||
// <body> if the api/config response was malformed or 500'd. Only runs
|
||||
// the first time (theme-ready is idempotent). Also clears the fail-safe
|
||||
// timeout (if still set) the same way the happy-path did above.
|
||||
try {
|
||||
const root = document.documentElement;
|
||||
if (!root.hasAttribute("data-theme")) root.setAttribute("data-theme", "dark");
|
||||
if (!root.classList.contains("theme-ready")) root.classList.add("theme-ready");
|
||||
if (typeof window.__s2sVeilLiftTimeout === "number") {
|
||||
window.clearTimeout(window.__s2sVeilLiftTimeout);
|
||||
window.__s2sVeilLiftTimeout = null;
|
||||
}
|
||||
} catch (_) { /* defensive no-op */ }
|
||||
if (DEBUG) console.debug(`[ui] config: allowDirect=${allowDirect} lbMode=${lbMode}`);
|
||||
// Login chip + remaining-budget (no-op / hidden when the limiter is off).
|
||||
void account.refresh();
|
||||
syncToolsUi();
|
||||
syncConnectionUi();
|
||||
// Apply theme runtime: show/hide theme-only elements, wire up white-mic
|
||||
// button forwarders, unhide chat-form in white theme, etc. Must run after
|
||||
// `s2sTheme` is set and the data-theme attr has been applied to html.
|
||||
_applyThemeRuntime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spanish (Castilian) label map for the "Escuchando..." status caption below
|
||||
* the white-theme blue mic circle. Mirrors every client state.
|
||||
*/
|
||||
const WHITE_STATUS_LABELS = Object.freeze({
|
||||
idle: "Toca para empezar",
|
||||
connecting: "Conectando…",
|
||||
queued: "En cola…",
|
||||
"your-turn":"Escuchando…",
|
||||
processing: "Pensando…",
|
||||
"ai-speaking":"Hablando…",
|
||||
error: "Algo salió mal",
|
||||
stopped: "Detenido",
|
||||
});
|
||||
|
||||
let _themeRuntimeInitialized = false;
|
||||
|
||||
/**
|
||||
* Apply runtime wiring for the current theme:
|
||||
* 1. Flip [hidden] on every [data-theme-only="white" / "dark"] element in
|
||||
* the document so the correct parallel UI is shown.
|
||||
* 2. Remove the hardcoded `hidden + display:none !important` on #chat-form
|
||||
* in white theme (we want the text input + send button visible, just
|
||||
* like the screenshot input bar).
|
||||
* 3. Click forwarders: #white-mic-circle → real #main-circle,
|
||||
* #white-stop-btn → real #stop-btn.
|
||||
* 4. MutationObserver on #main-circle `.className` to mirror every
|
||||
* `state-*` class live to #white-mic-circle so it swaps its visuals
|
||||
* (mic icon / stop icon / spinner) exactly in sync with the orb.
|
||||
* 5. Wrap `setCaption()` so the Spanish label `#white-mic-caption` gets
|
||||
* updated at the same time as the default orb caption.
|
||||
* 6. Quick action button listeners: populate chat-input / or send via
|
||||
* client.sendUserText() when a session is live.
|
||||
* Called by fetchConfig() AFTER s2sTheme and <html data-theme> are set.
|
||||
* Re-runs safely on every config fetch (guard `_themeRuntimeInitialized` for
|
||||
* the one-shot observer / listeners).
|
||||
*/
|
||||
function _applyThemeRuntime() {
|
||||
const isWhite = s2sTheme === "white";
|
||||
|
||||
// 1. Toggle visibility of every element tagged data-theme-only.
|
||||
// Elements with `hidden` and `[data-theme-only="white"]` should be
|
||||
// UN-hidden when theme === white; `hidden + dark-only` → UN-hidden when
|
||||
// theme !== white (native dark). Any element not tagged is unchanged.
|
||||
const onlyWhite = document.querySelectorAll("[data-theme-only=\"white\"]");
|
||||
onlyWhite.forEach(el => {
|
||||
if (isWhite) {
|
||||
el.removeAttribute("hidden");
|
||||
if (el.style && el.style.display === "none") el.style.display = "";
|
||||
} else {
|
||||
el.setAttribute("hidden", "");
|
||||
}
|
||||
});
|
||||
const onlyDark = document.querySelectorAll("[data-theme-only=\"dark\"]");
|
||||
onlyDark.forEach(el => {
|
||||
if (isWhite) {
|
||||
el.setAttribute("hidden", "");
|
||||
} else {
|
||||
el.removeAttribute("hidden");
|
||||
if (el.style && el.style.display === "none") el.style.display = "";
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Chat form input: in WHITE theme, we need the text input visible to
|
||||
// match the screenshot. The HTML ships with `hidden` + inline
|
||||
// `display:none !important` because the original dark theme hid it
|
||||
// (per user request 2026-09-04). Clear both here, restore when leaving.
|
||||
const chatForm = document.getElementById("chat-form");
|
||||
if (chatForm) {
|
||||
if (isWhite) {
|
||||
chatForm.removeAttribute("hidden");
|
||||
chatForm.style.removeProperty("display");
|
||||
} else if (!chatForm.hasAttribute("hidden")) {
|
||||
// Reapply original dark-theme defaults (preserve existing user edit).
|
||||
chatForm.setAttribute("hidden", "");
|
||||
chatForm.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot wiring (never re-add listeners/observer on subsequent calls).
|
||||
if (_themeRuntimeInitialized) return;
|
||||
_themeRuntimeInitialized = true;
|
||||
|
||||
// 3a. Click forwarder: white mic circle → the real start/stop orb handler.
|
||||
const whiteMic = document.getElementById("white-mic-circle");
|
||||
const realCircle = document.getElementById("main-circle");
|
||||
if (whiteMic && realCircle) {
|
||||
whiteMic.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
realCircle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
}
|
||||
|
||||
// 3b. Click forwarder: white "Detener" pill → hidden real stop side-btn.
|
||||
const whiteStop = document.getElementById("white-stop-btn");
|
||||
const realStop = document.getElementById("stop-btn");
|
||||
if (whiteStop && realStop) {
|
||||
whiteStop.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
realStop.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Live sync of #main-circle className → #white-mic-circle className
|
||||
// (states: .state-idle, .state-connecting, .state-your-turn, .state-processing,
|
||||
// .state-ai-speaking, .state-error, .state-queued). White-mic styles
|
||||
// use these same state-* classes (CSS prefixes with .white-mic-circle.state-*)
|
||||
// so we simply shadow every class on the orb button to the new button.
|
||||
if (whiteMic && realCircle && typeof MutationObserver !== "undefined") {
|
||||
const syncStateClasses = () => {
|
||||
// Keep the base class + data-theme-only marker but clear/re-add the rest.
|
||||
const tokens = Array.from(realCircle.classList);
|
||||
for (const cl of Array.from(whiteMic.classList)) {
|
||||
if (cl.startsWith("state-")) whiteMic.classList.remove(cl);
|
||||
}
|
||||
for (const cl of tokens) {
|
||||
if (cl.startsWith("state-")) whiteMic.classList.add(cl);
|
||||
}
|
||||
// Also show/hide the white stop pill in sync with the real stop-btn
|
||||
// (which is shown when connected).
|
||||
if (whiteStop) {
|
||||
const visible = !realStop.hasAttribute("aria-hidden") || realStop.getAttribute("aria-hidden") === "false";
|
||||
if (visible) {
|
||||
whiteStop.style.removeProperty("display");
|
||||
whiteStop.removeAttribute("hidden");
|
||||
} else {
|
||||
whiteStop.setAttribute("hidden", "");
|
||||
}
|
||||
}
|
||||
};
|
||||
syncStateClasses();
|
||||
const obs = new MutationObserver(() => syncStateClasses());
|
||||
obs.observe(realCircle, { attributes: true, attributeFilter: ["class", "aria-hidden"] });
|
||||
if (realStop) obs.observe(realStop, { attributes: true, attributeFilter: ["aria-hidden", "class"] });
|
||||
|
||||
// 5. Set the initial Spanish caption for theme=white + wrap setCaption()
|
||||
// so any future setCaption() call also updates the Spanish label.
|
||||
const capWhite = document.getElementById("white-mic-caption");
|
||||
if (capWhite) {
|
||||
capWhite.textContent = WHITE_STATUS_LABELS[currentState] || WHITE_STATUS_LABELS.idle;
|
||||
const origSetCaption = setCaption;
|
||||
// Re-assign the global (window / module scope var holds function ref) so
|
||||
// each caller of setCaption(text, kind) now also drives the Spanish label.
|
||||
// We intentionally DON'T replace the signature — old code keeps working.
|
||||
(function (prev) {
|
||||
window.setCaption = function (text, kind) {
|
||||
prev(text, kind);
|
||||
// When in white theme we prefer the Spanish state-based label from
|
||||
// WHITE_STATUS_LABELS whenever possible, falling back to raw text.
|
||||
if (s2sTheme === "white" && capWhite) {
|
||||
const fromState = WHITE_STATUS_LABELS[currentState];
|
||||
capWhite.textContent = fromState || text || WHITE_STATUS_LABELS.idle;
|
||||
const muted = kind === "muted";
|
||||
capWhite.classList.toggle("muted", !!muted);
|
||||
capWhite.classList.toggle("error", kind === "error");
|
||||
}
|
||||
};
|
||||
})(origSetCaption);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Quick action buttons: clicking "Resumen" / "Consultar datos" /
|
||||
// "Crear respuesta" populates the text input or — if a realtime
|
||||
// session is live — sends it as user text straight away.
|
||||
document.querySelectorAll(".quick-action-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const action = btn.getAttribute("data-action") || "";
|
||||
const prompts = {
|
||||
"resumen": "haz un resumen ejecutivo del último informe programado",
|
||||
"consultar-datos": "consulto los datos más recientes del dashboard, dame las cifras clave",
|
||||
"crear-respuesta": "redacta un borrador de respuesta profesional con tono ejecutivo",
|
||||
};
|
||||
const text = prompts[action] || "";
|
||||
if (!text) return;
|
||||
if (client && typeof client.sendUserText === "function") {
|
||||
// Live session: send directly as if the user typed + pressed enter.
|
||||
client.sendUserText(text);
|
||||
} else {
|
||||
// No session: prefill the text input so they can send manually later.
|
||||
const inp = document.getElementById("chat-input");
|
||||
if (inp) {
|
||||
inp.value = text;
|
||||
inp.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
inp.focus({ preventScroll: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3822
-121
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 CHAT_BUBBLE_SVG = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
|
||||
const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_SVG}<span class="chat-empty-title">No messages yet</span><span class="chat-empty-hint">Tap the orb and start talking</span></div>`;
|
||||
/**
|
||||
* @param {string} prefix
|
||||
* @param {string} [sep]
|
||||
* @returns {string} safe filename prefix (a-z 0-9 _ - no spaces / slashes / unicode),
|
||||
* sanitised so any OS accepts it.
|
||||
*/
|
||||
function _safeName(prefix, sep = "-") {
|
||||
const base = String(prefix || "").toLowerCase().trim()
|
||||
.normalize("NFKD").replace(/[^\x20-\x7E]/g, "")
|
||||
.replace(/[^a-z0-9]+/gi, sep || "-")
|
||||
.replace(new RegExp(`^${sep}+|${sep}+$`, "g"), "");
|
||||
return base || "vocero";
|
||||
}
|
||||
|
||||
export class ChatView {
|
||||
constructor() {
|
||||
@@ -58,6 +71,11 @@ export class ChatView {
|
||||
this._chatHistory = $("#chat-history");
|
||||
/** @type {HTMLElement} */
|
||||
this._bubbleStack = $("#bubble-stack");
|
||||
/** @type {HTMLButtonElement | null} Export conversation download button in header. */
|
||||
this._exportBtn = /** @type {HTMLButtonElement | null} */ (document.getElementById("chat-export-btn"));
|
||||
if (this._exportBtn) {
|
||||
this._exportBtn.addEventListener("click", () => this.exportConversation());
|
||||
}
|
||||
|
||||
// Inline chat is always "open" (permanently visible on screen).
|
||||
this._panelOpen = true;
|
||||
@@ -103,6 +121,55 @@ export class ChatView {
|
||||
});
|
||||
}
|
||||
|
||||
// ── User name / initials helpers ────────────────────────────────────────
|
||||
/** @returns {string} display name for the current user (e.g. "Alessandro").
|
||||
* Reuses extractVoceroUserName + initialsForName exposed on window by main.js
|
||||
* so we don't duplicate the nested-object lookup logic (profile/user/customer
|
||||
* keys, first+last name fallback, etc.). Falls back to "Usuario" (Spanish)
|
||||
* when no profile has been resolved yet — never returns "" because the
|
||||
* white-theme chat label is rendered into a pseudo-element that would show
|
||||
* a lonely timestamp otherwise.
|
||||
*/
|
||||
_resolveUserName() {
|
||||
const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null;
|
||||
const profile =
|
||||
/** @type {Record<string, unknown> | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null;
|
||||
const full = ex ? ex(profile) : "";
|
||||
if (!full) return "Usuario";
|
||||
// Return ONLY the first token so the narrow chat label line fits:
|
||||
// "Alessandro Valenti" → "Alessandro", matches the screenshot style.
|
||||
const first = String(full).trim().split(/\s+/)[0] || full;
|
||||
return first;
|
||||
}
|
||||
/** @returns {string} 2-letter uppercase initials, e.g. "Alessandro Valenti" → "AV". */
|
||||
_resolveUserInitials() {
|
||||
const mk = typeof window._s2sInitialsFor === "function" ? window._s2sInitialsFor : null;
|
||||
const profile =
|
||||
/** @type {Record<string, unknown> | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null;
|
||||
const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null;
|
||||
const full = ex ? ex(profile) : "";
|
||||
if (mk && full) {
|
||||
const init = mk(full);
|
||||
if (init) return init;
|
||||
}
|
||||
return "AV";
|
||||
}
|
||||
/** @returns {string} "HH:MM" 24h using the current locale, no seconds. */
|
||||
_nowStamp() {
|
||||
try {
|
||||
return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
} catch (_) {
|
||||
const d = new Date();
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
}
|
||||
/** Assistant display name — fixed Spanish label, same as the topbar brand. */
|
||||
get _assistantName() {
|
||||
return "Vocero";
|
||||
}
|
||||
|
||||
// ── Panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
_openPanel() {
|
||||
@@ -213,6 +280,18 @@ export class ChatView {
|
||||
* Build a role-labelled message element. Ephemeral bubbles and persistent
|
||||
* history rows share the same shape and differ only in their class prefix
|
||||
* (`bubble`/`bubble-*` vs `hist-msg`/`hist-*`).
|
||||
*
|
||||
* White-theme label + avatar data attributes (populated on both the outer
|
||||
* wrapper and the inner body):
|
||||
* wrapper .{hist-msg|bubble}.user → data-user-name / data-user-initials / data-timestamp
|
||||
* wrapper .{hist-msg|bubble}.assistant → data-asst-name / data-timestamp
|
||||
* inner .{hist|bubble}-body → data-user-name | data-asst-name + data-timestamp
|
||||
*
|
||||
* The .{hist|bubble}-body::before pseudo-element in style.css uses attr()
|
||||
* to render the "Nombre HH:MM" label aligned to the bubble corner; the
|
||||
* wrapper .user::before pseudo uses attr(data-user-initials) to paint the
|
||||
* circular avatar's 2-letter monogram.
|
||||
*
|
||||
* @param {{ container: string, prefix: string, role: "user"|"assistant", text: string, partial?: boolean }} o
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
@@ -220,7 +299,27 @@ export class ChatView {
|
||||
const el = document.createElement("div");
|
||||
el.className = `${container} ${role}`;
|
||||
const label = role === "user" ? "You" : "Assistant";
|
||||
const stamp = this._nowStamp();
|
||||
if (role === "user") {
|
||||
const uName = this._resolveUserName();
|
||||
const uInit = this._resolveUserInitials();
|
||||
el.setAttribute("data-user-name", uName);
|
||||
el.setAttribute("data-user-initials", uInit);
|
||||
el.setAttribute("data-timestamp", stamp);
|
||||
} else {
|
||||
el.setAttribute("data-asst-name", this._assistantName);
|
||||
el.setAttribute("data-timestamp", stamp);
|
||||
}
|
||||
el.innerHTML = `<div class="${prefix}-role">${label}</div><div class="${prefix}-body${partial ? " partial" : ""}">${escHtml(text)}</div>`;
|
||||
const body = /** @type {HTMLDivElement} */ (el.lastElementChild);
|
||||
if (body) {
|
||||
if (role === "user") {
|
||||
body.setAttribute("data-user-name", el.getAttribute("data-user-name") || "");
|
||||
} else {
|
||||
body.setAttribute("data-asst-name", this._assistantName);
|
||||
}
|
||||
body.setAttribute("data-timestamp", stamp);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -417,6 +516,186 @@ export class ChatView {
|
||||
this._asstByResp.clear();
|
||||
}
|
||||
|
||||
// ── Export / download conversation ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialize the current persistent chat history (.hist-msg rows inside
|
||||
* #chat-history) into a structured JSON object. Covers:
|
||||
* - user / assistant text messages (role, text, timestamp, name, initials)
|
||||
* - tool-call expandable rows (role=tool, name, args, output)
|
||||
* - webcam / snapshot images (role=image, dataUrl)
|
||||
* Empty state rows (.chat-empty) and ephemeral orb bubbles are ignored.
|
||||
* @returns {{
|
||||
* exportedAt: string,
|
||||
* messageCount: number,
|
||||
* messages: Array<{ type: string; timestamp?: string; [k: string]: unknown }>,
|
||||
* }}
|
||||
*/
|
||||
_serializeConversation() {
|
||||
/** @type {Array<{ type: string; timestamp?: string; [k: string]: unknown }>} */
|
||||
const messages = [];
|
||||
if (!this._chatHistory) return { exportedAt: new Date().toISOString(), messageCount: 0, messages };
|
||||
const rows = /** @type {HTMLElement[]} */ ([...this._chatHistory.querySelectorAll(":scope > .hist-msg")]);
|
||||
for (const row of rows) {
|
||||
const ts = row.getAttribute("data-timestamp") || undefined;
|
||||
if (row.classList.contains("user") || row.classList.contains("assistant")) {
|
||||
const body = /** @type {HTMLElement | null} */ (row.querySelector(".hist-body"));
|
||||
if (!body) continue;
|
||||
const role = row.classList.contains("user") ? "user" : "assistant";
|
||||
const out = /** @type {{ type: string; text: string; timestamp?: string; name?: string; initials?: string; interrupted?: boolean }} */ ({
|
||||
type: role,
|
||||
text: body.textContent || "",
|
||||
});
|
||||
if (ts) out.timestamp = ts;
|
||||
if (role === "user") {
|
||||
const n = row.getAttribute("data-user-name");
|
||||
const i = row.getAttribute("data-user-initials");
|
||||
if (n) out.name = n;
|
||||
if (i) out.initials = i;
|
||||
}
|
||||
if (row.classList.contains("interrupted")) out.interrupted = true;
|
||||
messages.push(out);
|
||||
} else if (row.classList.contains("tool")) {
|
||||
const img = /** @type {HTMLImageElement | null} */ (row.querySelector("img.hist-image"));
|
||||
if (img) {
|
||||
messages.push({ type: "image", timestamp: ts, dataUrl: img.src || "" });
|
||||
} else {
|
||||
const nameEl = /** @type {HTMLElement | null} */ (row.querySelector(".hist-tool-name"));
|
||||
const blocks = /** @type {HTMLElement[]} */ ([...row.querySelectorAll(".hist-tool-block")]);
|
||||
const args = blocks[0]?.textContent || "";
|
||||
const output = blocks[1]?.textContent || "";
|
||||
messages.push({
|
||||
type: "tool",
|
||||
timestamp: ts,
|
||||
name: nameEl?.textContent || "",
|
||||
args,
|
||||
output,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
messageCount: messages.length,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-print the JSON conversation structure to a human-readable plain
|
||||
* text log (one entry per "HH:MM Nombre: texto"). Tool calls are rendered
|
||||
* as indented blocks ("TOOL: nombre" followed by Input/Output sections).
|
||||
* @param {ReturnType<ChatView["_serializeConversation"]>} conv
|
||||
* @returns {string}
|
||||
*/
|
||||
_conversationToText(conv) {
|
||||
const lines = [];
|
||||
const date = (() => { try { return new Date(conv.exportedAt).toLocaleString(); } catch { return conv.exportedAt; } })();
|
||||
lines.push(`Conversación Vocero — ${date}`);
|
||||
lines.push(`Mensajes totales: ${conv.messageCount}`);
|
||||
lines.push("—".repeat(52));
|
||||
lines.push("");
|
||||
for (const m of conv.messages) {
|
||||
if (m.type === "user" || m.type === "assistant") {
|
||||
const label = m.type === "user" ? (m.name ? String(m.name) : "Usuario") : "Vocero";
|
||||
const head = [m.timestamp, label].filter(Boolean).join(" ");
|
||||
lines.push(`${head}${m.interrupted ? " [interrumpida]" : ""}:`);
|
||||
lines.push(String(m.text || "").trim());
|
||||
lines.push("");
|
||||
} else if (m.type === "tool") {
|
||||
const head = [m.timestamp, `HERRAMIENTA: ${String(m.name || "").trim()}`].filter(Boolean).join(" ");
|
||||
lines.push(head);
|
||||
const args = String(m.args || "").trim();
|
||||
const output = String(m.output || "").trim();
|
||||
if (args) { lines.push(" Input:"); lines.push(" " + args.split("\n").join("\n ")); }
|
||||
if (output) { lines.push(" Output:"); lines.push(" " + output.split("\n").join("\n ")); }
|
||||
lines.push("");
|
||||
} else if (m.type === "image") {
|
||||
const head = [m.timestamp, "SNAPSHOT (imagen web-cámara enviada al modelo)"].filter(Boolean).join(" ");
|
||||
lines.push(head);
|
||||
lines.push(" " + String(m.dataUrl || "").slice(0, 80) + (String(m.dataUrl || "").length > 80 ? "…" : ""));
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser "download" for the given string content. Uses the
|
||||
* classic Blob+URL+a.click pattern — no backend call, fully client-side,
|
||||
* works offline and never leaks the conversation content anywhere.
|
||||
* @param {string} content
|
||||
* @param {string} filename
|
||||
* @param {string} mime
|
||||
*/
|
||||
_downloadBlob(content, filename, mime) {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => a.remove(), 0);
|
||||
} finally {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry-point wired to the #chat-export-btn in the chat header.
|
||||
* Serializes the current conversation and produces TWO downloads:
|
||||
* 1. vocero-conversacion-YYYYMMDD-HHMMSS.json (structured data)
|
||||
* 2. vocero-conversacion-YYYYMMDD-HHMMSS.txt (log legibile para humanos)
|
||||
* UI feedback: briefly swaps the download icon for a ✓ "hecho" tick then
|
||||
* restores the original SVG so the user gets a visual ack that the export
|
||||
* completed (idempotent / errors are logged to console — never throws).
|
||||
*/
|
||||
exportConversation() {
|
||||
try {
|
||||
const conv = this._serializeConversation();
|
||||
const profile = /** @type {Record<string, unknown> | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null;
|
||||
const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null;
|
||||
const userName = ex && profile ? String(ex(profile) || "").trim() : "";
|
||||
const stampBase = (() => {
|
||||
const d = new Date();
|
||||
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
})();
|
||||
const prefix = _safeName(`vocero-conversacion${userName ? "-" + userName : ""}`, "-");
|
||||
const jsonName = `${prefix}-${stampBase}.json`;
|
||||
const txtName = `${prefix}-${stampBase}.txt`;
|
||||
const jsonText = JSON.stringify(conv, null, 2);
|
||||
const plainText = this._conversationToText(conv);
|
||||
if (conv.messageCount === 0) {
|
||||
// Don't offer downloads of empty logs; flash the button briefly to
|
||||
// signal "pressed" without spamming the user with empty files.
|
||||
if (this._exportBtn) {
|
||||
this._exportBtn.classList.add("chat-export-empty");
|
||||
setTimeout(() => this._exportBtn?.classList.remove("chat-export-empty"), 600);
|
||||
}
|
||||
if (typeof window !== "undefined" && typeof console !== "undefined") {
|
||||
console.info("[ui/chat.js:exportConversation] No hay mensajes aún — exportación omitida.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._downloadBlob(jsonText, jsonName, "application/json;charset=utf-8");
|
||||
this._downloadBlob(plainText, txtName, "text/plain;charset=utf-8");
|
||||
// Quick visual OK: paint the button green for 650ms. Avoids modifying
|
||||
// innerHTML (we'd otherwise lose the SVG on mobile fast-clicks).
|
||||
if (this._exportBtn) {
|
||||
this._exportBtn.classList.add("chat-export-ok");
|
||||
setTimeout(() => this._exportBtn?.classList.remove("chat-export-ok"), 650);
|
||||
}
|
||||
} catch (err) {
|
||||
if (typeof window !== "undefined" && typeof console !== "undefined") {
|
||||
console.error("[ui/chat.js:exportConversation] Error exportando conversación:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Client event handlers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
+467
-18
@@ -53,6 +53,187 @@ function reportWsDebug(hypothesisId, location, msg, data = {}) {
|
||||
void data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🔎 WS DEBUG TRACER (sempre attivo, 2026-09-24)
|
||||
// Banner + stile etichette condiviso per tutta la famiglia di log [S2S-WS:*].
|
||||
// Non è più abilitato solo via localStorage.s2s.debug: gli eventi di vita
|
||||
// (connect, close, errore) e l'handshake iniziale sono loggati always-on;
|
||||
// i messaggi bulk (input_audio_buffer.append) vengono solo contati per non
|
||||
// spammare la Console.
|
||||
// ---------------------------------------------------------------------------
|
||||
(function _wsDebugBanner() {
|
||||
console.info(
|
||||
"%c🟡 [S2S-WS] client caricato (ws/s2s-ws-client.js v2026-09-24 23:12) — handshake, stato, messaggi chiave e tool-call loggati; PREFETCH APIPILOT strumenti AD OGNI INIZIO SESSIONE + post-login phone (cache per-utente, cambio phone = refresh=true automatico; invalid. cache globale __s2sInvalidateApipilotCache(); anti-leak token; anti-spam audio).",
|
||||
"background:#fef3c7;color:#92400e;font-weight:700;padding:4px 8px;border-radius:4px;"
|
||||
);
|
||||
})();
|
||||
/** @param {number} status 0=connect,1=ok,2=warn,3=err @param {string} tag @param {string} msg @param {unknown[]} extra */
|
||||
function _wsLog(status, tag, msg, ...extra) {
|
||||
const palette = [
|
||||
["color:#1d4ed8;font-weight:700;", "background:#dbeafe;color:#1e3a8a;font-weight:800;padding:1px 6px;border-radius:3px;"], // CONNECT info
|
||||
["color:#047857;font-weight:700;", "background:#d1fae5;color:#065f46;font-weight:800;padding:1px 6px;border-radius:3px;"], // OK
|
||||
["color:#b45309;font-weight:700;", "background:#fef3c7;color:#92400e;font-weight:800;padding:1px 6px;border-radius:3px;"], // WARN
|
||||
["color:#b91c1c;font-weight:700;", "background:#fee2e2;color:#7f1d1d;font-weight:800;padding:1px 6px;border-radius:3px;"], // ERR
|
||||
];
|
||||
const [labelFg, badgeBg] = palette[Math.max(0, Math.min(3, status))];
|
||||
const badge = `%c[S2S-WS:${tag}]`;
|
||||
console.log(`${badge}%c ${msg}`, badgeBg, labelFg, ...extra);
|
||||
}
|
||||
/** Contatore per audio chunk bulk, per evitare spam. */
|
||||
let _wsMicChunkCount = 0;
|
||||
let _wsMicChunkLastLogTs = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🔧 APIPILOT PREFETCH AD INIZIO SESSIONE (2026-09-24)
|
||||
// Ogni volta che s2s-ws-client avvia una connessione Realtime (metodo connect())
|
||||
// viene scaldata la cache ApiPilot lato server chiamando /api/apipilot/tools.
|
||||
// Cache TTL lato client 240 s (= TTL server 300 s meno 60 s margine).
|
||||
// Il prefetch è FIRE-AND-FORGET: non blocca MAI l'handshake WS, non fallisce
|
||||
// connect() anche se il proxy restituisce 5xx.
|
||||
// ---------------------------------------------------------------------------
|
||||
/** @type {{at:number, data:any, key:string|null}|null} */
|
||||
let _apipilotClientCache = null;
|
||||
const _APIPILOT_CLIENT_TTL_MS = 240_000; // 4 minuti
|
||||
/** Contatore sessioni totali (per log), incrementato ad ogni connect(). */
|
||||
let _wsSessionCounter = 0;
|
||||
/** Chiave cache per utente: ultimi 8 cifre phoneNumber o fingerprint token. */
|
||||
function _apipilotUserKey() {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const phone = (typeof window !== "undefined" && window.voceroUserPhoneNumber) || "";
|
||||
if (phone && phone.length >= 8) return `P:${phone.slice(-8)}`;
|
||||
// @ts-ignore
|
||||
const tk = typeof window !== "undefined" && typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
|
||||
if (tk && tk.length >= 6) return `T:${tk.slice(-6)}`;
|
||||
return "anon";
|
||||
} catch (_) { return "anon"; }
|
||||
}
|
||||
/** Invalida la cache ApiPilot lato client. Esporta globalmente per main.js (dopo login phone). */
|
||||
function _invalidateApipilotClientCache() {
|
||||
_apipilotClientCache = null;
|
||||
console.info(
|
||||
"%c🔧 [S2S-WS:APIPILOT] Cache client invalidata (nuovo login utente o refresh=true forzato)",
|
||||
"background:#fde68a;color:#92400e;font-weight:700;padding:1px 6px;border-radius:3px;"
|
||||
);
|
||||
}
|
||||
|
||||
// Stesso identico marker e regole del lato server Python (server.py#L555-L556).
|
||||
// Lo ripetiamo identico in JS per garantire:
|
||||
// (1) stessa classifica dedup
|
||||
// (2) stesso formato regole marker AI
|
||||
// (3) dedup by marker stringa <<<APIPILOT_TOOLS_LIST_BEGIN>>> in effectiveReasoningInstructions
|
||||
// (così non appendiamo due volte se sia server che client provano entrambi).
|
||||
const APIPILOT_PASS1_LIST_MARKER_JS = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>";
|
||||
|
||||
/**
|
||||
* Restituisce il testo del blocco PASSATA-1 da iniettare nel prompt instructions LLM.
|
||||
* FORMATO IDENTICO al Python _resolve_apipilot_reasoning_context_block(tools=[...]).
|
||||
* - Inizio con APIPILOT_PASS1_LIST_MARKER_JS
|
||||
* - Fine con <<<APIPILOT_TOOLS_LIST_END>>>
|
||||
* - Lingua: SPAGNOLO (stesso di profile inject e istruzioni AI)
|
||||
* - Cap description a 500 chars
|
||||
* - Max 100 strumenti
|
||||
* @param {any[]|null|undefined} tools Lista da cache oppure null (usa cache corrente)
|
||||
* @returns {string} Blocco di testo PASSATA-1 (stringa vuota se lista vuota / nessuno strumento / nessuna cache)
|
||||
*/
|
||||
function buildApipilotPass1PromptBlock(tools) {
|
||||
let list = Array.isArray(tools) ? tools : null;
|
||||
if (!list) {
|
||||
// Fallback: usa cache client locale corrente (se disponibile)
|
||||
const c = _apipilotClientCache;
|
||||
if (c && c.data && Array.isArray(c.data.tools)) list = c.data.tools;
|
||||
}
|
||||
if (!Array.isArray(list) || list.length === 0) return "";
|
||||
const capped = list.slice(0, 100);
|
||||
/** @type {string[]} */
|
||||
const entries = [];
|
||||
for (const t of capped) {
|
||||
if (!t || typeof t !== "object") continue;
|
||||
const name = typeof t.name === "string" ? t.name : "";
|
||||
if (!name) continue;
|
||||
const desc = typeof t.description === "string" ? t.description : "";
|
||||
const params = (t.parameters && typeof t.parameters === "object") ? t.parameters : null;
|
||||
const requiresAuth = Boolean(t.requires_auth);
|
||||
const req = (params && Array.isArray(params.required))
|
||||
? params.required.filter((x) => typeof x === "string")
|
||||
: [];
|
||||
let descCut = desc.slice(0, 500);
|
||||
if (desc.length > 500) descCut += "…";
|
||||
let snippet = `- name: ${name}\n requires_auth: ${requiresAuth ? "true" : "false"}`;
|
||||
if (req.length) snippet += `\n required_parameters: [${req.slice(0, 40).join(", ")}]`;
|
||||
if (descCut) snippet += `\n description: ${descCut}`;
|
||||
entries.push(snippet);
|
||||
}
|
||||
if (!entries.length) return "";
|
||||
console.warn("APIPILOT_PASS1_LIST_MARKER_JS", APIPILOT_PASS1_LIST_MARKER_JS);
|
||||
const markers = (
|
||||
`${APIPILOT_PASS1_LIST_MARKER_JS}\n` +
|
||||
"### Herramientas dinámicas disponibles (ApiPilot AquaPortal)\n" +
|
||||
"\n" +
|
||||
"Cada herramienta tiene un campo `name` (identificador único, NO lo modifiques, " +
|
||||
"NO camelCase, NO traducir) y un campo `description` que describe el caso de uso.\n" +
|
||||
"\n" +
|
||||
"REGLAS EJECUCIÓN OBLIGATORIAS:\n" +
|
||||
"1. Si la petición DEL USUARIO (transcript) coincide claramente con la `description` " +
|
||||
"de EXACTAMENTE una (1) herramienta de la lista inferior → DEBES emitir EXCLUSIVAMENTE " +
|
||||
"una sola riga come PRIMERA línea de TU RESPUESTA, con questo formato sintattico:\n" +
|
||||
' <<<APIPILOT_CALL:NAME=nome_herramienta,ARGUMENTS={"parametro1":"valor1","parametro2":"valor2"}>>>\n' +
|
||||
"2. La parte ARGUMENTS={…} DEBE ser JSON válido (virgolette doppie, virgole, senza " +
|
||||
"commenti). Includi SOLAMENTE i campi elencati in `required_parameters:` (se ci sono).\n" +
|
||||
"3. Subito DOPO la riga marker NON aggiungere altro testo (niente spiegazioni, " +
|
||||
"niente anticipazioni). Il motore eseguirà lo strumento e ti risomministrerà il " +
|
||||
"risultato in una seconda chiamata.\n" +
|
||||
"4. Se NESSUNA `description` corrisponde, NON emettere marker e rispondi normalmente.\n" +
|
||||
"5. Se più di una descrizione corrisponde, scegli la più specifica e usa il marker.\n" +
|
||||
"\n" +
|
||||
"Lista herramientas (name / description):\n" +
|
||||
`${entries.join("\n")}\n` +
|
||||
"<<<APIPILOT_TOOLS_LIST_END>>>"
|
||||
);
|
||||
console.warn("markers", markers);
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlla se le istruzioni in input contengono già il marker PASS1 (dedup).
|
||||
* @param {string|undefined|null} text
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function instructionsAlreadyHasApipilotPass1(text) {
|
||||
return typeof text === "string" && text.includes(APIPILOT_PASS1_LIST_MARKER_JS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Espone globalmente una funzione helper per:
|
||||
* - main.js: costruire effectiveReasoningInstructions appendendo la lista.
|
||||
* - altri moduli che volessero leggerla.
|
||||
* Il check dedup è fatto automaticamente dal chiamante prima di appendere.
|
||||
* @returns {{block: string, count: number, marker: string, alreadyPresent:(t:string)=>boolean}}
|
||||
*/
|
||||
function getApipilotPass1ContextInfo() {
|
||||
const c = _apipilotClientCache;
|
||||
const tools = (c && c.data && Array.isArray(c.data.tools)) ? c.data.tools : [];
|
||||
const block = buildApipilotPass1PromptBlock(tools);
|
||||
return {
|
||||
block,
|
||||
count: tools.length,
|
||||
marker: APIPILOT_PASS1_LIST_MARKER_JS,
|
||||
alreadyPresent: instructionsAlreadyHasApipilotPass1,
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
Object.defineProperty(window, "__s2sInvalidateApipilotCache", {
|
||||
value: _invalidateApipilotClientCache,
|
||||
writable: true, configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "__s2sGetApipilotPass1ContextInfo", {
|
||||
value: getApipilotPass1ContextInfo,
|
||||
writable: true, configurable: true,
|
||||
});
|
||||
}
|
||||
} catch (_) { /* noop */ }
|
||||
|
||||
/**
|
||||
* @typedef {Object} WsClientOptions
|
||||
* @property {string} [sessionUrl] URL to POST for the session handshake (returns
|
||||
@@ -275,6 +456,12 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
*/
|
||||
async connect() {
|
||||
if (this._ws) throw new Error("Already connected");
|
||||
// Contatore sessioni totali: utile nel tracer per correlare gli eventi
|
||||
// di [S2S-WS:*] a un numero di sessione crescente.
|
||||
_wsSessionCounter += 1;
|
||||
const sessionIdShort = `#${_wsSessionCounter}`;
|
||||
this._sessionSeq = _wsSessionCounter;
|
||||
_wsLog(0, "SESSION", `${sessionIdShort} handshake iniziato — status: creating-session`);
|
||||
|
||||
let connectUrl;
|
||||
if (this._directUrl) {
|
||||
@@ -293,6 +480,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
// keeps a freed slot from being spent on someone who stepped away, and the
|
||||
// click is a fresh gesture (re-arms the AudioContext on iOS).
|
||||
if (waited) {
|
||||
_wsLog(1, "QUEUE", `${sessionIdShort} tuo turno — attesa click join per confermare slot`);
|
||||
await this._awaitJoin(grant);
|
||||
if (this._closed) throw _codedError("connect aborted", "aborted");
|
||||
}
|
||||
@@ -308,10 +496,101 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
this.options.micStream = await this._acquireMic();
|
||||
}
|
||||
|
||||
// Spin up the AudioContext + worklets in parallel with the WS dial.
|
||||
// Spin up the AudioContext + worklets in parallel with the WS dial,
|
||||
// E IN PARALLELO scaldiamo ApiPilot (strumenti dinamici AquaPortal).
|
||||
// Il prefetch è non bloccante: se fallisce, non fallisce connect().
|
||||
const audioReady = this._setupAudio();
|
||||
const wsReady = this._openWebSocket(connectUrl);
|
||||
await Promise.all([audioReady, wsReady]);
|
||||
const apipilotReady = this._prefetchApipilotTools(sessionIdShort);
|
||||
await Promise.all([audioReady, wsReady, apipilotReady.catch(() => void 0)]);
|
||||
_wsLog(1, "SESSION", `${sessionIdShort} connessione stabilita + audio pipeline + apipilot OK`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔧 PRE-FETCH strumenti ApiPilot ad ogni inizio di sessione Realtime.
|
||||
* - Proxy sul server: GET api/apipilot/tools?include_unauthorized=true
|
||||
* - Il wrapper FETCH globale traccia come [FETCH:APIPILOT:TOOLS] in Console.
|
||||
* - Cache TTL locale 240 s per evitare chiamate ridondanti se l'utente
|
||||
* chiude e riapre sessioni in rapida successione (server TTL=300 s).
|
||||
* - SE window.__s2sApipilot=false → salta subito.
|
||||
* - MAI fallisce: sempre catch interno.
|
||||
* @param {string} sessionIdShort Log tag "sessione #N"
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _prefetchApipilotTools(sessionIdShort) {
|
||||
try {
|
||||
// @ts-ignore: __s2sApipilot è settato da main.js dopo fetchConfig()
|
||||
const enabled = typeof window !== "undefined" && !!window.__s2sApipilot;
|
||||
if (!enabled) {
|
||||
_wsLog(2, "APIPILOT", `${sessionIdShort} feature flag apipilot=false sul server — salto prefetch strumenti`);
|
||||
return;
|
||||
}
|
||||
const currentUser = _apipilotUserKey();
|
||||
const now = Date.now();
|
||||
const cached = _apipilotClientCache;
|
||||
// HIT cache solo se: (1) ancora fresca TTL (2) stesso utente key (non cambio phone/token tra sessioni)
|
||||
if (cached && (now - cached.at <= _APIPILOT_CLIENT_TTL_MS) && cached.key === currentUser) {
|
||||
const ageSec = Math.round((now - cached.at) / 1000);
|
||||
const n =
|
||||
cached.data && typeof cached.data.count === "number"
|
||||
? cached.data.count
|
||||
: Array.isArray(cached.data && cached.data.tools)
|
||||
? cached.data.tools.length
|
||||
: "?";
|
||||
_wsLog(1, "APIPILOT", `${sessionIdShort} HIT cache client strumenti (user=${currentUser}, age=${ageSec}s, N=${n}, TTL=${Math.round(_APIPILOT_CLIENT_TTL_MS/1000)}s)`);
|
||||
return;
|
||||
}
|
||||
// Propaga header x-demo-session se disponibile in window (per auth fingerprint lato server S2S).
|
||||
/** @type {Record<string,string>} */
|
||||
const extraHeaders = {};
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (typeof window.__s2sDemoSessionToken === "string" && window.__s2sDemoSessionToken.length > 4) {
|
||||
// @ts-ignore
|
||||
extraHeaders["x-demo-session"] = window.__s2sDemoSessionToken;
|
||||
}
|
||||
} catch (_) { /* noop */ }
|
||||
// Usa refresh=true SE è cambiato l'utente (cache.key diversa o null) oppure la cache è stata invalidata globalmente.
|
||||
const needRefresh = !cached || cached.key !== currentUser || (now - cached.at > _APIPILOT_CLIENT_TTL_MS);
|
||||
const qsRefresh = needRefresh ? "&refresh=true" : "";
|
||||
const t0 = performance.now();
|
||||
_wsLog(0, "APIPILOT", `${sessionIdShort} MISS cache (user=${currentUser}, refresh=${needRefresh ? "Y" : "N"}) → GET api/apipilot/tools (proxy a S2S_APIPILOT_TOOLS_URL server)...`);
|
||||
const res = await fetch(`api/apipilot/tools?include_unauthorized=true${qsRefresh}`, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
headers: Object.keys(extraHeaders).length ? extraHeaders : undefined,
|
||||
});
|
||||
const dt = performance.now() - t0;
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const j = await res.json().catch(() => ({}));
|
||||
const ok = j && j.success && j.data;
|
||||
if (!ok) {
|
||||
throw new Error(`envelope non corretto: success=${Boolean(j && j.success)}`);
|
||||
}
|
||||
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
|
||||
const count = typeof j.data.count === "number" ? j.data.count : tools.length;
|
||||
// Aggiorna cache locale (in caso di hit la prossima sessione non chiamerà).
|
||||
_apipilotClientCache = { at: Date.now(), data: { count, tools: tools.slice(0, 100) }, key: currentUser };
|
||||
const names = tools
|
||||
.slice(0, 5)
|
||||
.map((t) => (typeof t.name === "string" ? t.name : "?"))
|
||||
.join(", ");
|
||||
_wsLog(
|
||||
1,
|
||||
"APIPILOT",
|
||||
`${sessionIdShort} READY strumenti APIPILOT caricati: N=${count}, t=${Math.round(dt)}ms, user=${currentUser} [primi 5: ${names || "(nessuno)"}]`
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
_wsLog(
|
||||
3,
|
||||
"APIPILOT",
|
||||
`${sessionIdShort} prefetch FALLITO: ${msg} — diagnostica lato server log [APIPILOT-DISCOVERY]`
|
||||
);
|
||||
// NON rilanciare: connect() deve comunque andare a buon fine.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,30 +660,40 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
async _postSession() {
|
||||
const url = this._sessionUrl;
|
||||
console.log("[ws] POST", url);
|
||||
_wsLog(0, "SESSION", `POST /session → ${url}`);
|
||||
const t0 = performance.now();
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const dur = (performance.now() - t0).toFixed(1);
|
||||
if (response.status === 402) {
|
||||
// The session proxy refused: today's per-tier time budget is spent. Surface
|
||||
// it as a typed error so the UI shows the limit modal, not a crash.
|
||||
const body = await response.json().catch(() => ({}));
|
||||
_wsLog(3, "SESSION", `/session HTTP 402: limite giornaliero esaurito (tier=${body?.tier ?? "?"}) dur=${dur} ms`);
|
||||
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
|
||||
}
|
||||
if (response.status === 503) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (body?.state === "at_capacity") {
|
||||
_wsLog(2, "QUEUE:FULL", `/session HTTP 503 state=at_capacity (coda piena) dur=${dur} ms`);
|
||||
throw _codedError("The queue is full — try again shortly.", "queue-full");
|
||||
}
|
||||
_wsLog(3, "SESSION", `/session HTTP 503 (fallback sconosciuto) dur=${dur} ms`);
|
||||
throw new Error("/session failed (503)");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
_wsLog(3, "SESSION", `/session fallita HTTP ${response.status}: ${text.slice(0, 180)} dur=${dur} ms`);
|
||||
throw new Error(`/session failed (${response.status}): ${text}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.state === "queued") {
|
||||
_wsLog(
|
||||
2,
|
||||
"QUEUE:WAIT",
|
||||
`/session → stato=queued queueId=${json.queue_id ?? "?"} position=${json.position ?? "?"} pollEvery=${json.poll_interval_s ?? "?"}s dur=${dur} ms`
|
||||
);
|
||||
return {
|
||||
state: "queued",
|
||||
queueId: json.queue_id,
|
||||
@@ -412,7 +701,13 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
pollIntervalS: json.poll_interval_s,
|
||||
};
|
||||
}
|
||||
return { state: "granted", grant: this._parseGrant(json) };
|
||||
const grant = this._parseGrant(json);
|
||||
_wsLog(
|
||||
1,
|
||||
"SESSION",
|
||||
`/session OK → GRANT sessionId=${grant.sessionId?.slice(0, 10)}… pendingTimeout=${grant.pendingTimeoutS}s dur=${dur} ms`
|
||||
);
|
||||
return { state: "granted", grant };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -426,8 +721,15 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
const intervalMs = Math.max(1, ticket.pollIntervalS || 2) * 1000;
|
||||
this._queueId = ticket.queueId;
|
||||
this._emitQueue(ticket.position);
|
||||
_wsLog(
|
||||
2,
|
||||
"QUEUE:POLL",
|
||||
`Inizio poll coda queueId=${ticket.queueId.slice(0, 10)}… initialPos=${ticket.position} intervalMs=${intervalMs}`
|
||||
);
|
||||
let pollNum = 0;
|
||||
|
||||
while (true) {
|
||||
pollNum += 1;
|
||||
await this._queueSleep(intervalMs);
|
||||
if (this._closed) throw _codedError("queue wait aborted", "aborted");
|
||||
|
||||
@@ -442,9 +744,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
|
||||
if (response.status === 402) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
_wsLog(3, "QUEUE:LIMIT", `poll #${pollNum} HTTP 402 — limite giornaliero (tier=${body?.tier ?? "?"})`);
|
||||
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
|
||||
}
|
||||
if (response.status === 404) {
|
||||
_wsLog(3, "QUEUE:EXPIRED", `poll #${pollNum} HTTP 404 — ticket scaduto queueId=${this._queueId.slice(0, 10)}…`);
|
||||
throw _codedError("Queue timed out", "queue-expired");
|
||||
}
|
||||
if (!response.ok) continue; // 502/503 — transient, retry
|
||||
@@ -452,10 +756,14 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
const json = await response.json().catch(() => null);
|
||||
if (!json) continue;
|
||||
if (json.state === "queued") {
|
||||
if (this._debug || pollNum === 1 || pollNum % 10 === 0) {
|
||||
_wsLog(2, "QUEUE:POLL", `poll #${pollNum} position=${json.position ?? "?"}`);
|
||||
}
|
||||
this._emitQueue(json.position);
|
||||
continue;
|
||||
}
|
||||
// Reached the front and claimed a slot.
|
||||
_wsLog(1, "QUEUE:FRONT", `poll #${pollNum} — RAGGIUNTA la prima posizione. Assegno slot compute.`);
|
||||
this._queueId = "";
|
||||
return this._parseGrant(json);
|
||||
}
|
||||
@@ -573,6 +881,23 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
/** @param {string} connectUrl */
|
||||
_openWebSocket(connectUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Non loggiamo l'URL completo (potrebbe contenere session_token in
|
||||
// chiaro nella querystring). Mostriamo solo host + path + un hash della
|
||||
// parte segreta per audit senza leak token.
|
||||
let maskedUrl = connectUrl;
|
||||
try {
|
||||
const u = new URL(connectUrl, window.location.href);
|
||||
const secretPortions = [];
|
||||
u.searchParams.forEach((v, k) => {
|
||||
const lower = k.toLowerCase();
|
||||
const isSecret = /token|session|auth|jwt|secret|key/i.test(lower) || String(v).length > 16;
|
||||
secretPortions.push(`${k}=${isSecret ? `✂️…${String(v).slice(-4)} (${String(v).length} chars)` : v}`);
|
||||
});
|
||||
maskedUrl = `${u.protocol}//${u.host}${u.pathname} ? ${secretPortions.join(" & ") || "(nessun query param)"}`;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
_wsLog(0, "CONNECT", `new WebSocket() → ${maskedUrl}`);
|
||||
const ws = new WebSocket(connectUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
this._ws = ws;
|
||||
@@ -580,11 +905,13 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
const onceOpen = () => {
|
||||
ws.removeEventListener("open", onceOpen);
|
||||
ws.removeEventListener("error", onceErr);
|
||||
_wsLog(1, "CONNECT", `WebSocket APERTO su ${maskedUrl} readyState=${ws.readyState}`);
|
||||
resolve();
|
||||
};
|
||||
const onceErr = (e) => {
|
||||
ws.removeEventListener("open", onceOpen);
|
||||
ws.removeEventListener("error", onceErr);
|
||||
_wsLog(3, "CONNECT", `WebSocket IMPOSSIBILE da aprire: event.type=${e?.type ?? "error"} url=${maskedUrl}`);
|
||||
reject(new Error(`WebSocket failed to open: ${e?.type ?? "error"}`));
|
||||
};
|
||||
ws.addEventListener("open", onceOpen);
|
||||
@@ -594,6 +921,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
ws.addEventListener("close", (e) => this._onWsClose(e));
|
||||
ws.addEventListener("error", (e) => {
|
||||
console.error("[ws] socket error", e);
|
||||
_wsLog(3, "ERR", `WebSocket error: event.type=${e?.type ?? "error"} wasClean=${"wasClean" in e ? String(/** @type {CloseEvent} */ (e).wasClean) : "?"}`, e);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -647,9 +975,33 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
|
||||
const type = event?.type;
|
||||
if (typeof type !== "string") return;
|
||||
// Always-on tracing for high-signal events (lifecycle, transcript/tool calls,
|
||||
// errors). For bulk streams (audio/text deltas) we log only count / first
|
||||
// + last so the console doesn't scroll into uselessness.
|
||||
const isHighSignal = /^(session\.|error$|conversation\.item\.created|conversation\.item\.input_audio_transcription\.completed|response\.created|response\.output_item\.done|response\.audio_transcript\.done|response\.output_audio_transcript\.done|response\.done|response\.function_call_arguments\.done|rate_limits\.updated)/i.test(
|
||||
type,
|
||||
);
|
||||
if (isHighSignal) {
|
||||
const isError = type === "error";
|
||||
const extra = type.startsWith("conversation.item.input_audio_transcription")
|
||||
? ` item=${event.item_id} ci=${event.content_index} — ${String(event.delta ?? event.transcript ?? "").slice(0, 180)}`
|
||||
: type.startsWith("response.")
|
||||
? ` resp=${String(event.response_id ?? event.response?.id ?? "").slice(0, 10)} status=${event.response?.status ?? "-"} ${String(event.transcript ?? event.text ?? event.delta ?? "").slice(0, 200)}`
|
||||
: type === "response.function_call_arguments.done"
|
||||
? ` tool=${event.name} callId=${String(event.call_id ?? "").slice(0, 10)} argsPreview=${String(event.arguments ?? "").slice(0, 220)}`
|
||||
: type === "error"
|
||||
? ` code=${String(event.code ?? "-")} message=${String(event.message ?? "").slice(0, 200)}`
|
||||
: type === "rate_limits.updated"
|
||||
? ` ${String(JSON.stringify(event.rate_limits ?? [])).slice(0, 220)}`
|
||||
: "";
|
||||
_wsLog(isError ? 3 : (type.startsWith("response.") && /(text_done|audio_transcript\.done|output_item\.done|output_audio_transcript\.done|function_call_arguments)/i.test(type) ? 1 : 0),
|
||||
"MSG",
|
||||
`← ${type}${extra}`
|
||||
);
|
||||
}
|
||||
// Opt-in event tracing for diagnosing turn/transcript issues. Enable with
|
||||
// `localStorage.setItem("s2s.debug", "1")` in the browser console.
|
||||
if (this._debug) {
|
||||
if (this._debug && !isHighSignal) {
|
||||
const extra = type.startsWith("conversation.item.input_audio_transcription")
|
||||
? ` item=${event.item_id} ci=${event.content_index} ${event.delta ?? event.transcript ?? ""}`
|
||||
: type.startsWith("response.")
|
||||
@@ -1008,6 +1360,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
/** @param {CloseEvent} ev */
|
||||
_onWsClose(ev) {
|
||||
console.log("[ws] socket closed:", ev.code, ev.reason);
|
||||
_wsLog(
|
||||
(ev.code === 1000 || ev.code === 1001) ? 1 : (ev.code ? 3 : 2),
|
||||
"CLOSE",
|
||||
`WebSocket CHIUSO code=${ev.code ?? "?"} reason=${String(ev.reason ?? "(nessuna)").slice(0, 160)} wasClean=${ev.wasClean}`
|
||||
);
|
||||
if (this._status === "closed" || this._status === "error") return;
|
||||
if (ev.code === 1000) {
|
||||
this._setStatus("closed");
|
||||
@@ -1037,6 +1394,38 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
output: { voice: this.options.voice },
|
||||
},
|
||||
};
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2026-09-25 INIETTA API-PILOT HERRAMIENTAS DINÁMICAS (PASADA-1) nel session.update.
|
||||
// 3 strati di assicurazione (in ordine di priorità crescente, dedup by marker):
|
||||
// (A) main.js effectiveReasoningInstructions() ha già appeso la lista.
|
||||
// (B) NOSTRO server.py proxy realtime ri-inietta (se routing lo attraversa).
|
||||
// (C) QUI s2s-ws-client: ultimo punto di catch-all prima dell'invio al
|
||||
// canale, GARANTISCE che la lista sia SEMPRE presente in istruzioni
|
||||
// (anche se qualcuno fa updateSession() con text sovrascritto poco dopo).
|
||||
{
|
||||
const currentInstr = (typeof session.instructions === "string") ? session.instructions : "";
|
||||
if (!instructionsAlreadyHasApipilotPass1(currentInstr)) {
|
||||
try {
|
||||
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
|
||||
? window.__s2sGetApipilotPass1ContextInfo()
|
||||
: null;
|
||||
const blk = (info && typeof info.block === "string") ? info.block : buildApipilotPass1PromptBlock(null);
|
||||
if (blk && blk.trim()) {
|
||||
session.instructions = currentInstr
|
||||
? `${currentInstr}\n\n${blk}`
|
||||
: blk;
|
||||
const lenBlk = blk.length;
|
||||
const cntTools = (info && typeof info.count === "number") ? info.count : "?";
|
||||
_wsLog(
|
||||
1, "APIPILOT",
|
||||
`_sendSessionUpdate: APPENDED PASS-1 instructions (N=${cntTools}, +${lenBlk} chars). origLen=${currentInstr.length} newLen=${session.instructions.length}`,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
/* never-fail */
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tools are declared here; the backend already accepts them in
|
||||
// session.update and emits response.function_call_arguments.done when the
|
||||
// model decides to call one. Only include the keys when we actually have
|
||||
@@ -1049,8 +1438,9 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
reportWsDebug("B", "s2s-ws-client.js:_sendSessionUpdate", "sending initial session.update", {
|
||||
toolCount: this._tools.length,
|
||||
toolNames: this._tools.map((tool) => tool.name),
|
||||
hasInstructions: !!this.options.instructions,
|
||||
instructionLength: (this.options.instructions || "").length,
|
||||
hasInstructions: !!session.instructions,
|
||||
instructionLength: (session.instructions || "").length,
|
||||
apipilotPass1Present: instructionsAlreadyHasApipilotPass1(session.instructions),
|
||||
});
|
||||
// #endregion
|
||||
this._send({ type: "session.update", session });
|
||||
@@ -1061,14 +1451,35 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
updateSession(patch) {
|
||||
/** @type {Record<string, any>} */
|
||||
const session = { type: "realtime" };
|
||||
if (patch.instructions) session.instructions = patch.instructions;
|
||||
if (patch.instructions) {
|
||||
let instr = patch.instructions;
|
||||
// ── 2026-09-25 catch-all apipilot PASS-1 anche per patch live. ──
|
||||
if (typeof instr === "string" && !instructionsAlreadyHasApipilotPass1(instr)) {
|
||||
try {
|
||||
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
|
||||
? window.__s2sGetApipilotPass1ContextInfo()
|
||||
: null;
|
||||
const blk = (info && typeof info.block === "string") ? info.block : buildApipilotPass1PromptBlock(null);
|
||||
if (blk && blk.trim()) {
|
||||
const origLen = instr.length;
|
||||
instr = `${instr}\n\n${blk}`;
|
||||
_wsLog(
|
||||
1, "APIPILOT",
|
||||
`updateSession: APPENDED PASS-1 on live patch (N=${(info && typeof info.count === "number") ? info.count : "?"}, +${blk.length} chars). origLen=${origLen} newLen=${instr.length}`,
|
||||
);
|
||||
}
|
||||
} catch (_) { /* never-fail */ }
|
||||
}
|
||||
session.instructions = instr;
|
||||
}
|
||||
if (patch.voice) session.audio = { output: { voice: patch.voice } };
|
||||
if (Object.keys(session).length > 1) {
|
||||
// #region debug-point C:update-session
|
||||
reportWsDebug("C", "s2s-ws-client.js:updateSession", "sending patch session.update", {
|
||||
hasInstructions: !!patch.instructions,
|
||||
instructionLength: (patch.instructions || "").length,
|
||||
hasInstructions: !!session.instructions,
|
||||
instructionLength: (session.instructions || "").length,
|
||||
hasVoice: !!patch.voice,
|
||||
apipilotPass1Present: typeof session.instructions === "string" && instructionsAlreadyHasApipilotPass1(session.instructions),
|
||||
});
|
||||
// #endregion
|
||||
this._send({ type: "session.update", session });
|
||||
@@ -1365,14 +1776,46 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
/** @param {Record<string, unknown>} event */
|
||||
_send(event) {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
||||
// Anti-spam bulk-rate limiter per eventi audio (~25 al secondo).
|
||||
// Loggiamo un resoconto ogni 250 chunk (circa 10s a 25Hz) invece di ogni singolo.
|
||||
if (event.type === "input_audio_buffer.append") {
|
||||
_wsMicChunkCount += 1;
|
||||
const now = performance.now();
|
||||
if (_wsMicChunkCount === 1 || now - _wsMicChunkLastLogTs >= 2500 || _wsMicChunkCount % 250 === 0) {
|
||||
_wsMicChunkLastLogTs = now;
|
||||
_wsLog(0, "SEND:BULK", `→ input_audio_buffer.append chunk #${_wsMicChunkCount} audio_len=${String(event.audio ?? "").length} chars (base64)`);
|
||||
}
|
||||
let payloadLength = 0;
|
||||
let stringifyMs = 0;
|
||||
let payload = "";
|
||||
if (event.type === "conversation.item.create") {
|
||||
const startedAt = performance.now();
|
||||
payload = JSON.stringify(event);
|
||||
stringifyMs = performance.now() - startedAt;
|
||||
payloadLength = payload.length;
|
||||
// #region debug-point A:conversation-item-create
|
||||
reportWsDebug("A", "s2s-ws-client.js:_send", "sending conversation.item.create payload", {
|
||||
payloadLength,
|
||||
stringifyMs: Math.round(stringifyMs),
|
||||
});
|
||||
// #endregion
|
||||
}
|
||||
if (this._debug && (event.type === "session.update" || event.type === "conversation.item.create")) {
|
||||
const preview = (payload || JSON.stringify(event)).slice(0, 400);
|
||||
console.debug(`[Vocero WS OUT] type=${event.type} payload=${preview}`);
|
||||
}
|
||||
this._ws.send(payload || JSON.stringify(event));
|
||||
return;
|
||||
}
|
||||
// Messaggi non bulk: log sempre [S2S-WS:SEND] con anteprima del payload.
|
||||
let payloadLength = 0;
|
||||
let stringifyMs = 0;
|
||||
let payload = "";
|
||||
const startedAt = performance.now();
|
||||
payload = JSON.stringify(event);
|
||||
stringifyMs = performance.now() - startedAt;
|
||||
payloadLength = payload.length;
|
||||
if (event.type === "conversation.item.create") {
|
||||
const startedAt = performance.now();
|
||||
payload = JSON.stringify(event);
|
||||
stringifyMs = performance.now() - startedAt;
|
||||
payloadLength = payload.length;
|
||||
// #region debug-point A:conversation-item-create
|
||||
reportWsDebug("A", "s2s-ws-client.js:_send", "sending conversation.item.create payload", {
|
||||
payloadLength,
|
||||
@@ -1380,11 +1823,17 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
});
|
||||
// #endregion
|
||||
}
|
||||
const isToolish = /tool|function|response\.create/i.test(event.type || "");
|
||||
const preview = payload.slice(0, isToolish ? 600 : 260);
|
||||
_wsLog(
|
||||
isToolish ? 1 : 0,
|
||||
"SEND",
|
||||
`→ ${String(event.type ?? "?")} payload=${payloadLength} bytes stringify=${Math.round(stringifyMs)} ms — ${preview}${payload.length > (isToolish ? 600 : 260) ? "…" : ""}`
|
||||
);
|
||||
if (this._debug && (event.type === "session.update" || event.type === "conversation.item.create")) {
|
||||
const preview = (payload || JSON.stringify(event)).slice(0, 400);
|
||||
console.debug(`[Vocero WS OUT] type=${event.type} payload=${preview}`);
|
||||
console.debug(`[Vocero WS OUT] type=${event.type} payload=${payload.slice(0, 400)}`);
|
||||
}
|
||||
this._ws.send(payload || JSON.stringify(event));
|
||||
this._ws.send(payload);
|
||||
}
|
||||
|
||||
async close() {
|
||||
|
||||
@@ -4,16 +4,80 @@ LLM Output Processor
|
||||
Intercepts LLM output to:
|
||||
1. Extract tool calls and send them via text_output_queue
|
||||
2. Forward clean text to TTS pipeline
|
||||
3. 2026-09-25: SANITIZE APIPILOT internal markers before clients / TTS (anti-leak tecnico)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from queue import Queue
|
||||
|
||||
from speech_to_speech.baseHandler import BaseHandler
|
||||
from speech_to_speech.pipeline.events import AssistantTextEvent, ResponseFailedEvent, TokenUsageEvent
|
||||
|
||||
# 2026-09-25 SANITIZE APIPILOT CALL/RESULT markers in LLM output BEFORE:
|
||||
# (a) AssistantTextEvent viene mandato ai client WS (e loggato come <<< TEXT:/ASSISTANT:)
|
||||
# (b) TTSInput viene mandato a Qwen3-TTS (che lo pronuncerebbe ad alta voce).
|
||||
# Formati osservati reali oggi (upstream Vocero start_pipeline_rag):
|
||||
# 1) <<<APIPILOT_CALL:NAME=tool,ARGUMENTS=json>>> standard 3x
|
||||
# 2) <APIPILOT_CALL:NAME=get_user_briefing,ARGUMENTS={"format":"voice"}
|
||||
# singolo '<' '>' + args inventati da AI (format=voice)
|
||||
# 3) <<<APIPILOT_CALL:...}> \n>> spezzato multi-riga
|
||||
# 4) >>>APIPILOT_CALL:...<<< invertito (errore sintassi AI)
|
||||
# 5) <<<APIPILOT_RESULT:PASS2>>> + blocco result.
|
||||
_APIPILOT_CALL_NORM = re.compile(
|
||||
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_APIPILOT_CALL_INV = re.compile(
|
||||
r"[>]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[<]{1,3}",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_APIPILOT_RESULT = re.compile(
|
||||
r"[<]{1,3}\s*APIPILOT_RESULT(?::PASS2)?[^<>\n]{0,4000}[>]{1,3}",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_APIPILOT_SPANISH_PLACEHOLDER = ("Voy a consultar tus datos, un momento por favor.")
|
||||
|
||||
|
||||
def _sanitize_apipilot_before_client_and_tts(text: str) -> tuple[str, bool, str]:
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return text, False, ""
|
||||
found_call = False
|
||||
found_call_tool = ""
|
||||
def _repl_call(m: re.Match) -> str:
|
||||
nonlocal found_call, found_call_tool
|
||||
found_call = True
|
||||
try:
|
||||
nm = m.group(1)
|
||||
if isinstance(nm, str) and nm.strip():
|
||||
found_call_tool = nm.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return f" {_APIPILOT_SPANISH_PLACEHOLDER} "
|
||||
new_text = _APIPILOT_CALL_NORM.sub(_repl_call, text)
|
||||
if not found_call:
|
||||
new_text = _APIPILOT_CALL_INV.sub(_repl_call, new_text)
|
||||
found_result = False
|
||||
if _APIPILOT_RESULT.search(new_text):
|
||||
new_text = _APIPILOT_RESULT.sub(" ", new_text)
|
||||
found_result = True
|
||||
# safety catchall frammenti marker senza wrapper (se marker troncato mid-stream)
|
||||
low = new_text.lower()
|
||||
if "apipilot" in low and ("call" in low or "result" in low or "name=" in low or "arguments=" in low):
|
||||
new_text = re.sub(r"(?i)\bAPIPILOT(?:_CALL|_RESULT)?\b", " ", new_text)
|
||||
new_text = re.sub(r"(?i)\b(?:NAME|ARGUMENTS|STATUS|PASS2)\s*[:=]", " ", new_text)
|
||||
found_call = True
|
||||
if found_call or found_result:
|
||||
new_text = re.sub(r"\s{2,}", " ", new_text).strip()
|
||||
tag = ""
|
||||
if found_call:
|
||||
tag = f"CALL({found_call_tool or '?'})"
|
||||
if found_result:
|
||||
tag = (tag + "+" if tag else "") + "RESULT"
|
||||
return new_text, (found_call or found_result), tag
|
||||
from speech_to_speech.pipeline.handler_types import LLMOut, TTSIn
|
||||
from speech_to_speech.pipeline.messages import EndOfResponse, LLMResponseChunk, TokenUsage, TTSInput
|
||||
from speech_to_speech.pipeline.queue_types import TextEventItem
|
||||
@@ -120,24 +184,44 @@ class LMOutputProcessor(BaseHandler[LLMOut, TTSIn]):
|
||||
|
||||
logger.debug(f"LM processor: text='{lm_output.text}', tools={lm_output.tools}")
|
||||
|
||||
# 2026-09-25 SANITIZE SINGOLO PUNTO CENTRALE: PRIMA di AssistantTextEvent (client/log)
|
||||
# E PRIMA di TTSInput (Qwen3-TTS che pronuncerebbe marker APIPILOT CALL).
|
||||
clean_text = lm_output.text
|
||||
any_sanitized = False
|
||||
sanitize_tag = ""
|
||||
if clean_text and clean_text.strip():
|
||||
clean_text, any_sanitized, sanitize_tag = _sanitize_apipilot_before_client_and_tts(clean_text)
|
||||
if any_sanitized:
|
||||
logger.warning(
|
||||
"[LM-OUTPUT-APIPILOT-SANITIZE] %s — tool=%s rawLen=%d cleanLen=%d (rimosso / sostituito con placeholder spagnolo PRIMA di client + TTS).",
|
||||
sanitize_tag or "-",
|
||||
(sanitize_tag if sanitize_tag else "marker"),
|
||||
len(lm_output.text or ""),
|
||||
len(clean_text or ""),
|
||||
)
|
||||
# evita che lo stesso chunk (marker solo) arrivi a TTS come "" → Hello.
|
||||
# Se dopo sanitize testo vuoto MA avevamo trovato marker → placeholder.
|
||||
if not clean_text.strip():
|
||||
clean_text = _APIPILOT_SPANISH_PLACEHOLDER
|
||||
|
||||
if self.text_output_queue is not None:
|
||||
event = AssistantTextEvent(
|
||||
text=lm_output.text,
|
||||
text=clean_text,
|
||||
turn_id=lm_output.turn_id,
|
||||
turn_revision=lm_output.turn_revision,
|
||||
cancel_generation=lm_output.cancel_generation,
|
||||
)
|
||||
if lm_output.tools:
|
||||
event.tools = lm_output.tools
|
||||
logger.info(f"Sending to clients: text='{lm_output.text}', tools={[t.name for t in lm_output.tools]}")
|
||||
logger.info(f"Sending to clients: text='{clean_text}', tools={[t.name for t in lm_output.tools]}")
|
||||
else:
|
||||
logger.debug(f"Sending to clients: text='{lm_output.text}' (no tools)")
|
||||
logger.debug(f"Sending to clients: text='{clean_text}' (no tools)")
|
||||
self.text_output_queue.put(event)
|
||||
|
||||
if lm_output.text and response_wants_audio(lm_output.response):
|
||||
logger.debug(f"Forwarding to TTS: '{lm_output.text}'")
|
||||
if clean_text and response_wants_audio(lm_output.response):
|
||||
logger.debug(f"Forwarding to TTS: '{clean_text}' (original marker was: {sanitize_tag if any_sanitized else 'none'})")
|
||||
yield TTSInput(
|
||||
text=lm_output.text,
|
||||
text=clean_text,
|
||||
language_code=lm_output.language_code,
|
||||
runtime_config=lm_output.runtime_config,
|
||||
response=lm_output.response,
|
||||
|
||||
@@ -12,6 +12,78 @@ import math
|
||||
import re
|
||||
import tempfile
|
||||
import unicodedata
|
||||
|
||||
# 2026-09-25 SANITIZE APIPILOT MARKERS PRIMA DI TTS (anti-leak pronunciato da Qwen3).
|
||||
# Lato server S2S Python abbiamo gia' gate + sanitize ma come DOPPIA RETE
|
||||
# di sicurezza sanitizziamo DIRETTAMENTE il testo che entra in Qwen3-TTS.
|
||||
# Formati osservati reali oggi:
|
||||
# (a) <<<APIPILOT_CALL:NAME=tool,ARGUMENTS=json>>> standard 3 parentesi
|
||||
# (b) <APIPILOT_CALL:NAME=tool,ARGUMENTS={"format":"voice"}
|
||||
# singolo < > (caso user oggi)
|
||||
# (c) <<<APIPILOT_CALL:...}> \n>> spezzato piu' righe
|
||||
# (d) >>>APIPILOT_CALL:...<<< invertito
|
||||
# Regola: CALL -> placeholder spagnolo (frase naturale utente), RESULT -> strip.
|
||||
_APIPILOT_CALL_NORMAL_RE = re.compile(
|
||||
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_APIPILOT_CALL_INVERTED_RE = re.compile(
|
||||
r"[>]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[<]{1,3}",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_APIPILOT_RESULT_RE = re.compile(
|
||||
r"[<]{1,3}\s*APIPILOT_RESULT(?::PASS2)?[^<>\n]{0,2000}[>]{1,3}",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_APIPILOT_SPANISH_PLACEHOLDER = "Voy a consultar tus datos, un momento por favor."
|
||||
|
||||
|
||||
def _tts_sanitize_apipilot_markers(text: str) -> tuple[str, bool, str]:
|
||||
"""Rimuove marker APIPILOT dal testo TTS.
|
||||
|
||||
Returns (sanitized_text, found_any, log_tag).
|
||||
"""
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return text, False, "empty"
|
||||
found_call = False
|
||||
found_result = False
|
||||
tool_name = ""
|
||||
# 1) marker CALL standard + invertito -> placeholder spagnolo
|
||||
def _repl_call(m: re.Match) -> str:
|
||||
nonlocal found_call, tool_name
|
||||
found_call = True
|
||||
try:
|
||||
nm = m.group(1)
|
||||
if isinstance(nm, str) and nm.strip():
|
||||
tool_name = nm.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return f" {_APIPILOT_SPANISH_PLACEHOLDER} "
|
||||
text2 = _APIPILOT_CALL_NORMAL_RE.sub(_repl_call, text)
|
||||
if not found_call:
|
||||
text2 = _APIPILOT_CALL_INVERTED_RE.sub(_repl_call, text2)
|
||||
# 2) marker RESULT -> strip silenzioso (non sono pronunciabili)
|
||||
if _APIPILOT_RESULT_RE.search(text2):
|
||||
found_result = True
|
||||
text2 = _APIPILOT_RESULT_RE.sub(" ", text2)
|
||||
# 3) safety: FRAMMENTI marker singoli che non hanno wrapper completo
|
||||
low = text2.lower()
|
||||
if "apipilot" in low and ("call" in low or "result" in low):
|
||||
import re as _re_sf
|
||||
text2 = _re_sf.sub(r"(?i)\bAPIPILOT(?:_CALL|_RESULT)?\b", " ", text2)
|
||||
text2 = _re_sf.sub(r"(?i)\b(?:NAME|ARGUMENTS|STATUS|PASS2)\s*[:=]", " ", text2)
|
||||
found_call = True
|
||||
if found_call or found_result:
|
||||
text2 = re.sub(r"\s{2,}", " ", text2).strip()
|
||||
tag = "-"
|
||||
if found_call and found_result:
|
||||
tag = f"CALL({tool_name or '?'})+RESULT"
|
||||
elif found_call:
|
||||
tag = f"CALL({tool_name or '?'})"
|
||||
elif found_result:
|
||||
tag = "RESULT"
|
||||
return text2, (found_call or found_result), tag
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from sys import platform
|
||||
@@ -709,7 +781,20 @@ class Qwen3TTSHandler(BaseHandler[TTSIn, TTSOut]):
|
||||
|
||||
coalesced_text, _language_code, _saw_end_of_response = self._coalesce_pending_tts_input(tts_input)
|
||||
|
||||
text = coalesced_text or "Hello."
|
||||
raw_text = coalesced_text or "Hello."
|
||||
# 2026-09-25 SANITIZE APIPILOT PRIMA DI TTS (doppia rete sicurezza):
|
||||
# Anche se lato server S2s Python abbiamo gia' gate audio drop + sanitize,
|
||||
# Qwen3-TTS puo' ricevere marker per race / nuovi formati. Quindi QUI
|
||||
# eliminiamo / sostituiamo con placeholder PRIMA di chiamare generate().
|
||||
text, found_marker, sanitize_tag = _tts_sanitize_apipilot_markers(raw_text)
|
||||
if found_marker:
|
||||
logger.warning(
|
||||
"[TTS-APIPILOT-SANITIZE] marker=%s rawLen=%d sanitizedLen=%d → sostituito con placeholder spagnolo / strip.",
|
||||
sanitize_tag, len(raw_text or ""), len(text or ""),
|
||||
)
|
||||
if not text.strip():
|
||||
# Safety se il marker era l'unica cosa: ripetiamo placeholder invece di Hello.
|
||||
text = _APIPILOT_SPANISH_PLACEHOLDER
|
||||
|
||||
model_type = self._model_type()
|
||||
self._apply_session_voice_override(model_type, runtime_config, response)
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ export S2S_PROXY_TARGET
|
||||
|
||||
echo "💻 Avvio dell'interfaccia S2S Client UI (Porta $UI_PORT)..."
|
||||
echo "🔌 Proxy realtime verso $S2S_PROXY_TARGET"
|
||||
echo "💡 Premi Ctrl+C in questo terminale per fermarla."
|
||||
echo " Premi Ctrl+C in questo terminale per fermarla."
|
||||
echo "----------------------------------------------------"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
Reference in New Issue
Block a user