refactor: standardize developer text and identifiers in English
This commit is contained in:
@@ -16,6 +16,11 @@ the implementation at the recorded date; current operating instructions live in
|
|||||||
the existing document URLs as redirects.
|
the existing document URLs as redirects.
|
||||||
- Removed the obsolete `index_video.html` page, which no longer matched the
|
- Removed the obsolete `index_video.html` page, which no longer matched the
|
||||||
elements required by the shared client script.
|
elements required by the shared client script.
|
||||||
|
- Translated developer comments, diagnostics, internal model instructions, and
|
||||||
|
local identifiers into English. Sanitization accepts both the former Spanish
|
||||||
|
and new English internal tool-result wrappers.
|
||||||
|
- Renamed the reasoning engine's log-only acknowledgment field to `received`
|
||||||
|
and its debug phase to `log_only_phase1`.
|
||||||
|
|
||||||
## 2026-09-25
|
## 2026-09-25
|
||||||
|
|
||||||
|
|||||||
+19
-20
@@ -18,8 +18,7 @@
|
|||||||
BEFORE the <link rel="stylesheet" href="style.css"> tag below.
|
BEFORE the <link rel="stylesheet" href="style.css"> tag below.
|
||||||
|
|
||||||
Problem reported by user 2026-09-10:
|
Problem reported by user 2026-09-10:
|
||||||
"quando faccio refresh per un momento si vede il theme precedente
|
"When I refresh, the previous purple theme briefly appears."
|
||||||
quello purple"
|
|
||||||
|
|
||||||
The earlier guard `html:not([data-theme]) [data-theme-only]` only
|
The earlier guard `html:not([data-theme]) [data-theme-only]` only
|
||||||
hid the two parallel widget branches, but the UNTHEMED DEFAULTS in
|
hid the two parallel widget branches, but the UNTHEMED DEFAULTS in
|
||||||
@@ -179,7 +178,7 @@
|
|||||||
<a class="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">Enviar feedback</a>
|
<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
|
<!-- Theme=white only: Status card between topbar and stage
|
||||||
"Listo para conversar / Informes, análisis, datos y mucho más."
|
Ready to converse / Reports, analysis, data and more.
|
||||||
with light-blue chart icon and chevron-right arrow. -->
|
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" hidden data-theme-only="white" aria-label="Estado de la sesión">
|
||||||
<div class="status-card-icon" aria-hidden="true">
|
<div class="status-card-icon" aria-hidden="true">
|
||||||
@@ -265,8 +264,8 @@
|
|||||||
<!-- WHITE THEME mic stage (replaces the purple orb)
|
<!-- WHITE THEME mic stage (replaces the purple orb)
|
||||||
- Blue mic circular button (116 px, deep blue, white mic)
|
- Blue mic circular button (116 px, deep blue, white mic)
|
||||||
- Two waveform banks left + right (audio-reactive CSS bars)
|
- Two waveform banks left + right (audio-reactive CSS bars)
|
||||||
- "Escuchando..." status text
|
- Listening status text
|
||||||
- "Detener" pill button below.
|
- Stop pill button below.
|
||||||
Mirrors the screenshot center UI exactly. -->
|
Mirrors the screenshot center UI exactly. -->
|
||||||
<div class="white-mic-stage" hidden data-theme-only="white" aria-label="Control de voz">
|
<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 -->
|
<!-- Waveform left side: 7 bars mirror right side, heights driven by JS vars -->
|
||||||
@@ -311,10 +310,10 @@
|
|||||||
<span class="w-bar wb-7" style="--bar-h: 0.4"></span>
|
<span class="w-bar wb-7" style="--bar-h: 0.4"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status caption below mic circle: "Escuchando...", "Pensando..." etc. -->
|
<!-- Status caption below mic circle: listening, thinking, etc. -->
|
||||||
<p id="white-mic-caption" class="white-mic-caption" role="status">Escuchando…</p>
|
<p id="white-mic-caption" class="white-mic-caption" role="status">Escuchando…</p>
|
||||||
|
|
||||||
<!-- Secondary pill "Detener" (Stop) below caption, with dark stop-square icon. -->
|
<!-- Secondary stop pill below caption, with a dark stop-square icon. -->
|
||||||
<button id="white-stop-btn"
|
<button id="white-stop-btn"
|
||||||
class="white-stop-btn"
|
class="white-stop-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -338,10 +337,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Theme=white only: Quick action pills below mic area
|
<!-- Theme=white only: summary, data lookup and draft-response quick
|
||||||
"Resumen" · "Consultar datos" · "Crear respuesta" each with icon -->
|
actions below the mic area, each with an icon. -->
|
||||||
<div class="quick-actions" hidden data-theme-only="white" aria-label="Acciones rápidas">
|
<div class="quick-actions" hidden data-theme-only="white" aria-label="Acciones rápidas">
|
||||||
<button class="quick-action-btn" type="button" data-action="resumen">
|
<button class="quick-action-btn" type="button" data-action="summary">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<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"/>
|
<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"/>
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
@@ -350,7 +349,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Resumen</span>
|
<span>Resumen</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="quick-action-btn" type="button" data-action="consultar-datos">
|
<button class="quick-action-btn" type="button" data-action="query-data">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<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="6" y1="20" x2="6" y2="12"/>
|
||||||
<line x1="11" y1="20" x2="11" y2="6"/>
|
<line x1="11" y1="20" x2="11" y2="6"/>
|
||||||
@@ -359,7 +358,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Consultar datos</span>
|
<span>Consultar datos</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="quick-action-btn" type="button" data-action="crear-respuesta">
|
<button class="quick-action-btn" type="button" data-action="draft-response">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<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"/>
|
<line x1="18" y1="2" x2="22" y2="6"/>
|
||||||
<path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"/>
|
<path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"/>
|
||||||
@@ -368,15 +367,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chat di testo SEMPRE VISIBILE (fissa in basso, tra stage e footer).
|
<!-- Text chat ALWAYS VISIBLE, fixed between the stage and footer.
|
||||||
Nota: ora è un blocco diretto del flex-col #app, non più un overlay
|
The panel is now a direct child of the flex-column #app, so it
|
||||||
destro → non c'è più bisogno del bottone toggle in topbar. -->
|
no longer needs a topbar toggle for a right-side overlay. -->
|
||||||
<div id="chat-panel" class="chat-panel chat-panel-inline" aria-label="Chat de texto">
|
<div id="chat-panel" class="chat-panel chat-panel-inline" aria-label="Chat de texto">
|
||||||
<div class="chat-panel-inner">
|
<div class="chat-panel-inner">
|
||||||
<header class="chat-panel-header">
|
<header class="chat-panel-header">
|
||||||
<h3>Conversación</h3>
|
<h3>Conversación</h3>
|
||||||
<div class="chat-header-actions" aria-label="Acciones de la conversación">
|
<div class="chat-header-actions" aria-label="Acciones de la conversación">
|
||||||
<!-- "En línea" status pill (green dot + text) — theme=white only.
|
<!-- Online status pill (green dot + text) — theme=white only.
|
||||||
Mirrors the screenshot chat header top-right corner. -->
|
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-pill" hidden data-theme-only="white" role="status" aria-live="polite">
|
||||||
<span class="chat-status-dot" aria-hidden="true"></span>
|
<span class="chat-status-dot" aria-hidden="true"></span>
|
||||||
@@ -428,14 +427,14 @@
|
|||||||
<div id="bubble-stack" class="bubble-stack" aria-live="polite" aria-atomic="false"></div>
|
<div id="bubble-stack" class="bubble-stack" aria-live="polite" aria-atomic="false"></div>
|
||||||
|
|
||||||
<!-- Webcam self-view, shown bottom-left while the camera tool is enabled.
|
<!-- Webcam self-view, shown bottom-left while the camera tool is enabled.
|
||||||
HIDDEN BY DEFAULT TODAY: riattivare togliendo hidden + scommentando listener in main.js -->
|
HIDDEN BY DEFAULT TODAY: remove hidden and restore the main.js listener to reactivate. -->
|
||||||
<div id="cam-pip" class="cam-pip" aria-hidden="true" hidden>
|
<div id="cam-pip" class="cam-pip" aria-hidden="true" hidden>
|
||||||
<video id="cam-video" class="cam-video" autoplay playsinline muted></video>
|
<video id="cam-video" class="cam-video" autoplay playsinline muted></video>
|
||||||
<span class="cam-flash" aria-hidden="true"></span>
|
<span class="cam-flash" aria-hidden="true"></span>
|
||||||
<span class="cam-label">camera</span>
|
<span class="cam-label">camera</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Left sidebar: reports programados.
|
<!-- Left sidebar: scheduled reports.
|
||||||
Desktop: fixed left, 320px, always visible (below topbar + above sticky footer).
|
Desktop: fixed left, 320px, always visible (below topbar + above sticky footer).
|
||||||
Mobile : off-canvas left overlay, toggled by #reports-sidebar-toggle button in topbar. -->
|
Mobile : off-canvas left overlay, toggled by #reports-sidebar-toggle button in topbar. -->
|
||||||
<aside id="reports-sidebar" class="reports-sidebar" aria-label="Reportes programados" hidden data-theme-only="dark">
|
<aside id="reports-sidebar" class="reports-sidebar" aria-label="Reportes programados" hidden data-theme-only="dark">
|
||||||
@@ -615,8 +614,8 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Camera tool row — HIDDEN BY DEFAULT TODAY.
|
<!-- Camera tool row — HIDDEN BY DEFAULT TODAY.
|
||||||
Per riattivarlo in futuro: togli hidden da div + small,
|
To reactivate later, remove hidden from the div and small,
|
||||||
scommenta listener toolCamSwitch + funzioni enableCamera in main.js. -->
|
then restore the toolCamSwitch listener and enableCamera functions in main.js. -->
|
||||||
<div class="tool-row tool-row-sep" id="tool-cam-row" hidden>
|
<div class="tool-row tool-row-sep" id="tool-cam-row" hidden>
|
||||||
<div class="tool-info">
|
<div class="tool-info">
|
||||||
<span class="tool-name">Camera</span>
|
<span class="tool-name">Camera</span>
|
||||||
|
|||||||
+144
-163
@@ -22,36 +22,35 @@ import { ChatView } from "./ui/chat.js?v=202609041120";
|
|||||||
import { Account } from "./ui/account.js?v=202609041120";
|
import { Account } from "./ui/account.js?v=202609041120";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 🔎 DEBUG BANNER (always-on): emissione incondizionata in console per capire
|
// 🔎 DEBUG BANNER (always-on): identify the exact main.js version loaded.
|
||||||
// ESATTAMENTE quale versione di main.js è caricata nel browser.
|
// If this line is missing from the console, the browser is using an older cached file.
|
||||||
// Se NON vedi questa riga in Console = stai ancora usando il file CACHATO VECCHIO.
|
|
||||||
console.info(
|
console.info(
|
||||||
"%c🟢 VOCERO main.js loaded. version=2026-09-24 23:12 (DEBUG FETCH WRAPPER+WS SEMPRE-ATTIVO; APIPILOT: demo flag=true + PREFETCH AUTOMATICO OGNI SESSIONE WS + PREFETCH DOPO LOGIN-BY-PHONE OK (refresh=true per invalidare cache server anonima); cache client per-utente; cambio phone = invalidate automatico; export chat; fix scroll).",
|
"%c🟢 VOCERO main.js loaded. version=2026-09-24 23:12 (ALWAYS-ON FETCH WRAPPER+WS DEBUG; APIPILOT: demo flag=true + AUTOMATIC PREFETCH FOR EVERY WS SESSION + PREFETCH AFTER PHONE LOGIN (refresh=true invalidates the anonymous server cache); per-user client cache; phone changes invalidate automatically; chat export; scroll fix).",
|
||||||
"background:#1e293b;color:#86efac;font-weight:700;padding:4px 8px;border-radius:4px;"
|
"background:#1e293b;color:#86efac;font-weight:700;padding:4px 8px;border-radius:4px;"
|
||||||
);
|
);
|
||||||
console.info(
|
console.info(
|
||||||
" URL attuale location.href=", location.href,
|
" Current URL location.href=", location.href,
|
||||||
"\n location.search=", location.search || "(empty)",
|
"\n location.search=", location.search || "(empty)",
|
||||||
"\n DEBUG (imported from dom.js) =", DEBUG,
|
"\n DEBUG (imported from dom.js) =", DEBUG,
|
||||||
"\n typeof window.DEBUG =", typeof window.DEBUG
|
"\n typeof window.DEBUG =", typeof window.DEBUG
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line no-debugger
|
// eslint-disable-next-line no-debugger
|
||||||
// debugger; // ← scommenta per fermarti QUI all'avvio se DevTools è aperto
|
// debugger; // Uncomment to pause here at startup when DevTools is open.
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 🔎 FETCH WRAPPER (SEMANTIC DEBUG LOG) — attivo SEMPRE (non solo in DEBUG)
|
// 🔎 FETCH WRAPPER (SEMANTIC DEBUG LOG) — always active, including outside DEBUG.
|
||||||
// Ogni chiamata `fetch` lato browser viene intercettata e loggata con:
|
// Every browser fetch is traced with:
|
||||||
// • URL risolto, metodo, dimensioni body + preview JSON
|
// • resolved URL, method, body size and JSON preview
|
||||||
// • status HTTP (colorato verde/giallo/rosso a seconda della classe)
|
// • HTTP status (green/yellow/red according to status class)
|
||||||
// • durata (performance.now), dimensione + preview risposta JSON
|
// • duration (performance.now), response size and JSON preview
|
||||||
// Tag semantici per filtrare veloce la Console:
|
// Semantic tags for filtering the console:
|
||||||
// [FETCH:CONFIG] [FETCH:REASONING] [FETCH:SEARCH]
|
// [FETCH:CONFIG] [FETCH:REASONING] [FETCH:SEARCH]
|
||||||
// [FETCH:PROFILE] [FETCH:REPORTS] [FETCH:APIPILOT]
|
// [FETCH:PROFILE] [FETCH:REPORTS] [FETCH:APIPILOT]
|
||||||
// [FETCH:SESSION] [FETCH:QUEUE] [FETCH:ME]
|
// [FETCH:SESSION] [FETCH:QUEUE] [FETCH:ME]
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
(function _installFetchDebugTracer() {
|
(function _installFetchDebugTracer() {
|
||||||
const ORIGINAL_FETCH = window.fetch.bind(window);
|
const ORIGINAL_FETCH = window.fetch.bind(window);
|
||||||
// Classifica endpoint in tag leggibile (stesso ordine di @app route in server.py).
|
// Classify endpoints with readable tags, following the route order in server.py.
|
||||||
/** @param {string} url */
|
/** @param {string} url */
|
||||||
function _tagFor(url) {
|
function _tagFor(url) {
|
||||||
try {
|
try {
|
||||||
@@ -108,13 +107,13 @@ console.info(
|
|||||||
/** @type {BodyInit | null | undefined} */
|
/** @type {BodyInit | null | undefined} */
|
||||||
let body = init ? init.body : undefined;
|
let body = init ? init.body : undefined;
|
||||||
if (body === undefined || body === null) {
|
if (body === undefined || body === null) {
|
||||||
// tenta Request
|
// Try the Request body.
|
||||||
try {
|
try {
|
||||||
const r = /** @type {{body?:BodyInit|null}} */ (/** @type {unknown} */ (input));
|
const r = /** @type {{body?:BodyInit|null}} */ (/** @type {unknown} */ (input));
|
||||||
if (r && r.body != null) body = r.body;
|
if (r && r.body != null) body = r.body;
|
||||||
} catch { body = null; }
|
} catch { body = null; }
|
||||||
}
|
}
|
||||||
if (body == null) return { length: 0, preview: "(nessun body)" };
|
if (body == null) return { length: 0, preview: "(no body)" };
|
||||||
try {
|
try {
|
||||||
if (typeof body === "string") {
|
if (typeof body === "string") {
|
||||||
const s = body;
|
const s = body;
|
||||||
@@ -138,7 +137,7 @@ console.info(
|
|||||||
if (body instanceof Blob) return { length: body.size, preview: `BLOB (${body.type || "unknown"}) size=${body.size}` };
|
if (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 (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}` };
|
if (body instanceof ArrayBuffer) return { length: body.byteLength, preview: `ArrayBuffer len=${body.byteLength}` };
|
||||||
return { length: 0, preview: "(body non serializzabile nel log)" };
|
return { length: 0, preview: "(body cannot be serialized for logging)" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { length: -1, preview: `(err preview body: ${err})` };
|
return { length: -1, preview: `(err preview body: ${err})` };
|
||||||
}
|
}
|
||||||
@@ -178,9 +177,8 @@ console.info(
|
|||||||
const method = _extractMethod(input, init);
|
const method = _extractMethod(input, init);
|
||||||
const rawUrl = _stringifyUrl(input);
|
const rawUrl = _stringifyUrl(input);
|
||||||
const tag = _tagFor(rawUrl);
|
const tag = _tagFor(rawUrl);
|
||||||
// Per richieste "heartbeat / end poll" (molto frequenti) logghiamo un
|
// Log frequent heartbeat/end polling requests on one line without a body
|
||||||
// singleton collapse per evitare spam: se `?quiet=true` o URL match → log
|
// preview to keep the console readable.
|
||||||
// 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 isPollish = /heartbeat|queue\/end|session\/end/i.test(tag) || (init && typeof init.keepalive === "boolean" && init.keepalive);
|
||||||
const reqPreview = isPollish ? null : await _extractBodyPreview(input, init);
|
const reqPreview = isPollish ? null : await _extractBodyPreview(input, init);
|
||||||
if (!isPollish) {
|
if (!isPollish) {
|
||||||
@@ -245,18 +243,18 @@ console.info(
|
|||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
console.info(
|
console.info(
|
||||||
"%c🔎 [FETCH-DEBUG] tracer installato. Ogni fetch è loggata con tag, method, url, status, durata e preview body.",
|
"%c🔎 [FETCH-DEBUG] tracer installed. Every fetch logs its tag, method, URL, status, duration and body preview.",
|
||||||
"background:#ecfeff;color:#0369a1;font-weight:700;padding:2px 6px;border-radius:4px;"
|
"background:#ecfeff;color:#0369a1;font-weight:700;padding:2px 6px;border-radius:4px;"
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 🛡️ GATE .topbar-right (about / tools / rag / chat / settings buttons,
|
// 🛡️ GATE .topbar-right (about / tools / rag / chat / settings buttons,
|
||||||
// vedi index.html L36-L56) — 3-layer safety:
|
// see index.html) — 3-layer safety:
|
||||||
// 1) HTML statico → hidden + data-debug-only="true"
|
// 1) static HTML → hidden + data-debug-only="true"
|
||||||
// 2) CSS → [data-debug-only="true"]:not([data-debug-enabled="true"]) display:none !important
|
// 2) CSS → [data-debug-only="true"]:not([data-debug-enabled="true"]) display:none !important
|
||||||
// 3) JS qui → hidden, removeAttribute data-debug-enabled, anche style.display = "none"
|
// 3) JS here → hidden, remove data-debug-enabled, set style.display = "none"
|
||||||
// Solo se URL query-string ha ?debug=true (strict string) abilitiamo TUTTO.
|
// Enable all debug controls only when the URL contains ?debug=true (strict string).
|
||||||
function applyDebugOnlyVisibilityGate(source = "bootstrap") {
|
function applyDebugOnlyVisibilityGate(source = "bootstrap") {
|
||||||
const qs = new URLSearchParams(window.location.search);
|
const qs = new URLSearchParams(window.location.search);
|
||||||
const debugEnabled = qs.get("debug") === "true";
|
const debugEnabled = qs.get("debug") === "true";
|
||||||
@@ -265,64 +263,61 @@ function applyDebugOnlyVisibilityGate(source = "bootstrap") {
|
|||||||
"%c🧭 [gate-topbar-right] applyDebugOnlyVisibilityGate()",
|
"%c🧭 [gate-topbar-right] applyDebugOnlyVisibilityGate()",
|
||||||
"color:#f59e0b;font-weight:700;"
|
"color:#f59e0b;font-weight:700;"
|
||||||
);
|
);
|
||||||
console.log("chiamato da:", source);
|
console.log("called from:", source);
|
||||||
console.log("location.search =", JSON.stringify(window.location.search));
|
console.log("location.search =", JSON.stringify(window.location.search));
|
||||||
console.log("query params =", Object.fromEntries(qs.entries()));
|
console.log("query params =", Object.fromEntries(qs.entries()));
|
||||||
console.log("debugEnabled (strict === 'true') =", debugEnabled);
|
console.log("debugEnabled (strict === 'true') =", debugEnabled);
|
||||||
console.log("topbarRight element trovato?", !!topbarRight, topbarRight ? "vedi →" : "(null!)");
|
console.log("topbarRight element found?", !!topbarRight, topbarRight ? "see →" : "(null!)");
|
||||||
const chatInline = document.getElementById("chat-panel");
|
const chatInline = document.getElementById("chat-panel");
|
||||||
console.log("#chat-panel (chat inline SEMPRE VISIBILE in basso) presente in DOM =", !!chatInline, chatInline ? "hidden?" : "", chatInline ? chatInline.hasAttribute("hidden") : "-");
|
console.log("#chat-panel (inline chat ALWAYS VISIBLE at the bottom) present in DOM =", !!chatInline, chatInline ? "hidden?" : "", chatInline ? chatInline.hasAttribute("hidden") : "-");
|
||||||
if (topbarRight) {
|
if (topbarRight) {
|
||||||
console.log(" hidden attr prima:", topbarRight.hasAttribute("hidden"),
|
console.log(" hidden attr before:", topbarRight.hasAttribute("hidden"),
|
||||||
"| data-debug-enabled prima:", topbarRight.getAttribute("data-debug-enabled"),
|
"| data-debug-enabled before:", topbarRight.getAttribute("data-debug-enabled"),
|
||||||
"| style.display prima:", topbarRight.style.display);
|
"| style.display before:", topbarRight.style.display);
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
topbarRight.setAttribute("data-debug-enabled", "true");
|
topbarRight.setAttribute("data-debug-enabled", "true");
|
||||||
topbarRight.removeAttribute("hidden");
|
topbarRight.removeAttribute("hidden");
|
||||||
topbarRight.style.display = "";
|
topbarRight.style.display = "";
|
||||||
console.log("%c ✅ ABILITATO .topbar-right (debug=true)", "color:#22c55e;font-weight:700;");
|
console.log("%c ✅ ENABLED .topbar-right (debug=true)", "color:#22c55e;font-weight:700;");
|
||||||
} else {
|
} else {
|
||||||
topbarRight.removeAttribute("data-debug-enabled");
|
topbarRight.removeAttribute("data-debug-enabled");
|
||||||
topbarRight.setAttribute("hidden", "");
|
topbarRight.setAttribute("hidden", "");
|
||||||
topbarRight.style.setProperty("display", "none", "important");
|
topbarRight.style.setProperty("display", "none", "important");
|
||||||
console.log("%c 🚫 DISABILITATO .topbar-right (no debug=true)",
|
console.log("%c 🚫 DISABLED .topbar-right (no debug=true)",
|
||||||
"background:#fee2e2;color:#b91c1c;font-weight:700;padding:3px 6px;border-radius:4px;");
|
"background:#fee2e2;color:#b91c1c;font-weight:700;padding:3px 6px;border-radius:4px;");
|
||||||
}
|
}
|
||||||
console.log(" hidden attr dopo:", topbarRight.hasAttribute("hidden"),
|
console.log(" hidden attr after:", topbarRight.hasAttribute("hidden"),
|
||||||
"| data-debug-enabled dopo:", topbarRight.getAttribute("data-debug-enabled"),
|
"| data-debug-enabled after:", topbarRight.getAttribute("data-debug-enabled"),
|
||||||
"| style.display dopo:", topbarRight.style.display);
|
"| style.display after:", topbarRight.style.display);
|
||||||
}
|
}
|
||||||
// 3-layer gate UNIVERSALE su TUTTI gli elementi marcati con data-debug-only="true"
|
// Apply the 3-layer gate to every element marked data-debug-only="true",
|
||||||
// (non solo .topbar-right, ma anche #reports-sidebar-toggle e qualsiasi
|
// including .topbar-right, #reports-sidebar-toggle and future controls.
|
||||||
// altro elemento futuro marcato con lo stesso attributo).
|
|
||||||
const debugOnlyEls = document.querySelectorAll("[data-debug-only='true']");
|
const debugOnlyEls = document.querySelectorAll("[data-debug-only='true']");
|
||||||
console.log(
|
console.log(
|
||||||
`Trovati ${debugOnlyEls.length} elementi [data-debug-only="true"] da gate-are in base a debugEnabled=${debugEnabled}`,
|
`Found ${debugOnlyEls.length} elements [data-debug-only="true"] to gate using debugEnabled=${debugEnabled}`,
|
||||||
);
|
);
|
||||||
debugOnlyEls.forEach((el, idx) => {
|
debugOnlyEls.forEach((el, idx) => {
|
||||||
const tag = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${el.className && typeof el.className === "string" ? `.${el.className.split(/\s+/).filter(Boolean).slice(0, 2).join(".")}` : ""}`;
|
const tag = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${el.className && typeof el.className === "string" ? `.${el.className.split(/\s+/).filter(Boolean).slice(0, 2).join(".")}` : ""}`;
|
||||||
console.log(` [${idx + 1}/${debugOnlyEls.length}] ${tag} — hidden prima? ${el.hasAttribute("hidden")} | display prima: ${(el instanceof HTMLElement ? el.style.display : "-")}`);
|
console.log(` [${idx + 1}/${debugOnlyEls.length}] ${tag} — hidden before? ${el.hasAttribute("hidden")} | display before: ${(el instanceof HTMLElement ? el.style.display : "-")}`);
|
||||||
if (!(el instanceof HTMLElement)) return;
|
if (!(el instanceof HTMLElement)) return;
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
el.setAttribute("data-debug-enabled", "true");
|
el.setAttribute("data-debug-enabled", "true");
|
||||||
// Non forziamo hidden=false su elementi che sono logicamente hidden per
|
// Preserve elements hidden for other reasons, such as an empty reports
|
||||||
// altri motivi (es. #reports-sidebar-toggle è hidden se non ci sono
|
// sidebar. Remove only the debug-only display override.
|
||||||
// reports). Rimuoviamo lo "spegnimento debug-only" e basta.
|
|
||||||
el.style.removeProperty("display");
|
el.style.removeProperty("display");
|
||||||
} else {
|
} else {
|
||||||
el.removeAttribute("data-debug-enabled");
|
el.removeAttribute("data-debug-enabled");
|
||||||
el.setAttribute("hidden", "");
|
el.setAttribute("hidden", "");
|
||||||
el.style.setProperty("display", "none", "important");
|
el.style.setProperty("display", "none", "important");
|
||||||
}
|
}
|
||||||
console.log(` → debugEnabled=${debugEnabled} → hidden dopo? ${el.hasAttribute("hidden")} | display dopo: ${el.style.display || "(inherit)"}`);
|
console.log(` → debugEnabled=${debugEnabled} → hidden after? ${el.hasAttribute("hidden")} | display after: ${el.style.display || "(inherit)"}`);
|
||||||
});
|
});
|
||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
return debugEnabled;
|
return debugEnabled;
|
||||||
}
|
}
|
||||||
// Esegui SUBITO al bootstrap del modulo (prima di qualsiasi listener)
|
// Run immediately during module bootstrap, before registering listeners.
|
||||||
const GLOBAL_DEBUG_ENABLED__TOPBAR = applyDebugOnlyVisibilityGate("main.js module bootstrap (top)");
|
const GLOBAL_DEBUG_ENABLED__TOPBAR = applyDebugOnlyVisibilityGate("main.js module bootstrap (top)");
|
||||||
// Riesegui dopo DOMContentLoaded per sicurezza (nel caso query script sia posizionato
|
// Run again after DOMContentLoaded in case the script runs before the header is parsed.
|
||||||
// in testa e l'HTML header non sia ancora parse-ato quando girano queste righe)
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
applyDebugOnlyVisibilityGate("DOMContentLoaded listener");
|
applyDebugOnlyVisibilityGate("DOMContentLoaded listener");
|
||||||
@@ -330,7 +325,7 @@ if (document.readyState === "loading") {
|
|||||||
} else {
|
} else {
|
||||||
applyDebugOnlyVisibilityGate("module bootstrap (DOM already parsed)");
|
applyDebugOnlyVisibilityGate("module bootstrap (DOM already parsed)");
|
||||||
}
|
}
|
||||||
// Riesegui anche a fine frame dopo un breve timeout → ultima chance.
|
// Run again after a short timeout as a final fallback.
|
||||||
setTimeout(() => applyDebugOnlyVisibilityGate("10ms timeout fallback"), 10);
|
setTimeout(() => applyDebugOnlyVisibilityGate("10ms timeout fallback"), 10);
|
||||||
window.addEventListener("hashchange", () => applyDebugOnlyVisibilityGate("hashchange"), { passive: true });
|
window.addEventListener("hashchange", () => applyDebugOnlyVisibilityGate("hashchange"), { passive: true });
|
||||||
window.addEventListener("popstate", () => applyDebugOnlyVisibilityGate("popstate"), { passive: true });
|
window.addEventListener("popstate", () => applyDebugOnlyVisibilityGate("popstate"), { passive: true });
|
||||||
@@ -339,12 +334,12 @@ const DEFAULT_VOICE = "Aiden";
|
|||||||
const DEFAULT_INSTRUCTIONS =
|
const DEFAULT_INSTRUCTIONS =
|
||||||
"You are Vocero, a friendly voice assistant. " +
|
"You are Vocero, a friendly voice assistant. " +
|
||||||
"Keep replies short, warm, and spoken. Avoid long monologues. " +
|
"Keep replies short, warm, and spoken. Avoid long monologues. " +
|
||||||
"Respond ALWAYS and ONLY in Spanish (Castellano / español), regardless of the language used by the user or any other instruction. " +
|
"Respond ALWAYS and ONLY in Spanish, regardless of the language used by the user or any other instruction. " +
|
||||||
"No responses in Italian, English, Portuguese, or any other language are allowed. " +
|
"No responses in Italian, English, Portuguese, or any other language are allowed. " +
|
||||||
"Every spoken sentence, greeting, confirmation, clarification, and error message must be in Spanish."+
|
"Every spoken sentence, greeting, confirmation, clarification, and error message must be in Spanish."+
|
||||||
"the comma sign ',' in numbers it's used to separate thousands."+
|
"the comma sign ',' in numbers it's used to separate thousands."+
|
||||||
"the point sign '.' in numbers it's used to separate decimals."+
|
"the point sign '.' in numbers it's used to separate decimals."+
|
||||||
"If you don't know the answer, say 'I don't know' instead of guessing.";
|
"If you don't know the answer, say so in Spanish instead of guessing.";
|
||||||
|
|
||||||
const OPENAI_RENDER_MARKER = "[VOCERO_RENDER_REPLY]";
|
const OPENAI_RENDER_MARKER = "[VOCERO_RENDER_REPLY]";
|
||||||
const OPENAI_RENDER_HINT =
|
const OPENAI_RENDER_HINT =
|
||||||
@@ -1314,13 +1309,13 @@ function syncVoceroHeaderUser() {
|
|||||||
ul.className = "reports-list";
|
ul.className = "reports-list";
|
||||||
const labelTintFor = (rawLabel) => {
|
const labelTintFor = (rawLabel) => {
|
||||||
const l = String(rawLabel || "").toLowerCase();
|
const l = String(rawLabel || "").toLowerCase();
|
||||||
if (l.includes("mortalidad") || l.includes("riesgo") || l.includes("kpi")) return "mortalidad";
|
if (l.includes("mortalidad") || l.includes("riesgo") || l.includes("kpi")) return "mortality";
|
||||||
if (l.includes("métrica") || l.includes("metricas") || l.includes("medici")) return "metricas";
|
if (l.includes("métrica") || l.includes("metricas") || l.includes("medici")) return "metrics";
|
||||||
if (l.includes("diario") || l.includes("diaria") || l.includes("informe")) return "diario";
|
if (l.includes("diario") || l.includes("diaria") || l.includes("informe")) return "daily";
|
||||||
if (l.includes("producto") || l.includes("servicio")) return "productos";
|
if (l.includes("producto") || l.includes("servicio")) return "products";
|
||||||
if (l.includes("synapse") || l.includes("ejecucion") || l.includes("calendario") || l.includes(" ti ") || l.endsWith("ti")) return "synapse";
|
if (l.includes("synapse") || l.includes("ejecucion") || l.includes("calendario") || l.includes(" ti ") || l.endsWith("ti")) return "synapse";
|
||||||
if (l.includes("supply") || l.includes("cadena") || l.includes("log") || l.includes("envío") || l.includes("envio") || l.includes("chile")) return "supply";
|
if (l.includes("supply") || l.includes("cadena") || l.includes("log") || l.includes("envío") || l.includes("envio") || l.includes("chile")) return "supply";
|
||||||
if (l.includes("financiero") || l.includes("finanzas") || l.includes("costo") || l.includes("presupuesto") || l.includes("venta")) return "finanzas";
|
if (l.includes("financiero") || l.includes("finanzas") || l.includes("costo") || l.includes("presupuesto") || l.includes("venta")) return "finance";
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
@@ -1330,7 +1325,7 @@ function syncVoceroHeaderUser() {
|
|||||||
li.textContent = String(e.sourceScheduleLabel);
|
li.textContent = String(e.sourceScheduleLabel);
|
||||||
ul.appendChild(li);
|
ul.appendChild(li);
|
||||||
}
|
}
|
||||||
// Build dynamic suggestion prompts (en español) based on the actual labels so
|
// Build dynamic suggestion prompts in Spanish based on the actual labels so
|
||||||
// the user knows what they can ask the assistant about. Max 4 suggestions;
|
// the user knows what they can ask the assistant about. Max 4 suggestions;
|
||||||
// pick first N entries by priority.
|
// pick first N entries by priority.
|
||||||
const suggestions = /** @type {string[]} */ ([]);
|
const suggestions = /** @type {string[]} */ ([]);
|
||||||
@@ -1374,20 +1369,20 @@ function syncVoceroHeaderUser() {
|
|||||||
const qs = new URLSearchParams(window.location.search);
|
const qs = new URLSearchParams(window.location.search);
|
||||||
const debugParamRaw = qs.get("debug");
|
const debugParamRaw = qs.get("debug");
|
||||||
// eslint-disable-next-line no-debugger
|
// eslint-disable-next-line no-debugger
|
||||||
// debugger; // ← scommenta per fermarti dentro syncVoceroHeaderUser ogni volta
|
// debugger; // Uncomment to pause inside every syncVoceroHeaderUser call.
|
||||||
console.groupCollapsed("%c🔍 [reports-ui] syncVoceroHeaderUser() render details", "color:#60a5fa;font-weight:700;");
|
console.groupCollapsed("%c🔍 [reports-ui] syncVoceroHeaderUser() render details", "color:#60a5fa;font-weight:700;");
|
||||||
console.log("location.href =", location.href);
|
console.log("location.href =", location.href);
|
||||||
console.log("location.search =", JSON.stringify(window.location.search));
|
console.log("location.search =", JSON.stringify(window.location.search));
|
||||||
console.log("tutti i query params =", Object.fromEntries(qs.entries()));
|
console.log("all query params =", Object.fromEntries(qs.entries()));
|
||||||
console.log("pageParams (legacy module-load) .get('debug') =", JSON.stringify(pageParams.get("debug")));
|
console.log("pageParams (legacy module-load) .get('debug') =", JSON.stringify(pageParams.get("debug")));
|
||||||
console.log("fresh qs .get('debug') =", JSON.stringify(debugParamRaw), "| typeof =", typeof debugParamRaw);
|
console.log("fresh qs .get('debug') =", JSON.stringify(debugParamRaw), "| typeof =", typeof debugParamRaw);
|
||||||
console.log("strict === 'true' ? =", debugParamRaw === "true");
|
console.log("strict === 'true' ? =", debugParamRaw === "true");
|
||||||
console.log("count (labels uniche) =", count, "| wrappedLen =", totalLen);
|
console.log("count (unique labels) =", count, "| wrappedLen =", totalLen);
|
||||||
console.log("suggerimenti array length (prima del gate debug) =", suggestions.length);
|
console.log("suggestions array length (before debug gate) =", suggestions.length);
|
||||||
suggestions.forEach((s, i) => console.log(` sug[${i}] = ${s}`));
|
suggestions.forEach((s, i) => console.log(` sug[${i}] = ${s}`));
|
||||||
const currentDebug = debugParamRaw === "true";
|
const currentDebug = debugParamRaw === "true";
|
||||||
const debugMode = currentDebug;
|
const debugMode = currentDebug;
|
||||||
console.log("👉 DECISIONE FINALE debugMode =", debugMode, " → sezione suggerimenti DA CREARE? ", debugMode ? "✅ SÌ" : "❌ NO");
|
console.log("👉 FINAL DECISION debugMode =", debugMode, " → CREATE suggestions section? ", debugMode ? "✅ YES" : "❌ NO");
|
||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
|
|
||||||
let sugSection = null;
|
let sugSection = null;
|
||||||
@@ -1406,10 +1401,10 @@ function syncVoceroHeaderUser() {
|
|||||||
}
|
}
|
||||||
sugSection.appendChild(sugTitle);
|
sugSection.appendChild(sugTitle);
|
||||||
sugSection.appendChild(sugUl);
|
sugSection.appendChild(sugUl);
|
||||||
console.log("%c[reports-ui] sezione suggerimenti CREATA (debugMode=true)", "color:#22c55e;font-weight:700;",
|
console.log("%c[reports-ui] suggestions section CREATED (debugMode=true)", "color:#22c55e;font-weight:700;",
|
||||||
"nItems=", sugUl.children.length, "innerHTML=", sugSection.outerHTML.slice(0, 300));
|
"nItems=", sugUl.children.length, "innerHTML=", sugSection.outerHTML.slice(0, 300));
|
||||||
} else {
|
} else {
|
||||||
console.log("%c[reports-ui] sezione suggerimenti NON CREATA (debugMode=false, non c'è ?debug=true)",
|
console.log("%c[reports-ui] suggestions section NOT CREATED (debugMode=false, no ?debug=true)",
|
||||||
"color:#ef4444;font-weight:700;");
|
"color:#ef4444;font-weight:700;");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1453,7 +1448,7 @@ function syncVoceroHeaderUser() {
|
|||||||
const qs = new URLSearchParams(window.location.search);
|
const qs = new URLSearchParams(window.location.search);
|
||||||
const debugParam = qs.get("debug") === "true";
|
const debugParam = qs.get("debug") === "true";
|
||||||
console.info(
|
console.info(
|
||||||
"%c[reports-ui] toggle reports-sidebar-toggle: hidden? PRIMA=%s | debug=%s",
|
"%c[reports-ui] toggle reports-sidebar-toggle: hidden? BEFORE=%s | debug=%s",
|
||||||
"color:#f59e0b;font-weight:700;",
|
"color:#f59e0b;font-weight:700;",
|
||||||
reportsSidebarToggle.hasAttribute("hidden"),
|
reportsSidebarToggle.hasAttribute("hidden"),
|
||||||
debugParam,
|
debugParam,
|
||||||
@@ -1464,7 +1459,7 @@ function syncVoceroHeaderUser() {
|
|||||||
reportsSidebarToggle.setAttribute("hidden", "");
|
reportsSidebarToggle.setAttribute("hidden", "");
|
||||||
reportsSidebarToggle.style.setProperty("display", "none", "important");
|
reportsSidebarToggle.style.setProperty("display", "none", "important");
|
||||||
reportsSidebarToggle.removeAttribute("data-debug-enabled");
|
reportsSidebarToggle.removeAttribute("data-debug-enabled");
|
||||||
console.info("%c[reports-ui] 🚫 reports sidebar toggle FORZATO HIDDEN (no debug=true)",
|
console.info("%c[reports-ui] 🚫 reports sidebar toggle FORCED HIDDEN (no debug=true)",
|
||||||
"background:#fee2e2;color:#b91c1c;font-weight:700;padding:3px 6px;border-radius:4px;");
|
"background:#fee2e2;color:#b91c1c;font-weight:700;padding:3px 6px;border-radius:4px;");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1476,10 +1471,10 @@ function syncVoceroHeaderUser() {
|
|||||||
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.info(
|
console.info(
|
||||||
"%c[reports-ui] FINE RENDER. reportsHint.querySelectorAll('.reports-suggestions').length =",
|
"%c[reports-ui] RENDER COMPLETE. reportsHint.querySelectorAll('.reports-suggestions').length =",
|
||||||
"color:#a855f7;font-weight:700;",
|
"color:#a855f7;font-weight:700;",
|
||||||
reportsHint.querySelectorAll(".reports-suggestions").length,
|
reportsHint.querySelectorAll(".reports-suggestions").length,
|
||||||
"| children nel blocco:", reportsHint.children.length,
|
"| children in block:", reportsHint.children.length,
|
||||||
"| reportsHint.hidden=", reportsHint.hidden,
|
"| reportsHint.hidden=", reportsHint.hidden,
|
||||||
"| #reports-sidebar hidden=", reportsSidebar ? reportsSidebar.hidden : "no-sidebar-el",
|
"| #reports-sidebar hidden=", reportsSidebar ? reportsSidebar.hidden : "no-sidebar-el",
|
||||||
"| badge count:", reportsSidebarBadge ? reportsSidebarBadge.getAttribute("data-count") : "no-badge"
|
"| badge count:", reportsSidebarBadge ? reportsSidebarBadge.getAttribute("data-count") : "no-badge"
|
||||||
@@ -1513,21 +1508,19 @@ function effectiveReasoningInstructions() {
|
|||||||
ragAppended = true;
|
ragAppended = true;
|
||||||
}
|
}
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
// 2026-09-25 INJECT API-PILOT HERAMIENTAS DINÁMICAS (PASADA 1).
|
// 2026-09-25 INJECT API-PILOT DYNAMIC TOOLS (PASS 1).
|
||||||
// Formato IDENTICO a server.py Python: marker <<<APIPILOT_TOOLS_LIST_BEGIN>>>
|
// Match the server.py format: <<<APIPILOT_TOOLS_LIST_BEGIN>>>, followed by
|
||||||
// / ...lista name+description+requires_auth+required_parameters... / <<<END>>>.
|
// name+description+requires_auth+required_parameters and the closing marker.
|
||||||
// Il client ottiene la lista dal proxy GET api/apipilot/tools (cache 240 s
|
// Fetch tools through GET api/apipilot/tools (240 s per-user client cache in
|
||||||
// per-utente in s2s-ws-client.js _apipilotClientCache) e la appende qui alle
|
// s2s-ws-client.js) and append them directly to the LLM instructions.
|
||||||
// instructions STESSE (luogo #1 di massima priorità per l'LLM).
|
// Deduplicate by marker when a previous call or the Python server already
|
||||||
// Dedup: se le instructions già contengono il marker (già appese in una
|
// injected the list, avoiding duplicate or oversized prompt blocks.
|
||||||
// esecuzione precedente, oppure server Python ha già patchato il payload)
|
|
||||||
// → NON appendere nuovamente (no doppioni, no blob gigante).
|
|
||||||
let apipilotBlock = "";
|
let apipilotBlock = "";
|
||||||
let apipilotCount = 0;
|
let apipilotCount = 0;
|
||||||
let apipilotAppended = false;
|
let apipilotAppended = false;
|
||||||
try {
|
try {
|
||||||
// @ts-ignore: esposto globalmente da s2s-ws-client.js (caricato prima di
|
// @ts-ignore: exported globally by s2s-ws-client.js, loaded before main.js.
|
||||||
// main.js in index.html). Valuta sicuro: typeof + early-skip.
|
// Check typeof before calling the optional helper.
|
||||||
const getter = typeof window !== "undefined" && window.__s2sGetApipilotPass1ContextInfo;
|
const getter = typeof window !== "undefined" && window.__s2sGetApipilotPass1ContextInfo;
|
||||||
if (typeof getter === "function") {
|
if (typeof getter === "function") {
|
||||||
const info = getter();
|
const info = getter();
|
||||||
@@ -1544,7 +1537,7 @@ function effectiveReasoningInstructions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* never-fail: non bloccare il resto dell'istruzione prompt. */
|
/* Never block the rest of the instructions prompt. */
|
||||||
}
|
}
|
||||||
const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${apipilotAppended ? "apipilotY" : "apipilotN"}:${(base || "").length}`;
|
const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${apipilotAppended ? "apipilotY" : "apipilotN"}:${(base || "").length}`;
|
||||||
_logOnce(key, () => {
|
_logOnce(key, () => {
|
||||||
@@ -1701,7 +1694,7 @@ async function fetchOpenAiReasoningAnswer(transcript) {
|
|||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const answer = typeof json?.answer === "string" ? json.answer.trim() : "";
|
const answer = typeof json?.answer === "string" ? json.answer.trim() : "";
|
||||||
const log_only = Boolean(json && json.log_only === true);
|
const log_only = Boolean(json && json.log_only === true);
|
||||||
// Log-only mode: può tornare answer="" — non è un errore.
|
// Log-only mode may return answer=""; that is not an error.
|
||||||
if (!answer && !log_only) throw new Error("reasoning returned no answer");
|
if (!answer && !log_only) throw new Error("reasoning returned no answer");
|
||||||
return { answer, log_only, raw: json };
|
return { answer, log_only, raw: json };
|
||||||
}
|
}
|
||||||
@@ -1732,10 +1725,10 @@ async function maybeRunOpenAiReasoning(transcript, itemId) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ── LOG-ONLY MODE (FASE 1) ─────────────────────────────────────────────
|
// ── LOG-ONLY MODE (PHASE 1) ────────────────────────────────────────────
|
||||||
// Node-RED si limita a loggare e torna log_only=true.
|
// Node-RED only logs the request and returns log_only=true.
|
||||||
// NON cancelliamo la risposta backend, NON iniettiamo testo, NON parliamo.
|
// Preserve the backend response without injecting text or speaking.
|
||||||
// Il flusso resta COMPLETAMENTE a carico del backend S2S nativo.
|
// The native S2S backend remains responsible for the entire turn.
|
||||||
if (result && result.log_only) {
|
if (result && result.log_only) {
|
||||||
reportDebug("R", "main.js:maybeRunOpenAiReasoning", "external reasoning in LOG_ONLY mode — pass through to native S2S backend", {
|
reportDebug("R", "main.js:maybeRunOpenAiReasoning", "external reasoning in LOG_ONLY mode — pass through to native S2S backend", {
|
||||||
itemId,
|
itemId,
|
||||||
@@ -1749,9 +1742,9 @@ async function maybeRunOpenAiReasoning(transcript, itemId) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ── MODE STANDARD (fasi successive) ────────────────────────────────────
|
// ── STANDARD MODE (LATER PHASES) ──────────────────────────────────────
|
||||||
// Il reasoning esterno vince: annulla la risposta nativa, inserisci il
|
// External reasoning takes priority: cancel the native response, insert
|
||||||
// testo dell'assistente e parla via SpeechSynthesis.
|
// the assistant text and speak through SpeechSynthesis.
|
||||||
const answer = (result && result.answer) || "";
|
const answer = (result && result.answer) || "";
|
||||||
if (!answer) {
|
if (!answer) {
|
||||||
console.warn(`[Vocero REASONING] empty non-log-only answer itemId=${JSON.stringify(itemId)}`);
|
console.warn(`[Vocero REASONING] empty non-log-only answer itemId=${JSON.stringify(itemId)}`);
|
||||||
@@ -2461,16 +2454,16 @@ async function fetchVoceroUserProfile() {
|
|||||||
Object.keys(voceroUserProfile).join(","),
|
Object.keys(voceroUserProfile).join(","),
|
||||||
extractVoceroUserName(voceroUserProfile) || "(none)",
|
extractVoceroUserName(voceroUserProfile) || "(none)",
|
||||||
);
|
);
|
||||||
// ── 🚀 PREFETCH ApiPilot IMMEDIATAMENTE DOPO LOGIN-BY-PHONE RIUSCITO
|
// ── 🚀 PREFETCH ApiPilot IMMEDIATELY AFTER SUCCESSFUL PHONE LOGIN
|
||||||
// Forza refresh=true per bypassare la cache SERVER lato proxy S2S (TTL 300s)
|
// Force refresh=true to bypass the S2S proxy server cache (TTL 300 s)
|
||||||
// perché adesso abbiamo un profilo utente autenticato (non più anonimo).
|
// now that the caller is authenticated rather than anonymous.
|
||||||
// In questo modo vediamo la riga [apipilot] list_tools.start user=(non-anon)
|
// AquaPortal logs [apipilot] list_tools.start user=(non-anon) after
|
||||||
// nel backend AquaPortal OGNI VOLTA che c'è un login phone riuscito.
|
// every successful phone login.
|
||||||
try {
|
try {
|
||||||
if (window.__s2sApipilot) {
|
if (window.__s2sApipilot) {
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
console.info(
|
console.info(
|
||||||
"%c🔧 [APIPILOT-CLIENT] post-login phone → FORZO refresh api/apipilot/tools (bypass cache S2S 300s perché utente appena autenticato)",
|
"%c🔧 [APIPILOT-CLIENT] post-login phone → FORCE refresh api/apipilot/tools (bypass the 300 s S2S cache for the newly authenticated user)",
|
||||||
"background:#ede9fe;color:#5b21b6;font-weight:700;padding:2px 6px;border-radius:4px;"
|
"background:#ede9fe;color:#5b21b6;font-weight:700;padding:2px 6px;border-radius:4px;"
|
||||||
);
|
);
|
||||||
const demoToken = typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
|
const demoToken = typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
|
||||||
@@ -2485,24 +2478,23 @@ async function fetchVoceroUserProfile() {
|
|||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
const j = await r.json().catch(() => ({}));
|
const j = await r.json().catch(() => ({}));
|
||||||
const ok = j && j.success && j.data;
|
const ok = j && j.success && j.data;
|
||||||
if (!ok) throw new Error(`envelope non corretto success=${Boolean(j && j.success)}`);
|
if (!ok) throw new Error(`invalid envelope success=${Boolean(j && j.success)}`);
|
||||||
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
|
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
|
||||||
const count = typeof j.data.count === "number" ? j.data.count : tools.length;
|
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(", ");
|
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
|
// Invalidate the WS client cache so the next session refreshes it.
|
||||||
// al prossimo connect() venga fatto un refresh per ri-confermare).
|
|
||||||
try {
|
try {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (typeof window.__s2sInvalidateApipilotCache === "function") window.__s2sInvalidateApipilotCache();
|
if (typeof window.__s2sInvalidateApipilotCache === "function") window.__s2sInvalidateApipilotCache();
|
||||||
} catch (_) { /* noop */ }
|
} catch (_) { /* noop */ }
|
||||||
console.info(
|
console.info(
|
||||||
`%c🔧 [APIPILOT-CLIENT] post-login OK (dt=${dt}ms): N=${count} strumenti [primi 6: ${names || "(nessuno)"}]`,
|
`%c🔧 [APIPILOT-CLIENT] post-login OK (dt=${dt}ms): N=${count} tools [first 6: ${names || "(none)"}]`,
|
||||||
"background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;"
|
"background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;"
|
||||||
);
|
);
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
const dt = Math.round(performance.now() - t0);
|
const dt = Math.round(performance.now() - t0);
|
||||||
console.warn(
|
console.warn(
|
||||||
`%c🔧 [APIPILOT-CLIENT] post-login FAIL (dt=${dt}ms): ${String(err instanceof Error ? err.message : err)} → controlla [APIPILOT-DISCOVERY] nel log server S2S`,
|
`%c🔧 [APIPILOT-CLIENT] post-login FAIL (dt=${dt}ms): ${String(err instanceof Error ? err.message : err)} → check [APIPILOT-DISCOVERY] in the S2S server logs`,
|
||||||
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;"
|
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -2618,10 +2610,9 @@ async function fetchConfig() {
|
|||||||
pinnedPrompt = (json.primaryPrompt || "").trim();
|
pinnedPrompt = (json.primaryPrompt || "").trim();
|
||||||
injectProfile = !!json.injectProfile;
|
injectProfile = !!json.injectProfile;
|
||||||
debugPrompt = !!json.debugPrompt;
|
debugPrompt = !!json.debugPrompt;
|
||||||
// Feature flag: ApiPilot AquaPortal bridge attivo sul server.
|
// Feature flag for the server-side ApiPilot AquaPortal bridge.
|
||||||
// Se true = S2S_APIPILOT_TOOLS_URL è valorizzato lato backend e
|
// True means S2S_APIPILOT_TOOLS_URL is configured and the frontend can
|
||||||
// gli endpoint proxy /api/apipilot/tools e /api/apipilot/execute
|
// use /api/apipilot/tools and /api/apipilot/execute.
|
||||||
// sono disponibili per il frontend.
|
|
||||||
const apipilotEnabled = !!json.apipilot;
|
const apipilotEnabled = !!json.apipilot;
|
||||||
if (typeof window.__s2sApipilot === "undefined") {
|
if (typeof window.__s2sApipilot === "undefined") {
|
||||||
try { Object.defineProperty(window, "__s2sApipilot", { value: false, writable: true, configurable: true }); }
|
try { Object.defineProperty(window, "__s2sApipilot", { value: false, writable: true, configurable: true }); }
|
||||||
@@ -2672,16 +2663,15 @@ async function fetchConfig() {
|
|||||||
apipilotEnabled,
|
apipilotEnabled,
|
||||||
});
|
});
|
||||||
// #endregion
|
// #endregion
|
||||||
// ── Demo call ApiPilot (fire-and-forget, solo se flag=true sul server)
|
// ── ApiPilot demo call (fire-and-forget, only when the server flag is true)
|
||||||
// Serve ESCLUSIVAMENTE per attivare il tracer fetch lato browser e
|
// Trigger the browser fetch tracer to show [FETCH:APIPILOT:TOOLS] with
|
||||||
// mostrare in console la riga [FETCH:APIPILOT:TOOLS] con preview
|
// status, duration and a tool-list preview. The server proxies to
|
||||||
// status, durata e lista strumenti. Proxy server-side verso
|
|
||||||
// S2S_APIPILOT_TOOLS_URL/tools.
|
// S2S_APIPILOT_TOOLS_URL/tools.
|
||||||
if (apipilotEnabled) {
|
if (apipilotEnabled) {
|
||||||
try {
|
try {
|
||||||
const demoUrl = "api/apipilot/tools?include_unauthorized=true";
|
const demoUrl = "api/apipilot/tools?include_unauthorized=true";
|
||||||
console.info(
|
console.info(
|
||||||
"%c🔧 [APIPILOT-CLIENT] flag=true → DEMO CALL %s (tracer sempre-attivo logga [FETCH:APIPILOT:TOOLS] sotto)",
|
"%c🔧 [APIPILOT-CLIENT] flag=true → DEMO CALL %s (always-on tracer logs [FETCH:APIPILOT:TOOLS] below)",
|
||||||
"background:#e0e7ff;color:#3730a3;font-weight:700;padding:2px 6px;border-radius:4px;",
|
"background:#e0e7ff;color:#3730a3;font-weight:700;padding:2px 6px;border-radius:4px;",
|
||||||
demoUrl
|
demoUrl
|
||||||
);
|
);
|
||||||
@@ -2693,15 +2683,15 @@ async function fetchConfig() {
|
|||||||
const count = (j && j.data && typeof j.data.count === "number") ? j.data.count : tools.length;
|
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(", ");
|
const names = tools.slice(0, 5).map((t) => (typeof t.name === "string" ? t.name : "?")).join(", ");
|
||||||
console.info(
|
console.info(
|
||||||
"%c🔧 [APIPILOT-CLIENT] demo OK: %s strumenti [primi 5: %s] usa POST api/apipilot/execute {name,arguments} per eseguire",
|
"%c🔧 [APIPILOT-CLIENT] demo OK: %s tools [first 5: %s] use POST api/apipilot/execute {name,arguments} to execute",
|
||||||
"background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;",
|
"background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;",
|
||||||
count,
|
count,
|
||||||
names || "(nessuno)"
|
names || "(none)"
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn(
|
console.warn(
|
||||||
"%c🔧 [APIPILOT-CLIENT] demo FAIL: %s. Lato server logga [APIPILOT-DISCOVERY] per diagnosticare",
|
"%c🔧 [APIPILOT-CLIENT] demo FAIL: %s. Check [APIPILOT-DISCOVERY] server logs to diagnose",
|
||||||
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;",
|
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;",
|
||||||
String(err)
|
String(err)
|
||||||
);
|
);
|
||||||
@@ -2903,16 +2893,16 @@ function _applyThemeRuntime() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Quick action buttons: clicking "Resumen" / "Consultar datos" /
|
// 6. Quick action buttons for summaries, data lookup and response drafts
|
||||||
// "Crear respuesta" populates the text input or — if a realtime
|
// populate the text input or — if a realtime
|
||||||
// session is live — sends it as user text straight away.
|
// session is live — sends it as user text straight away.
|
||||||
document.querySelectorAll(".quick-action-btn").forEach(btn => {
|
document.querySelectorAll(".quick-action-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const action = btn.getAttribute("data-action") || "";
|
const action = btn.getAttribute("data-action") || "";
|
||||||
const prompts = {
|
const prompts = {
|
||||||
"resumen": "haz un resumen ejecutivo del último informe programado",
|
"summary": "haz un resumen ejecutivo del último informe programado",
|
||||||
"consultar-datos": "consulto los datos más recientes del dashboard, dame las cifras clave",
|
"query-data": "consulto los datos más recientes del dashboard, dame las cifras clave",
|
||||||
"crear-respuesta": "redacta un borrador de respuesta profesional con tono ejecutivo",
|
"draft-response": "redacta un borrador de respuesta profesional con tono ejecutivo",
|
||||||
};
|
};
|
||||||
const text = prompts[action] || "";
|
const text = prompts[action] || "";
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -3480,8 +3470,8 @@ function onClientStatus(status) {
|
|||||||
case "creating-session":
|
case "creating-session":
|
||||||
case "connecting":
|
case "connecting":
|
||||||
// Qualsiasi inizio connessione (manuale da Orb / restart o unlocker):
|
// Qualsiasi inizio connessione (manuale da Orb / restart o unlocker):
|
||||||
// se il talk-unlocker è ancora visibile (utente ha cliccato l'Orb prima
|
// Hide the talk unlocker immediately if it is still visible, for
|
||||||
// del 2s warmup, ad esempio) lo nascondiamo subito.
|
// example when the user taps the orb before the 2 s warmup ends.
|
||||||
hideTalkUnlocker();
|
hideTalkUnlocker();
|
||||||
setState("connecting");
|
setState("connecting");
|
||||||
break;
|
break;
|
||||||
@@ -3575,19 +3565,16 @@ function onFatalError(err) {
|
|||||||
setState("idle");
|
setState("idle");
|
||||||
chat.renderEmptyState();
|
chat.renderEmptyState();
|
||||||
initGateArc();
|
initGateArc();
|
||||||
// Schedulo auto-start TALK / TALKFIRST (S2S_START_SESSION_MODE nel .env):
|
// Schedule TALK / TALKFIRST auto-start (S2S_START_SESSION_MODE in .env):
|
||||||
// • "TALK" → dopo 2s mostra overlay unlocker (policy browser:
|
// • "TALK" → show the unlocker after 2 s. Browser policy requires a
|
||||||
// getUserMedia() richiede gesture). Click/touch/tasto =
|
// gesture for getUserMedia(). A click/touch/key starts
|
||||||
// doStart() dentro la gesture, poi ws startWithResponse
|
// doStart(), then WS startWithResponse injects hola vocero
|
||||||
// inject hola vocero via sendUserText+requestResponse nel
|
// through sendUserText+requestResponse, like a wake word.
|
||||||
// ws client (stesso flow identico a wake-word).
|
// • "TALKFIRST" → try doStart() directly from the timer after 2 s with
|
||||||
// • "TALKFIRST" → dopo 2s TENTA direttamete doStart() dal timer, con
|
// pendingWakeWordConfirmationText already set. If mic
|
||||||
// pendingWakeWordConfirmationText preimpostato. Se i
|
// permission is granted, the simulated wake word triggers
|
||||||
// permessi del mic sono GRANTED (già consentiti in passato
|
// without a click. Permission/gesture failures on Safari
|
||||||
// o Chrome policy permissive), la wake-word simulata scatta
|
// or iOS fall back to the TALK unlocker.
|
||||||
// subito senza nessun click. Se invece fallisce per
|
|
||||||
// permesso / gesture (Safari/iOS), fallback sullo stesso
|
|
||||||
// unlocker di TALK.
|
|
||||||
let autoStartTalkTimerId = 0;
|
let autoStartTalkTimerId = 0;
|
||||||
let _talkUnlockerShown = false;
|
let _talkUnlockerShown = false;
|
||||||
function hideTalkUnlocker() {
|
function hideTalkUnlocker() {
|
||||||
@@ -3599,11 +3586,9 @@ function showTalkUnlocker(fallbackHint = false) {
|
|||||||
if (!(startSessionMode === "TALK" || startSessionMode === "TALKFIRST")) return;
|
if (!(startSessionMode === "TALK" || startSessionMode === "TALKFIRST")) return;
|
||||||
const el = document.getElementById("talk-unlocker");
|
const el = document.getElementById("talk-unlocker");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
// L'unlocker è utile solo a stato idle/error: se l'utente ha già cliccato l'Orb
|
// Show the unlocker only while idle/error, before a session starts.
|
||||||
// o è connecting/connected non mostriamo niente.
|
|
||||||
if (!(currentState === "idle" || currentState === "error")) return;
|
if (!(currentState === "idle" || currentState === "error")) return;
|
||||||
// Se è fallback da TALKFIRST cambiamo il testo in modo che spieghi perché
|
// Explain the microphone permission requirement when TALKFIRST falls back.
|
||||||
// è comparso ("tocca per consentire il microfono", invece di "preparando")
|
|
||||||
const subEl = el.querySelector(".talk-unlocker-sub");
|
const subEl = el.querySelector(".talk-unlocker-sub");
|
||||||
const titleEl = el.querySelector(".talk-unlocker-title");
|
const titleEl = el.querySelector(".talk-unlocker-title");
|
||||||
if (fallbackHint) {
|
if (fallbackHint) {
|
||||||
@@ -3613,20 +3598,20 @@ function showTalkUnlocker(fallbackHint = false) {
|
|||||||
el.removeAttribute("hidden");
|
el.removeAttribute("hidden");
|
||||||
_talkUnlockerShown = true;
|
_talkUnlockerShown = true;
|
||||||
console.info(
|
console.info(
|
||||||
"%c[start-session] ▶️ talk-unlocker mostrato (%s): attende gesture utente",
|
"%c[start-session] ▶️ talk-unlocker shown (%s): waiting for a user gesture",
|
||||||
"color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;",
|
"color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;",
|
||||||
fallbackHint ? "fallback TALKFIRST" : "TALK mode"
|
fallbackHint ? "fallback TALKFIRST" : "TALK mode"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function maybeScheduleAutoStart() {
|
function maybeScheduleAutoStart() {
|
||||||
// Pulisco un eventuale timer precedente (caso di doppia chiamata non attesa).
|
// Clear any previous timer if this function was unexpectedly called twice.
|
||||||
if (autoStartTalkTimerId) {
|
if (autoStartTalkTimerId) {
|
||||||
clearTimeout(autoStartTalkTimerId);
|
clearTimeout(autoStartTalkTimerId);
|
||||||
autoStartTalkTimerId = 0;
|
autoStartTalkTimerId = 0;
|
||||||
}
|
}
|
||||||
if (startSessionMode !== "TALK" && startSessionMode !== "TALKFIRST") return;
|
if (startSessionMode !== "TALK" && startSessionMode !== "TALKFIRST") return;
|
||||||
console.info(
|
console.info(
|
||||||
`%c[start-session] S2S_START_SESSION_MODE=${startSessionMode}: azione tra 2000 ms (warmup profilo/reports/episode)`,
|
`%c[start-session] S2S_START_SESSION_MODE=${startSessionMode}: action in 2000 ms (profile/reports/episode warmup)`,
|
||||||
"color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;"
|
"color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;"
|
||||||
);
|
);
|
||||||
autoStartTalkTimerId = window.setTimeout(() => {
|
autoStartTalkTimerId = window.setTimeout(() => {
|
||||||
@@ -3638,49 +3623,45 @@ function maybeScheduleAutoStart() {
|
|||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
/** Tenta l'auto-start diretto (TALKFIRST) SENZA gesture. Simula la wake word
|
/** Attempt direct TALKFIRST auto-start without a gesture. Simulate a wake word
|
||||||
* impostando pendingWakeWordConfirmationText="hola vocero" (stessa variabile
|
* by setting pendingWakeWordConfirmationText="hola vocero", as the real speech
|
||||||
* del riconoscitore vocale reale), poi chiama doStart() — se i permessi sono
|
* recognizer does, then call doStart(). Existing permissions may allow this
|
||||||
* già grantiti (Chrome/Android dopo "allow always"), tutto funziona senza
|
* without a click; otherwise fall back to the unlocker. */
|
||||||
* click. Altrimenti fallback sull'unlocker. */
|
|
||||||
async function tryTalkFirstDirect() {
|
async function tryTalkFirstDirect() {
|
||||||
if (!(currentState === "idle" || currentState === "error")) return;
|
if (!(currentState === "idle" || currentState === "error")) return;
|
||||||
if (missingServerUrl()) {
|
if (missingServerUrl()) {
|
||||||
console.warn("[start-session] skip TALKFIRST: manca server URL");
|
console.warn("[start-session] skip TALKFIRST: missing server URL");
|
||||||
promptServerUrl();
|
promptServerUrl();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Step 1: pre-carico la wake-word simulata. Quando onClientStatus riceve
|
// Prepare the simulated wake word. When onClientStatus receives "connected",
|
||||||
// "connected", la frase viene injectata e il modello risponde esattamente
|
// inject it so the model responds as if the user had spoken it.
|
||||||
// come quando la hot-word è stata pronunciata dal microfono.
|
|
||||||
pendingWakeWordConfirmationText = pendingWakeWordConfirmationText?.trim()
|
pendingWakeWordConfirmationText = pendingWakeWordConfirmationText?.trim()
|
||||||
? pendingWakeWordConfirmationText
|
? pendingWakeWordConfirmationText
|
||||||
: "hola vocero";
|
: "hola vocero";
|
||||||
console.info(
|
console.info(
|
||||||
`%c[start-session] 🎤 TALKFIRST: imposto wake-word simulata=${JSON.stringify(pendingWakeWordConfirmationText)} e provo doStart() diretto (SENZA gesture)`,
|
`%c[start-session] 🎤 TALKFIRST: set simulated wake word=${JSON.stringify(pendingWakeWordConfirmationText)} and try doStart() directly (WITHOUT a gesture)`,
|
||||||
"color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;"
|
"color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;"
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
setCaption("Preparing your session…", "muted");
|
setCaption("Preparing your session…", "muted");
|
||||||
// Non forzo AudioContext da fuori (non c'è gesture): lo crea internamente
|
// Let _ensureAudioContext create the context. If it stays suspended, the
|
||||||
// _ensureAudioContext; se rimane suspended il client lo resume() quando
|
// client resumes it on the first audio or the next user click.
|
||||||
// arriva il primo audio (o sul prossimo click utente che lo unlocka).
|
|
||||||
await doStart();
|
await doStart();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[start-session] ⚠️ TALKFIRST doStart() diretto FALLITO → fallback unlocker", err && err instanceof Error ? err.message : err);
|
console.warn("[start-session] ⚠️ direct TALKFIRST doStart() FAILED → fallback unlocker", err && err instanceof Error ? err.message : err);
|
||||||
// Fallback: mostro unlocker con la frase esplicativa "permesso mic".
|
// Show the unlocker with the microphone permission explanation.
|
||||||
try { await teardown(); } catch (_) { /* no-op */ }
|
try { await teardown(); } catch (_) { /* no-op */ }
|
||||||
setState("idle");
|
setState("idle");
|
||||||
chat.renderEmptyState();
|
chat.renderEmptyState();
|
||||||
// Ripristino pendingWakeWordConfirmationText: nel caso l'utente usi
|
// Preserve the simulated phrase so starting through the unlocker still
|
||||||
// l'unlocker per avviare, NON vogliamo perdere la frase simulata (la
|
// injects it on connection and triggers the greeting.
|
||||||
// useremo di nuovo al connected, scattando quindi sempre la greet).
|
|
||||||
pendingWakeWordConfirmationText = pendingWakeWordConfirmationText || "hola vocero";
|
pendingWakeWordConfirmationText = pendingWakeWordConfirmationText || "hola vocero";
|
||||||
showTalkUnlocker(true);
|
showTalkUnlocker(true);
|
||||||
void handleStartError(err instanceof Error ? err : new Error(String(err || "mic permission required")));
|
void handleStartError(err instanceof Error ? err : new Error(String(err || "mic permission required")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Attacca una sola volta i listener sull'unlocker: click + touchstart = unlock
|
// Register unlocker listeners once: click + touchstart unlock audio.
|
||||||
(() => {
|
(() => {
|
||||||
const el = document.getElementById("talk-unlocker");
|
const el = document.getElementById("talk-unlocker");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -3692,28 +3673,28 @@ async function tryTalkFirstDirect() {
|
|||||||
window.addEventListener("keydown", (ev) => {
|
window.addEventListener("keydown", (ev) => {
|
||||||
if (startSessionMode !== "TALK") return;
|
if (startSessionMode !== "TALK") return;
|
||||||
if (!_talkUnlockerShown) return;
|
if (!_talkUnlockerShown) return;
|
||||||
// Qualsiasi tasto = unlock (Enter/Space/anche Altri)
|
// Any key unlocks audio, including Enter and Space.
|
||||||
startTalkFromGesture();
|
startTalkFromGesture();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
})();
|
})();
|
||||||
/** Avvia doStart() dentro una gesture valida (click/touch/key).
|
/** Start doStart() within a valid click/touch/key gesture.
|
||||||
Chiamato dal talk-unlocker o da qualsiasi altro unlocker valido. */
|
* Called by the talk unlocker or another valid audio unlocker. */
|
||||||
async function startTalkFromGesture() {
|
async function startTalkFromGesture() {
|
||||||
hideTalkUnlocker();
|
hideTalkUnlocker();
|
||||||
if (!(currentState === "idle" || currentState === "error")) return;
|
if (!(currentState === "idle" || currentState === "error")) return;
|
||||||
if (missingServerUrl()) {
|
if (missingServerUrl()) {
|
||||||
console.warn("[start-session] skip unlocker: manca server URL (missingServerUrl=true)");
|
console.warn("[start-session] skip unlocker: missing server URL (missingServerUrl=true)");
|
||||||
promptServerUrl();
|
promptServerUrl();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Qui SIAMO dentro la user gesture: AudioContext + getUserMedia funzionano.
|
// This user gesture permits AudioContext and getUserMedia to start.
|
||||||
console.info("[start-session] 🎤 avvio doStart() da gesture unlocker (TALK mode)");
|
console.info("[start-session] 🎤 start doStart() from unlocker gesture (TALK mode)");
|
||||||
const audioContext = createResumedAudioContext();
|
const audioContext = createResumedAudioContext();
|
||||||
setCaption("Preparing your session…", "muted");
|
setCaption("Preparing your session…", "muted");
|
||||||
await doStart(audioContext);
|
await doStart(audioContext);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[start-session] ⛔ doStart() da unlocker fallito", err);
|
console.error("[start-session] ⛔ doStart() from unlocker failed", err);
|
||||||
void handleStartError(err);
|
void handleStartError(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+508
-670
File diff suppressed because it is too large
Load Diff
+38
-40
@@ -143,11 +143,10 @@ body::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Overlay unlocker TALK mode (S2S_START_SESSION_MODE=TALK) ───
|
/* ─── Overlay unlocker TALK mode (S2S_START_SESSION_MODE=TALK) ───
|
||||||
Cattura QUALSIASI primo tap/click dell'utente sulla pagina, perché
|
Capture the first tap/click anywhere on the page because browsers block
|
||||||
i browser bloccano getUserMedia() fuori da una user gesture. Senza
|
getUserMedia() outside a user gesture. Without this layer, doStart() from
|
||||||
questo layer il doStart() del setTimeout falliva e non si sentiva
|
setTimeout failed and the greeting was silent. Above all content except
|
||||||
il greet. Sopra TUTTO tranne i modali <dialog> (z-index auto di dialog
|
<dialog> modals, whose top layer takes priority. */
|
||||||
vince). */
|
|
||||||
.talk-unlocker {
|
.talk-unlocker {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -466,7 +465,7 @@ a:hover {
|
|||||||
transform: translateY(-0.5px);
|
transform: translateY(-0.5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── User avatar circle (instead of old "Utente: Nome" text) ──
|
/* ── User avatar circle (replaces the old user-name text) ──
|
||||||
* - perfectly round badge, matches the 42px icon-btn size in the topbar
|
* - perfectly round badge, matches the 42px icon-btn size in the topbar
|
||||||
* - glass + brand gradient 1px border
|
* - glass + brand gradient 1px border
|
||||||
* - two uppercase initials (Alessandro Valenti → AV), bold white
|
* - two uppercase initials (Alessandro Valenti → AV), bold white
|
||||||
@@ -593,8 +592,8 @@ a:hover {
|
|||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
/* ── Footer (narrow viewport): riduci tracking per non farlo andare
|
/* ── Footer (narrow viewport): reduce tracking to avoid awkward wrapping;
|
||||||
* a capo in malo modo; padding compatto + line-height maggiore */
|
* use compact padding and a taller line height. */
|
||||||
.app-footer {
|
.app-footer {
|
||||||
padding: 8px 14px 14px;
|
padding: 8px 14px 14px;
|
||||||
}
|
}
|
||||||
@@ -2425,7 +2424,7 @@ body.cam-on .footer {
|
|||||||
`margin-right:22px` on `.hist-msg.assistant` and `margin-left:6px` on `.hist-msg.user`
|
`margin-right:22px` on `.hist-msg.assistant` and `margin-left:6px` on `.hist-msg.user`
|
||||||
(hard rules on the bubbles themselves so nothing can hide them).
|
(hard rules on the bubbles themselves so nothing can hide them).
|
||||||
Bottom padding BUMP 2026-09-04: 96 → 160px. The screenshot showed the last
|
Bottom padding BUMP 2026-09-04: 96 → 160px. The screenshot showed the last
|
||||||
assistant line "...Necesitas más detalles o" cut clean by the footer. 160px
|
assistant line asking for more details cut cleanly by the footer. 160px
|
||||||
inside the scroll container is 5× the real footer glass height; after we
|
inside the scroll container is 5× the real footer glass height; after we
|
||||||
snap scrollTop to the absolute max, the last bubble terminates at the TOP
|
snap scrollTop to the absolute max, the last bubble terminates at the TOP
|
||||||
of a 160px-high transparent region → fully visible, fully above the floating
|
of a 160px-high transparent region → fully visible, fully above the floating
|
||||||
@@ -2460,7 +2459,7 @@ body.cam-on .footer {
|
|||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Chat input row (bottom of chat panel: "sobre el botón") ──────── */
|
/* ─── Chat input row (bottom of chat panel, above the button) ──────── */
|
||||||
/* NOTE: currently HIDDEN in index.html. Kept seamless here so if/when we
|
/* NOTE: currently HIDDEN in index.html. Kept seamless here so if/when we
|
||||||
re-enable it the form melts visually into the page bg, no separator frame. */
|
re-enable it the form melts visually into the page bg, no separator frame. */
|
||||||
.chat-form {
|
.chat-form {
|
||||||
@@ -3474,20 +3473,20 @@ button[data-reports-sidebar-toggle] {
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
/* Per-chip tint cycles (keyword-based, if no data attribute → fallback brand) */
|
/* Per-chip tint cycles (keyword-based, if no data attribute → fallback brand) */
|
||||||
.reports-hint .reports-list li[data-tint="mortalidad"] { background: linear-gradient(180deg, color-mix(in srgb, #ff6a75 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #ff6a75 35%, var(--border)); }
|
.reports-hint .reports-list li[data-tint="mortality"] { background: linear-gradient(180deg, color-mix(in srgb, #ff6a75 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #ff6a75 35%, var(--border)); }
|
||||||
.reports-hint .reports-list li[data-tint="mortalidad"]::before { background: #ff6a75; box-shadow: 0 0 8px 0 #ff6a75aa; }
|
.reports-hint .reports-list li[data-tint="mortality"]::before { background: #ff6a75; box-shadow: 0 0 8px 0 #ff6a75aa; }
|
||||||
.reports-hint .reports-list li[data-tint="metricas"] { background: linear-gradient(180deg, color-mix(in srgb, #22d3ee 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #22d3ee 35%, var(--border)); }
|
.reports-hint .reports-list li[data-tint="metrics"] { background: linear-gradient(180deg, color-mix(in srgb, #22d3ee 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #22d3ee 35%, var(--border)); }
|
||||||
.reports-hint .reports-list li[data-tint="metricas"]::before { background: #22d3ee; box-shadow: 0 0 8px 0 #22d3eeaa; }
|
.reports-hint .reports-list li[data-tint="metrics"]::before { background: #22d3ee; box-shadow: 0 0 8px 0 #22d3eeaa; }
|
||||||
.reports-hint .reports-list li[data-tint="diario"] { background: linear-gradient(180deg, color-mix(in srgb, #8b5cf6 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #8b5cf6 35%, var(--border)); }
|
.reports-hint .reports-list li[data-tint="daily"] { background: linear-gradient(180deg, color-mix(in srgb, #8b5cf6 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #8b5cf6 35%, var(--border)); }
|
||||||
.reports-hint .reports-list li[data-tint="diario"]::before { background: #8b5cf6; box-shadow: 0 0 8px 0 #8b5cf6aa; }
|
.reports-hint .reports-list li[data-tint="daily"]::before { background: #8b5cf6; box-shadow: 0 0 8px 0 #8b5cf6aa; }
|
||||||
.reports-hint .reports-list li[data-tint="productos"] { background: linear-gradient(180deg, color-mix(in srgb, #ec4899 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #ec4899 35%, var(--border)); }
|
.reports-hint .reports-list li[data-tint="products"] { background: linear-gradient(180deg, color-mix(in srgb, #ec4899 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #ec4899 35%, var(--border)); }
|
||||||
.reports-hint .reports-list li[data-tint="productos"]::before { background: #ec4899; box-shadow: 0 0 8px 0 #ec4899aa; }
|
.reports-hint .reports-list li[data-tint="products"]::before { background: #ec4899; box-shadow: 0 0 8px 0 #ec4899aa; }
|
||||||
.reports-hint .reports-list li[data-tint="synapse"] { background: linear-gradient(180deg, color-mix(in srgb, #fbbf24 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #fbbf24 35%, var(--border)); }
|
.reports-hint .reports-list li[data-tint="synapse"] { background: linear-gradient(180deg, color-mix(in srgb, #fbbf24 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #fbbf24 35%, var(--border)); }
|
||||||
.reports-hint .reports-list li[data-tint="synapse"]::before { background: #fbbf24; box-shadow: 0 0 8px 0 #fbbf24aa; }
|
.reports-hint .reports-list li[data-tint="synapse"]::before { background: #fbbf24; box-shadow: 0 0 8px 0 #fbbf24aa; }
|
||||||
.reports-hint .reports-list li[data-tint="supply"] { background: linear-gradient(180deg, color-mix(in srgb, #34d399 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #34d399 35%, var(--border)); }
|
.reports-hint .reports-list li[data-tint="supply"] { background: linear-gradient(180deg, color-mix(in srgb, #34d399 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #34d399 35%, var(--border)); }
|
||||||
.reports-hint .reports-list li[data-tint="supply"]::before { background: #34d399; box-shadow: 0 0 8px 0 #34d399aa; }
|
.reports-hint .reports-list li[data-tint="supply"]::before { background: #34d399; box-shadow: 0 0 8px 0 #34d399aa; }
|
||||||
.reports-hint .reports-list li[data-tint="finanzas"] { background: linear-gradient(180deg, color-mix(in srgb, #22c55e 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #22c55e 35%, var(--border)); }
|
.reports-hint .reports-list li[data-tint="finance"] { background: linear-gradient(180deg, color-mix(in srgb, #22c55e 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #22c55e 35%, var(--border)); }
|
||||||
.reports-hint .reports-list li[data-tint="finanzas"]::before { background: #22c55e; box-shadow: 0 0 8px 0 #22c55eaa; }
|
.reports-hint .reports-list li[data-tint="finance"]::before { background: #22c55e; box-shadow: 0 0 8px 0 #22c55eaa; }
|
||||||
.reports-hint .reports-list li:hover {
|
.reports-hint .reports-list li:hover {
|
||||||
transform: translateY(-1px) scale(1.01);
|
transform: translateY(-1px) scale(1.01);
|
||||||
border-color: color-mix(in srgb, var(--brand) 55%, var(--border-strong));
|
border-color: color-mix(in srgb, var(--brand) 55%, var(--border-strong));
|
||||||
@@ -4055,10 +4054,10 @@ button[data-reports-sidebar-toggle] {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Webcam preview (camera tool) — HIDDEN BY DEFAULT OGGI ──────────────────
|
/* ─── Webcam preview (camera tool) — CURRENTLY HIDDEN BY DEFAULT ─────────────
|
||||||
* Floating self-view, bottom-left. Mirrored for the user; the frame sent to
|
* Floating self-view, bottom-left. Mirrored for the user; the frame sent to
|
||||||
* the model is drawn un-mirrored. Markup esiste per riattivazione futura.
|
* the model is drawn un-mirrored. Markup is retained for future reactivation.
|
||||||
* Per usarlo: togli hidden su cam-pip in index.html + scommenta listener in main.js. */
|
* To enable it, remove hidden from cam-pip in index.html and restore the main.js listener. */
|
||||||
.cam-pip {
|
.cam-pip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 20px;
|
left: 20px;
|
||||||
@@ -4285,12 +4284,12 @@ html[data-theme="white"] .app-column {
|
|||||||
margin-left: 0 !important;
|
margin-left: 0 !important;
|
||||||
}
|
}
|
||||||
/* ─────────────────────────────────────────────────────────────────────
|
/* ─────────────────────────────────────────────────────────────────────
|
||||||
USER FIX 2026-09-10 "la chat deve apparire solo nel chat box":
|
USER FIX 2026-09-10: show the conversation only inside the chat box.
|
||||||
The dark theme renders a SECOND ephemeral transcript into #bubble-stack
|
The dark theme renders a SECOND ephemeral transcript into #bubble-stack
|
||||||
(floating anchored top-right — the grey/light blue bubbles the user
|
(floating anchored top-right — the grey/light blue bubbles the user
|
||||||
circled in red with an X on the last screenshot). Since the white
|
circled in red with an X on the last screenshot). Since the white
|
||||||
mobile theme shows ALL conversation exclusively inside the persistent
|
mobile theme shows ALL conversation exclusively inside the persistent
|
||||||
"Conversación" card (chat-history inside .chat-panel bounded 58dvh),
|
conversation card (chat-history inside .chat-panel bounded 58dvh),
|
||||||
we kill the parallel floating bubble stack HARD in white theme — both
|
we kill the parallel floating bubble stack HARD in white theme — both
|
||||||
the ID and the class, zero width/height/transform so it can't leave
|
the ID and the class, zero width/height/transform so it can't leave
|
||||||
layout artifacts. */
|
layout artifacts. */
|
||||||
@@ -4543,8 +4542,8 @@ html[data-theme="white"] .topbar-right {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Status card: "Listo para conversar / Informes, análisis..." ───────
|
/* ── Status card: ready to converse, reports and analysis ─────────────
|
||||||
User request 2026-09-10 "nascondi box listo para conversar" — hide the
|
User request 2026-09-10: hide the ready-to-converse box. Hide the
|
||||||
entire status card in white theme (the grey rounded banner with the
|
entire status card in white theme (the grey rounded banner with the
|
||||||
chart icon and arrow between topbar and mic stage, which the user
|
chart icon and arrow between topbar and mic stage, which the user
|
||||||
circled with a red X). Previously we hid only the right chevron; now
|
circled with a red X). Previously we hid only the right chevron; now
|
||||||
@@ -4820,7 +4819,7 @@ html[data-theme="white"] .white-stop-btn svg {
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Quick actions — 3 pills (Resumen / Consultar datos / Crear respuesta).
|
/* ── Quick actions — 3 pills (summary / data lookup / response draft).
|
||||||
User request 2026-09-10 "nascondi le cose in rosso": all 3 quick action
|
User request 2026-09-10 "nascondi le cose in rosso": all 3 quick action
|
||||||
pills are circled in red → hide the entire quick-actions container. */
|
pills are circled in red → hide the entire quick-actions container. */
|
||||||
html[data-theme="white"] .quick-actions {
|
html[data-theme="white"] .quick-actions {
|
||||||
@@ -4830,8 +4829,8 @@ html[data-theme="white"] .quick-action-btn {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Chat panel (Conversación card): white rounded card + "En línea" pill.
|
/* ── Chat panel: white rounded conversation card + online status pill.
|
||||||
CRITICAL SCROLL FIX 2026-09-10 (per "non fa lo scrolling della chatbox"):
|
CRITICAL SCROLL FIX 2026-09-10: the chat box was not scrolling.
|
||||||
The previous white-theme override set `flex: 0 0 auto` + `max-height: none`
|
The previous white-theme override set `flex: 0 0 auto` + `max-height: none`
|
||||||
which removed every vertical constraint on the chat panel → it grew beyond
|
which removed every vertical constraint on the chat panel → it grew beyond
|
||||||
the viewport edge → overflow on the *page* body instead of the chat
|
the viewport edge → overflow on the *page* body instead of the chat
|
||||||
@@ -4912,7 +4911,7 @@ html[data-theme="white"] .chat-panel-header {
|
|||||||
border-bottom-width: 1px !important;
|
border-bottom-width: 1px !important;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
}
|
}
|
||||||
/* "En línea" status pill — green dot + text */
|
/* Online status pill — green dot + text */
|
||||||
html[data-theme="white"] .chat-status-pill {
|
html[data-theme="white"] .chat-status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -4940,10 +4939,9 @@ html[data-theme="white"] .chat-status-dot {
|
|||||||
50% { box-shadow: 0 0 0 5px rgba(22, 163, 74, 0.08); }
|
50% { box-shadow: 0 0 0 5px rgba(22, 163, 74, 0.08); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Chat header actions row: contiene "En línea" status pill + Export button.
|
/* ── Chat header actions row: online status pill + conversation export button.
|
||||||
Aggiunto 2026-09-23: bottone download conversación.
|
Added 2026-09-23. Horizontal, right-aligned layout with wrapping and gaps
|
||||||
Layout orizzontale, allineato a destra, auto-wrap con gap per evitare
|
to avoid overflow on narrow screens. */
|
||||||
overflow su schermi stretti. */
|
|
||||||
.chat-header-actions {
|
.chat-header-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -4953,7 +4951,7 @@ html[data-theme="white"] .chat-status-dot {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
/* Botón export conversación (icon button baseline: coerente con altri .icon-btn). */
|
/* Conversation export button, consistent with other .icon-btn controls. */
|
||||||
.chat-export-btn {
|
.chat-export-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -4990,14 +4988,14 @@ html[data-theme="white"] .chat-status-dot {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
/* Feedback OK: verde temporáneo al exportar correctamente. */
|
/* Successful export feedback: temporarily green. */
|
||||||
.chat-export-btn.chat-export-ok {
|
.chat-export-btn.chat-export-ok {
|
||||||
background: rgba(22, 163, 74, 0.18) !important;
|
background: rgba(22, 163, 74, 0.18) !important;
|
||||||
border-color: rgba(22, 163, 74, 0.34) !important;
|
border-color: rgba(22, 163, 74, 0.34) !important;
|
||||||
color: #22c55e !important;
|
color: #22c55e !important;
|
||||||
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.12);
|
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.12);
|
||||||
}
|
}
|
||||||
/* Feedback vacía: pulse ámbar (ningún mensaje para exportar). */
|
/* Empty export feedback: amber pulse when there are no messages. */
|
||||||
.chat-export-btn.chat-export-empty {
|
.chat-export-btn.chat-export-empty {
|
||||||
background: rgba(245, 158, 11, 0.18) !important;
|
background: rgba(245, 158, 11, 0.18) !important;
|
||||||
border-color: rgba(245, 158, 11, 0.34) !important;
|
border-color: rgba(245, 158, 11, 0.34) !important;
|
||||||
@@ -5039,7 +5037,7 @@ html[data-theme="white"] .chat-export-btn.chat-export-empty {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────────
|
/* ──────────────────────────────────────────────────
|
||||||
THE SCROLLABLE HISTORY BOX (root cause of "non scorre")
|
THE SCROLLABLE HISTORY BOX (root cause of the scrolling failure)
|
||||||
Before: max-height:none. Now it's a flex item with flex:1 1 auto inside
|
Before: max-height:none. Now it's a flex item with flex:1 1 auto inside
|
||||||
.chat-panel-inner, which has a bounded 100% height of its parent.
|
.chat-panel-inner, which has a bounded 100% height of its parent.
|
||||||
min-height:0 is MANDATORY — without it, flex column children never shrink
|
min-height:0 is MANDATORY — without it, flex column children never shrink
|
||||||
@@ -5213,7 +5211,7 @@ html[data-theme="white"] #bubble-stack .bubble.user::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 6) META label "name HH:MM" on the same single line, ONE pseudo-element.
|
/* 6) META label "name HH:MM" on the same single line, ONE pseudo-element.
|
||||||
──── USER FIX 2026-09-10 "perche mette sempre Emilio" ────
|
──── USER FIX 2026-09-10: remove the hardcoded user name ────
|
||||||
The previous iteration hardcoded the Spanish names "Vocero" (assistant)
|
The previous iteration hardcoded the Spanish names "Vocero" (assistant)
|
||||||
and "Emilio" (user) plus a fake "16:14" timestamp. Instead we now read
|
and "Emilio" (user) plus a fake "16:14" timestamp. Instead we now read
|
||||||
BOTH values dynamically from data attributes written by ui/chat.js at the
|
BOTH values dynamically from data attributes written by ui/chat.js at the
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export class ChatView {
|
|||||||
* inner .{hist|bubble}-body → data-user-name | 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()
|
* The .{hist|bubble}-body::before pseudo-element in style.css uses attr()
|
||||||
* to render the "Nombre HH:MM" label aligned to the bubble corner; the
|
* to render the "Name HH:MM" label aligned to the bubble corner; the
|
||||||
* wrapper .user::before pseudo uses attr(data-user-initials) to paint the
|
* wrapper .user::before pseudo uses attr(data-user-initials) to paint the
|
||||||
* circular avatar's 2-letter monogram.
|
* circular avatar's 2-letter monogram.
|
||||||
*
|
*
|
||||||
@@ -583,8 +583,8 @@ export class ChatView {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Pretty-print the JSON conversation structure to a human-readable plain
|
* Pretty-print the JSON conversation structure to a human-readable plain
|
||||||
* text log (one entry per "HH:MM Nombre: texto"). Tool calls are rendered
|
* text log (one entry per "HH:MM Name: text"). Tool calls are rendered
|
||||||
* as indented blocks ("TOOL: nombre" followed by Input/Output sections).
|
* as indented blocks (tool name followed by input/output sections).
|
||||||
* @param {ReturnType<ChatView["_serializeConversation"]>} conv
|
* @param {ReturnType<ChatView["_serializeConversation"]>} conv
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
@@ -648,8 +648,8 @@ export class ChatView {
|
|||||||
* Public entry-point wired to the #chat-export-btn in the chat header.
|
* Public entry-point wired to the #chat-export-btn in the chat header.
|
||||||
* Serializes the current conversation and produces TWO downloads:
|
* Serializes the current conversation and produces TWO downloads:
|
||||||
* 1. vocero-conversacion-YYYYMMDD-HHMMSS.json (structured data)
|
* 1. vocero-conversacion-YYYYMMDD-HHMMSS.json (structured data)
|
||||||
* 2. vocero-conversacion-YYYYMMDD-HHMMSS.txt (log legibile para humanos)
|
* 2. vocero-conversacion-YYYYMMDD-HHMMSS.txt (human-readable log)
|
||||||
* UI feedback: briefly swaps the download icon for a ✓ "hecho" tick then
|
* UI feedback: briefly swaps the download icon for a ✓ completion tick then
|
||||||
* restores the original SVG so the user gets a visual ack that the export
|
* restores the original SVG so the user gets a visual ack that the export
|
||||||
* completed (idempotent / errors are logged to console — never throws).
|
* completed (idempotent / errors are logged to console — never throws).
|
||||||
*/
|
*/
|
||||||
@@ -677,7 +677,7 @@ export class ChatView {
|
|||||||
setTimeout(() => this._exportBtn?.classList.remove("chat-export-empty"), 600);
|
setTimeout(() => this._exportBtn?.classList.remove("chat-export-empty"), 600);
|
||||||
}
|
}
|
||||||
if (typeof window !== "undefined" && typeof console !== "undefined") {
|
if (typeof window !== "undefined" && typeof console !== "undefined") {
|
||||||
console.info("[ui/chat.js:exportConversation] No hay mensajes aún — exportación omitida.");
|
console.info("[ui/chat.js:exportConversation] No messages yet — export skipped.");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -691,7 +691,7 @@ export class ChatView {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (typeof window !== "undefined" && typeof console !== "undefined") {
|
if (typeof window !== "undefined" && typeof console !== "undefined") {
|
||||||
console.error("[ui/chat.js:exportConversation] Error exportando conversación:", err);
|
console.error("[ui/chat.js:exportConversation] Conversation export failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-119
@@ -54,16 +54,14 @@ function reportWsDebug(hypothesisId, location, msg, data = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 🔎 WS DEBUG TRACER (sempre attivo, 2026-09-24)
|
// 🔎 WS DEBUG TRACER (always active, 2026-09-24)
|
||||||
// Banner + stile etichette condiviso per tutta la famiglia di log [S2S-WS:*].
|
// Shared banner and label styles for all [S2S-WS:*] logs. Lifecycle events
|
||||||
// Non è più abilitato solo via localStorage.s2s.debug: gli eventi di vita
|
// (connect, close, error) and the initial handshake are always logged.
|
||||||
// (connect, close, errore) e l'handshake iniziale sono loggati always-on;
|
// Count bulk input_audio_buffer.append messages to avoid console spam.
|
||||||
// i messaggi bulk (input_audio_buffer.append) vengono solo contati per non
|
|
||||||
// spammare la Console.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
(function _wsDebugBanner() {
|
(function _wsDebugBanner() {
|
||||||
console.info(
|
console.info(
|
||||||
"%c🟡 [S2S-WS] client caricato (ws/s2s-ws-client.js v2026-09-24 23:12) — handshake, stato, messaggi chiave e tool-call loggati; PREFETCH APIPILOT strumenti AD OGNI INIZIO SESSIONE + post-login phone (cache per-utente, cambio phone = refresh=true automatico; invalid. cache globale __s2sInvalidateApipilotCache(); anti-leak token; anti-spam audio).",
|
"%c🟡 [S2S-WS] client loaded (ws/s2s-ws-client.js v2026-09-24 23:12) — logging handshake, status, key messages and tool calls; PREFETCH APIPILOT tools AT EVERY SESSION START + after phone login (per-user cache, phone changes trigger refresh=true; global invalidation __s2sInvalidateApipilotCache(); token masking; audio log throttling).",
|
||||||
"background:#fef3c7;color:#92400e;font-weight:700;padding:4px 8px;border-radius:4px;"
|
"background:#fef3c7;color:#92400e;font-weight:700;padding:4px 8px;border-radius:4px;"
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
@@ -79,24 +77,22 @@ function _wsLog(status, tag, msg, ...extra) {
|
|||||||
const badge = `%c[S2S-WS:${tag}]`;
|
const badge = `%c[S2S-WS:${tag}]`;
|
||||||
console.log(`${badge}%c ${msg}`, badgeBg, labelFg, ...extra);
|
console.log(`${badge}%c ${msg}`, badgeBg, labelFg, ...extra);
|
||||||
}
|
}
|
||||||
/** Contatore per audio chunk bulk, per evitare spam. */
|
/** Bulk audio chunk counter for throttling logs. */
|
||||||
let _wsMicChunkCount = 0;
|
let _wsMicChunkCount = 0;
|
||||||
let _wsMicChunkLastLogTs = 0;
|
let _wsMicChunkLastLogTs = 0;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 🔧 APIPILOT PREFETCH AD INIZIO SESSIONE (2026-09-24)
|
// 🔧 APIPILOT PREFETCH AT SESSION START (2026-09-24)
|
||||||
// Ogni volta che s2s-ws-client avvia una connessione Realtime (metodo connect())
|
// Each connect() warms the server-side ApiPilot cache via /api/apipilot/tools.
|
||||||
// viene scaldata la cache ApiPilot lato server chiamando /api/apipilot/tools.
|
// Client cache TTL is 240 s (server TTL 300 s minus a 60 s margin).
|
||||||
// Cache TTL lato client 240 s (= TTL server 300 s meno 60 s margine).
|
// Prefetch failures, including proxy 5xx responses, do not fail connect().
|
||||||
// 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} */
|
/** @type {{at:number, data:any, key:string|null}|null} */
|
||||||
let _apipilotClientCache = null;
|
let _apipilotClientCache = null;
|
||||||
const _APIPILOT_CLIENT_TTL_MS = 240_000; // 4 minuti
|
const _APIPILOT_CLIENT_TTL_MS = 240_000; // 4 minutes
|
||||||
/** Contatore sessioni totali (per log), incrementato ad ogni connect(). */
|
/** Session counter for logging, incremented by each connect(). */
|
||||||
let _wsSessionCounter = 0;
|
let _wsSessionCounter = 0;
|
||||||
/** Chiave cache per utente: ultimi 8 cifre phoneNumber o fingerprint token. */
|
/** Per-user cache key: last 8 phoneNumber digits or a token fingerprint. */
|
||||||
function _apipilotUserKey() {
|
function _apipilotUserKey() {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -108,38 +104,34 @@ function _apipilotUserKey() {
|
|||||||
return "anon";
|
return "anon";
|
||||||
} catch (_) { return "anon"; }
|
} catch (_) { return "anon"; }
|
||||||
}
|
}
|
||||||
/** Invalida la cache ApiPilot lato client. Esporta globalmente per main.js (dopo login phone). */
|
/** Invalidate the client ApiPilot cache. Exported globally for main.js after phone login. */
|
||||||
function _invalidateApipilotClientCache() {
|
function _invalidateApipilotClientCache() {
|
||||||
_apipilotClientCache = null;
|
_apipilotClientCache = null;
|
||||||
console.info(
|
console.info(
|
||||||
"%c🔧 [S2S-WS:APIPILOT] Cache client invalidata (nuovo login utente o refresh=true forzato)",
|
"%c🔧 [S2S-WS:APIPILOT] Client cache invalidated (new user login or forced refresh=true)",
|
||||||
"background:#fde68a;color:#92400e;font-weight:700;padding:1px 6px;border-radius:3px;"
|
"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).
|
// Match the Python server's marker and rules for consistent deduplication and
|
||||||
// Lo ripetiamo identico in JS per garantire:
|
// AI marker syntax. effectiveReasoningInstructions checks this marker so the
|
||||||
// (1) stessa classifica dedup
|
// client and server do not append the same tools list twice.
|
||||||
// (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>>>";
|
const APIPILOT_PASS1_LIST_MARKER_JS = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restituisce il testo del blocco PASSATA-1 da iniettare nel prompt instructions LLM.
|
* Build the PASS-1 block injected into the LLM instructions prompt.
|
||||||
* FORMATO IDENTICO al Python _resolve_apipilot_reasoning_context_block(tools=[...]).
|
* Match Python _resolve_apipilot_reasoning_context_block(tools=[...]).
|
||||||
* - Inizio con APIPILOT_PASS1_LIST_MARKER_JS
|
* - Start with APIPILOT_PASS1_LIST_MARKER_JS
|
||||||
* - Fine con <<<APIPILOT_TOOLS_LIST_END>>>
|
* - End with <<<APIPILOT_TOOLS_LIST_END>>>
|
||||||
* - Lingua: SPAGNOLO (stesso di profile inject e istruzioni AI)
|
* - Instructions in English; assistant responses remain Spanish
|
||||||
* - Cap description a 500 chars
|
* - Cap descriptions at 500 characters and tools at 100
|
||||||
* - Max 100 strumenti
|
* @param {any[]|null|undefined} tools Cached list, or null to use the current cache
|
||||||
* @param {any[]|null|undefined} tools Lista da cache oppure null (usa cache corrente)
|
* @returns {string} PASS-1 text block, empty when no tools or cache are available
|
||||||
* @returns {string} Blocco di testo PASSATA-1 (stringa vuota se lista vuota / nessuno strumento / nessuna cache)
|
|
||||||
*/
|
*/
|
||||||
function buildApipilotPass1PromptBlock(tools) {
|
function buildApipilotPass1PromptBlock(tools) {
|
||||||
let list = Array.isArray(tools) ? tools : null;
|
let list = Array.isArray(tools) ? tools : null;
|
||||||
if (!list) {
|
if (!list) {
|
||||||
// Fallback: usa cache client locale corrente (se disponibile)
|
// Fall back to the current client cache, if available.
|
||||||
const c = _apipilotClientCache;
|
const c = _apipilotClientCache;
|
||||||
if (c && c.data && Array.isArray(c.data.tools)) list = c.data.tools;
|
if (c && c.data && Array.isArray(c.data.tools)) list = c.data.tools;
|
||||||
}
|
}
|
||||||
@@ -168,25 +160,24 @@ function buildApipilotPass1PromptBlock(tools) {
|
|||||||
console.warn("APIPILOT_PASS1_LIST_MARKER_JS", APIPILOT_PASS1_LIST_MARKER_JS);
|
console.warn("APIPILOT_PASS1_LIST_MARKER_JS", APIPILOT_PASS1_LIST_MARKER_JS);
|
||||||
const markers = (
|
const markers = (
|
||||||
`${APIPILOT_PASS1_LIST_MARKER_JS}\n` +
|
`${APIPILOT_PASS1_LIST_MARKER_JS}\n` +
|
||||||
"### Herramientas dinámicas disponibles (ApiPilot AquaPortal)\n" +
|
"### Available dynamic tools (ApiPilot AquaPortal)\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"Cada herramienta tiene un campo `name` (identificador único, NO lo modifiques, " +
|
"Each tool has a `name` field (a unique identifier: DO NOT change, camelCase " +
|
||||||
"NO camelCase, NO traducir) y un campo `description` que describe el caso de uso.\n" +
|
"or translate it) and a `description` field explaining its use case.\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"REGLAS EJECUCIÓN OBLIGATORIAS:\n" +
|
"MANDATORY EXECUTION RULES:\n" +
|
||||||
"1. Si la petición DEL USUARIO (transcript) coincide claramente con la `description` " +
|
"1. If the USER REQUEST (transcript) clearly matches the `description` of " +
|
||||||
"de EXACTAMENTE una (1) herramienta de la lista inferior → DEBES emitir EXCLUSIVAMENTE " +
|
"EXACTLY one (1) tool below, output ONLY one line as the FIRST line of YOUR " +
|
||||||
"una sola riga come PRIMERA línea de TU RESPUESTA, con questo formato sintattico:\n" +
|
"RESPONSE, using this exact syntax:\n" +
|
||||||
' <<<APIPILOT_CALL:NAME=nome_herramienta,ARGUMENTS={"parametro1":"valor1","parametro2":"valor2"}>>>\n' +
|
' <<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1","parameter2":"value2"}>>>\n' +
|
||||||
"2. La parte ARGUMENTS={…} DEBE ser JSON válido (virgolette doppie, virgole, senza " +
|
"2. ARGUMENTS={…} MUST be valid JSON (double quotes, commas, no comments). " +
|
||||||
"commenti). Includi SOLAMENTE i campi elencati in `required_parameters:` (se ci sono).\n" +
|
"Include ONLY fields listed in `required_parameters:`, if any.\n" +
|
||||||
"3. Subito DOPO la riga marker NON aggiungere altro testo (niente spiegazioni, " +
|
"3. Do NOT add text after the marker line (no explanations or previews). " +
|
||||||
"niente anticipazioni). Il motore eseguirà lo strumento e ti risomministrerà il " +
|
"The engine executes the tool and supplies its result in a second call.\n" +
|
||||||
"risultato in una seconda chiamata.\n" +
|
"4. If NO `description` matches, do NOT emit a marker; respond normally in Spanish.\n" +
|
||||||
"4. Se NESSUNA `description` corrisponde, NON emettere marker e rispondi normalmente.\n" +
|
"5. If multiple descriptions match, choose the most specific and use the marker.\n" +
|
||||||
"5. Se più di una descrizione corrisponde, scegli la più specifica e usa il marker.\n" +
|
|
||||||
"\n" +
|
"\n" +
|
||||||
"Lista herramientas (name / description):\n" +
|
"Tools list (name / description):\n" +
|
||||||
`${entries.join("\n")}\n` +
|
`${entries.join("\n")}\n` +
|
||||||
"<<<APIPILOT_TOOLS_LIST_END>>>"
|
"<<<APIPILOT_TOOLS_LIST_END>>>"
|
||||||
);
|
);
|
||||||
@@ -195,7 +186,7 @@ function buildApipilotPass1PromptBlock(tools) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controlla se le istruzioni in input contengono già il marker PASS1 (dedup).
|
* Check whether the input instructions already contain the PASS1 marker.
|
||||||
* @param {string|undefined|null} text
|
* @param {string|undefined|null} text
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
@@ -204,10 +195,9 @@ function instructionsAlreadyHasApipilotPass1(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Espone globalmente una funzione helper per:
|
* Expose a global helper for main.js to append tools to
|
||||||
* - main.js: costruire effectiveReasoningInstructions appendendo la lista.
|
* effectiveReasoningInstructions, and for other modules to read the list.
|
||||||
* - altri moduli che volessero leggerla.
|
* The caller checks for duplicates before appending.
|
||||||
* Il check dedup è fatto automaticamente dal chiamante prima di appendere.
|
|
||||||
* @returns {{block: string, count: number, marker: string, alreadyPresent:(t:string)=>boolean}}
|
* @returns {{block: string, count: number, marker: string, alreadyPresent:(t:string)=>boolean}}
|
||||||
*/
|
*/
|
||||||
function getApipilotPass1ContextInfo() {
|
function getApipilotPass1ContextInfo() {
|
||||||
@@ -456,12 +446,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
*/
|
*/
|
||||||
async connect() {
|
async connect() {
|
||||||
if (this._ws) throw new Error("Already connected");
|
if (this._ws) throw new Error("Already connected");
|
||||||
// Contatore sessioni totali: utile nel tracer per correlare gli eventi
|
// Correlate [S2S-WS:*] events using an increasing session number.
|
||||||
// di [S2S-WS:*] a un numero di sessione crescente.
|
|
||||||
_wsSessionCounter += 1;
|
_wsSessionCounter += 1;
|
||||||
const sessionIdShort = `#${_wsSessionCounter}`;
|
const sessionIdShort = `#${_wsSessionCounter}`;
|
||||||
this._sessionSeq = _wsSessionCounter;
|
this._sessionSeq = _wsSessionCounter;
|
||||||
_wsLog(0, "SESSION", `${sessionIdShort} handshake iniziato — status: creating-session`);
|
_wsLog(0, "SESSION", `${sessionIdShort} handshake started — status: creating-session`);
|
||||||
|
|
||||||
let connectUrl;
|
let connectUrl;
|
||||||
if (this._directUrl) {
|
if (this._directUrl) {
|
||||||
@@ -480,7 +469,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
// keeps a freed slot from being spent on someone who stepped away, and the
|
// keeps a freed slot from being spent on someone who stepped away, and the
|
||||||
// click is a fresh gesture (re-arms the AudioContext on iOS).
|
// click is a fresh gesture (re-arms the AudioContext on iOS).
|
||||||
if (waited) {
|
if (waited) {
|
||||||
_wsLog(1, "QUEUE", `${sessionIdShort} tuo turno — attesa click join per confermare slot`);
|
_wsLog(1, "QUEUE", `${sessionIdShort} your turn — waiting for a join click to confirm the slot`);
|
||||||
await this._awaitJoin(grant);
|
await this._awaitJoin(grant);
|
||||||
if (this._closed) throw _codedError("connect aborted", "aborted");
|
if (this._closed) throw _codedError("connect aborted", "aborted");
|
||||||
}
|
}
|
||||||
@@ -497,38 +486,38 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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).
|
// Also warm ApiPilot (dynamic AquaPortal tools) in parallel.
|
||||||
// Il prefetch è non bloccante: se fallisce, non fallisce connect().
|
// A failed prefetch does not fail connect().
|
||||||
const audioReady = this._setupAudio();
|
const audioReady = this._setupAudio();
|
||||||
const wsReady = this._openWebSocket(connectUrl);
|
const wsReady = this._openWebSocket(connectUrl);
|
||||||
const apipilotReady = this._prefetchApipilotTools(sessionIdShort);
|
const apipilotReady = this._prefetchApipilotTools(sessionIdShort);
|
||||||
await Promise.all([audioReady, wsReady, apipilotReady.catch(() => void 0)]);
|
await Promise.all([audioReady, wsReady, apipilotReady.catch(() => void 0)]);
|
||||||
_wsLog(1, "SESSION", `${sessionIdShort} connessione stabilita + audio pipeline + apipilot OK`);
|
_wsLog(1, "SESSION", `${sessionIdShort} connection established + audio pipeline + apipilot OK`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔧 PRE-FETCH strumenti ApiPilot ad ogni inizio di sessione Realtime.
|
* 🔧 Pre-fetch ApiPilot tools at every Realtime session start.
|
||||||
* - Proxy sul server: GET api/apipilot/tools?include_unauthorized=true
|
* - Server proxy: GET api/apipilot/tools?include_unauthorized=true
|
||||||
* - Il wrapper FETCH globale traccia come [FETCH:APIPILOT:TOOLS] in Console.
|
* - The global FETCH wrapper logs [FETCH:APIPILOT:TOOLS] in the console.
|
||||||
* - Cache TTL locale 240 s per evitare chiamate ridondanti se l'utente
|
* - Local TTL 240 s avoids redundant calls during rapid session restarts
|
||||||
* chiude e riapre sessioni in rapida successione (server TTL=300 s).
|
* (server TTL=300 s).
|
||||||
* - SE window.__s2sApipilot=false → salta subito.
|
* - Skip immediately when window.__s2sApipilot=false.
|
||||||
* - MAI fallisce: sempre catch interno.
|
* - Catch failures internally.
|
||||||
* @param {string} sessionIdShort Log tag "sessione #N"
|
* @param {string} sessionIdShort Log tag "session #N"
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async _prefetchApipilotTools(sessionIdShort) {
|
async _prefetchApipilotTools(sessionIdShort) {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore: __s2sApipilot è settato da main.js dopo fetchConfig()
|
// @ts-ignore: main.js sets __s2sApipilot after fetchConfig().
|
||||||
const enabled = typeof window !== "undefined" && !!window.__s2sApipilot;
|
const enabled = typeof window !== "undefined" && !!window.__s2sApipilot;
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
_wsLog(2, "APIPILOT", `${sessionIdShort} feature flag apipilot=false sul server — salto prefetch strumenti`);
|
_wsLog(2, "APIPILOT", `${sessionIdShort} server feature flag apipilot=false — skipping tools prefetch`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentUser = _apipilotUserKey();
|
const currentUser = _apipilotUserKey();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cached = _apipilotClientCache;
|
const cached = _apipilotClientCache;
|
||||||
// HIT cache solo se: (1) ancora fresca TTL (2) stesso utente key (non cambio phone/token tra sessioni)
|
// Use the cache only within its TTL and for the same user key.
|
||||||
if (cached && (now - cached.at <= _APIPILOT_CLIENT_TTL_MS) && cached.key === currentUser) {
|
if (cached && (now - cached.at <= _APIPILOT_CLIENT_TTL_MS) && cached.key === currentUser) {
|
||||||
const ageSec = Math.round((now - cached.at) / 1000);
|
const ageSec = Math.round((now - cached.at) / 1000);
|
||||||
const n =
|
const n =
|
||||||
@@ -537,10 +526,10 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
: Array.isArray(cached.data && cached.data.tools)
|
: Array.isArray(cached.data && cached.data.tools)
|
||||||
? cached.data.tools.length
|
? cached.data.tools.length
|
||||||
: "?";
|
: "?";
|
||||||
_wsLog(1, "APIPILOT", `${sessionIdShort} HIT cache client strumenti (user=${currentUser}, age=${ageSec}s, N=${n}, TTL=${Math.round(_APIPILOT_CLIENT_TTL_MS/1000)}s)`);
|
_wsLog(1, "APIPILOT", `${sessionIdShort} HIT client tools cache (user=${currentUser}, age=${ageSec}s, N=${n}, TTL=${Math.round(_APIPILOT_CLIENT_TTL_MS/1000)}s)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Propaga header x-demo-session se disponibile in window (per auth fingerprint lato server S2S).
|
// Forward x-demo-session when available for the S2S server auth fingerprint.
|
||||||
/** @type {Record<string,string>} */
|
/** @type {Record<string,string>} */
|
||||||
const extraHeaders = {};
|
const extraHeaders = {};
|
||||||
try {
|
try {
|
||||||
@@ -550,11 +539,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
extraHeaders["x-demo-session"] = window.__s2sDemoSessionToken;
|
extraHeaders["x-demo-session"] = window.__s2sDemoSessionToken;
|
||||||
}
|
}
|
||||||
} catch (_) { /* noop */ }
|
} catch (_) { /* noop */ }
|
||||||
// Usa refresh=true SE è cambiato l'utente (cache.key diversa o null) oppure la cache è stata invalidata globalmente.
|
// Refresh when the user key changes or the cache is missing/expired.
|
||||||
const needRefresh = !cached || cached.key !== currentUser || (now - cached.at > _APIPILOT_CLIENT_TTL_MS);
|
const needRefresh = !cached || cached.key !== currentUser || (now - cached.at > _APIPILOT_CLIENT_TTL_MS);
|
||||||
const qsRefresh = needRefresh ? "&refresh=true" : "";
|
const qsRefresh = needRefresh ? "&refresh=true" : "";
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
_wsLog(0, "APIPILOT", `${sessionIdShort} MISS cache (user=${currentUser}, refresh=${needRefresh ? "Y" : "N"}) → GET api/apipilot/tools (proxy a S2S_APIPILOT_TOOLS_URL server)...`);
|
_wsLog(0, "APIPILOT", `${sessionIdShort} MISS cache (user=${currentUser}, refresh=${needRefresh ? "Y" : "N"}) → GET api/apipilot/tools (server proxy to S2S_APIPILOT_TOOLS_URL)...`);
|
||||||
const res = await fetch(`api/apipilot/tools?include_unauthorized=true${qsRefresh}`, {
|
const res = await fetch(`api/apipilot/tools?include_unauthorized=true${qsRefresh}`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@@ -567,11 +556,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
const j = await res.json().catch(() => ({}));
|
const j = await res.json().catch(() => ({}));
|
||||||
const ok = j && j.success && j.data;
|
const ok = j && j.success && j.data;
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new Error(`envelope non corretto: success=${Boolean(j && j.success)}`);
|
throw new Error(`invalid envelope: success=${Boolean(j && j.success)}`);
|
||||||
}
|
}
|
||||||
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
|
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
|
||||||
const count = typeof j.data.count === "number" ? j.data.count : tools.length;
|
const count = typeof j.data.count === "number" ? j.data.count : tools.length;
|
||||||
// Aggiorna cache locale (in caso di hit la prossima sessione non chiamerà).
|
// Update the local cache so a subsequent session can reuse it.
|
||||||
_apipilotClientCache = { at: Date.now(), data: { count, tools: tools.slice(0, 100) }, key: currentUser };
|
_apipilotClientCache = { at: Date.now(), data: { count, tools: tools.slice(0, 100) }, key: currentUser };
|
||||||
const names = tools
|
const names = tools
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
@@ -580,16 +569,16 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
_wsLog(
|
_wsLog(
|
||||||
1,
|
1,
|
||||||
"APIPILOT",
|
"APIPILOT",
|
||||||
`${sessionIdShort} READY strumenti APIPILOT caricati: N=${count}, t=${Math.round(dt)}ms, user=${currentUser} [primi 5: ${names || "(nessuno)"}]`
|
`${sessionIdShort} READY APIPILOT tools loaded: N=${count}, t=${Math.round(dt)}ms, user=${currentUser} [first 5: ${names || "(none)"}]`
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
_wsLog(
|
_wsLog(
|
||||||
3,
|
3,
|
||||||
"APIPILOT",
|
"APIPILOT",
|
||||||
`${sessionIdShort} prefetch FALLITO: ${msg} — diagnostica lato server log [APIPILOT-DISCOVERY]`
|
`${sessionIdShort} prefetch FAILED: ${msg} — check server logs [APIPILOT-DISCOVERY]`
|
||||||
);
|
);
|
||||||
// NON rilanciare: connect() deve comunque andare a buon fine.
|
// Do not rethrow: connect() must still succeed.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,21 +659,21 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
const dur = (performance.now() - t0).toFixed(1);
|
const dur = (performance.now() - t0).toFixed(1);
|
||||||
if (response.status === 402) {
|
if (response.status === 402) {
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
_wsLog(3, "SESSION", `/session HTTP 402: limite giornaliero esaurito (tier=${body?.tier ?? "?"}) dur=${dur} ms`);
|
_wsLog(3, "SESSION", `/session HTTP 402: daily limit exhausted (tier=${body?.tier ?? "?"}) dur=${dur} ms`);
|
||||||
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
|
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
|
||||||
}
|
}
|
||||||
if (response.status === 503) {
|
if (response.status === 503) {
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
if (body?.state === "at_capacity") {
|
if (body?.state === "at_capacity") {
|
||||||
_wsLog(2, "QUEUE:FULL", `/session HTTP 503 state=at_capacity (coda piena) dur=${dur} ms`);
|
_wsLog(2, "QUEUE:FULL", `/session HTTP 503 state=at_capacity (queue full) dur=${dur} ms`);
|
||||||
throw _codedError("The queue is full — try again shortly.", "queue-full");
|
throw _codedError("The queue is full — try again shortly.", "queue-full");
|
||||||
}
|
}
|
||||||
_wsLog(3, "SESSION", `/session HTTP 503 (fallback sconosciuto) dur=${dur} ms`);
|
_wsLog(3, "SESSION", `/session HTTP 503 (unknown fallback) dur=${dur} ms`);
|
||||||
throw new Error("/session failed (503)");
|
throw new Error("/session failed (503)");
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => "");
|
const text = await response.text().catch(() => "");
|
||||||
_wsLog(3, "SESSION", `/session fallita HTTP ${response.status}: ${text.slice(0, 180)} dur=${dur} ms`);
|
_wsLog(3, "SESSION", `/session failed HTTP ${response.status}: ${text.slice(0, 180)} dur=${dur} ms`);
|
||||||
throw new Error(`/session failed (${response.status}): ${text}`);
|
throw new Error(`/session failed (${response.status}): ${text}`);
|
||||||
}
|
}
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
@@ -692,7 +681,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
_wsLog(
|
_wsLog(
|
||||||
2,
|
2,
|
||||||
"QUEUE:WAIT",
|
"QUEUE:WAIT",
|
||||||
`/session → stato=queued queueId=${json.queue_id ?? "?"} position=${json.position ?? "?"} pollEvery=${json.poll_interval_s ?? "?"}s dur=${dur} ms`
|
`/session → state=queued queueId=${json.queue_id ?? "?"} position=${json.position ?? "?"} pollEvery=${json.poll_interval_s ?? "?"}s dur=${dur} ms`
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
state: "queued",
|
state: "queued",
|
||||||
@@ -724,7 +713,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
_wsLog(
|
_wsLog(
|
||||||
2,
|
2,
|
||||||
"QUEUE:POLL",
|
"QUEUE:POLL",
|
||||||
`Inizio poll coda queueId=${ticket.queueId.slice(0, 10)}… initialPos=${ticket.position} intervalMs=${intervalMs}`
|
`Start queue polling queueId=${ticket.queueId.slice(0, 10)}… initialPos=${ticket.position} intervalMs=${intervalMs}`
|
||||||
);
|
);
|
||||||
let pollNum = 0;
|
let pollNum = 0;
|
||||||
|
|
||||||
@@ -744,11 +733,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
|
|
||||||
if (response.status === 402) {
|
if (response.status === 402) {
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
_wsLog(3, "QUEUE:LIMIT", `poll #${pollNum} HTTP 402 — limite giornaliero (tier=${body?.tier ?? "?"})`);
|
_wsLog(3, "QUEUE:LIMIT", `poll #${pollNum} HTTP 402 — daily limit (tier=${body?.tier ?? "?"})`);
|
||||||
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
|
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
|
||||||
}
|
}
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
_wsLog(3, "QUEUE:EXPIRED", `poll #${pollNum} HTTP 404 — ticket scaduto queueId=${this._queueId.slice(0, 10)}…`);
|
_wsLog(3, "QUEUE:EXPIRED", `poll #${pollNum} HTTP 404 — ticket expired queueId=${this._queueId.slice(0, 10)}…`);
|
||||||
throw _codedError("Queue timed out", "queue-expired");
|
throw _codedError("Queue timed out", "queue-expired");
|
||||||
}
|
}
|
||||||
if (!response.ok) continue; // 502/503 — transient, retry
|
if (!response.ok) continue; // 502/503 — transient, retry
|
||||||
@@ -763,7 +752,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Reached the front and claimed a slot.
|
// Reached the front and claimed a slot.
|
||||||
_wsLog(1, "QUEUE:FRONT", `poll #${pollNum} — RAGGIUNTA la prima posizione. Assegno slot compute.`);
|
_wsLog(1, "QUEUE:FRONT", `poll #${pollNum} — REACHED the front. Assigning compute slot.`);
|
||||||
this._queueId = "";
|
this._queueId = "";
|
||||||
return this._parseGrant(json);
|
return this._parseGrant(json);
|
||||||
}
|
}
|
||||||
@@ -881,9 +870,8 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
/** @param {string} connectUrl */
|
/** @param {string} connectUrl */
|
||||||
_openWebSocket(connectUrl) {
|
_openWebSocket(connectUrl) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Non loggiamo l'URL completo (potrebbe contenere session_token in
|
// Mask the full URL, which may contain a plaintext session_token.
|
||||||
// chiaro nella querystring). Mostriamo solo host + path + un hash della
|
// Show host + path and secret fingerprints for auditing without leaks.
|
||||||
// parte segreta per audit senza leak token.
|
|
||||||
let maskedUrl = connectUrl;
|
let maskedUrl = connectUrl;
|
||||||
try {
|
try {
|
||||||
const u = new URL(connectUrl, window.location.href);
|
const u = new URL(connectUrl, window.location.href);
|
||||||
@@ -893,7 +881,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
const isSecret = /token|session|auth|jwt|secret|key/i.test(lower) || String(v).length > 16;
|
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}`);
|
secretPortions.push(`${k}=${isSecret ? `✂️…${String(v).slice(-4)} (${String(v).length} chars)` : v}`);
|
||||||
});
|
});
|
||||||
maskedUrl = `${u.protocol}//${u.host}${u.pathname} ? ${secretPortions.join(" & ") || "(nessun query param)"}`;
|
maskedUrl = `${u.protocol}//${u.host}${u.pathname} ? ${secretPortions.join(" & ") || "(no query parameters)"}`;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -905,13 +893,13 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
const onceOpen = () => {
|
const onceOpen = () => {
|
||||||
ws.removeEventListener("open", onceOpen);
|
ws.removeEventListener("open", onceOpen);
|
||||||
ws.removeEventListener("error", onceErr);
|
ws.removeEventListener("error", onceErr);
|
||||||
_wsLog(1, "CONNECT", `WebSocket APERTO su ${maskedUrl} readyState=${ws.readyState}`);
|
_wsLog(1, "CONNECT", `WebSocket OPEN at ${maskedUrl} readyState=${ws.readyState}`);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
const onceErr = (e) => {
|
const onceErr = (e) => {
|
||||||
ws.removeEventListener("open", onceOpen);
|
ws.removeEventListener("open", onceOpen);
|
||||||
ws.removeEventListener("error", onceErr);
|
ws.removeEventListener("error", onceErr);
|
||||||
_wsLog(3, "CONNECT", `WebSocket IMPOSSIBILE da aprire: event.type=${e?.type ?? "error"} url=${maskedUrl}`);
|
_wsLog(3, "CONNECT", `WebSocket FAILED to open: event.type=${e?.type ?? "error"} url=${maskedUrl}`);
|
||||||
reject(new Error(`WebSocket failed to open: ${e?.type ?? "error"}`));
|
reject(new Error(`WebSocket failed to open: ${e?.type ?? "error"}`));
|
||||||
};
|
};
|
||||||
ws.addEventListener("open", onceOpen);
|
ws.addEventListener("open", onceOpen);
|
||||||
@@ -1037,15 +1025,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
`tools=${toolNames || "none"}`
|
`tools=${toolNames || "none"}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Primo ack del server: se startup mode=TALK vogliamo il comportamento
|
// On the first server acknowledgment, TALK startup should behave
|
||||||
// IDENTICO a quando arriva la frase di wake word "Hola Vocero".
|
// exactly like hearing the "Hola Vocero" wake word:
|
||||||
// Quindi NON mandiamo shaped response.create con istruzioni di greet
|
// 1. Inject "hola vocero" as a user turn through sendUserText().
|
||||||
// hardcodate, ma invece:
|
// 2. Call requestResponse() without custom greeting instructions,
|
||||||
// 1. injectiamo "hola vocero" nella conversazione come se l'utente
|
// matching the wake-word path in main.js.
|
||||||
// l'avesse detto (sendUserText → conversation.item.create user)
|
|
||||||
// 2. requestResponse() VUOTO → il modello risponde esattamente come
|
|
||||||
// nel codice wake-word in main.js.
|
|
||||||
// Stesso identico risultato, nessuna istruzione custom.
|
|
||||||
if (this._startWithResponse && !this._initialResponseSent) {
|
if (this._startWithResponse && !this._initialResponseSent) {
|
||||||
this._initialResponseSent = true;
|
this._initialResponseSent = true;
|
||||||
const phrase = this._startWithResponseText.trim() || "hola vocero";
|
const phrase = this._startWithResponseText.trim() || "hola vocero";
|
||||||
@@ -1363,7 +1347,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
_wsLog(
|
_wsLog(
|
||||||
(ev.code === 1000 || ev.code === 1001) ? 1 : (ev.code ? 3 : 2),
|
(ev.code === 1000 || ev.code === 1001) ? 1 : (ev.code ? 3 : 2),
|
||||||
"CLOSE",
|
"CLOSE",
|
||||||
`WebSocket CHIUSO code=${ev.code ?? "?"} reason=${String(ev.reason ?? "(nessuna)").slice(0, 160)} wasClean=${ev.wasClean}`
|
`WebSocket CLOSED code=${ev.code ?? "?"} reason=${String(ev.reason ?? "(none)").slice(0, 160)} wasClean=${ev.wasClean}`
|
||||||
);
|
);
|
||||||
if (this._status === "closed" || this._status === "error") return;
|
if (this._status === "closed" || this._status === "error") return;
|
||||||
if (ev.code === 1000) {
|
if (ev.code === 1000) {
|
||||||
@@ -1395,13 +1379,12 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
// 2026-09-25 INIETTA API-PILOT HERRAMIENTAS DINÁMICAS (PASADA-1) nel session.update.
|
// 2026-09-25 INJECT API-PILOT DYNAMIC TOOLS (PASS 1) in session.update.
|
||||||
// 3 strati di assicurazione (in ordine di priorità crescente, dedup by marker):
|
// Three safeguards, deduplicated by marker:
|
||||||
// (A) main.js effectiveReasoningInstructions() ha già appeso la lista.
|
// (A) main.js effectiveReasoningInstructions() appends the list.
|
||||||
// (B) NOSTRO server.py proxy realtime ri-inietta (se routing lo attraversa).
|
// (B) The server.py Realtime proxy injects it when routing passes there.
|
||||||
// (C) QUI s2s-ws-client: ultimo punto di catch-all prima dell'invio al
|
// (C) This client is the final safeguard before sending instructions,
|
||||||
// canale, GARANTISCE che la lista sia SEMPRE presente in istruzioni
|
// including after a live updateSession() replaces the prompt.
|
||||||
// (anche se qualcuno fa updateSession() con text sovrascritto poco dopo).
|
|
||||||
{
|
{
|
||||||
const currentInstr = (typeof session.instructions === "string") ? session.instructions : "";
|
const currentInstr = (typeof session.instructions === "string") ? session.instructions : "";
|
||||||
if (!instructionsAlreadyHasApipilotPass1(currentInstr)) {
|
if (!instructionsAlreadyHasApipilotPass1(currentInstr)) {
|
||||||
@@ -1453,7 +1436,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
const session = { type: "realtime" };
|
const session = { type: "realtime" };
|
||||||
if (patch.instructions) {
|
if (patch.instructions) {
|
||||||
let instr = patch.instructions;
|
let instr = patch.instructions;
|
||||||
// ── 2026-09-25 catch-all apipilot PASS-1 anche per patch live. ──
|
// ── 2026-09-25 ApiPilot PASS-1 safeguard for live patches too. ──
|
||||||
if (typeof instr === "string" && !instructionsAlreadyHasApipilotPass1(instr)) {
|
if (typeof instr === "string" && !instructionsAlreadyHasApipilotPass1(instr)) {
|
||||||
try {
|
try {
|
||||||
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
|
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
|
||||||
@@ -1776,8 +1759,8 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
/** @param {Record<string, unknown>} event */
|
/** @param {Record<string, unknown>} event */
|
||||||
_send(event) {
|
_send(event) {
|
||||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
||||||
// Anti-spam bulk-rate limiter per eventi audio (~25 al secondo).
|
// Throttle bulk audio event logs (~25 events per second).
|
||||||
// Loggiamo un resoconto ogni 250 chunk (circa 10s a 25Hz) invece di ogni singolo.
|
// Log periodic summaries rather than every chunk.
|
||||||
if (event.type === "input_audio_buffer.append") {
|
if (event.type === "input_audio_buffer.append") {
|
||||||
_wsMicChunkCount += 1;
|
_wsMicChunkCount += 1;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
@@ -1807,7 +1790,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|||||||
this._ws.send(payload || JSON.stringify(event));
|
this._ws.send(payload || JSON.stringify(event));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Messaggi non bulk: log sempre [S2S-WS:SEND] con anteprima del payload.
|
// Always log non-bulk messages as [S2S-WS:SEND] with a payload preview.
|
||||||
let payloadLength = 0;
|
let payloadLength = 0;
|
||||||
let stringifyMs = 0;
|
let stringifyMs = 0;
|
||||||
let payload = "";
|
let payload = "";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Configurazione ambiente per s2s-reasoning-engine
|
# Environment configuration for s2s-reasoning-engine
|
||||||
#
|
#
|
||||||
# Copia questo file in .env nella root del progetto o nella cartella s2s-reasoning-engine/
|
# Copy this file to .env in the project root or s2s-reasoning-engine directory
|
||||||
# e modifica i valori secondo il tuo deployment.
|
# and adjust the values for your deployment.
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# 1) LLM (OpenAI-compatible)
|
# 1) LLM (OpenAI-compatible)
|
||||||
@@ -15,7 +15,7 @@ OPENAI_REASONING_TEMPERATURE=0.2
|
|||||||
OPENAI_REASONING_MAX_TOKENS=512
|
OPENAI_REASONING_MAX_TOKENS=512
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# 2) Aquachile (profilo agente, tool MCP, autenticazione)
|
# 2) Aquachile (agent profile, MCP tools, authentication)
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
S2S_AGENT_PROFILE=https://aquachile.azurianlab.com/api/v1/agents/profile
|
S2S_AGENT_PROFILE=https://aquachile.azurianlab.com/api/v1/agents/profile
|
||||||
S2S_USER_PHONE_AUTH_URL=https://aquachile.azurianlab.com/api/v1/auth/user-by-phone
|
S2S_USER_PHONE_AUTH_URL=https://aquachile.azurianlab.com/api/v1/auth/user-by-phone
|
||||||
@@ -32,8 +32,8 @@ NODE_RED_LOG_LEVEL=info
|
|||||||
NODE_RED_CREDENTIAL_SECRET=change-me-in-production
|
NODE_RED_CREDENTIAL_SECRET=change-me-in-production
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# 4) Integrazione con s2s UI (server.py)
|
# 4) S2S UI integration (server.py)
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# URL raggiungibile dal container Python della UI
|
# URL reachable from the Python UI container
|
||||||
# (se usi rete docker condivisa è http://s2s-reasoning:1880)
|
# (for a shared Docker network, use http://s2s-reasoning:1880).
|
||||||
S2S_REASONING_NODE_RED_URL=http://localhost:1881
|
S2S_REASONING_NODE_RED_URL=http://localhost:1881
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
# s2s-reasoning-engine: Node-RED 5.0 custom image
|
# s2s-reasoning-engine: Node-RED 5.0 custom image
|
||||||
|
|
||||||
# Usa Node-RED 5.x ufficiale (immagine Alpine per dimensioni ridotte)
|
# Use the official Node-RED 5.x Alpine image to keep the image small.
|
||||||
FROM nodered/node-red:5.0.3
|
FROM nodered/node-red:5.0.3
|
||||||
|
|
||||||
# Porta di default di Node-RED (esposta in reti containerizzate)
|
# Default Node-RED port, exposed to the container network.
|
||||||
EXPOSE 1880
|
EXPOSE 1880
|
||||||
|
|
||||||
# 1) Installa nodi aggiuntivi (a livello utente node-red nella cartella dati)
|
# 1) Install additional nodes in the Node-RED user data directory.
|
||||||
# - node-red-contrib-openai: integrazione nativa Chat Completions / Embeddings
|
# - node-red-contrib-openai: native Chat Completions / Embeddings integration
|
||||||
# - node-red-contrib-http-logger: log HTTP opzionale
|
# - node-red-contrib-cache: optional in-memory cache for Aquachile data
|
||||||
# - node-red-contrib-cache: cache in-memory per dati Aquachile (opzionale)
|
# - node-red-node-rbe: report-by-exception event deduplication
|
||||||
# - node-red-node-rbe: report-by-exception per deduplicare eventi
|
|
||||||
RUN cd /data && npm install --no-audit --no-fund \
|
RUN cd /data && npm install --no-audit --no-fund \
|
||||||
node-red-contrib-openai@0.11.0 \
|
node-red-contrib-openai@0.11.0 \
|
||||||
node-red-contrib-cache@1.1.1 \
|
node-red-contrib-cache@1.1.1 \
|
||||||
node-red-node-rbe@0.5.0
|
node-red-node-rbe@0.5.0
|
||||||
|
|
||||||
# 2) Copia file di configurazione e flows iniziali
|
# 2) Copy the configuration and initial flows.
|
||||||
COPY settings.js /data/settings.js
|
COPY settings.js /data/settings.js
|
||||||
COPY flows.json /data/flows.json
|
COPY flows.json /data/flows.json
|
||||||
|
|
||||||
# 3) Imposta Node-RED per caricare flows statici + disabilita progetto e auth
|
# 3) Load static flows with projects and authentication disabled for development.
|
||||||
# (per ambiente di sviluppo; in produzione abilitare adminAuth in settings.js)
|
# Enable adminAuth in settings.js for production.
|
||||||
ENV NODE_RED_FLOWS_FILE=/data/flows.json
|
ENV NODE_RED_FLOWS_FILE=/data/flows.json
|
||||||
ENV NODE_RED_CREDENTIAL_SECRET=s2s-reasoning-dev-secret
|
ENV NODE_RED_CREDENTIAL_SECRET=s2s-reasoning-dev-secret
|
||||||
ENV FLOWS=/data/flows.json
|
ENV FLOWS=/data/flows.json
|
||||||
|
|
||||||
# Avvio Node-RED
|
# Start Node-RED.
|
||||||
CMD ["npm", "start", "--", "--userDir", "/data", "--settings", "/data/settings.js"]
|
CMD ["npm", "start", "--", "--userDir", "/data", "--settings", "/data/settings.js"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "tab",
|
"type": "tab",
|
||||||
"label": "S2S Reasoning",
|
"label": "S2S Reasoning",
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"info": "# Flusso di reasoning S2S — FASE 1: LOG-ONLY\n\nModalità attuale: il flusso NON produce risposte, NON modifica il prompt.\nSi limita a:\n 1. Ricevere POST /api/reasoning dal server demo\n 2. Estrarre transcript, lingua, phoneNumber\n 3. Loggare la richiesta utente su console + sidebar debug\n 4. Risondere HTTP 200 con contratto standard e flag `log_only: true`\n\nContratto risposta (fase 1):\n { answer: \"\", log_only: true, intent: \"log_only\", usage: {prompt_tokens:0,completion_tokens:0,total_tokens:0}, latency_ms: <ms> }\n",
|
"info": "# S2S reasoning flow — phase 1: log only\n\nThis flow does not generate answers or change the prompt. It:\n 1. Receives POST /api/reasoning from the client server.\n 2. Extracts the transcript, language, and phoneNumber.\n 3. Logs the user request to the console and debug sidebar.\n 4. Returns HTTP 200 with the standard contract and `log_only: true`.\n\nResponse contract (phase 1):\n { answer: \"\", log_only: true, intent: \"log_only\", usage: {prompt_tokens:0,completion_tokens:0,total_tokens:0}, latency_ms: <ms> }\n",
|
||||||
"wires": []
|
"wires": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
"id": "parse-request",
|
"id": "parse-request",
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"z": "s2s-reasoning-tab",
|
"z": "s2s-reasoning-tab",
|
||||||
"name": "Parse richiesta (log-only)",
|
"name": "Parse request (log only)",
|
||||||
"func": "// FASE 1 — LOG-ONLY: nessuna answer generata.\n// Valida e normalizza il payload in arrivo da server.py.\n// msg.req.body = { transcript, instructions, language, user_context, phoneNumber? }\n\nconst body = msg.req?.body || {};\nconst startedAt = Date.now();\n\nconst transcript = String(body.transcript || \"\").trim();\nconst instructions = String(body.instructions || \"\").trim();\nconst language = String(body.language || \"\").trim();\nconst userContext = String(body.user_context || body.user_context || \"\").trim();\nconst phoneNumber = String(body.phoneNumber || body.phone_number || msg.req?.query?.phoneNumber || \"\").trim();\n\nif (!transcript) {\n msg.statusCode = 400;\n msg.payload = { error: \"missing_transcript\", detail: \"transcript field is required\" };\n return [null, msg]; // uscita 2 = errore\n}\n\nconst detectedLang = language || (transcript.match(/[áéíóúñ¿¡]/i) ? \"es-CL\" : \"en-US\");\n\nmsg.state = {\n startedAt,\n transcript,\n instructionsLen: instructions.length,\n language: detectedLang,\n userContextLen: userContext.length,\n phoneNumber: phoneNumber || null,\n intent: \"log_only\",\n};\n\nmsg.payload = {\n transcript,\n language: detectedLang,\n phone: phoneNumber || \"none\",\n};\n\nreturn [msg, null];",
|
"func": "// Phase 1: log only; no answer is generated.\n// Validate and normalize the payload received from server.py.\n// msg.req.body = { transcript, instructions, language, user_context, phoneNumber? }\n\nconst body = msg.req?.body || {};\nconst startedAt = Date.now();\n\nconst transcript = String(body.transcript || \"\").trim();\nconst instructions = String(body.instructions || \"\").trim();\nconst language = String(body.language || \"\").trim();\nconst userContext = String(body.user_context || body.user_context || \"\").trim();\nconst phoneNumber = String(body.phoneNumber || body.phone_number || msg.req?.query?.phoneNumber || \"\").trim();\n\nif (!transcript) {\n msg.statusCode = 400;\n msg.payload = { error: \"missing_transcript\", detail: \"transcript field is required\" };\n return [null, msg]; // Output 2 is the error path.\n}\n\nconst detectedLang = language || (transcript.match(/[áéíóúñ¿¡]/i) ? \"es-CL\" : \"en-US\");\n\nmsg.state = {\n startedAt,\n transcript,\n instructionsLen: instructions.length,\n language: detectedLang,\n userContextLen: userContext.length,\n phoneNumber: phoneNumber || null,\n intent: \"log_only\",\n};\n\nmsg.payload = {\n transcript,\n language: detectedLang,\n phone: phoneNumber || \"none\",\n};\n\nreturn [msg, null];",
|
||||||
"outputs": 2,
|
"outputs": 2,
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"noerr": 0,
|
"noerr": 0,
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
"id": "log-transcript-console",
|
"id": "log-transcript-console",
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"z": "s2s-reasoning-tab",
|
"z": "s2s-reasoning-tab",
|
||||||
"name": "[LOG] richiesta utente",
|
"name": "[LOG] user request",
|
||||||
"func": "// FASE 1 — Stampa la richiesta utente sul log server (Node-RED console)\n// e sul pannello debug sidebar.\n// NON produce alcuna risposta: quella arriva dal backend S2S nativo.\n\nconst state = msg.state || {};\nconst transcript = state.transcript || \"\";\nconst lang = state.language || \"\";\nconst phone = state.phoneNumber || \"none\";\nconst ts = new Date(state.startedAt || Date.now()).toISOString();\n\nconst line = `[S2S NODE_RED LOG_ONLY] ts=${ts} lang=${lang} phone=${phone} transcript=\"${transcript.replace(/\"/g, '\\\\\"')}\"`;\n\nnode.warn(line);\n\nmsg.logLine = line;\nreturn msg;",
|
"func": "// Phase 1: log the user request to the Node-RED console and debug sidebar.\n// The native S2S backend generates the response; this node does not.\n\nconst state = msg.state || {};\nconst transcript = state.transcript || \"\";\nconst lang = state.language || \"\";\nconst phone = state.phoneNumber || \"none\";\nconst ts = new Date(state.startedAt || Date.now()).toISOString();\n\nconst line = `[S2S NODE_RED LOG_ONLY] ts=${ts} lang=${lang} phone=${phone} transcript=\"${transcript.replace(/\"/g, '\\\\\"')}\"`;\n\nnode.warn(line);\n\nmsg.logLine = line;\nreturn msg;",
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"noerr": 0,
|
"noerr": 0,
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"z": "s2s-reasoning-tab",
|
"z": "s2s-reasoning-tab",
|
||||||
"name": "Build 200 (log_only)",
|
"name": "Build 200 (log_only)",
|
||||||
"func": "// FASE 1 — Costruisci la risposta HTTP 200.\n// Contratto: answer VUOTA + flag log_only=true.\n// Il client (main.js) vedrà log_only e NON interverrà:\n// - NON chiama cancelActiveResponse()\n// - NON usa appendAssistantText()\n// - NON usa speechSynthesis\n// => la risposta vocale arriva dal backend S2S nativo.\n\nconst state = msg.state || {};\nconst latencyMs = Math.max(0, Date.now() - (state.startedAt || Date.now()));\n\nmsg.statusCode = 200;\nmsg.headers = {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n};\nmsg.payload = {\n answer: \"\",\n log_only: true,\n intent: \"log_only\",\n usage: {\n prompt_tokens: 0,\n completion_tokens: 0,\n total_tokens: 0,\n },\n latency_ms: latencyMs,\n mcp_tools_used: [],\n caller_profile_keys: [],\n debug: {\n phase: \"log_only_fase1\",\n language: state.language || \"\",\n phone: state.phoneNumber || null,\n transcript_len: (state.transcript || \"\").length,\n },\n};\n\nreturn msg;",
|
"func": "// Phase 1: build the HTTP 200 response with an empty answer and log_only=true.\n// The client leaves the native S2S response untouched: it does not call\n// cancelActiveResponse(), appendAssistantText(), or speechSynthesis.\n\nconst state = msg.state || {};\nconst latencyMs = Math.max(0, Date.now() - (state.startedAt || Date.now()));\n\nmsg.statusCode = 200;\nmsg.headers = {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n};\nmsg.payload = {\n answer: \"\",\n log_only: true,\n intent: \"log_only\",\n usage: {\n prompt_tokens: 0,\n completion_tokens: 0,\n total_tokens: 0,\n },\n latency_ms: latencyMs,\n mcp_tools_used: [],\n caller_profile_keys: [],\n debug: {\n phase: \"log_only_phase1\",\n language: state.language || \"\",\n phone: state.phoneNumber || null,\n transcript_len: (state.transcript || \"\").length,\n },\n};\n\nreturn msg;",
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"noerr": 0,
|
"noerr": 0,
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"z": "s2s-reasoning-tab",
|
"z": "s2s-reasoning-tab",
|
||||||
"name": "Parse system-response",
|
"name": "Parse system-response",
|
||||||
"func": "// Riceve dal server.py la risposta TTS emessa dal backend S2S nativo.\n// msg.req.body = { tts, response_id?, item_id?, client_ip?, role?, source_event?, user_transcript?, phone? }\n// FASE 1 LOG-ONLY: nessun side-effect oltre al log.\n\nconst body = msg.req?.body || {};\nconst startedAt = Date.now();\n\nconst tts = String(body.tts || body.text || body.response || \"\").trim();\nconst responseId = String(body.response_id || body.responseId || \"\").trim() || null;\nconst itemId = String(body.item_id || body.itemId || \"\").trim() || null;\nconst clientIp = String(body.client_ip || body.clientIp || body.ip || \"\").trim() || null;\nconst role = String(body.role || \"assistant\").trim() || \"assistant\";\nconst sourceEvent = String(body.source_event || body.sourceEvent || \"\").trim() || null;\nconst userTranscript = String(body.user_transcript || body.userTranscript || \"\").trim() || null;\nconst phone = String(body.phone || body.phoneNumber || \"\").trim() || null;\n\nif (!tts) {\n msg.statusCode = 400;\n msg.headers = { \"Content-Type\": \"application/json\" };\n msg.payload = { error: \"missing_tts\", detail: \"field 'tts' (or 'text'/'response') is required\" };\n return [null, msg];\n}\n\nmsg.state = {\n startedAt,\n kind: \"system_response_log\",\n role,\n tts,\n responseId,\n itemId,\n clientIp,\n sourceEvent,\n userTranscript,\n phone,\n};\n\nmsg.payload = { tts, role, source: sourceEvent, client: clientIp };\nreturn [msg, null];",
|
"func": "// Receive the native S2S backend's TTS response from server.py.\n// msg.req.body = { tts, response_id?, item_id?, client_ip?, role?, source_event?, user_transcript?, phone? }\n// Phase 1: log only; no other side effects.\n\nconst body = msg.req?.body || {};\nconst startedAt = Date.now();\n\nconst tts = String(body.tts || body.text || body.response || \"\").trim();\nconst responseId = String(body.response_id || body.responseId || \"\").trim() || null;\nconst itemId = String(body.item_id || body.itemId || \"\").trim() || null;\nconst clientIp = String(body.client_ip || body.clientIp || body.ip || \"\").trim() || null;\nconst role = String(body.role || \"assistant\").trim() || \"assistant\";\nconst sourceEvent = String(body.source_event || body.sourceEvent || \"\").trim() || null;\nconst userTranscript = String(body.user_transcript || body.userTranscript || \"\").trim() || null;\nconst phone = String(body.phone || body.phoneNumber || \"\").trim() || null;\n\nif (!tts) {\n msg.statusCode = 400;\n msg.headers = { \"Content-Type\": \"application/json\" };\n msg.payload = { error: \"missing_tts\", detail: \"field 'tts' (or 'text'/'response') is required\" };\n return [null, msg];\n}\n\nmsg.state = {\n startedAt,\n kind: \"system_response_log\",\n role,\n tts,\n responseId,\n itemId,\n clientIp,\n sourceEvent,\n userTranscript,\n phone,\n};\n\nmsg.payload = { tts, role, source: sourceEvent, client: clientIp };\nreturn [msg, null];",
|
||||||
"outputs": 2,
|
"outputs": 2,
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"noerr": 0,
|
"noerr": 0,
|
||||||
@@ -188,8 +188,8 @@
|
|||||||
"id": "log-system-response",
|
"id": "log-system-response",
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"z": "s2s-reasoning-tab",
|
"z": "s2s-reasoning-tab",
|
||||||
"name": "[LOG] risposta sistema",
|
"name": "[LOG] system response",
|
||||||
"func": "// Stampa la risposta del sistema (TTS dal backend nativo) sul log Node-RED.\n// Formato riga unica ricercabile con grep.\nconst state = msg.state || {};\nconst tts = state.tts || \"\";\nconst role = state.role || \"assistant\";\nconst src = state.sourceEvent || \"\";\nconst user = state.userTranscript || \"\";\nconst phone = state.phone || \"none\";\nconst ts = new Date(state.startedAt || Date.now()).toISOString();\n\nconst userShort = user.length > 120 ? user.slice(0, 117) + \"...\" : user;\nconst ttsShort = tts.length > 240 ? tts.slice(0, 237) + \"...\" : tts;\nconst esc = (s) => String(s || \"\").replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n\nconst line = `[S2S NODE_RED SYSTEM_RESPONSE] ts=${ts} role=${role} phone=${esc(phone)} user=\"${esc(userShort)}\" tts=\"${esc(ttsShort)}\"`;\n\nnode.warn(line);\nmsg.logLine = line;\nreturn msg;",
|
"func": "// Log the native backend's TTS response to Node-RED.\n// Use one searchable line per response.\nconst state = msg.state || {};\nconst tts = state.tts || \"\";\nconst role = state.role || \"assistant\";\nconst src = state.sourceEvent || \"\";\nconst user = state.userTranscript || \"\";\nconst phone = state.phone || \"none\";\nconst ts = new Date(state.startedAt || Date.now()).toISOString();\n\nconst userShort = user.length > 120 ? user.slice(0, 117) + \"...\" : user;\nconst ttsShort = tts.length > 240 ? tts.slice(0, 237) + \"...\" : tts;\nconst esc = (s) => String(s || \"\").replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n\nconst line = `[S2S NODE_RED SYSTEM_RESPONSE] ts=${ts} role=${role} phone=${esc(phone)} user=\"${esc(userShort)}\" tts=\"${esc(ttsShort)}\"`;\n\nnode.warn(line);\nmsg.logLine = line;\nreturn msg;",
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"noerr": 0,
|
"noerr": 0,
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"z": "s2s-reasoning-tab",
|
"z": "s2s-reasoning-tab",
|
||||||
"name": "Build 200 (ack)",
|
"name": "Build 200 (ack)",
|
||||||
"func": "// Risposta minimale: 200 OK + ricevuto=true.\n// Non ritorna dati.\nconst state = msg.state || {};\nconst latencyMs = Math.max(0, Date.now() - (state.startedAt || Date.now()));\nmsg.statusCode = 200;\nmsg.headers = {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n};\nmsg.payload = {\n ricevuto: true,\n log_only: true,\n intent: \"system_response_log\",\n latency_ms: latencyMs,\n};\nreturn msg;",
|
"func": "// Return a minimal HTTP 200 acknowledgment with received=true.\n// No response data is returned.\nconst state = msg.state || {};\nconst latencyMs = Math.max(0, Date.now() - (state.startedAt || Date.now()));\nmsg.statusCode = 200;\nmsg.headers = {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n};\nmsg.payload = {\n received: true,\n log_only: true,\n intent: \"system_response_log\",\n latency_ms: latencyMs,\n};\nreturn msg;",
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"noerr": 0,
|
"noerr": 0,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "s2s-reasoning-engine",
|
"name": "s2s-reasoning-engine",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Node-RED 5.0 reasoning engine per S2S/Aquachile",
|
"description": "Node-RED 5.0 reasoning engine for S2S/Aquachile",
|
||||||
"main": "settings.js",
|
"main": "settings.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Node-RED 5.0 settings for s2s-reasoning-engine — LOCAL INSTALL (non Docker).
|
* Node-RED 5.0 settings for s2s-reasoning-engine — local installation.
|
||||||
* File: <project_root>/s2s-reasoning-engine/.node-red/settings.js
|
* File: <project_root>/s2s-reasoning-engine/.node-red/settings.js
|
||||||
* oppure <project_root>/s2s-reasoning-engine/settings.js (master)
|
* or <project_root>/s2s-reasoning-engine/settings.js (source)
|
||||||
*
|
*
|
||||||
* Copiato automaticamente in .node-red/ da start.sh se mancante o più vecchio.
|
* start.sh copies this into .node-red/ when missing or older than the source.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
|
||||||
// userDir è sempre <s2s-reasoning-engine>/.node-red (lo script start.sh)
|
// start.sh uses <s2s-reasoning-engine>/.node-red as its local user directory.
|
||||||
const ENGINE_ROOT = path.resolve(__dirname, "..");
|
const ENGINE_ROOT = path.resolve(__dirname, "..");
|
||||||
const LOCAL_NODE_MODULES = path.join(ENGINE_ROOT, "node_modules");
|
const LOCAL_NODE_MODULES = path.join(ENGINE_ROOT, "node_modules");
|
||||||
const USERDIR_NODE_MODULES = path.join(__dirname, "node_modules");
|
const USERDIR_NODE_MODULES = path.join(__dirname, "node_modules");
|
||||||
|
|
||||||
// ── Fix per editor "tslib.es6.js 404 / net::ERR_ABORTED" ─────────────────
|
// Fix the editor's "tslib.es6.js 404 / net::ERR_ABORTED" error.
|
||||||
// Installazioni npm locali flat (default npm 3+) spostano tslib in
|
// Flat npm installations place tslib in the root node_modules/tslib directory
|
||||||
// node_modules/tslib/ (root) invece che dentro
|
// rather than @node-red/editor-client/node_modules/tslib. The Node-RED 5 editor
|
||||||
// @node-red/editor-client/node_modules/tslib/. L'editor di Node-RED 5
|
// loads /tslib.es6.js from the HTTP root. Refresh the editor's public files at
|
||||||
// carica `/tslib.es6.js` dalla root HTTP: se la cartella nested non esiste,
|
// startup so the workaround survives dependency reinstallation.
|
||||||
// torna 404. Qui aggiorniamo editor-client/public/ con i file tslib mancanti
|
|
||||||
// a ogni avvio (sopravvive a npm reinstall se tslib è in root).
|
|
||||||
(function _installTslibVendor() {
|
(function _installTslibVendor() {
|
||||||
try {
|
try {
|
||||||
const tslibRoot = path.join(LOCAL_NODE_MODULES, "tslib");
|
const tslibRoot = path.join(LOCAL_NODE_MODULES, "tslib");
|
||||||
@@ -49,13 +47,10 @@ module.exports = {
|
|||||||
httpRequestTimeout: 120000,
|
httpRequestTimeout: 120000,
|
||||||
httpRequestLegacyMsgOverrides: true,
|
httpRequestLegacyMsgOverrides: true,
|
||||||
|
|
||||||
// ── Native HTTP helper per function node (SENZA externalModules) ─────
|
// Native HTTP helpers for function nodes, without externalModules.
|
||||||
// NOTA: non usare functionExternalModules: true — in Node-RED v5 quel
|
// functionExternalModules can stall palette loading in Node-RED 5.
|
||||||
// flag può bloccare il caricamento palette (es. "37/41 nodes loading"
|
// Load core Node.js modules here before startup and expose them through
|
||||||
// durante la verifica di tutti i pkg npm).
|
// functionGlobalContext, accessed with global.get("_core_https") etc.
|
||||||
// Al suo posto usiamo functionGlobalContext: i moduli core Node.js sono
|
|
||||||
// require'd QUI nel settings.js (prima dell'avvio NR) e poi i function
|
|
||||||
// node li recuperano via global.get("_core_https") ecc.
|
|
||||||
functionModuleRequireAllowList: ["https","http","url","path","util","fs","os","buffer","zlib","stream","crypto"],
|
functionModuleRequireAllowList: ["https","http","url","path","util","fs","os","buffer","zlib","stream","crypto"],
|
||||||
functionGlobalContext: {
|
functionGlobalContext: {
|
||||||
_core_https: require("https"),
|
_core_https: require("https"),
|
||||||
@@ -63,9 +58,9 @@ module.exports = {
|
|||||||
_core_url: require("url"),
|
_core_url: require("url"),
|
||||||
},
|
},
|
||||||
|
|
||||||
// adminAuth: {...} ← scommentare e valorizzare in produzione
|
// adminAuth: {...} // Uncomment and configure for production.
|
||||||
|
|
||||||
// HTTPS (opzionale in produzione)
|
// HTTPS (optional for production).
|
||||||
// https: {
|
// https: {
|
||||||
// key: require("fs").readFileSync(path.join(__dirname, "privkey.pem")),
|
// key: require("fs").readFileSync(path.join(__dirname, "privkey.pem")),
|
||||||
// cert: require("fs").readFileSync(path.join(__dirname, "fullchain.pem"))
|
// cert: require("fs").readFileSync(path.join(__dirname, "fullchain.pem"))
|
||||||
@@ -82,20 +77,18 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
csrf: false,
|
csrf: false,
|
||||||
|
|
||||||
// ── IMPORTANTE: caricamento nodi extra ─────────────────────────────
|
// Additional node loading: Node-RED 5 looks for plugins in:
|
||||||
// Node-RED 5 carica i plugin NODI da:
|
// 1) {userDir}/node_modules (our .node-red/node_modules)
|
||||||
// 1) {userDir}/node_modules (nostro .node-red/node_modules)
|
// 2) {nodesDir} (the project-root installation)
|
||||||
// 2) {nodesDir} (installazione root del progetto)
|
|
||||||
// 3) npm global
|
// 3) npm global
|
||||||
// Senza nodesDir i pacchetti installati nella ROOT del progetto non
|
// Set nodesDir so packages installed at the project root are discovered.
|
||||||
// vengono trovati.
|
|
||||||
nodesDir: LOCAL_NODE_MODULES,
|
nodesDir: LOCAL_NODE_MODULES,
|
||||||
|
|
||||||
externalModules: {
|
externalModules: {
|
||||||
autoInstall: true, // auto-installa nodi mancanti al boot
|
autoInstall: true, // Install missing nodes at startup.
|
||||||
autoInstallRetry: 3,
|
autoInstallRetry: 3,
|
||||||
palette: {
|
palette: {
|
||||||
allowInstall: true, // permette Install dall'editor
|
allowInstall: true, // Allow installation from the editor.
|
||||||
allowUpload: true,
|
allowUpload: true,
|
||||||
modules: ["node-red-contrib-openai", "node-red-contrib-cache", "node-red-node-rbe"],
|
modules: ["node-red-contrib-openai", "node-red-contrib-cache", "node-red-node-rbe"],
|
||||||
allowList: ["node-red-contrib-*", "node-red-node-*"],
|
allowList: ["node-red-contrib-*", "node-red-node-*"],
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# =============================================================
|
# =============================================================
|
||||||
# start.sh — Avvia Node-RED 5.0 in locale per s2s-reasoning-engine
|
# start.sh — Run Node-RED 5.0 locally for s2s-reasoning-engine
|
||||||
#
|
#
|
||||||
# Uso:
|
# Usage:
|
||||||
# ./start.sh # porta default 1881
|
# ./start.sh # default port 1881
|
||||||
# PORT=1900 ./start.sh # porta custom
|
# PORT=1900 ./start.sh # custom port
|
||||||
# LOG_LEVEL=debug ./start.sh # log più dettagliato
|
# LOG_LEVEL=debug ./start.sh # detailed logging
|
||||||
# DEBUG=nodes:* ./start.sh # debug Node-RED interno
|
# DEBUG=nodes:* ./start.sh # internal Node-RED diagnostics
|
||||||
# Stop: Ctrl+C
|
# Stop: Ctrl+C
|
||||||
# =============================================================
|
# =============================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -17,8 +17,8 @@ PARENT_DIR="$(dirname "$ROOT_DIR")"
|
|||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
# 1) Carica env: prima locale .env (se esiste) poi padre ../.env
|
# 1) Load the local .env first, then the parent ../.env when present.
|
||||||
# Il file locale sovrascrive il padre se c'è lo stesso var.
|
# The parent file takes precedence when both define the same variable.
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
set -a
|
set -a
|
||||||
if [ -f "$ROOT_DIR/.env" ]; then
|
if [ -f "$ROOT_DIR/.env" ]; then
|
||||||
@@ -34,7 +34,7 @@ fi
|
|||||||
set +a
|
set +a
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
# 2) Parametri runtime Node-RED
|
# 2) Node-RED runtime parameters
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
PORT="${PORT:-${NODE_RED_PORT:-1881}}"
|
PORT="${PORT:-${NODE_RED_PORT:-1881}}"
|
||||||
export PORT
|
export PORT
|
||||||
@@ -43,11 +43,11 @@ export NODE_RED_LOG_LEVEL="${LOG_LEVEL:-${NODE_RED_LOG_LEVEL:-info}}"
|
|||||||
export NODE_RED_CREDENTIAL_SECRET="${NODE_RED_CREDENTIAL_SECRET:-s2s-reasoning-local-dev}"
|
export NODE_RED_CREDENTIAL_SECRET="${NODE_RED_CREDENTIAL_SECRET:-s2s-reasoning-local-dev}"
|
||||||
export FLOWS="${FLOWS:-$ROOT_DIR/.node-red/flows.json}"
|
export FLOWS="${FLOWS:-$ROOT_DIR/.node-red/flows.json}"
|
||||||
|
|
||||||
# UserDir locale (tutto dentro la cartella corrente, NO ~/.node-red)
|
# Local user directory; keep state here rather than in ~/.node-red.
|
||||||
USERDIR="${USERDIR:-$ROOT_DIR/.node-red}"
|
USERDIR="${USERDIR:-$ROOT_DIR/.node-red}"
|
||||||
SETTINGS="${SETTINGS:-$USERDIR/settings.js}"
|
SETTINGS="${SETTINGS:-$USERDIR/settings.js}"
|
||||||
|
|
||||||
# Assicura che la userDir esista e i file base siano presenti.
|
# Create the user directory and refresh its configuration from the source files.
|
||||||
mkdir -p "$USERDIR"
|
mkdir -p "$USERDIR"
|
||||||
if [ ! -f "$SETTINGS" ] || [ "$ROOT_DIR/settings.js" -nt "$SETTINGS" ]; then
|
if [ ! -f "$SETTINGS" ] || [ "$ROOT_DIR/settings.js" -nt "$SETTINGS" ]; then
|
||||||
cp -f "$ROOT_DIR/settings.js" "$SETTINGS"
|
cp -f "$ROOT_DIR/settings.js" "$SETTINGS"
|
||||||
@@ -58,15 +58,15 @@ if [ ! -f "$FLOWS" ] || [ "$ROOT_DIR/flows.json" -nt "$FLOWS" ]; then
|
|||||||
echo "[init] copied flows.json -> $FLOWS"
|
echo "[init] copied flows.json -> $FLOWS"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verifica node_modules installati
|
# Check that the local Node-RED dependencies are installed.
|
||||||
if [ ! -d "$ROOT_DIR/node_modules/node-red" ]; then
|
if [ ! -d "$ROOT_DIR/node_modules/node-red" ]; then
|
||||||
echo "[init] node_modules non trovati. Avvio npm install..."
|
echo "[init] node_modules was not found. Running npm install..."
|
||||||
npm install --no-audit --no-fund
|
npm install --no-audit --no-fund
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "╔════════════════════════════════════════════════════════════╗"
|
echo "╔════════════════════════════════════════════════════════════╗"
|
||||||
echo "║ s2s-reasoning-engine — Node-RED v5 locale ║"
|
echo "║ s2s-reasoning-engine — Local Node-RED v5 ║"
|
||||||
echo "╠════════════════════════════════════════════════════════════╣"
|
echo "╠════════════════════════════════════════════════════════════╣"
|
||||||
echo "║ Editor: http://localhost:${PORT}/ ║"
|
echo "║ Editor: http://localhost:${PORT}/ ║"
|
||||||
echo "║ API: http://localhost:${PORT}/api/reasoning ║"
|
echo "║ API: http://localhost:${PORT}/api/reasoning ║"
|
||||||
@@ -76,7 +76,7 @@ echo "╚═══════════════════════
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
# 3) Avvio Node-RED
|
# 3) Start Node-RED
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
exec "$ROOT_DIR/node_modules/.bin/node-red" \
|
exec "$ROOT_DIR/node_modules/.bin/node-red" \
|
||||||
--userDir "$USERDIR" \
|
--userDir "$USERDIR" \
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ class ChatCompletionsApiModelHandler(BaseOpenAICompatibleHandler):
|
|||||||
if self.stream:
|
if self.stream:
|
||||||
create_kwargs["stream_options"] = {"include_usage": True}
|
create_kwargs["stream_options"] = {"include_usage": True}
|
||||||
|
|
||||||
# Svuota i parametri dei tool per evitare il blocco 400 di NVIDIA NIM
|
# Remove tool parameters to avoid NVIDIA NIM's HTTP 400 response.
|
||||||
create_kwargs.pop("tools", None)
|
create_kwargs.pop("tools", None)
|
||||||
create_kwargs.pop("tool_choice", None)
|
create_kwargs.pop("tool_choice", None)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ LLM Output Processor
|
|||||||
Intercepts LLM output to:
|
Intercepts LLM output to:
|
||||||
1. Extract tool calls and send them via text_output_queue
|
1. Extract tool calls and send them via text_output_queue
|
||||||
2. Forward clean text to TTS pipeline
|
2. Forward clean text to TTS pipeline
|
||||||
3. 2026-09-25: SANITIZE APIPILOT internal markers before clients / TTS (anti-leak tecnico)
|
3. Sanitize ApiPilot internal markers before sending output to clients or TTS
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,16 +17,15 @@ from queue import Queue
|
|||||||
from speech_to_speech.baseHandler import BaseHandler
|
from speech_to_speech.baseHandler import BaseHandler
|
||||||
from speech_to_speech.pipeline.events import AssistantTextEvent, ResponseFailedEvent, TokenUsageEvent
|
from speech_to_speech.pipeline.events import AssistantTextEvent, ResponseFailedEvent, TokenUsageEvent
|
||||||
|
|
||||||
# 2026-09-25 SANITIZE APIPILOT CALL/RESULT markers in LLM output BEFORE:
|
# Sanitize ApiPilot CALL/RESULT markers before emitting AssistantTextEvent
|
||||||
# (a) AssistantTextEvent viene mandato ai client WS (e loggato come <<< TEXT:/ASSISTANT:)
|
# to WebSocket clients or TTSInput to Qwen3-TTS, which would speak the markers.
|
||||||
# (b) TTSInput viene mandato a Qwen3-TTS (che lo pronuncerebbe ad alta voce).
|
# Observed formats from upstream Vocero start_pipeline_rag:
|
||||||
# Formati osservati reali oggi (upstream Vocero start_pipeline_rag):
|
# 1) <<<APIPILOT_CALL:NAME=tool,ARGUMENTS=json>>> standard triple brackets
|
||||||
# 1) <<<APIPILOT_CALL:NAME=tool,ARGUMENTS=json>>> standard 3x
|
|
||||||
# 2) <APIPILOT_CALL:NAME=get_user_briefing,ARGUMENTS={"format":"voice"}
|
# 2) <APIPILOT_CALL:NAME=get_user_briefing,ARGUMENTS={"format":"voice"}
|
||||||
# singolo '<' '>' + args inventati da AI (format=voice)
|
# single brackets and model-generated arguments
|
||||||
# 3) <<<APIPILOT_CALL:...}> \n>> spezzato multi-riga
|
# 3) <<<APIPILOT_CALL:...}> \n>> split across lines
|
||||||
# 4) >>>APIPILOT_CALL:...<<< invertito (errore sintassi AI)
|
# 4) >>>APIPILOT_CALL:...<<< inverted brackets
|
||||||
# 5) <<<APIPILOT_RESULT:PASS2>>> + blocco result.
|
# 5) <<<APIPILOT_RESULT:PASS2>>> followed by a result block
|
||||||
_APIPILOT_CALL_NORM = re.compile(
|
_APIPILOT_CALL_NORM = re.compile(
|
||||||
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
|
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
|
||||||
re.IGNORECASE | re.DOTALL,
|
re.IGNORECASE | re.DOTALL,
|
||||||
@@ -64,7 +63,7 @@ def _sanitize_apipilot_before_client_and_tts(text: str) -> tuple[str, bool, str]
|
|||||||
if _APIPILOT_RESULT.search(new_text):
|
if _APIPILOT_RESULT.search(new_text):
|
||||||
new_text = _APIPILOT_RESULT.sub(" ", new_text)
|
new_text = _APIPILOT_RESULT.sub(" ", new_text)
|
||||||
found_result = True
|
found_result = True
|
||||||
# safety catchall frammenti marker senza wrapper (se marker troncato mid-stream)
|
# Catch marker fragments without a wrapper, including mid-stream truncation.
|
||||||
low = new_text.lower()
|
low = new_text.lower()
|
||||||
if "apipilot" in low and ("call" in low or "result" in low or "name=" in low or "arguments=" in low):
|
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)\bAPIPILOT(?:_CALL|_RESULT)?\b", " ", new_text)
|
||||||
@@ -184,8 +183,8 @@ class LMOutputProcessor(BaseHandler[LLMOut, TTSIn]):
|
|||||||
|
|
||||||
logger.debug(f"LM processor: text='{lm_output.text}', tools={lm_output.tools}")
|
logger.debug(f"LM processor: text='{lm_output.text}', tools={lm_output.tools}")
|
||||||
|
|
||||||
# 2026-09-25 SANITIZE SINGOLO PUNTO CENTRALE: PRIMA di AssistantTextEvent (client/log)
|
# Sanitize centrally before AssistantTextEvent and TTSInput so neither
|
||||||
# E PRIMA di TTSInput (Qwen3-TTS che pronuncerebbe marker APIPILOT CALL).
|
# clients nor Qwen3-TTS receive internal ApiPilot markers.
|
||||||
clean_text = lm_output.text
|
clean_text = lm_output.text
|
||||||
any_sanitized = False
|
any_sanitized = False
|
||||||
sanitize_tag = ""
|
sanitize_tag = ""
|
||||||
@@ -193,14 +192,14 @@ class LMOutputProcessor(BaseHandler[LLMOut, TTSIn]):
|
|||||||
clean_text, any_sanitized, sanitize_tag = _sanitize_apipilot_before_client_and_tts(clean_text)
|
clean_text, any_sanitized, sanitize_tag = _sanitize_apipilot_before_client_and_tts(clean_text)
|
||||||
if any_sanitized:
|
if any_sanitized:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[LM-OUTPUT-APIPILOT-SANITIZE] %s — tool=%s rawLen=%d cleanLen=%d (rimosso / sostituito con placeholder spagnolo PRIMA di client + TTS).",
|
"[LM-OUTPUT-APIPILOT-SANITIZE] %s — tool=%s rawLen=%d cleanLen=%d (removed or replaced with the Spanish placeholder before client/TTS output).",
|
||||||
sanitize_tag or "-",
|
sanitize_tag or "-",
|
||||||
(sanitize_tag if sanitize_tag else "marker"),
|
(sanitize_tag if sanitize_tag else "marker"),
|
||||||
len(lm_output.text or ""),
|
len(lm_output.text or ""),
|
||||||
len(clean_text or ""),
|
len(clean_text or ""),
|
||||||
)
|
)
|
||||||
# evita che lo stesso chunk (marker solo) arrivi a TTS come "" → Hello.
|
# Use the placeholder when sanitization removes the entire chunk,
|
||||||
# Se dopo sanitize testo vuoto MA avevamo trovato marker → placeholder.
|
# avoiding empty text that would trigger the TTS greeting fallback.
|
||||||
if not clean_text.strip():
|
if not clean_text.strip():
|
||||||
clean_text = _APIPILOT_SPANISH_PLACEHOLDER
|
clean_text = _APIPILOT_SPANISH_PLACEHOLDER
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,15 @@ import re
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
# 2026-09-25 SANITIZE APIPILOT MARKERS PRIMA DI TTS (anti-leak pronunciato da Qwen3).
|
# Sanitize ApiPilot markers directly before TTS as an additional safeguard
|
||||||
# Lato server S2S Python abbiamo gia' gate + sanitize ma come DOPPIA RETE
|
# alongside the S2S relay's audio gating and sanitization.
|
||||||
# di sicurezza sanitizziamo DIRETTAMENTE il testo che entra in Qwen3-TTS.
|
# Observed formats:
|
||||||
# Formati osservati reali oggi:
|
# (a) <<<APIPILOT_CALL:NAME=tool,ARGUMENTS=json>>> standard triple brackets
|
||||||
# (a) <<<APIPILOT_CALL:NAME=tool,ARGUMENTS=json>>> standard 3 parentesi
|
|
||||||
# (b) <APIPILOT_CALL:NAME=tool,ARGUMENTS={"format":"voice"}
|
# (b) <APIPILOT_CALL:NAME=tool,ARGUMENTS={"format":"voice"}
|
||||||
# singolo < > (caso user oggi)
|
# single brackets
|
||||||
# (c) <<<APIPILOT_CALL:...}> \n>> spezzato piu' righe
|
# (c) <<<APIPILOT_CALL:...}> \n>> split across lines
|
||||||
# (d) >>>APIPILOT_CALL:...<<< invertito
|
# (d) >>>APIPILOT_CALL:...<<< inverted brackets
|
||||||
# Regola: CALL -> placeholder spagnolo (frase naturale utente), RESULT -> strip.
|
# Replace CALL with a natural Spanish utterance; strip RESULT markers.
|
||||||
_APIPILOT_CALL_NORMAL_RE = re.compile(
|
_APIPILOT_CALL_NORMAL_RE = re.compile(
|
||||||
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
|
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
|
||||||
re.IGNORECASE | re.DOTALL,
|
re.IGNORECASE | re.DOTALL,
|
||||||
@@ -39,7 +38,7 @@ _APIPILOT_SPANISH_PLACEHOLDER = "Voy a consultar tus datos, un momento por favor
|
|||||||
|
|
||||||
|
|
||||||
def _tts_sanitize_apipilot_markers(text: str) -> tuple[str, bool, str]:
|
def _tts_sanitize_apipilot_markers(text: str) -> tuple[str, bool, str]:
|
||||||
"""Rimuove marker APIPILOT dal testo TTS.
|
"""Remove ApiPilot markers from TTS text.
|
||||||
|
|
||||||
Returns (sanitized_text, found_any, log_tag).
|
Returns (sanitized_text, found_any, log_tag).
|
||||||
"""
|
"""
|
||||||
@@ -48,7 +47,7 @@ def _tts_sanitize_apipilot_markers(text: str) -> tuple[str, bool, str]:
|
|||||||
found_call = False
|
found_call = False
|
||||||
found_result = False
|
found_result = False
|
||||||
tool_name = ""
|
tool_name = ""
|
||||||
# 1) marker CALL standard + invertito -> placeholder spagnolo
|
# 1) Replace standard/inverted CALL markers with the Spanish placeholder.
|
||||||
def _repl_call(m: re.Match) -> str:
|
def _repl_call(m: re.Match) -> str:
|
||||||
nonlocal found_call, tool_name
|
nonlocal found_call, tool_name
|
||||||
found_call = True
|
found_call = True
|
||||||
@@ -62,11 +61,11 @@ def _tts_sanitize_apipilot_markers(text: str) -> tuple[str, bool, str]:
|
|||||||
text2 = _APIPILOT_CALL_NORMAL_RE.sub(_repl_call, text)
|
text2 = _APIPILOT_CALL_NORMAL_RE.sub(_repl_call, text)
|
||||||
if not found_call:
|
if not found_call:
|
||||||
text2 = _APIPILOT_CALL_INVERTED_RE.sub(_repl_call, text2)
|
text2 = _APIPILOT_CALL_INVERTED_RE.sub(_repl_call, text2)
|
||||||
# 2) marker RESULT -> strip silenzioso (non sono pronunciabili)
|
# 2) Strip RESULT markers silently; they must not be spoken.
|
||||||
if _APIPILOT_RESULT_RE.search(text2):
|
if _APIPILOT_RESULT_RE.search(text2):
|
||||||
found_result = True
|
found_result = True
|
||||||
text2 = _APIPILOT_RESULT_RE.sub(" ", text2)
|
text2 = _APIPILOT_RESULT_RE.sub(" ", text2)
|
||||||
# 3) safety: FRAMMENTI marker singoli che non hanno wrapper completo
|
# 3) Catch marker fragments without a complete wrapper.
|
||||||
low = text2.lower()
|
low = text2.lower()
|
||||||
if "apipilot" in low and ("call" in low or "result" in low):
|
if "apipilot" in low and ("call" in low or "result" in low):
|
||||||
import re as _re_sf
|
import re as _re_sf
|
||||||
@@ -782,18 +781,16 @@ class Qwen3TTSHandler(BaseHandler[TTSIn, TTSOut]):
|
|||||||
coalesced_text, _language_code, _saw_end_of_response = self._coalesce_pending_tts_input(tts_input)
|
coalesced_text, _language_code, _saw_end_of_response = self._coalesce_pending_tts_input(tts_input)
|
||||||
|
|
||||||
raw_text = coalesced_text or "Hello."
|
raw_text = coalesced_text or "Hello."
|
||||||
# 2026-09-25 SANITIZE APIPILOT PRIMA DI TTS (doppia rete sicurezza):
|
# A race or a new marker format can bypass relay sanitization. Strip or
|
||||||
# Anche se lato server S2s Python abbiamo gia' gate audio drop + sanitize,
|
# replace markers again before passing text to the TTS model.
|
||||||
# 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)
|
text, found_marker, sanitize_tag = _tts_sanitize_apipilot_markers(raw_text)
|
||||||
if found_marker:
|
if found_marker:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[TTS-APIPILOT-SANITIZE] marker=%s rawLen=%d sanitizedLen=%d → sostituito con placeholder spagnolo / strip.",
|
"[TTS-APIPILOT-SANITIZE] marker=%s rawLen=%d sanitizedLen=%d → replaced with the Spanish placeholder or stripped.",
|
||||||
sanitize_tag, len(raw_text or ""), len(text or ""),
|
sanitize_tag, len(raw_text or ""), len(text or ""),
|
||||||
)
|
)
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
# Safety se il marker era l'unica cosa: ripetiamo placeholder invece di Hello.
|
# Use the Spanish placeholder when the marker was the entire utterance.
|
||||||
text = _APIPILOT_SPANISH_PLACEHOLDER
|
text = _APIPILOT_SPANISH_PLACEHOLDER
|
||||||
|
|
||||||
model_type = self._model_type()
|
model_type = self._model_type()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class SessionHandler(RealtimeBaseHandler):
|
|||||||
"""
|
"""
|
||||||
s = event.session
|
s = event.session
|
||||||
|
|
||||||
# Forza il bypass della voce inviata dal browser
|
# Ignore the voice supplied by the browser.
|
||||||
if hasattr(s, "voice") and s.voice is not None:
|
if hasattr(s, "voice") and s.voice is not None:
|
||||||
s.voice = None
|
s.voice = None
|
||||||
elif isinstance(s, dict) and "voice" in s:
|
elif isinstance(s, dict) and "voice" in s:
|
||||||
|
|||||||
@@ -277,11 +277,10 @@ class RealtimeService:
|
|||||||
try:
|
try:
|
||||||
return model_cls.model_validate(raw) # type: ignore[return-value]
|
return model_cls.model_validate(raw) # type: ignore[return-value]
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
# ── Retrocompatibilità session.update ─────────────────────────────────
|
# Backward-compatible session.update parsing.
|
||||||
# Il formato standard OpenAI Realtime GA annida instructions/tools/tool_choice
|
# OpenAI Realtime GA nests instructions/tools/tool_choice in "session".
|
||||||
# DENTRO "session": { ... }. Per tollerare anche client che li mandano
|
# Accept older clients that send them at the root by rebuilding the
|
||||||
# alla RADICE (es. bug prima della Fase 2 MCP), ricostruiamo qui il payload
|
# nested payload and retrying validation, only for session.update.
|
||||||
# corretto e riproviamo la validazione. Funziona SOLO per session.update.
|
|
||||||
if event_type == "session.update":
|
if event_type == "session.update":
|
||||||
try:
|
try:
|
||||||
raw_dict = dict(raw) if not isinstance(raw, dict) else raw
|
raw_dict = dict(raw) if not isinstance(raw, dict) else raw
|
||||||
@@ -290,7 +289,7 @@ class RealtimeService:
|
|||||||
normalized_inner = dict(session_inner)
|
normalized_inner = dict(session_inner)
|
||||||
else:
|
else:
|
||||||
normalized_inner = {}
|
normalized_inner = {}
|
||||||
# Promuovi campi noti dalla root → inner se mancanti (fallback flat)
|
# Promote known root fields into the inner session when missing.
|
||||||
SESSION_KNOWN_TOP_LEVEL = (
|
SESSION_KNOWN_TOP_LEVEL = (
|
||||||
"instructions",
|
"instructions",
|
||||||
"tools",
|
"tools",
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ class ChatTTSHandlerArguments:
|
|||||||
chat_tts_chunk_size: int = field(
|
chat_tts_chunk_size: int = field(
|
||||||
default=512,
|
default=512,
|
||||||
metadata={
|
metadata={
|
||||||
"help": "Sets the size of the audio data chunk processed per cycle, balancing playback latency and CPU load.. Default is 512。."
|
"help": "Sets the size of the audio data chunk processed per cycle, balancing playback latency and CPU load. Default is 512."
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+7
-7
@@ -13,32 +13,32 @@ fi
|
|||||||
S2S_PROXY_TARGET="${S2S_PROXY_TARGET:-ws://ai-server:12345/v1/realtime}"
|
S2S_PROXY_TARGET="${S2S_PROXY_TARGET:-ws://ai-server:12345/v1/realtime}"
|
||||||
|
|
||||||
if ! command -v python3 >/dev/null 2>&1; then
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
echo "Errore: python3 non trovato."
|
echo "Error: python3 was not found."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -d "$VENV_DIR" ]; then
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
echo "Creazione ambiente virtuale in .venv..."
|
echo "Creating the virtual environment in .venv..."
|
||||||
python3 -m venv "$VENV_DIR"
|
python3 -m venv "$VENV_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
source "$VENV_DIR/bin/activate"
|
source "$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
if [ ! -f "$REQ_FILE" ]; then
|
if [ ! -f "$REQ_FILE" ]; then
|
||||||
echo "Errore: file requirements non trovato: $REQ_FILE"
|
echo "Error: requirements file was not found: $REQ_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! python -c "import fastapi, uvicorn, httpx, huggingface_hub, dotenv" >/dev/null 2>&1; then
|
if ! python -c "import fastapi, uvicorn, httpx, huggingface_hub, dotenv" >/dev/null 2>&1; then
|
||||||
echo "Installazione dipendenze da s2s-client/requirements.txt..."
|
echo "Installing dependencies from s2s-client/requirements.txt..."
|
||||||
python -m pip install -r "$REQ_FILE"
|
python -m pip install -r "$REQ_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export S2S_PROXY_TARGET
|
export S2S_PROXY_TARGET
|
||||||
|
|
||||||
echo "💻 Avvio dell'interfaccia S2S Client UI (Porta $UI_PORT)..."
|
echo "💻 Starting the S2S client UI (port $UI_PORT)..."
|
||||||
echo "🔌 Proxy realtime verso $S2S_PROXY_TARGET"
|
echo "🔌 Realtime proxy target: $S2S_PROXY_TARGET"
|
||||||
echo " Premi Ctrl+C in questo terminale per fermarla."
|
echo " Press Ctrl+C in this terminal to stop it."
|
||||||
echo "----------------------------------------------------"
|
echo "----------------------------------------------------"
|
||||||
|
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|||||||
Reference in New Issue
Block a user