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.
|
||||
- Removed the obsolete `index_video.html` page, which no longer matched the
|
||||
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
|
||||
|
||||
|
||||
+19
-20
@@ -18,8 +18,7 @@
|
||||
BEFORE the <link rel="stylesheet" href="style.css"> tag below.
|
||||
|
||||
Problem reported by user 2026-09-10:
|
||||
"quando faccio refresh per un momento si vede il theme precedente
|
||||
quello purple"
|
||||
"When I refresh, the previous purple theme briefly appears."
|
||||
|
||||
The earlier guard `html:not([data-theme]) [data-theme-only]` only
|
||||
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>
|
||||
|
||||
<!-- 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. -->
|
||||
<div class="status-card" hidden data-theme-only="white" aria-label="Estado de la sesión">
|
||||
<div class="status-card-icon" aria-hidden="true">
|
||||
@@ -265,8 +264,8 @@
|
||||
<!-- WHITE THEME mic stage (replaces the purple orb)
|
||||
- Blue mic circular button (116 px, deep blue, white mic)
|
||||
- Two waveform banks left + right (audio-reactive CSS bars)
|
||||
- "Escuchando..." status text
|
||||
- "Detener" pill button below.
|
||||
- Listening status text
|
||||
- Stop pill button below.
|
||||
Mirrors the screenshot center UI exactly. -->
|
||||
<div class="white-mic-stage" hidden data-theme-only="white" aria-label="Control de voz">
|
||||
<!-- Waveform left side: 7 bars mirror right side, heights driven by JS vars -->
|
||||
@@ -311,10 +310,10 @@
|
||||
<span class="w-bar wb-7" style="--bar-h: 0.4"></span>
|
||||
</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>
|
||||
|
||||
<!-- 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"
|
||||
class="white-stop-btn"
|
||||
type="button"
|
||||
@@ -338,10 +337,10 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Theme=white only: Quick action pills below mic area
|
||||
"Resumen" · "Consultar datos" · "Crear respuesta" each with icon -->
|
||||
<!-- Theme=white only: summary, data lookup and draft-response quick
|
||||
actions below the mic area, each with an icon. -->
|
||||
<div class="quick-actions" hidden data-theme-only="white" aria-label="Acciones rápidas">
|
||||
<button class="quick-action-btn" type="button" data-action="resumen">
|
||||
<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">
|
||||
<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"/>
|
||||
@@ -350,7 +349,7 @@
|
||||
</svg>
|
||||
<span>Resumen</span>
|
||||
</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">
|
||||
<line x1="6" y1="20" x2="6" y2="12"/>
|
||||
<line x1="11" y1="20" x2="11" y2="6"/>
|
||||
@@ -359,7 +358,7 @@
|
||||
</svg>
|
||||
<span>Consultar datos</span>
|
||||
</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">
|
||||
<line x1="18" y1="2" x2="22" y2="6"/>
|
||||
<path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"/>
|
||||
@@ -368,15 +367,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat di testo SEMPRE VISIBILE (fissa in basso, tra stage e footer).
|
||||
Nota: ora è un blocco diretto del flex-col #app, non più un overlay
|
||||
destro → non c'è più bisogno del bottone toggle in topbar. -->
|
||||
<!-- Text chat ALWAYS VISIBLE, fixed between the stage and footer.
|
||||
The panel is now a direct child of the flex-column #app, so it
|
||||
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 class="chat-panel-inner">
|
||||
<header class="chat-panel-header">
|
||||
<h3>Conversación</h3>
|
||||
<div class="chat-header-actions" aria-label="Acciones de la conversación">
|
||||
<!-- "En línea" status pill (green dot + text) — theme=white only.
|
||||
<!-- Online status pill (green dot + text) — theme=white only.
|
||||
Mirrors the screenshot chat header top-right corner. -->
|
||||
<span class="chat-status-pill" hidden data-theme-only="white" role="status" aria-live="polite">
|
||||
<span class="chat-status-dot" aria-hidden="true"></span>
|
||||
@@ -428,14 +427,14 @@
|
||||
<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.
|
||||
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>
|
||||
<video id="cam-video" class="cam-video" autoplay playsinline muted></video>
|
||||
<span class="cam-flash" aria-hidden="true"></span>
|
||||
<span class="cam-label">camera</span>
|
||||
</div>
|
||||
|
||||
<!-- Left sidebar: reports programados.
|
||||
<!-- Left sidebar: scheduled reports.
|
||||
Desktop: fixed left, 320px, always visible (below topbar + above sticky footer).
|
||||
Mobile : off-canvas left overlay, toggled by #reports-sidebar-toggle button in topbar. -->
|
||||
<aside id="reports-sidebar" class="reports-sidebar" aria-label="Reportes programados" hidden data-theme-only="dark">
|
||||
@@ -615,8 +614,8 @@
|
||||
</label>
|
||||
|
||||
<!-- Camera tool row — HIDDEN BY DEFAULT TODAY.
|
||||
Per riattivarlo in futuro: togli hidden da div + small,
|
||||
scommenta listener toolCamSwitch + funzioni enableCamera in main.js. -->
|
||||
To reactivate later, remove hidden from the div and small,
|
||||
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-info">
|
||||
<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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🔎 DEBUG BANNER (always-on): emissione incondizionata in console per capire
|
||||
// ESATTAMENTE quale versione di main.js è caricata nel browser.
|
||||
// Se NON vedi questa riga in Console = stai ancora usando il file CACHATO VECCHIO.
|
||||
// 🔎 DEBUG BANNER (always-on): identify the exact main.js version loaded.
|
||||
// If this line is missing from the console, the browser is using an older cached file.
|
||||
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;"
|
||||
);
|
||||
console.info(
|
||||
" URL attuale location.href=", location.href,
|
||||
" Current URL location.href=", location.href,
|
||||
"\n location.search=", location.search || "(empty)",
|
||||
"\n DEBUG (imported from dom.js) =", DEBUG,
|
||||
"\n typeof window.DEBUG =", typeof window.DEBUG
|
||||
);
|
||||
// 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)
|
||||
// Ogni chiamata `fetch` lato browser viene intercettata e loggata con:
|
||||
// • URL risolto, metodo, dimensioni body + preview JSON
|
||||
// • status HTTP (colorato verde/giallo/rosso a seconda della classe)
|
||||
// • durata (performance.now), dimensione + preview risposta JSON
|
||||
// Tag semantici per filtrare veloce la Console:
|
||||
// 🔎 FETCH WRAPPER (SEMANTIC DEBUG LOG) — always active, including outside DEBUG.
|
||||
// Every browser fetch is traced with:
|
||||
// • resolved URL, method, body size and JSON preview
|
||||
// • HTTP status (green/yellow/red according to status class)
|
||||
// • duration (performance.now), response size and JSON preview
|
||||
// Semantic tags for filtering the console:
|
||||
// [FETCH:CONFIG] [FETCH:REASONING] [FETCH:SEARCH]
|
||||
// [FETCH:PROFILE] [FETCH:REPORTS] [FETCH:APIPILOT]
|
||||
// [FETCH:SESSION] [FETCH:QUEUE] [FETCH:ME]
|
||||
// ---------------------------------------------------------------------------
|
||||
(function _installFetchDebugTracer() {
|
||||
const ORIGINAL_FETCH = window.fetch.bind(window);
|
||||
// Classifica endpoint in tag leggibile (stesso ordine di @app route in server.py).
|
||||
// Classify endpoints with readable tags, following the route order in server.py.
|
||||
/** @param {string} url */
|
||||
function _tagFor(url) {
|
||||
try {
|
||||
@@ -108,13 +107,13 @@ console.info(
|
||||
/** @type {BodyInit | null | undefined} */
|
||||
let body = init ? init.body : undefined;
|
||||
if (body === undefined || body === null) {
|
||||
// tenta Request
|
||||
// Try the Request body.
|
||||
try {
|
||||
const r = /** @type {{body?:BodyInit|null}} */ (/** @type {unknown} */ (input));
|
||||
if (r && r.body != null) body = r.body;
|
||||
} catch { body = null; }
|
||||
}
|
||||
if (body == null) return { length: 0, preview: "(nessun body)" };
|
||||
if (body == null) return { length: 0, preview: "(no body)" };
|
||||
try {
|
||||
if (typeof body === "string") {
|
||||
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 (ArrayBuffer.isView(body)) return { length: body.byteLength, preview: `ArrayBufferView len=${body.byteLength}` };
|
||||
if (body instanceof ArrayBuffer) return { length: body.byteLength, preview: `ArrayBuffer len=${body.byteLength}` };
|
||||
return { length: 0, preview: "(body non serializzabile nel log)" };
|
||||
return { length: 0, preview: "(body cannot be serialized for logging)" };
|
||||
} catch (err) {
|
||||
return { length: -1, preview: `(err preview body: ${err})` };
|
||||
}
|
||||
@@ -178,9 +177,8 @@ console.info(
|
||||
const method = _extractMethod(input, init);
|
||||
const rawUrl = _stringifyUrl(input);
|
||||
const tag = _tagFor(rawUrl);
|
||||
// Per richieste "heartbeat / end poll" (molto frequenti) logghiamo un
|
||||
// singleton collapse per evitare spam: se `?quiet=true` o URL match → log
|
||||
// a riga singola e non mostriamo preview.
|
||||
// Log frequent heartbeat/end polling requests on one line without a body
|
||||
// preview to keep the console readable.
|
||||
const isPollish = /heartbeat|queue\/end|session\/end/i.test(tag) || (init && typeof init.keepalive === "boolean" && init.keepalive);
|
||||
const reqPreview = isPollish ? null : await _extractBodyPreview(input, init);
|
||||
if (!isPollish) {
|
||||
@@ -245,18 +243,18 @@ console.info(
|
||||
return response;
|
||||
};
|
||||
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;"
|
||||
);
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🛡️ GATE .topbar-right (about / tools / rag / chat / settings buttons,
|
||||
// vedi index.html L36-L56) — 3-layer safety:
|
||||
// 1) HTML statico → hidden + data-debug-only="true"
|
||||
// see index.html) — 3-layer safety:
|
||||
// 1) static HTML → hidden + data-debug-only="true"
|
||||
// 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"
|
||||
// Solo se URL query-string ha ?debug=true (strict string) abilitiamo TUTTO.
|
||||
// 3) JS here → hidden, remove data-debug-enabled, set style.display = "none"
|
||||
// Enable all debug controls only when the URL contains ?debug=true (strict string).
|
||||
function applyDebugOnlyVisibilityGate(source = "bootstrap") {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const debugEnabled = qs.get("debug") === "true";
|
||||
@@ -265,64 +263,61 @@ function applyDebugOnlyVisibilityGate(source = "bootstrap") {
|
||||
"%c🧭 [gate-topbar-right] applyDebugOnlyVisibilityGate()",
|
||||
"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("query params =", Object.fromEntries(qs.entries()));
|
||||
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");
|
||||
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) {
|
||||
console.log(" hidden attr prima:", topbarRight.hasAttribute("hidden"),
|
||||
"| data-debug-enabled prima:", topbarRight.getAttribute("data-debug-enabled"),
|
||||
"| style.display prima:", topbarRight.style.display);
|
||||
console.log(" hidden attr before:", topbarRight.hasAttribute("hidden"),
|
||||
"| data-debug-enabled before:", topbarRight.getAttribute("data-debug-enabled"),
|
||||
"| style.display before:", topbarRight.style.display);
|
||||
if (debugEnabled) {
|
||||
topbarRight.setAttribute("data-debug-enabled", "true");
|
||||
topbarRight.removeAttribute("hidden");
|
||||
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 {
|
||||
topbarRight.removeAttribute("data-debug-enabled");
|
||||
topbarRight.setAttribute("hidden", "");
|
||||
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;");
|
||||
}
|
||||
console.log(" hidden attr dopo:", topbarRight.hasAttribute("hidden"),
|
||||
"| data-debug-enabled dopo:", topbarRight.getAttribute("data-debug-enabled"),
|
||||
"| style.display dopo:", topbarRight.style.display);
|
||||
console.log(" hidden attr after:", topbarRight.hasAttribute("hidden"),
|
||||
"| data-debug-enabled after:", topbarRight.getAttribute("data-debug-enabled"),
|
||||
"| style.display after:", topbarRight.style.display);
|
||||
}
|
||||
// 3-layer gate UNIVERSALE su TUTTI gli elementi marcati con data-debug-only="true"
|
||||
// (non solo .topbar-right, ma anche #reports-sidebar-toggle e qualsiasi
|
||||
// altro elemento futuro marcato con lo stesso attributo).
|
||||
// Apply the 3-layer gate to every element marked data-debug-only="true",
|
||||
// including .topbar-right, #reports-sidebar-toggle and future controls.
|
||||
const debugOnlyEls = document.querySelectorAll("[data-debug-only='true']");
|
||||
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) => {
|
||||
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 (debugEnabled) {
|
||||
el.setAttribute("data-debug-enabled", "true");
|
||||
// Non forziamo hidden=false su elementi che sono logicamente hidden per
|
||||
// altri motivi (es. #reports-sidebar-toggle è hidden se non ci sono
|
||||
// reports). Rimuoviamo lo "spegnimento debug-only" e basta.
|
||||
// Preserve elements hidden for other reasons, such as an empty reports
|
||||
// sidebar. Remove only the debug-only display override.
|
||||
el.style.removeProperty("display");
|
||||
} else {
|
||||
el.removeAttribute("data-debug-enabled");
|
||||
el.setAttribute("hidden", "");
|
||||
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();
|
||||
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)");
|
||||
// Riesegui dopo DOMContentLoaded per sicurezza (nel caso query script sia posizionato
|
||||
// in testa e l'HTML header non sia ancora parse-ato quando girano queste righe)
|
||||
// Run again after DOMContentLoaded in case the script runs before the header is parsed.
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
applyDebugOnlyVisibilityGate("DOMContentLoaded listener");
|
||||
@@ -330,7 +325,7 @@ if (document.readyState === "loading") {
|
||||
} else {
|
||||
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);
|
||||
window.addEventListener("hashchange", () => applyDebugOnlyVisibilityGate("hashchange"), { passive: true });
|
||||
window.addEventListener("popstate", () => applyDebugOnlyVisibilityGate("popstate"), { passive: true });
|
||||
@@ -339,12 +334,12 @@ const DEFAULT_VOICE = "Aiden";
|
||||
const DEFAULT_INSTRUCTIONS =
|
||||
"You are Vocero, a friendly voice assistant. " +
|
||||
"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. " +
|
||||
"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 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_HINT =
|
||||
@@ -1314,13 +1309,13 @@ function syncVoceroHeaderUser() {
|
||||
ul.className = "reports-list";
|
||||
const labelTintFor = (rawLabel) => {
|
||||
const l = String(rawLabel || "").toLowerCase();
|
||||
if (l.includes("mortalidad") || l.includes("riesgo") || l.includes("kpi")) return "mortalidad";
|
||||
if (l.includes("métrica") || l.includes("metricas") || l.includes("medici")) return "metricas";
|
||||
if (l.includes("diario") || l.includes("diaria") || l.includes("informe")) return "diario";
|
||||
if (l.includes("producto") || l.includes("servicio")) return "productos";
|
||||
if (l.includes("mortalidad") || l.includes("riesgo") || l.includes("kpi")) return "mortality";
|
||||
if (l.includes("métrica") || l.includes("metricas") || l.includes("medici")) return "metrics";
|
||||
if (l.includes("diario") || l.includes("diaria") || l.includes("informe")) return "daily";
|
||||
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("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 "";
|
||||
};
|
||||
for (const e of entries) {
|
||||
@@ -1330,7 +1325,7 @@ function syncVoceroHeaderUser() {
|
||||
li.textContent = String(e.sourceScheduleLabel);
|
||||
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;
|
||||
// pick first N entries by priority.
|
||||
const suggestions = /** @type {string[]} */ ([]);
|
||||
@@ -1374,20 +1369,20 @@ function syncVoceroHeaderUser() {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const debugParamRaw = qs.get("debug");
|
||||
// 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.log("location.href =", location.href);
|
||||
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("fresh qs .get('debug') =", JSON.stringify(debugParamRaw), "| typeof =", typeof debugParamRaw);
|
||||
console.log("strict === 'true' ? =", debugParamRaw === "true");
|
||||
console.log("count (labels uniche) =", count, "| wrappedLen =", totalLen);
|
||||
console.log("suggerimenti array length (prima del gate debug) =", suggestions.length);
|
||||
console.log("count (unique labels) =", count, "| wrappedLen =", totalLen);
|
||||
console.log("suggestions array length (before debug gate) =", suggestions.length);
|
||||
suggestions.forEach((s, i) => console.log(` sug[${i}] = ${s}`));
|
||||
const currentDebug = debugParamRaw === "true";
|
||||
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();
|
||||
|
||||
let sugSection = null;
|
||||
@@ -1406,10 +1401,10 @@ function syncVoceroHeaderUser() {
|
||||
}
|
||||
sugSection.appendChild(sugTitle);
|
||||
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));
|
||||
} 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;");
|
||||
}
|
||||
|
||||
@@ -1453,7 +1448,7 @@ function syncVoceroHeaderUser() {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const debugParam = qs.get("debug") === "true";
|
||||
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;",
|
||||
reportsSidebarToggle.hasAttribute("hidden"),
|
||||
debugParam,
|
||||
@@ -1464,7 +1459,7 @@ function syncVoceroHeaderUser() {
|
||||
reportsSidebarToggle.setAttribute("hidden", "");
|
||||
reportsSidebarToggle.style.setProperty("display", "none", "important");
|
||||
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;");
|
||||
}
|
||||
}
|
||||
@@ -1476,10 +1471,10 @@ function syncVoceroHeaderUser() {
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
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;",
|
||||
reportsHint.querySelectorAll(".reports-suggestions").length,
|
||||
"| children nel blocco:", reportsHint.children.length,
|
||||
"| children in block:", reportsHint.children.length,
|
||||
"| reportsHint.hidden=", reportsHint.hidden,
|
||||
"| #reports-sidebar hidden=", reportsSidebar ? reportsSidebar.hidden : "no-sidebar-el",
|
||||
"| badge count:", reportsSidebarBadge ? reportsSidebarBadge.getAttribute("data-count") : "no-badge"
|
||||
@@ -1513,21 +1508,19 @@ function effectiveReasoningInstructions() {
|
||||
ragAppended = true;
|
||||
}
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2026-09-25 INJECT API-PILOT HERAMIENTAS DINÁMICAS (PASADA 1).
|
||||
// Formato IDENTICO a server.py Python: marker <<<APIPILOT_TOOLS_LIST_BEGIN>>>
|
||||
// / ...lista name+description+requires_auth+required_parameters... / <<<END>>>.
|
||||
// Il client ottiene la lista dal proxy GET api/apipilot/tools (cache 240 s
|
||||
// per-utente in s2s-ws-client.js _apipilotClientCache) e la appende qui alle
|
||||
// instructions STESSE (luogo #1 di massima priorità per l'LLM).
|
||||
// Dedup: se le instructions già contengono il marker (già appese in una
|
||||
// esecuzione precedente, oppure server Python ha già patchato il payload)
|
||||
// → NON appendere nuovamente (no doppioni, no blob gigante).
|
||||
// 2026-09-25 INJECT API-PILOT DYNAMIC TOOLS (PASS 1).
|
||||
// Match the server.py format: <<<APIPILOT_TOOLS_LIST_BEGIN>>>, followed by
|
||||
// name+description+requires_auth+required_parameters and the closing marker.
|
||||
// Fetch tools through GET api/apipilot/tools (240 s per-user client cache in
|
||||
// s2s-ws-client.js) and append them directly to the LLM instructions.
|
||||
// Deduplicate by marker when a previous call or the Python server already
|
||||
// injected the list, avoiding duplicate or oversized prompt blocks.
|
||||
let apipilotBlock = "";
|
||||
let apipilotCount = 0;
|
||||
let apipilotAppended = false;
|
||||
try {
|
||||
// @ts-ignore: esposto globalmente da s2s-ws-client.js (caricato prima di
|
||||
// main.js in index.html). Valuta sicuro: typeof + early-skip.
|
||||
// @ts-ignore: exported globally by s2s-ws-client.js, loaded before main.js.
|
||||
// Check typeof before calling the optional helper.
|
||||
const getter = typeof window !== "undefined" && window.__s2sGetApipilotPass1ContextInfo;
|
||||
if (typeof getter === "function") {
|
||||
const info = getter();
|
||||
@@ -1544,7 +1537,7 @@ function effectiveReasoningInstructions() {
|
||||
}
|
||||
}
|
||||
} 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}`;
|
||||
_logOnce(key, () => {
|
||||
@@ -1701,7 +1694,7 @@ async function fetchOpenAiReasoningAnswer(transcript) {
|
||||
const json = await res.json();
|
||||
const answer = typeof json?.answer === "string" ? json.answer.trim() : "";
|
||||
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");
|
||||
return { answer, log_only, raw: json };
|
||||
}
|
||||
@@ -1732,10 +1725,10 @@ async function maybeRunOpenAiReasoning(transcript, itemId) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ── LOG-ONLY MODE (FASE 1) ─────────────────────────────────────────────
|
||||
// Node-RED si limita a loggare e torna log_only=true.
|
||||
// NON cancelliamo la risposta backend, NON iniettiamo testo, NON parliamo.
|
||||
// Il flusso resta COMPLETAMENTE a carico del backend S2S nativo.
|
||||
// ── LOG-ONLY MODE (PHASE 1) ────────────────────────────────────────────
|
||||
// Node-RED only logs the request and returns log_only=true.
|
||||
// Preserve the backend response without injecting text or speaking.
|
||||
// The native S2S backend remains responsible for the entire turn.
|
||||
if (result && result.log_only) {
|
||||
reportDebug("R", "main.js:maybeRunOpenAiReasoning", "external reasoning in LOG_ONLY mode — pass through to native S2S backend", {
|
||||
itemId,
|
||||
@@ -1749,9 +1742,9 @@ async function maybeRunOpenAiReasoning(transcript, itemId) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// ── MODE STANDARD (fasi successive) ────────────────────────────────────
|
||||
// Il reasoning esterno vince: annulla la risposta nativa, inserisci il
|
||||
// testo dell'assistente e parla via SpeechSynthesis.
|
||||
// ── STANDARD MODE (LATER PHASES) ──────────────────────────────────────
|
||||
// External reasoning takes priority: cancel the native response, insert
|
||||
// the assistant text and speak through SpeechSynthesis.
|
||||
const answer = (result && result.answer) || "";
|
||||
if (!answer) {
|
||||
console.warn(`[Vocero REASONING] empty non-log-only answer itemId=${JSON.stringify(itemId)}`);
|
||||
@@ -2461,16 +2454,16 @@ async function fetchVoceroUserProfile() {
|
||||
Object.keys(voceroUserProfile).join(","),
|
||||
extractVoceroUserName(voceroUserProfile) || "(none)",
|
||||
);
|
||||
// ── 🚀 PREFETCH ApiPilot IMMEDIATAMENTE DOPO LOGIN-BY-PHONE RIUSCITO
|
||||
// Forza refresh=true per bypassare la cache SERVER lato proxy S2S (TTL 300s)
|
||||
// perché adesso abbiamo un profilo utente autenticato (non più anonimo).
|
||||
// In questo modo vediamo la riga [apipilot] list_tools.start user=(non-anon)
|
||||
// nel backend AquaPortal OGNI VOLTA che c'è un login phone riuscito.
|
||||
// ── 🚀 PREFETCH ApiPilot IMMEDIATELY AFTER SUCCESSFUL PHONE LOGIN
|
||||
// Force refresh=true to bypass the S2S proxy server cache (TTL 300 s)
|
||||
// now that the caller is authenticated rather than anonymous.
|
||||
// AquaPortal logs [apipilot] list_tools.start user=(non-anon) after
|
||||
// every successful phone login.
|
||||
try {
|
||||
if (window.__s2sApipilot) {
|
||||
const t0 = performance.now();
|
||||
console.info(
|
||||
"%c🔧 [APIPILOT-CLIENT] post-login phone → FORZO refresh api/apipilot/tools (bypass cache S2S 300s perché utente appena autenticato)",
|
||||
"%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;"
|
||||
);
|
||||
const demoToken = typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
|
||||
@@ -2485,24 +2478,23 @@ async function fetchVoceroUserProfile() {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const ok = j && j.success && j.data;
|
||||
if (!ok) throw new Error(`envelope non corretto success=${Boolean(j && j.success)}`);
|
||||
if (!ok) throw new Error(`invalid envelope success=${Boolean(j && j.success)}`);
|
||||
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
|
||||
const count = typeof j.data.count === "number" ? j.data.count : tools.length;
|
||||
const names = tools.slice(0, 6).map((t) => (typeof t.name === "string" ? t.name : "?")).join(", ");
|
||||
// Invalida cache client WS per ogni sessione futura (in modo che
|
||||
// al prossimo connect() venga fatto un refresh per ri-confermare).
|
||||
// Invalidate the WS client cache so the next session refreshes it.
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (typeof window.__s2sInvalidateApipilotCache === "function") window.__s2sInvalidateApipilotCache();
|
||||
} catch (_) { /* noop */ }
|
||||
console.info(
|
||||
`%c🔧 [APIPILOT-CLIENT] post-login OK (dt=${dt}ms): N=${count} strumenti [primi 6: ${names || "(nessuno)"}]`,
|
||||
`%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;"
|
||||
);
|
||||
}).catch((err) => {
|
||||
const dt = Math.round(performance.now() - t0);
|
||||
console.warn(
|
||||
`%c🔧 [APIPILOT-CLIENT] post-login FAIL (dt=${dt}ms): ${String(err instanceof Error ? err.message : err)} → controlla [APIPILOT-DISCOVERY] nel log server S2S`,
|
||||
`%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;"
|
||||
);
|
||||
});
|
||||
@@ -2618,10 +2610,9 @@ async function fetchConfig() {
|
||||
pinnedPrompt = (json.primaryPrompt || "").trim();
|
||||
injectProfile = !!json.injectProfile;
|
||||
debugPrompt = !!json.debugPrompt;
|
||||
// Feature flag: ApiPilot AquaPortal bridge attivo sul server.
|
||||
// Se true = S2S_APIPILOT_TOOLS_URL è valorizzato lato backend e
|
||||
// gli endpoint proxy /api/apipilot/tools e /api/apipilot/execute
|
||||
// sono disponibili per il frontend.
|
||||
// Feature flag for the server-side ApiPilot AquaPortal bridge.
|
||||
// True means S2S_APIPILOT_TOOLS_URL is configured and the frontend can
|
||||
// use /api/apipilot/tools and /api/apipilot/execute.
|
||||
const apipilotEnabled = !!json.apipilot;
|
||||
if (typeof window.__s2sApipilot === "undefined") {
|
||||
try { Object.defineProperty(window, "__s2sApipilot", { value: false, writable: true, configurable: true }); }
|
||||
@@ -2672,16 +2663,15 @@ async function fetchConfig() {
|
||||
apipilotEnabled,
|
||||
});
|
||||
// #endregion
|
||||
// ── Demo call ApiPilot (fire-and-forget, solo se flag=true sul server)
|
||||
// Serve ESCLUSIVAMENTE per attivare il tracer fetch lato browser e
|
||||
// mostrare in console la riga [FETCH:APIPILOT:TOOLS] con preview
|
||||
// status, durata e lista strumenti. Proxy server-side verso
|
||||
// ── ApiPilot demo call (fire-and-forget, only when the server flag is true)
|
||||
// Trigger the browser fetch tracer to show [FETCH:APIPILOT:TOOLS] with
|
||||
// status, duration and a tool-list preview. The server proxies to
|
||||
// S2S_APIPILOT_TOOLS_URL/tools.
|
||||
if (apipilotEnabled) {
|
||||
try {
|
||||
const demoUrl = "api/apipilot/tools?include_unauthorized=true";
|
||||
console.info(
|
||||
"%c🔧 [APIPILOT-CLIENT] flag=true → DEMO CALL %s (tracer sempre-attivo logga [FETCH:APIPILOT:TOOLS] sotto)",
|
||||
"%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;",
|
||||
demoUrl
|
||||
);
|
||||
@@ -2693,15 +2683,15 @@ async function fetchConfig() {
|
||||
const count = (j && j.data && typeof j.data.count === "number") ? j.data.count : tools.length;
|
||||
const names = tools.slice(0, 5).map((t) => (typeof t.name === "string" ? t.name : "?")).join(", ");
|
||||
console.info(
|
||||
"%c🔧 [APIPILOT-CLIENT] demo OK: %s strumenti [primi 5: %s] usa POST api/apipilot/execute {name,arguments} per eseguire",
|
||||
"%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;",
|
||||
count,
|
||||
names || "(nessuno)"
|
||||
names || "(none)"
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
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;",
|
||||
String(err)
|
||||
);
|
||||
@@ -2903,16 +2893,16 @@ function _applyThemeRuntime() {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Quick action buttons: clicking "Resumen" / "Consultar datos" /
|
||||
// "Crear respuesta" populates the text input or — if a realtime
|
||||
// 6. Quick action buttons for summaries, data lookup and response drafts
|
||||
// populate the text input or — if a realtime
|
||||
// session is live — sends it as user text straight away.
|
||||
document.querySelectorAll(".quick-action-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const action = btn.getAttribute("data-action") || "";
|
||||
const prompts = {
|
||||
"resumen": "haz un resumen ejecutivo del último informe programado",
|
||||
"consultar-datos": "consulto los datos más recientes del dashboard, dame las cifras clave",
|
||||
"crear-respuesta": "redacta un borrador de respuesta profesional con tono ejecutivo",
|
||||
"summary": "haz un resumen ejecutivo del último informe programado",
|
||||
"query-data": "consulto los datos más recientes del dashboard, dame las cifras clave",
|
||||
"draft-response": "redacta un borrador de respuesta profesional con tono ejecutivo",
|
||||
};
|
||||
const text = prompts[action] || "";
|
||||
if (!text) return;
|
||||
@@ -3480,8 +3470,8 @@ function onClientStatus(status) {
|
||||
case "creating-session":
|
||||
case "connecting":
|
||||
// Qualsiasi inizio connessione (manuale da Orb / restart o unlocker):
|
||||
// se il talk-unlocker è ancora visibile (utente ha cliccato l'Orb prima
|
||||
// del 2s warmup, ad esempio) lo nascondiamo subito.
|
||||
// Hide the talk unlocker immediately if it is still visible, for
|
||||
// example when the user taps the orb before the 2 s warmup ends.
|
||||
hideTalkUnlocker();
|
||||
setState("connecting");
|
||||
break;
|
||||
@@ -3575,19 +3565,16 @@ function onFatalError(err) {
|
||||
setState("idle");
|
||||
chat.renderEmptyState();
|
||||
initGateArc();
|
||||
// Schedulo auto-start TALK / TALKFIRST (S2S_START_SESSION_MODE nel .env):
|
||||
// • "TALK" → dopo 2s mostra overlay unlocker (policy browser:
|
||||
// getUserMedia() richiede gesture). Click/touch/tasto =
|
||||
// doStart() dentro la gesture, poi ws startWithResponse
|
||||
// inject hola vocero via sendUserText+requestResponse nel
|
||||
// ws client (stesso flow identico a wake-word).
|
||||
// • "TALKFIRST" → dopo 2s TENTA direttamete doStart() dal timer, con
|
||||
// pendingWakeWordConfirmationText preimpostato. Se i
|
||||
// permessi del mic sono GRANTED (già consentiti in passato
|
||||
// o Chrome policy permissive), la wake-word simulata scatta
|
||||
// subito senza nessun click. Se invece fallisce per
|
||||
// permesso / gesture (Safari/iOS), fallback sullo stesso
|
||||
// unlocker di TALK.
|
||||
// Schedule TALK / TALKFIRST auto-start (S2S_START_SESSION_MODE in .env):
|
||||
// • "TALK" → show the unlocker after 2 s. Browser policy requires a
|
||||
// gesture for getUserMedia(). A click/touch/key starts
|
||||
// doStart(), then WS startWithResponse injects hola vocero
|
||||
// through sendUserText+requestResponse, like a wake word.
|
||||
// • "TALKFIRST" → try doStart() directly from the timer after 2 s with
|
||||
// pendingWakeWordConfirmationText already set. If mic
|
||||
// permission is granted, the simulated wake word triggers
|
||||
// without a click. Permission/gesture failures on Safari
|
||||
// or iOS fall back to the TALK unlocker.
|
||||
let autoStartTalkTimerId = 0;
|
||||
let _talkUnlockerShown = false;
|
||||
function hideTalkUnlocker() {
|
||||
@@ -3599,11 +3586,9 @@ function showTalkUnlocker(fallbackHint = false) {
|
||||
if (!(startSessionMode === "TALK" || startSessionMode === "TALKFIRST")) return;
|
||||
const el = document.getElementById("talk-unlocker");
|
||||
if (!el) return;
|
||||
// L'unlocker è utile solo a stato idle/error: se l'utente ha già cliccato l'Orb
|
||||
// o è connecting/connected non mostriamo niente.
|
||||
// Show the unlocker only while idle/error, before a session starts.
|
||||
if (!(currentState === "idle" || currentState === "error")) return;
|
||||
// Se è fallback da TALKFIRST cambiamo il testo in modo che spieghi perché
|
||||
// è comparso ("tocca per consentire il microfono", invece di "preparando")
|
||||
// Explain the microphone permission requirement when TALKFIRST falls back.
|
||||
const subEl = el.querySelector(".talk-unlocker-sub");
|
||||
const titleEl = el.querySelector(".talk-unlocker-title");
|
||||
if (fallbackHint) {
|
||||
@@ -3613,20 +3598,20 @@ function showTalkUnlocker(fallbackHint = false) {
|
||||
el.removeAttribute("hidden");
|
||||
_talkUnlockerShown = true;
|
||||
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;",
|
||||
fallbackHint ? "fallback TALKFIRST" : "TALK mode"
|
||||
);
|
||||
}
|
||||
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) {
|
||||
clearTimeout(autoStartTalkTimerId);
|
||||
autoStartTalkTimerId = 0;
|
||||
}
|
||||
if (startSessionMode !== "TALK" && startSessionMode !== "TALKFIRST") return;
|
||||
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;"
|
||||
);
|
||||
autoStartTalkTimerId = window.setTimeout(() => {
|
||||
@@ -3638,49 +3623,45 @@ function maybeScheduleAutoStart() {
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
/** Tenta l'auto-start diretto (TALKFIRST) SENZA gesture. Simula la wake word
|
||||
* impostando pendingWakeWordConfirmationText="hola vocero" (stessa variabile
|
||||
* del riconoscitore vocale reale), poi chiama doStart() — se i permessi sono
|
||||
* già grantiti (Chrome/Android dopo "allow always"), tutto funziona senza
|
||||
* click. Altrimenti fallback sull'unlocker. */
|
||||
/** Attempt direct TALKFIRST auto-start without a gesture. Simulate a wake word
|
||||
* by setting pendingWakeWordConfirmationText="hola vocero", as the real speech
|
||||
* recognizer does, then call doStart(). Existing permissions may allow this
|
||||
* without a click; otherwise fall back to the unlocker. */
|
||||
async function tryTalkFirstDirect() {
|
||||
if (!(currentState === "idle" || currentState === "error")) return;
|
||||
if (missingServerUrl()) {
|
||||
console.warn("[start-session] skip TALKFIRST: manca server URL");
|
||||
console.warn("[start-session] skip TALKFIRST: missing server URL");
|
||||
promptServerUrl();
|
||||
return;
|
||||
}
|
||||
// Step 1: pre-carico la wake-word simulata. Quando onClientStatus riceve
|
||||
// "connected", la frase viene injectata e il modello risponde esattamente
|
||||
// come quando la hot-word è stata pronunciata dal microfono.
|
||||
// Prepare the simulated wake word. When onClientStatus receives "connected",
|
||||
// inject it so the model responds as if the user had spoken it.
|
||||
pendingWakeWordConfirmationText = pendingWakeWordConfirmationText?.trim()
|
||||
? pendingWakeWordConfirmationText
|
||||
: "hola vocero";
|
||||
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;"
|
||||
);
|
||||
try {
|
||||
setCaption("Preparing your session…", "muted");
|
||||
// Non forzo AudioContext da fuori (non c'è gesture): lo crea internamente
|
||||
// _ensureAudioContext; se rimane suspended il client lo resume() quando
|
||||
// arriva il primo audio (o sul prossimo click utente che lo unlocka).
|
||||
// Let _ensureAudioContext create the context. If it stays suspended, the
|
||||
// client resumes it on the first audio or the next user click.
|
||||
await doStart();
|
||||
} catch (err) {
|
||||
console.warn("[start-session] ⚠️ TALKFIRST doStart() diretto FALLITO → fallback unlocker", err && err instanceof Error ? err.message : err);
|
||||
// Fallback: mostro unlocker con la frase esplicativa "permesso mic".
|
||||
console.warn("[start-session] ⚠️ direct TALKFIRST doStart() FAILED → fallback unlocker", err && err instanceof Error ? err.message : err);
|
||||
// Show the unlocker with the microphone permission explanation.
|
||||
try { await teardown(); } catch (_) { /* no-op */ }
|
||||
setState("idle");
|
||||
chat.renderEmptyState();
|
||||
// Ripristino pendingWakeWordConfirmationText: nel caso l'utente usi
|
||||
// l'unlocker per avviare, NON vogliamo perdere la frase simulata (la
|
||||
// useremo di nuovo al connected, scattando quindi sempre la greet).
|
||||
// Preserve the simulated phrase so starting through the unlocker still
|
||||
// injects it on connection and triggers the greeting.
|
||||
pendingWakeWordConfirmationText = pendingWakeWordConfirmationText || "hola vocero";
|
||||
showTalkUnlocker(true);
|
||||
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");
|
||||
if (!el) return;
|
||||
@@ -3692,28 +3673,28 @@ async function tryTalkFirstDirect() {
|
||||
window.addEventListener("keydown", (ev) => {
|
||||
if (startSessionMode !== "TALK") return;
|
||||
if (!_talkUnlockerShown) return;
|
||||
// Qualsiasi tasto = unlock (Enter/Space/anche Altri)
|
||||
// Any key unlocks audio, including Enter and Space.
|
||||
startTalkFromGesture();
|
||||
}, { passive: true });
|
||||
})();
|
||||
/** Avvia doStart() dentro una gesture valida (click/touch/key).
|
||||
Chiamato dal talk-unlocker o da qualsiasi altro unlocker valido. */
|
||||
/** Start doStart() within a valid click/touch/key gesture.
|
||||
* Called by the talk unlocker or another valid audio unlocker. */
|
||||
async function startTalkFromGesture() {
|
||||
hideTalkUnlocker();
|
||||
if (!(currentState === "idle" || currentState === "error")) return;
|
||||
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();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Qui SIAMO dentro la user gesture: AudioContext + getUserMedia funzionano.
|
||||
console.info("[start-session] 🎤 avvio doStart() da gesture unlocker (TALK mode)");
|
||||
// This user gesture permits AudioContext and getUserMedia to start.
|
||||
console.info("[start-session] 🎤 start doStart() from unlocker gesture (TALK mode)");
|
||||
const audioContext = createResumedAudioContext();
|
||||
setCaption("Preparing your session…", "muted");
|
||||
await doStart(audioContext);
|
||||
} catch (err) {
|
||||
console.error("[start-session] ⛔ doStart() da unlocker fallito", err);
|
||||
console.error("[start-session] ⛔ doStart() from unlocker failed", 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) ───
|
||||
Cattura QUALSIASI primo tap/click dell'utente sulla pagina, perché
|
||||
i browser bloccano getUserMedia() fuori da una user gesture. Senza
|
||||
questo layer il doStart() del setTimeout falliva e non si sentiva
|
||||
il greet. Sopra TUTTO tranne i modali <dialog> (z-index auto di dialog
|
||||
vince). */
|
||||
Capture the first tap/click anywhere on the page because browsers block
|
||||
getUserMedia() outside a user gesture. Without this layer, doStart() from
|
||||
setTimeout failed and the greeting was silent. Above all content except
|
||||
<dialog> modals, whose top layer takes priority. */
|
||||
.talk-unlocker {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -466,7 +465,7 @@ a:hover {
|
||||
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
|
||||
* - glass + brand gradient 1px border
|
||||
* - two uppercase initials (Alessandro Valenti → AV), bold white
|
||||
@@ -593,8 +592,8 @@ a:hover {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
/* ── Footer (narrow viewport): riduci tracking per non farlo andare
|
||||
* a capo in malo modo; padding compatto + line-height maggiore */
|
||||
/* ── Footer (narrow viewport): reduce tracking to avoid awkward wrapping;
|
||||
* use compact padding and a taller line height. */
|
||||
.app-footer {
|
||||
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`
|
||||
(hard rules on the bubbles themselves so nothing can hide them).
|
||||
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
|
||||
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
|
||||
@@ -2460,7 +2459,7 @@ body.cam-on .footer {
|
||||
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
|
||||
re-enable it the form melts visually into the page bg, no separator frame. */
|
||||
.chat-form {
|
||||
@@ -3474,20 +3473,20 @@ button[data-reports-sidebar-toggle] {
|
||||
flex: none;
|
||||
}
|
||||
/* 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="mortalidad"]::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="metricas"]::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="diario"]::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="productos"]::before { background: #ec4899; box-shadow: 0 0 8px 0 #ec4899aa; }
|
||||
.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="mortality"]::before { background: #ff6a75; box-shadow: 0 0 8px 0 #ff6a75aa; }
|
||||
.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="metrics"]::before { background: #22d3ee; box-shadow: 0 0 8px 0 #22d3eeaa; }
|
||||
.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="daily"]::before { background: #8b5cf6; box-shadow: 0 0 8px 0 #8b5cf6aa; }
|
||||
.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="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"]::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"]::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="finanzas"]::before { background: #22c55e; box-shadow: 0 0 8px 0 #22c55eaa; }
|
||||
.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="finance"]::before { background: #22c55e; box-shadow: 0 0 8px 0 #22c55eaa; }
|
||||
.reports-hint .reports-list li:hover {
|
||||
transform: translateY(-1px) scale(1.01);
|
||||
border-color: color-mix(in srgb, var(--brand) 55%, var(--border-strong));
|
||||
@@ -4055,10 +4054,10 @@ button[data-reports-sidebar-toggle] {
|
||||
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
|
||||
* the model is drawn un-mirrored. Markup esiste per riattivazione futura.
|
||||
* Per usarlo: togli hidden su cam-pip in index.html + scommenta listener in main.js. */
|
||||
* the model is drawn un-mirrored. Markup is retained for future reactivation.
|
||||
* To enable it, remove hidden from cam-pip in index.html and restore the main.js listener. */
|
||||
.cam-pip {
|
||||
position: fixed;
|
||||
left: 20px;
|
||||
@@ -4285,12 +4284,12 @@ html[data-theme="white"] .app-column {
|
||||
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
|
||||
(floating anchored top-right — the grey/light blue bubbles the user
|
||||
circled in red with an X on the last screenshot). Since the white
|
||||
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
|
||||
the ID and the class, zero width/height/transform so it can't leave
|
||||
layout artifacts. */
|
||||
@@ -4543,8 +4542,8 @@ html[data-theme="white"] .topbar-right {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Status card: "Listo para conversar / Informes, análisis..." ───────
|
||||
User request 2026-09-10 "nascondi box listo para conversar" — hide the
|
||||
/* ── Status card: ready to converse, reports and analysis ─────────────
|
||||
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
|
||||
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
|
||||
@@ -4820,7 +4819,7 @@ html[data-theme="white"] .white-stop-btn svg {
|
||||
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
|
||||
pills are circled in red → hide the entire quick-actions container. */
|
||||
html[data-theme="white"] .quick-actions {
|
||||
@@ -4830,8 +4829,8 @@ html[data-theme="white"] .quick-action-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Chat panel (Conversación card): white rounded card + "En línea" pill.
|
||||
CRITICAL SCROLL FIX 2026-09-10 (per "non fa lo scrolling della chatbox"):
|
||||
/* ── Chat panel: white rounded conversation card + online status pill.
|
||||
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`
|
||||
which removed every vertical constraint on the chat panel → it grew beyond
|
||||
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;
|
||||
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 {
|
||||
display: inline-flex;
|
||||
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); }
|
||||
}
|
||||
|
||||
/* ── Chat header actions row: contiene "En línea" status pill + Export button.
|
||||
Aggiunto 2026-09-23: bottone download conversación.
|
||||
Layout orizzontale, allineato a destra, auto-wrap con gap per evitare
|
||||
overflow su schermi stretti. */
|
||||
/* ── Chat header actions row: online status pill + conversation export button.
|
||||
Added 2026-09-23. Horizontal, right-aligned layout with wrapping and gaps
|
||||
to avoid overflow on narrow screens. */
|
||||
.chat-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -4953,7 +4951,7 @@ html[data-theme="white"] .chat-status-dot {
|
||||
flex-wrap: wrap;
|
||||
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 {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -4990,14 +4988,14 @@ html[data-theme="white"] .chat-status-dot {
|
||||
pointer-events: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* Feedback OK: verde temporáneo al exportar correctamente. */
|
||||
/* Successful export feedback: temporarily green. */
|
||||
.chat-export-btn.chat-export-ok {
|
||||
background: rgba(22, 163, 74, 0.18) !important;
|
||||
border-color: rgba(22, 163, 74, 0.34) !important;
|
||||
color: #22c55e !important;
|
||||
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 {
|
||||
background: rgba(245, 158, 11, 0.18) !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
|
||||
.chat-panel-inner, which has a bounded 100% height of its parent.
|
||||
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.
|
||||
──── 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)
|
||||
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
|
||||
|
||||
@@ -288,7 +288,7 @@ export class ChatView {
|
||||
* inner .{hist|bubble}-body → data-user-name | data-asst-name + data-timestamp
|
||||
*
|
||||
* The .{hist|bubble}-body::before pseudo-element in style.css uses attr()
|
||||
* to render the "Nombre HH:MM" label aligned to the bubble corner; the
|
||||
* 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
|
||||
* circular avatar's 2-letter monogram.
|
||||
*
|
||||
@@ -583,8 +583,8 @@ export class ChatView {
|
||||
|
||||
/**
|
||||
* Pretty-print the JSON conversation structure to a human-readable plain
|
||||
* text log (one entry per "HH:MM Nombre: texto"). Tool calls are rendered
|
||||
* as indented blocks ("TOOL: nombre" followed by Input/Output sections).
|
||||
* text log (one entry per "HH:MM Name: text"). Tool calls are rendered
|
||||
* as indented blocks (tool name followed by input/output sections).
|
||||
* @param {ReturnType<ChatView["_serializeConversation"]>} conv
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -648,8 +648,8 @@ export class ChatView {
|
||||
* Public entry-point wired to the #chat-export-btn in the chat header.
|
||||
* Serializes the current conversation and produces TWO downloads:
|
||||
* 1. vocero-conversacion-YYYYMMDD-HHMMSS.json (structured data)
|
||||
* 2. vocero-conversacion-YYYYMMDD-HHMMSS.txt (log legibile para humanos)
|
||||
* UI feedback: briefly swaps the download icon for a ✓ "hecho" tick then
|
||||
* 2. vocero-conversacion-YYYYMMDD-HHMMSS.txt (human-readable log)
|
||||
* 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
|
||||
* 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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -691,7 +691,7 @@ export class ChatView {
|
||||
}
|
||||
} catch (err) {
|
||||
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)
|
||||
// Banner + stile etichette condiviso per tutta la famiglia di log [S2S-WS:*].
|
||||
// Non è più abilitato solo via localStorage.s2s.debug: gli eventi di vita
|
||||
// (connect, close, errore) e l'handshake iniziale sono loggati always-on;
|
||||
// i messaggi bulk (input_audio_buffer.append) vengono solo contati per non
|
||||
// spammare la Console.
|
||||
// 🔎 WS DEBUG TRACER (always active, 2026-09-24)
|
||||
// Shared banner and label styles for all [S2S-WS:*] logs. Lifecycle events
|
||||
// (connect, close, error) and the initial handshake are always logged.
|
||||
// Count bulk input_audio_buffer.append messages to avoid console spam.
|
||||
// ---------------------------------------------------------------------------
|
||||
(function _wsDebugBanner() {
|
||||
console.info(
|
||||
"%c🟡 [S2S-WS] client caricato (ws/s2s-ws-client.js v2026-09-24 23:12) — handshake, stato, messaggi chiave e tool-call loggati; PREFETCH APIPILOT strumenti AD OGNI INIZIO SESSIONE + post-login phone (cache per-utente, cambio phone = refresh=true automatico; invalid. cache globale __s2sInvalidateApipilotCache(); anti-leak token; anti-spam audio).",
|
||||
"%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;"
|
||||
);
|
||||
})();
|
||||
@@ -79,24 +77,22 @@ function _wsLog(status, tag, msg, ...extra) {
|
||||
const badge = `%c[S2S-WS:${tag}]`;
|
||||
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 _wsMicChunkLastLogTs = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🔧 APIPILOT PREFETCH AD INIZIO SESSIONE (2026-09-24)
|
||||
// Ogni volta che s2s-ws-client avvia una connessione Realtime (metodo connect())
|
||||
// viene scaldata la cache ApiPilot lato server chiamando /api/apipilot/tools.
|
||||
// Cache TTL lato client 240 s (= TTL server 300 s meno 60 s margine).
|
||||
// Il prefetch è FIRE-AND-FORGET: non blocca MAI l'handshake WS, non fallisce
|
||||
// connect() anche se il proxy restituisce 5xx.
|
||||
// 🔧 APIPILOT PREFETCH AT SESSION START (2026-09-24)
|
||||
// Each connect() warms the server-side ApiPilot cache via /api/apipilot/tools.
|
||||
// Client cache TTL is 240 s (server TTL 300 s minus a 60 s margin).
|
||||
// Prefetch failures, including proxy 5xx responses, do not fail connect().
|
||||
// ---------------------------------------------------------------------------
|
||||
/** @type {{at:number, data:any, key:string|null}|null} */
|
||||
let _apipilotClientCache = null;
|
||||
const _APIPILOT_CLIENT_TTL_MS = 240_000; // 4 minuti
|
||||
/** Contatore sessioni totali (per log), incrementato ad ogni connect(). */
|
||||
const _APIPILOT_CLIENT_TTL_MS = 240_000; // 4 minutes
|
||||
/** Session counter for logging, incremented by each connect(). */
|
||||
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() {
|
||||
try {
|
||||
// @ts-ignore
|
||||
@@ -108,38 +104,34 @@ function _apipilotUserKey() {
|
||||
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() {
|
||||
_apipilotClientCache = null;
|
||||
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;"
|
||||
);
|
||||
}
|
||||
|
||||
// Stesso identico marker e regole del lato server Python (server.py#L555-L556).
|
||||
// Lo ripetiamo identico in JS per garantire:
|
||||
// (1) stessa classifica dedup
|
||||
// (2) stesso formato regole marker AI
|
||||
// (3) dedup by marker stringa <<<APIPILOT_TOOLS_LIST_BEGIN>>> in effectiveReasoningInstructions
|
||||
// (così non appendiamo due volte se sia server che client provano entrambi).
|
||||
// Match the Python server's marker and rules for consistent deduplication and
|
||||
// AI marker syntax. effectiveReasoningInstructions checks this marker so the
|
||||
// client and server do not append the same tools list twice.
|
||||
const APIPILOT_PASS1_LIST_MARKER_JS = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>";
|
||||
|
||||
/**
|
||||
* Restituisce il testo del blocco PASSATA-1 da iniettare nel prompt instructions LLM.
|
||||
* FORMATO IDENTICO al Python _resolve_apipilot_reasoning_context_block(tools=[...]).
|
||||
* - Inizio con APIPILOT_PASS1_LIST_MARKER_JS
|
||||
* - Fine con <<<APIPILOT_TOOLS_LIST_END>>>
|
||||
* - Lingua: SPAGNOLO (stesso di profile inject e istruzioni AI)
|
||||
* - Cap description a 500 chars
|
||||
* - Max 100 strumenti
|
||||
* @param {any[]|null|undefined} tools Lista da cache oppure null (usa cache corrente)
|
||||
* @returns {string} Blocco di testo PASSATA-1 (stringa vuota se lista vuota / nessuno strumento / nessuna cache)
|
||||
* Build the PASS-1 block injected into the LLM instructions prompt.
|
||||
* Match Python _resolve_apipilot_reasoning_context_block(tools=[...]).
|
||||
* - Start with APIPILOT_PASS1_LIST_MARKER_JS
|
||||
* - End with <<<APIPILOT_TOOLS_LIST_END>>>
|
||||
* - Instructions in English; assistant responses remain Spanish
|
||||
* - Cap descriptions at 500 characters and tools at 100
|
||||
* @param {any[]|null|undefined} tools Cached list, or null to use the current cache
|
||||
* @returns {string} PASS-1 text block, empty when no tools or cache are available
|
||||
*/
|
||||
function buildApipilotPass1PromptBlock(tools) {
|
||||
let list = Array.isArray(tools) ? tools : null;
|
||||
if (!list) {
|
||||
// Fallback: usa cache client locale corrente (se disponibile)
|
||||
// Fall back to the current client cache, if available.
|
||||
const c = _apipilotClientCache;
|
||||
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);
|
||||
const markers = (
|
||||
`${APIPILOT_PASS1_LIST_MARKER_JS}\n` +
|
||||
"### Herramientas dinámicas disponibles (ApiPilot AquaPortal)\n" +
|
||||
"### Available dynamic tools (ApiPilot AquaPortal)\n" +
|
||||
"\n" +
|
||||
"Cada herramienta tiene un campo `name` (identificador único, NO lo modifiques, " +
|
||||
"NO camelCase, NO traducir) y un campo `description` que describe el caso de uso.\n" +
|
||||
"Each tool has a `name` field (a unique identifier: DO NOT change, camelCase " +
|
||||
"or translate it) and a `description` field explaining its use case.\n" +
|
||||
"\n" +
|
||||
"REGLAS EJECUCIÓN OBLIGATORIAS:\n" +
|
||||
"1. Si la petición DEL USUARIO (transcript) coincide claramente con la `description` " +
|
||||
"de EXACTAMENTE una (1) herramienta de la lista inferior → DEBES emitir EXCLUSIVAMENTE " +
|
||||
"una sola riga come PRIMERA línea de TU RESPUESTA, con questo formato sintattico:\n" +
|
||||
' <<<APIPILOT_CALL:NAME=nome_herramienta,ARGUMENTS={"parametro1":"valor1","parametro2":"valor2"}>>>\n' +
|
||||
"2. La parte ARGUMENTS={…} DEBE ser JSON válido (virgolette doppie, virgole, senza " +
|
||||
"commenti). Includi SOLAMENTE i campi elencati in `required_parameters:` (se ci sono).\n" +
|
||||
"3. Subito DOPO la riga marker NON aggiungere altro testo (niente spiegazioni, " +
|
||||
"niente anticipazioni). Il motore eseguirà lo strumento e ti risomministrerà il " +
|
||||
"risultato in una seconda chiamata.\n" +
|
||||
"4. Se NESSUNA `description` corrisponde, NON emettere marker e rispondi normalmente.\n" +
|
||||
"5. Se più di una descrizione corrisponde, scegli la più specifica e usa il marker.\n" +
|
||||
"MANDATORY EXECUTION RULES:\n" +
|
||||
"1. If the USER REQUEST (transcript) clearly matches the `description` of " +
|
||||
"EXACTLY one (1) tool below, output ONLY one line as the FIRST line of YOUR " +
|
||||
"RESPONSE, using this exact syntax:\n" +
|
||||
' <<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1","parameter2":"value2"}>>>\n' +
|
||||
"2. ARGUMENTS={…} MUST be valid JSON (double quotes, commas, no comments). " +
|
||||
"Include ONLY fields listed in `required_parameters:`, if any.\n" +
|
||||
"3. Do NOT add text after the marker line (no explanations or previews). " +
|
||||
"The engine executes the tool and supplies its result in a second call.\n" +
|
||||
"4. If NO `description` matches, do NOT emit a marker; respond normally in Spanish.\n" +
|
||||
"5. If multiple descriptions match, choose the most specific and use the marker.\n" +
|
||||
"\n" +
|
||||
"Lista herramientas (name / description):\n" +
|
||||
"Tools list (name / description):\n" +
|
||||
`${entries.join("\n")}\n` +
|
||||
"<<<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
|
||||
* @returns {boolean}
|
||||
*/
|
||||
@@ -204,10 +195,9 @@ function instructionsAlreadyHasApipilotPass1(text) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Espone globalmente una funzione helper per:
|
||||
* - main.js: costruire effectiveReasoningInstructions appendendo la lista.
|
||||
* - altri moduli che volessero leggerla.
|
||||
* Il check dedup è fatto automaticamente dal chiamante prima di appendere.
|
||||
* Expose a global helper for main.js to append tools to
|
||||
* effectiveReasoningInstructions, and for other modules to read the list.
|
||||
* The caller checks for duplicates before appending.
|
||||
* @returns {{block: string, count: number, marker: string, alreadyPresent:(t:string)=>boolean}}
|
||||
*/
|
||||
function getApipilotPass1ContextInfo() {
|
||||
@@ -456,12 +446,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
*/
|
||||
async connect() {
|
||||
if (this._ws) throw new Error("Already connected");
|
||||
// Contatore sessioni totali: utile nel tracer per correlare gli eventi
|
||||
// di [S2S-WS:*] a un numero di sessione crescente.
|
||||
// Correlate [S2S-WS:*] events using an increasing session number.
|
||||
_wsSessionCounter += 1;
|
||||
const sessionIdShort = `#${_wsSessionCounter}`;
|
||||
this._sessionSeq = _wsSessionCounter;
|
||||
_wsLog(0, "SESSION", `${sessionIdShort} handshake iniziato — status: creating-session`);
|
||||
_wsLog(0, "SESSION", `${sessionIdShort} handshake started — status: creating-session`);
|
||||
|
||||
let connectUrl;
|
||||
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
|
||||
// click is a fresh gesture (re-arms the AudioContext on iOS).
|
||||
if (waited) {
|
||||
_wsLog(1, "QUEUE", `${sessionIdShort} tuo turno — attesa click join per confermare slot`);
|
||||
_wsLog(1, "QUEUE", `${sessionIdShort} your turn — waiting for a join click to confirm the slot`);
|
||||
await this._awaitJoin(grant);
|
||||
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,
|
||||
// E IN PARALLELO scaldiamo ApiPilot (strumenti dinamici AquaPortal).
|
||||
// Il prefetch è non bloccante: se fallisce, non fallisce connect().
|
||||
// Also warm ApiPilot (dynamic AquaPortal tools) in parallel.
|
||||
// A failed prefetch does not fail connect().
|
||||
const audioReady = this._setupAudio();
|
||||
const wsReady = this._openWebSocket(connectUrl);
|
||||
const apipilotReady = this._prefetchApipilotTools(sessionIdShort);
|
||||
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.
|
||||
* - Proxy sul server: GET api/apipilot/tools?include_unauthorized=true
|
||||
* - Il wrapper FETCH globale traccia come [FETCH:APIPILOT:TOOLS] in Console.
|
||||
* - Cache TTL locale 240 s per evitare chiamate ridondanti se l'utente
|
||||
* chiude e riapre sessioni in rapida successione (server TTL=300 s).
|
||||
* - SE window.__s2sApipilot=false → salta subito.
|
||||
* - MAI fallisce: sempre catch interno.
|
||||
* @param {string} sessionIdShort Log tag "sessione #N"
|
||||
* 🔧 Pre-fetch ApiPilot tools at every Realtime session start.
|
||||
* - Server proxy: GET api/apipilot/tools?include_unauthorized=true
|
||||
* - The global FETCH wrapper logs [FETCH:APIPILOT:TOOLS] in the console.
|
||||
* - Local TTL 240 s avoids redundant calls during rapid session restarts
|
||||
* (server TTL=300 s).
|
||||
* - Skip immediately when window.__s2sApipilot=false.
|
||||
* - Catch failures internally.
|
||||
* @param {string} sessionIdShort Log tag "session #N"
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _prefetchApipilotTools(sessionIdShort) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
const currentUser = _apipilotUserKey();
|
||||
const now = Date.now();
|
||||
const cached = _apipilotClientCache;
|
||||
// HIT cache solo se: (1) ancora fresca TTL (2) stesso utente key (non cambio phone/token tra sessioni)
|
||||
// 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) {
|
||||
const ageSec = Math.round((now - cached.at) / 1000);
|
||||
const n =
|
||||
@@ -537,10 +526,10 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
: Array.isArray(cached.data && cached.data.tools)
|
||||
? cached.data.tools.length
|
||||
: "?";
|
||||
_wsLog(1, "APIPILOT", `${sessionIdShort} HIT cache client strumenti (user=${currentUser}, age=${ageSec}s, N=${n}, TTL=${Math.round(_APIPILOT_CLIENT_TTL_MS/1000)}s)`);
|
||||
_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;
|
||||
}
|
||||
// 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>} */
|
||||
const extraHeaders = {};
|
||||
try {
|
||||
@@ -550,11 +539,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
extraHeaders["x-demo-session"] = window.__s2sDemoSessionToken;
|
||||
}
|
||||
} catch (_) { /* noop */ }
|
||||
// Usa refresh=true SE è cambiato l'utente (cache.key diversa o null) oppure la cache è stata invalidata globalmente.
|
||||
// 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 qsRefresh = needRefresh ? "&refresh=true" : "";
|
||||
const t0 = performance.now();
|
||||
_wsLog(0, "APIPILOT", `${sessionIdShort} MISS cache (user=${currentUser}, refresh=${needRefresh ? "Y" : "N"}) → GET api/apipilot/tools (proxy a S2S_APIPILOT_TOOLS_URL server)...`);
|
||||
_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}`, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
@@ -567,11 +556,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
const ok = j && j.success && j.data;
|
||||
if (!ok) {
|
||||
throw new Error(`envelope non corretto: success=${Boolean(j && j.success)}`);
|
||||
throw new Error(`invalid envelope: success=${Boolean(j && j.success)}`);
|
||||
}
|
||||
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
|
||||
const count = typeof j.data.count === "number" ? j.data.count : tools.length;
|
||||
// Aggiorna cache locale (in caso di hit la prossima sessione non chiamerà).
|
||||
// Update the local cache so a subsequent session can reuse it.
|
||||
_apipilotClientCache = { at: Date.now(), data: { count, tools: tools.slice(0, 100) }, key: currentUser };
|
||||
const names = tools
|
||||
.slice(0, 5)
|
||||
@@ -580,16 +569,16 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
_wsLog(
|
||||
1,
|
||||
"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) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
_wsLog(
|
||||
3,
|
||||
"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);
|
||||
if (response.status === 402) {
|
||||
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 });
|
||||
}
|
||||
if (response.status === 503) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (body?.state === "at_capacity") {
|
||||
_wsLog(2, "QUEUE:FULL", `/session HTTP 503 state=at_capacity (coda piena) dur=${dur} ms`);
|
||||
_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");
|
||||
}
|
||||
_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)");
|
||||
}
|
||||
if (!response.ok) {
|
||||
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}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
@@ -692,7 +681,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
_wsLog(
|
||||
2,
|
||||
"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 {
|
||||
state: "queued",
|
||||
@@ -724,7 +713,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
_wsLog(
|
||||
2,
|
||||
"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;
|
||||
|
||||
@@ -744,11 +733,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
|
||||
if (response.status === 402) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
_wsLog(3, "QUEUE:LIMIT", `poll #${pollNum} HTTP 402 — limite giornaliero (tier=${body?.tier ?? "?"})`);
|
||||
_wsLog(3, "QUEUE:LIMIT", `poll #${pollNum} HTTP 402 — daily limit (tier=${body?.tier ?? "?"})`);
|
||||
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
|
||||
}
|
||||
if (response.status === 404) {
|
||||
_wsLog(3, "QUEUE:EXPIRED", `poll #${pollNum} HTTP 404 — ticket scaduto queueId=${this._queueId.slice(0, 10)}…`);
|
||||
_wsLog(3, "QUEUE:EXPIRED", `poll #${pollNum} HTTP 404 — ticket expired queueId=${this._queueId.slice(0, 10)}…`);
|
||||
throw _codedError("Queue timed out", "queue-expired");
|
||||
}
|
||||
if (!response.ok) continue; // 502/503 — transient, retry
|
||||
@@ -763,7 +752,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
continue;
|
||||
}
|
||||
// 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 = "";
|
||||
return this._parseGrant(json);
|
||||
}
|
||||
@@ -881,9 +870,8 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
/** @param {string} connectUrl */
|
||||
_openWebSocket(connectUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Non loggiamo l'URL completo (potrebbe contenere session_token in
|
||||
// chiaro nella querystring). Mostriamo solo host + path + un hash della
|
||||
// parte segreta per audit senza leak token.
|
||||
// Mask the full URL, which may contain a plaintext session_token.
|
||||
// Show host + path and secret fingerprints for auditing without leaks.
|
||||
let maskedUrl = connectUrl;
|
||||
try {
|
||||
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;
|
||||
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 {
|
||||
// ignore
|
||||
}
|
||||
@@ -905,13 +893,13 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
const onceOpen = () => {
|
||||
ws.removeEventListener("open", onceOpen);
|
||||
ws.removeEventListener("error", onceErr);
|
||||
_wsLog(1, "CONNECT", `WebSocket APERTO su ${maskedUrl} readyState=${ws.readyState}`);
|
||||
_wsLog(1, "CONNECT", `WebSocket OPEN at ${maskedUrl} readyState=${ws.readyState}`);
|
||||
resolve();
|
||||
};
|
||||
const onceErr = (e) => {
|
||||
ws.removeEventListener("open", onceOpen);
|
||||
ws.removeEventListener("error", onceErr);
|
||||
_wsLog(3, "CONNECT", `WebSocket IMPOSSIBILE da aprire: event.type=${e?.type ?? "error"} url=${maskedUrl}`);
|
||||
_wsLog(3, "CONNECT", `WebSocket FAILED to open: event.type=${e?.type ?? "error"} url=${maskedUrl}`);
|
||||
reject(new Error(`WebSocket failed to open: ${e?.type ?? "error"}`));
|
||||
};
|
||||
ws.addEventListener("open", onceOpen);
|
||||
@@ -1037,15 +1025,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
`tools=${toolNames || "none"}`
|
||||
);
|
||||
}
|
||||
// Primo ack del server: se startup mode=TALK vogliamo il comportamento
|
||||
// IDENTICO a quando arriva la frase di wake word "Hola Vocero".
|
||||
// Quindi NON mandiamo shaped response.create con istruzioni di greet
|
||||
// hardcodate, ma invece:
|
||||
// 1. injectiamo "hola vocero" nella conversazione come se l'utente
|
||||
// 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.
|
||||
// On the first server acknowledgment, TALK startup should behave
|
||||
// exactly like hearing the "Hola Vocero" wake word:
|
||||
// 1. Inject "hola vocero" as a user turn through sendUserText().
|
||||
// 2. Call requestResponse() without custom greeting instructions,
|
||||
// matching the wake-word path in main.js.
|
||||
if (this._startWithResponse && !this._initialResponseSent) {
|
||||
this._initialResponseSent = true;
|
||||
const phrase = this._startWithResponseText.trim() || "hola vocero";
|
||||
@@ -1363,7 +1347,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
_wsLog(
|
||||
(ev.code === 1000 || ev.code === 1001) ? 1 : (ev.code ? 3 : 2),
|
||||
"CLOSE",
|
||||
`WebSocket CHIUSO code=${ev.code ?? "?"} reason=${String(ev.reason ?? "(nessuna)").slice(0, 160)} wasClean=${ev.wasClean}`
|
||||
`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 (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.
|
||||
// 3 strati di assicurazione (in ordine di priorità crescente, dedup by marker):
|
||||
// (A) main.js effectiveReasoningInstructions() ha già appeso la lista.
|
||||
// (B) NOSTRO server.py proxy realtime ri-inietta (se routing lo attraversa).
|
||||
// (C) QUI s2s-ws-client: ultimo punto di catch-all prima dell'invio al
|
||||
// canale, GARANTISCE che la lista sia SEMPRE presente in istruzioni
|
||||
// (anche se qualcuno fa updateSession() con text sovrascritto poco dopo).
|
||||
// 2026-09-25 INJECT API-PILOT DYNAMIC TOOLS (PASS 1) in session.update.
|
||||
// Three safeguards, deduplicated by marker:
|
||||
// (A) main.js effectiveReasoningInstructions() appends the list.
|
||||
// (B) The server.py Realtime proxy injects it when routing passes there.
|
||||
// (C) This client is the final safeguard before sending instructions,
|
||||
// including after a live updateSession() replaces the prompt.
|
||||
{
|
||||
const currentInstr = (typeof session.instructions === "string") ? session.instructions : "";
|
||||
if (!instructionsAlreadyHasApipilotPass1(currentInstr)) {
|
||||
@@ -1453,7 +1436,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
const session = { type: "realtime" };
|
||||
if (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)) {
|
||||
try {
|
||||
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
|
||||
@@ -1776,8 +1759,8 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
/** @param {Record<string, unknown>} event */
|
||||
_send(event) {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
||||
// Anti-spam bulk-rate limiter per eventi audio (~25 al secondo).
|
||||
// Loggiamo un resoconto ogni 250 chunk (circa 10s a 25Hz) invece di ogni singolo.
|
||||
// Throttle bulk audio event logs (~25 events per second).
|
||||
// Log periodic summaries rather than every chunk.
|
||||
if (event.type === "input_audio_buffer.append") {
|
||||
_wsMicChunkCount += 1;
|
||||
const now = performance.now();
|
||||
@@ -1807,7 +1790,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
this._ws.send(payload || JSON.stringify(event));
|
||||
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 stringifyMs = 0;
|
||||
let payload = "";
|
||||
|
||||
Reference in New Issue
Block a user