feat: integrate ApiPilot tools and add light-theme voice UI

This commit is contained in:
2026-10-05 08:25:29 -04:00
parent 413ba08c21
commit 4d7c7a23df
9 changed files with 6930 additions and 171 deletions
+603 -4
View File
@@ -26,7 +26,7 @@ import { Account } from "./ui/account.js?v=202609041120";
// ESATTAMENTE quale versione di main.js è caricata nel browser.
// Se NON vedi questa riga in Console = stai ancora usando il file CACHATO VECCHIO.
console.info(
"%c🟢 VOCERO main.js loaded. version=2026-09-04 11:20 (FIX HEADER TRONCO: body safe-area-inset top/right/left 10px min + topbar padding-top 16 + margin-top safe. FIX SCROLL INSUFFICIENTE: chat-history padding-bottom 96→160px; chat-panel clamp 40-96→60-160; #app padding-bottom 56→120px+safe; footer safe-area bottom; step 4 retry +250ms (5 step tot); MAX_SAFE_INTEGER invece di 9M)",
"%c🟢 VOCERO main.js loaded. version=2026-09-24 23:12 (DEBUG FETCH WRAPPER+WS SEMPRE-ATTIVO; APIPILOT: demo flag=true + PREFETCH AUTOMATICO OGNI SESSIONE WS + PREFETCH DOPO LOGIN-BY-PHONE OK (refresh=true per invalidare cache server anonima); cache client per-utente; cambio phone = invalidate automatico; export chat; fix scroll).",
"background:#1e293b;color:#86efac;font-weight:700;padding:4px 8px;border-radius:4px;"
);
console.info(
@@ -38,6 +38,218 @@ console.info(
// eslint-disable-next-line no-debugger
// debugger; // ← scommenta per fermarti QUI all'avvio se DevTools è aperto
// ---------------------------------------------------------------------------
// 🔎 FETCH WRAPPER (SEMANTIC DEBUG LOG) — attivo SEMPRE (non solo in DEBUG)
// Ogni chiamata `fetch` lato browser viene intercettata e loggata con:
// • URL risolto, metodo, dimensioni body + preview JSON
// • status HTTP (colorato verde/giallo/rosso a seconda della classe)
// • durata (performance.now), dimensione + preview risposta JSON
// Tag semantici per filtrare veloce la Console:
// [FETCH:CONFIG] [FETCH:REASONING] [FETCH:SEARCH]
// [FETCH:PROFILE] [FETCH:REPORTS] [FETCH:APIPILOT]
// [FETCH:SESSION] [FETCH:QUEUE] [FETCH:ME]
// ---------------------------------------------------------------------------
(function _installFetchDebugTracer() {
const ORIGINAL_FETCH = window.fetch.bind(window);
// Classifica endpoint in tag leggibile (stesso ordine di @app route in server.py).
/** @param {string} url */
function _tagFor(url) {
try {
const path = url.startsWith("/") || url.startsWith("./") || url.startsWith("api/")
? "/" + url.replace(/^\.?\//, "").replace(/^\//, "")
: new URL(url, window.location.href).pathname;
if (/\/api\/apipilot\/tools\/?$/i.test(path)) return "APIPILOT:TOOLS";
if (/\/api\/apipilot\/execute\/?$/i.test(path)) return "APIPILOT:EXEC";
if (/\/api\/config\/?$/i.test(path)) return "CONFIG";
if (/\/api\/reasoning\/?$/i.test(path)) return "REASONING";
if (/\/api\/search\/?$/i.test(path)) return "SEARCH";
if (/\/api\/user\/phone\/?($|\?)/i.test(path)) return "PROFILE";
if (/\/api\/user\/reports\/?($|\?)/i.test(path)) return "REPORTS";
if (/\/api\/session\/heartbeat\/?$/i.test(path)) return "SESSION:HEARTBEAT";
if (/\/api\/session\/end\/?$/i.test(path)) return "SESSION:END";
if (/\/api\/session\/?$/i.test(path)) return "SESSION:START";
if (/\/api\/queue\/end\/?$/i.test(path)) return "QUEUE:END";
if (/\/api\/queue\/[^/?]+\/?$/i.test(path)) return "QUEUE:POLL";
if (/\/api\/me\/?$/i.test(path)) return "ACCOUNT:ME";
return "FETCH";
} catch {
return "FETCH";
}
}
/** @param {RequestInfo | URL} input */
function _stringifyUrl(input) {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
try {
// Request-like
return /** @type {{url:string}} */ (/** @type {unknown} */ (input)).url || String(input);
} catch {
return String(input);
}
}
/**
* @param {RequestInfo | URL} input
* @param {RequestInit | undefined} init
*/
function _extractMethod(input, init) {
if (init && typeof init.method === "string") return init.method.toUpperCase();
try {
const r = /** @type {{method?:string}} */ (/** @type {unknown} */ (input));
if (r && typeof r.method === "string") return r.method.toUpperCase();
} catch { /* noop */ }
return "GET";
}
/**
* @param {RequestInfo | URL} input
* @param {RequestInit | undefined} init
* @returns {Promise<{ length: number; preview: string }>}
*/
async function _extractBodyPreview(input, init) {
/** @type {BodyInit | null | undefined} */
let body = init ? init.body : undefined;
if (body === undefined || body === null) {
// tenta Request
try {
const r = /** @type {{body?:BodyInit|null}} */ (/** @type {unknown} */ (input));
if (r && r.body != null) body = r.body;
} catch { body = null; }
}
if (body == null) return { length: 0, preview: "(nessun body)" };
try {
if (typeof body === "string") {
const s = body;
try {
const j = JSON.parse(s);
const compact = JSON.stringify(j);
return { length: s.length, preview: "JSON: " + compact.slice(0, 240) };
} catch {
return { length: s.length, preview: "TEXT: " + s.slice(0, 240) };
}
}
if (body instanceof FormData) {
const pairs = [];
for (const [k, v] of body.entries()) pairs.push(`${k}=${String(v).slice(0, 48)}`);
return { length: pairs.length, preview: "FORM: " + pairs.join(" & ").slice(0, 240) };
}
if (body instanceof URLSearchParams) {
const s = body.toString();
return { length: s.length, preview: "QS: " + s.slice(0, 240) };
}
if (body instanceof Blob) return { length: body.size, preview: `BLOB (${body.type || "unknown"}) size=${body.size}` };
if (ArrayBuffer.isView(body)) return { length: body.byteLength, preview: `ArrayBufferView len=${body.byteLength}` };
if (body instanceof ArrayBuffer) return { length: body.byteLength, preview: `ArrayBuffer len=${body.byteLength}` };
return { length: 0, preview: "(body non serializzabile nel log)" };
} catch (err) {
return { length: -1, preview: `(err preview body: ${err})` };
}
}
/**
* @param {Response} response
* @returns {Promise<{ length: number; preview: string }>}
*/
async function _extractResponsePreview(response) {
try {
const clone = response.clone();
const ct = (clone.headers.get("content-type") || "").toLowerCase();
const text = await clone.text();
if (/application\/json|application\/.*\+json/i.test(ct) || (text.trim().startsWith("{") || text.trim().startsWith("["))) {
try {
const j = JSON.parse(text);
const compact = JSON.stringify(j);
return { length: text.length, preview: "JSON: " + compact.slice(0, 260) };
} catch {
return { length: text.length, preview: "TEXT/HTML: " + text.slice(0, 260) };
}
}
return { length: text.length, preview: "TEXT: " + text.slice(0, 200) };
} catch (err) {
return { length: -1, preview: `(err clone response: ${err})` };
}
}
/** @param {number} status */
function _statusStyle(status) {
if (status >= 200 && status < 300) return "color:#16a34a;font-weight:700;";
if (status >= 300 && status < 400) return "color:#ca8a04;font-weight:700;";
if (status >= 400 && status < 500) return "color:#b45309;font-weight:700;";
return "color:#dc2626;font-weight:700;";
}
window.fetch = async function debugWrappedFetch(input, init) {
const t0 = performance.now();
const method = _extractMethod(input, init);
const rawUrl = _stringifyUrl(input);
const tag = _tagFor(rawUrl);
// Per richieste "heartbeat / end poll" (molto frequenti) logghiamo un
// singleton collapse per evitare spam: se `?quiet=true` o URL match → log
// a riga singola e non mostriamo preview.
const isPollish = /heartbeat|queue\/end|session\/end/i.test(tag) || (init && typeof init.keepalive === "boolean" && init.keepalive);
const reqPreview = isPollish ? null : await _extractBodyPreview(input, init);
if (!isPollish) {
const groupTitle = `%c→ [FETCH:${tag}] ${method} ${rawUrl}`;
const groupStyle = "color:#2563eb;font-weight:700;";
console.groupCollapsed(groupTitle, groupStyle);
console.log(`method=${method} url=${rawUrl}`);
console.log(`bodyLen=${reqPreview?.length ?? 0} preview=${reqPreview?.preview ?? "(poll/skip)"}`);
console.groupEnd();
} else {
console.debug(
`%c→ [FETCH:${tag}] ${method} ${rawUrl}`,
"color:#64748b;font-weight:600;"
);
}
/** @type {Response} */
let response;
try {
// @ts-ignore
response = await ORIGINAL_FETCH(input, init);
} catch (err) {
const dur = (performance.now() - t0).toFixed(1);
console.groupCollapsed(
`%c✗ [FETCH:${tag}] NETWORK_ERROR ${method} ${rawUrl} (${dur} ms)`,
"background:#fee2e2;color:#b91c1c;font-weight:800;padding:2px 6px;border-radius:4px;"
);
console.error(err);
console.groupEnd();
throw err;
}
const dur = (performance.now() - t0).toFixed(1);
const resPreview = isPollish ? null : await _extractResponsePreview(response);
const statusStr = `${response.status} ${response.statusText}`.trim();
if (!isPollish) {
console.groupCollapsed(
`%c← [FETCH:${tag}] %c${statusStr} %c${method} ${rawUrl} (${dur} ms)`,
"color:#0f172a;font-weight:800;",
_statusStyle(response.status),
"color:#475569;font-weight:600;"
);
console.log(`HTTP status=${response.status} statusText=${response.statusText || "-"} durationMs=${dur}`);
const finalHeaders = [];
response.headers.forEach((v, k) => finalHeaders.push(`${k}: ${String(v).slice(0, 120)}`));
console.log(`Response headers (${finalHeaders.length}):`, finalHeaders);
if (resPreview) {
console.log(`responseLen=${resPreview.length} preview=${resPreview.preview}`);
}
console.groupEnd();
} else {
const sClass = (() => {
if (response.ok) return "color:#15803d;";
if (response.status >= 500) return "color:#dc2626;";
return "color:#ca8a04;";
})();
console.debug(
`%c← [FETCH:${tag}] %c${response.status} %c${method} ${rawUrl} (${dur} ms)`,
"color:#475569;font-weight:600;",
sClass,
"color:#64748b;"
);
}
return response;
};
console.info(
"%c🔎 [FETCH-DEBUG] tracer installato. Ogni fetch è loggata con tag, method, url, status, durata e preview body.",
"background:#ecfeff;color:#0369a1;font-weight:700;padding:2px 6px;border-radius:4px;"
);
})();
// ---------------------------------------------------------------------------
// 🛡️ GATE .topbar-right (about / tools / rag / chat / settings buttons,
// vedi index.html L36-L56) — 3-layer safety:
@@ -630,6 +842,11 @@ let debugPrompt = false;
// waiting for the user to say anything). Mirrors the server env
// S2S_START_SESSION_MODE. Empty / anything else = manual (tap the start).
let startSessionMode = "";
// Theme: mirrors S2S_THEME env on the server. Supported values: "" (default
// dark / purple theme), "white" (the light "Vocero app" mobile theme in the
// screenshot: white bg, blue mic circle, chat bubbles with avatars, blue send
// button). "dark" is an alias for "" (native dark theme — no overrides applied).
let s2sTheme = "";
// Custom RAG context entered by the user via the header "RAG" button dialog.
// Treated exactly like voceroUserContextText / injectProfile context:
@@ -799,6 +1016,8 @@ function initialsForName(rawName) {
const last = parts[parts.length - 1][0] ?? "";
return (first + last).toUpperCase();
}
window._s2sExtractName = extractVoceroUserName;
window._s2sInitialsFor = initialsForName;
/* ─── User profile dialog (opened by clicking avatar in the header) ─── */
/**
@@ -1293,8 +1512,53 @@ function effectiveReasoningInstructions() {
instructions = instructions ? `${instructions} ${ragCtx}` : ragCtx;
ragAppended = true;
}
const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${(base || "").length}`;
// ───────────────────────────────────────────────────────────────────────────
// 2026-09-25 INJECT API-PILOT HERAMIENTAS DINÁMICAS (PASADA 1).
// Formato IDENTICO a server.py Python: marker <<<APIPILOT_TOOLS_LIST_BEGIN>>>
// / ...lista name+description+requires_auth+required_parameters... / <<<END>>>.
// Il client ottiene la lista dal proxy GET api/apipilot/tools (cache 240 s
// per-utente in s2s-ws-client.js _apipilotClientCache) e la appende qui alle
// instructions STESSE (luogo #1 di massima priorità per l'LLM).
// Dedup: se le instructions già contengono il marker (già appese in una
// esecuzione precedente, oppure server Python ha già patchato il payload)
// → NON appendere nuovamente (no doppioni, no blob gigante).
let apipilotBlock = "";
let apipilotCount = 0;
let apipilotAppended = false;
try {
// @ts-ignore: esposto globalmente da s2s-ws-client.js (caricato prima di
// main.js in index.html). Valuta sicuro: typeof + early-skip.
const getter = typeof window !== "undefined" && window.__s2sGetApipilotPass1ContextInfo;
if (typeof getter === "function") {
const info = getter();
if (info && typeof info.block === "string" && typeof info.marker === "string") {
apipilotBlock = info.block;
apipilotCount = typeof info.count === "number" ? info.count : (apipilotBlock ? 1 : 0);
const already = typeof info.alreadyPresent === "function"
? Boolean(info.alreadyPresent(instructions))
: (typeof instructions === "string" && instructions.includes(info.marker));
if (apipilotBlock.trim() && !already) {
instructions = instructions ? `${instructions}\n\n${apipilotBlock}` : apipilotBlock;
apipilotAppended = true;
}
}
}
} catch (_) {
/* never-fail: non bloccare il resto dell'istruzione prompt. */
}
const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${apipilotAppended ? "apipilotY" : "apipilotN"}:${(base || "").length}`;
_logOnce(key, () => {
if (apipilotAppended) {
console.info(
`[INJECT-APIPILOT] ✅ BROWSER effectiveReasoningInstructions APPENDED ApiPilot tools list PASS-1 inside LLM prompt. apipilotBlockLen=%d Ntools=%d baseLen=%d profileAppended=%s ragAppended=%s -> finalLen=%d`,
apipilotBlock.length, apipilotCount, (base || "").length, profileAppended, ragAppended, instructions.length,
);
} else if (apipilotBlock) {
console.info(
`[INJECT-APIPILOT] ⏭️ BROWSER effectiveReasoningInstructions: ApiPilot tools list PASS-1 already present (dedup marker BEGIN). No append needed. blockLen=%d Ntools=%d`,
apipilotBlock.length, apipilotCount,
);
}
if (ragAppended) {
console.info(
`[CUSTOM-RAG] ✅ effectiveReasoningInstructions APPENDED custom RAG context inside LLM prompt. ragCtxLen=%d baseLen=%d profileAppended=%s -> finalLen=%d`,
@@ -1321,13 +1585,14 @@ function effectiveReasoningInstructions() {
}
});
if (debugPrompt) {
const key2 = `debugEffInst:${instructions.length}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}`;
const key2 = `debugEffInst:${instructions.length}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${apipilotAppended ? "apipilotY" : "apipilotN"}:${apipilotBlock.length}`;
_logOnce(key2, () => {
console.info(
`[DEBUG-PROMPT][BROWSER effectiveReasoningInstructions] FULL TEXT len=%d profile=%s rag=%s:\n%s\n`,
`[DEBUG-PROMPT][BROWSER effectiveReasoningInstructions] FULL TEXT len=%d profile=%s rag=%s apipilot=%s:\n%s\n`,
instructions.length,
ctx ? `${ctx.length}c` : "none",
ragCtx ? `${ragCtx.length}c` : "none",
apipilotBlock ? `${apipilotCount}tools/${apipilotBlock.length}c` : "none",
instructions,
);
if (ctx) {
@@ -2196,6 +2461,53 @@ async function fetchVoceroUserProfile() {
Object.keys(voceroUserProfile).join(","),
extractVoceroUserName(voceroUserProfile) || "(none)",
);
// ── 🚀 PREFETCH ApiPilot IMMEDIATAMENTE DOPO LOGIN-BY-PHONE RIUSCITO
// Forza refresh=true per bypassare la cache SERVER lato proxy S2S (TTL 300s)
// perché adesso abbiamo un profilo utente autenticato (non più anonimo).
// In questo modo vediamo la riga [apipilot] list_tools.start user=(non-anon)
// nel backend AquaPortal OGNI VOLTA che c'è un login phone riuscito.
try {
if (window.__s2sApipilot) {
const t0 = performance.now();
console.info(
"%c🔧 [APIPILOT-CLIENT] post-login phone → FORZO refresh api/apipilot/tools (bypass cache S2S 300s perché utente appena autenticato)",
"background:#ede9fe;color:#5b21b6;font-weight:700;padding:2px 6px;border-radius:4px;"
);
const demoToken = typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
const headers = {};
if (demoToken) headers["x-demo-session"] = demoToken;
fetch(`api/apipilot/tools?include_unauthorized=true&refresh=true&phone=${encodeURIComponent(voceroUserPhoneNumber.slice(0, 8))}`, {
method: "GET",
cache: "no-store",
headers,
}).then(async (r) => {
const dt = Math.round(performance.now() - t0);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const j = await r.json().catch(() => ({}));
const ok = j && j.success && j.data;
if (!ok) throw new Error(`envelope non corretto success=${Boolean(j && j.success)}`);
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
const count = typeof j.data.count === "number" ? j.data.count : tools.length;
const names = tools.slice(0, 6).map((t) => (typeof t.name === "string" ? t.name : "?")).join(", ");
// Invalida cache client WS per ogni sessione futura (in modo che
// al prossimo connect() venga fatto un refresh per ri-confermare).
try {
// @ts-ignore
if (typeof window.__s2sInvalidateApipilotCache === "function") window.__s2sInvalidateApipilotCache();
} catch (_) { /* noop */ }
console.info(
`%c🔧 [APIPILOT-CLIENT] post-login OK (dt=${dt}ms): N=${count} strumenti [primi 6: ${names || "(nessuno)"}]`,
"background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;"
);
}).catch((err) => {
const dt = Math.round(performance.now() - t0);
console.warn(
`%c🔧 [APIPILOT-CLIENT] post-login FAIL (dt=${dt}ms): ${String(err instanceof Error ? err.message : err)} → controlla [APIPILOT-DISCOVERY] nel log server S2S`,
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;"
);
});
}
} catch (_) { /* fire-and-forget silently swallow */ }
} else {
console.info(
`[INJECT-PROFILE] ⏭️ BROWSER fetchVoceroUserProfile HTTP 200 but empty/non-object JSON. profileIsNull=%s keys=none`,
@@ -2306,10 +2618,49 @@ async function fetchConfig() {
pinnedPrompt = (json.primaryPrompt || "").trim();
injectProfile = !!json.injectProfile;
debugPrompt = !!json.debugPrompt;
// Feature flag: ApiPilot AquaPortal bridge attivo sul server.
// Se true = S2S_APIPILOT_TOOLS_URL è valorizzato lato backend e
// gli endpoint proxy /api/apipilot/tools e /api/apipilot/execute
// sono disponibili per il frontend.
const apipilotEnabled = !!json.apipilot;
if (typeof window.__s2sApipilot === "undefined") {
try { Object.defineProperty(window, "__s2sApipilot", { value: false, writable: true, configurable: true }); }
catch (_) { /* noop */ }
}
window.__s2sApipilot = apipilotEnabled;
// Bootstrap-start mode: empty on generic deploys (tap to start).
startSessionMode = typeof json.startSessionMode === "string"
? json.startSessionMode.trim().toUpperCase()
: "";
// Theme: "" / "dark" → native dark theme (purple orb). "white" → light
// Vocero app mobile theme (white bg, blue mic, chat bubbles w/ avatars).
const rawTheme = typeof json.theme === "string" ? json.theme.trim().toLowerCase() : "";
const resolvedTheme = rawTheme === "white" ? "white" : "dark";
s2sTheme = resolvedTheme === "white" ? "white" : "";
const root = document.documentElement;
// NOTE: always set the attribute explicitly (never remove it) so the
// anti-FOUC guard `html:not([data-theme]) [data-theme-only]` expires
// as soon as the config round-trip completes (before it, both white &
// dark parallel UI blocks are kept invisible → no flash of purple orb
// fading into blue mic on S2S_THEME=white deploys).
root.setAttribute("data-theme", resolvedTheme);
// ── Anti-FOUC veil LIFT (user fix 2026-09-10 purple flash on refresh)
// The inline <style id="s2s-anti-fouc-veil"> in <head> keeps <body>
// completely unpainted until BOTH:
// a) data-theme attribute exists on <html> ← line ↑ above
// b) class="theme-ready" exists on <html> ← line ↓ below
// Two-signal guard avoids 1-frame paint leaks where some browsers
// (WebKit especially) commit the attribute mutation a few microtasks
// before the style cascade re-evaluates against it. Also clear the
// 2500ms fail-safe timeout so it doesn't fire 2.4s later for no
// reason on a perfectly healthy config round-trip.
try {
root.classList.add("theme-ready");
if (typeof window.__s2sVeilLiftTimeout === "number") {
window.clearTimeout(window.__s2sVeilLiftTimeout);
window.__s2sVeilLiftTimeout = null;
}
} catch (_) { /* classList.add on unknown element — defensive only */ }
// #region debug-point B:config-loaded
reportDebug("B", "main.js:fetchConfig", "loaded config from api/config", {
reasoningType,
@@ -2317,8 +2668,46 @@ async function fetchConfig() {
injectProfile,
debugPrompt,
startSessionMode,
theme: s2sTheme || "dark",
apipilotEnabled,
});
// #endregion
// ── Demo call ApiPilot (fire-and-forget, solo se flag=true sul server)
// Serve ESCLUSIVAMENTE per attivare il tracer fetch lato browser e
// mostrare in console la riga [FETCH:APIPILOT:TOOLS] con preview
// status, durata e lista strumenti. Proxy server-side verso
// S2S_APIPILOT_TOOLS_URL/tools.
if (apipilotEnabled) {
try {
const demoUrl = "api/apipilot/tools?include_unauthorized=true";
console.info(
"%c🔧 [APIPILOT-CLIENT] flag=true → DEMO CALL %s (tracer sempre-attivo logga [FETCH:APIPILOT:TOOLS] sotto)",
"background:#e0e7ff;color:#3730a3;font-weight:700;padding:2px 6px;border-radius:4px;",
demoUrl
);
fetch(demoUrl, { method: "GET", cache: "no-store" })
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const j = await r.json().catch(() => ({}));
const tools = (j && j.data && Array.isArray(j.data.tools)) ? j.data.tools : [];
const count = (j && j.data && typeof j.data.count === "number") ? j.data.count : tools.length;
const names = tools.slice(0, 5).map((t) => (typeof t.name === "string" ? t.name : "?")).join(", ");
console.info(
"%c🔧 [APIPILOT-CLIENT] demo OK: %s strumenti [primi 5: %s] usa POST api/apipilot/execute {name,arguments} per eseguire",
"background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;",
count,
names || "(nessuno)"
);
})
.catch((err) => {
console.warn(
"%c🔧 [APIPILOT-CLIENT] demo FAIL: %s. Lato server logga [APIPILOT-DISCOVERY] per diagnosticare",
"background:#fffbeb;color:#92400e;font-weight:700;padding:2px 6px;border-radius:4px;",
String(err)
);
});
} catch (_) { /* fire-and-forget: silently swallow */ }
}
// The conversation-time limiter rides on the LB being present.
limiterOn = lbMode;
}
@@ -2326,11 +2715,221 @@ async function fetchConfig() {
} catch {
// Config endpoint unreachable (e.g. static hosting): keep direct entry.
}
// ── Anti-FOUC veil LIFT (catch-all): also lift when fetchConfig took a
// non-OK / exception path so the user is never left on an invisible
// <body> if the api/config response was malformed or 500'd. Only runs
// the first time (theme-ready is idempotent). Also clears the fail-safe
// timeout (if still set) the same way the happy-path did above.
try {
const root = document.documentElement;
if (!root.hasAttribute("data-theme")) root.setAttribute("data-theme", "dark");
if (!root.classList.contains("theme-ready")) root.classList.add("theme-ready");
if (typeof window.__s2sVeilLiftTimeout === "number") {
window.clearTimeout(window.__s2sVeilLiftTimeout);
window.__s2sVeilLiftTimeout = null;
}
} catch (_) { /* defensive no-op */ }
if (DEBUG) console.debug(`[ui] config: allowDirect=${allowDirect} lbMode=${lbMode}`);
// Login chip + remaining-budget (no-op / hidden when the limiter is off).
void account.refresh();
syncToolsUi();
syncConnectionUi();
// Apply theme runtime: show/hide theme-only elements, wire up white-mic
// button forwarders, unhide chat-form in white theme, etc. Must run after
// `s2sTheme` is set and the data-theme attr has been applied to html.
_applyThemeRuntime();
}
/**
* Spanish (Castilian) label map for the "Escuchando..." status caption below
* the white-theme blue mic circle. Mirrors every client state.
*/
const WHITE_STATUS_LABELS = Object.freeze({
idle: "Toca para empezar",
connecting: "Conectando…",
queued: "En cola…",
"your-turn":"Escuchando…",
processing: "Pensando…",
"ai-speaking":"Hablando…",
error: "Algo salió mal",
stopped: "Detenido",
});
let _themeRuntimeInitialized = false;
/**
* Apply runtime wiring for the current theme:
* 1. Flip [hidden] on every [data-theme-only="white" / "dark"] element in
* the document so the correct parallel UI is shown.
* 2. Remove the hardcoded `hidden + display:none !important` on #chat-form
* in white theme (we want the text input + send button visible, just
* like the screenshot input bar).
* 3. Click forwarders: #white-mic-circle → real #main-circle,
* #white-stop-btn → real #stop-btn.
* 4. MutationObserver on #main-circle `.className` to mirror every
* `state-*` class live to #white-mic-circle so it swaps its visuals
* (mic icon / stop icon / spinner) exactly in sync with the orb.
* 5. Wrap `setCaption()` so the Spanish label `#white-mic-caption` gets
* updated at the same time as the default orb caption.
* 6. Quick action button listeners: populate chat-input / or send via
* client.sendUserText() when a session is live.
* Called by fetchConfig() AFTER s2sTheme and <html data-theme> are set.
* Re-runs safely on every config fetch (guard `_themeRuntimeInitialized` for
* the one-shot observer / listeners).
*/
function _applyThemeRuntime() {
const isWhite = s2sTheme === "white";
// 1. Toggle visibility of every element tagged data-theme-only.
// Elements with `hidden` and `[data-theme-only="white"]` should be
// UN-hidden when theme === white; `hidden + dark-only` → UN-hidden when
// theme !== white (native dark). Any element not tagged is unchanged.
const onlyWhite = document.querySelectorAll("[data-theme-only=\"white\"]");
onlyWhite.forEach(el => {
if (isWhite) {
el.removeAttribute("hidden");
if (el.style && el.style.display === "none") el.style.display = "";
} else {
el.setAttribute("hidden", "");
}
});
const onlyDark = document.querySelectorAll("[data-theme-only=\"dark\"]");
onlyDark.forEach(el => {
if (isWhite) {
el.setAttribute("hidden", "");
} else {
el.removeAttribute("hidden");
if (el.style && el.style.display === "none") el.style.display = "";
}
});
// 2. Chat form input: in WHITE theme, we need the text input visible to
// match the screenshot. The HTML ships with `hidden` + inline
// `display:none !important` because the original dark theme hid it
// (per user request 2026-09-04). Clear both here, restore when leaving.
const chatForm = document.getElementById("chat-form");
if (chatForm) {
if (isWhite) {
chatForm.removeAttribute("hidden");
chatForm.style.removeProperty("display");
} else if (!chatForm.hasAttribute("hidden")) {
// Reapply original dark-theme defaults (preserve existing user edit).
chatForm.setAttribute("hidden", "");
chatForm.style.setProperty("display", "none", "important");
}
}
// One-shot wiring (never re-add listeners/observer on subsequent calls).
if (_themeRuntimeInitialized) return;
_themeRuntimeInitialized = true;
// 3a. Click forwarder: white mic circle → the real start/stop orb handler.
const whiteMic = document.getElementById("white-mic-circle");
const realCircle = document.getElementById("main-circle");
if (whiteMic && realCircle) {
whiteMic.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
realCircle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
}
// 3b. Click forwarder: white "Detener" pill → hidden real stop side-btn.
const whiteStop = document.getElementById("white-stop-btn");
const realStop = document.getElementById("stop-btn");
if (whiteStop && realStop) {
whiteStop.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
realStop.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
}
// 4. Live sync of #main-circle className → #white-mic-circle className
// (states: .state-idle, .state-connecting, .state-your-turn, .state-processing,
// .state-ai-speaking, .state-error, .state-queued). White-mic styles
// use these same state-* classes (CSS prefixes with .white-mic-circle.state-*)
// so we simply shadow every class on the orb button to the new button.
if (whiteMic && realCircle && typeof MutationObserver !== "undefined") {
const syncStateClasses = () => {
// Keep the base class + data-theme-only marker but clear/re-add the rest.
const tokens = Array.from(realCircle.classList);
for (const cl of Array.from(whiteMic.classList)) {
if (cl.startsWith("state-")) whiteMic.classList.remove(cl);
}
for (const cl of tokens) {
if (cl.startsWith("state-")) whiteMic.classList.add(cl);
}
// Also show/hide the white stop pill in sync with the real stop-btn
// (which is shown when connected).
if (whiteStop) {
const visible = !realStop.hasAttribute("aria-hidden") || realStop.getAttribute("aria-hidden") === "false";
if (visible) {
whiteStop.style.removeProperty("display");
whiteStop.removeAttribute("hidden");
} else {
whiteStop.setAttribute("hidden", "");
}
}
};
syncStateClasses();
const obs = new MutationObserver(() => syncStateClasses());
obs.observe(realCircle, { attributes: true, attributeFilter: ["class", "aria-hidden"] });
if (realStop) obs.observe(realStop, { attributes: true, attributeFilter: ["aria-hidden", "class"] });
// 5. Set the initial Spanish caption for theme=white + wrap setCaption()
// so any future setCaption() call also updates the Spanish label.
const capWhite = document.getElementById("white-mic-caption");
if (capWhite) {
capWhite.textContent = WHITE_STATUS_LABELS[currentState] || WHITE_STATUS_LABELS.idle;
const origSetCaption = setCaption;
// Re-assign the global (window / module scope var holds function ref) so
// each caller of setCaption(text, kind) now also drives the Spanish label.
// We intentionally DON'T replace the signature — old code keeps working.
(function (prev) {
window.setCaption = function (text, kind) {
prev(text, kind);
// When in white theme we prefer the Spanish state-based label from
// WHITE_STATUS_LABELS whenever possible, falling back to raw text.
if (s2sTheme === "white" && capWhite) {
const fromState = WHITE_STATUS_LABELS[currentState];
capWhite.textContent = fromState || text || WHITE_STATUS_LABELS.idle;
const muted = kind === "muted";
capWhite.classList.toggle("muted", !!muted);
capWhite.classList.toggle("error", kind === "error");
}
};
})(origSetCaption);
}
}
// 6. Quick action buttons: clicking "Resumen" / "Consultar datos" /
// "Crear respuesta" populates the text input or — if a realtime
// session is live — sends it as user text straight away.
document.querySelectorAll(".quick-action-btn").forEach(btn => {
btn.addEventListener("click", () => {
const action = btn.getAttribute("data-action") || "";
const prompts = {
"resumen": "haz un resumen ejecutivo del último informe programado",
"consultar-datos": "consulto los datos más recientes del dashboard, dame las cifras clave",
"crear-respuesta": "redacta un borrador de respuesta profesional con tono ejecutivo",
};
const text = prompts[action] || "";
if (!text) return;
if (client && typeof client.sendUserText === "function") {
// Live session: send directly as if the user typed + pressed enter.
client.sendUserText(text);
} else {
// No session: prefill the text input so they can send manually later.
const inp = document.getElementById("chat-input");
if (inp) {
inp.value = text;
inp.dispatchEvent(new Event("input", { bubbles: true }));
inp.focus({ preventScroll: false });
}
}
});
});
}
/**