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