feat: integrate ApiPilot tools and add light-theme voice UI
This commit is contained in:
+467
-18
@@ -53,6 +53,187 @@ function reportWsDebug(hypothesisId, location, msg, data = {}) {
|
||||
void data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🔎 WS DEBUG TRACER (sempre attivo, 2026-09-24)
|
||||
// Banner + stile etichette condiviso per tutta la famiglia di log [S2S-WS:*].
|
||||
// Non è più abilitato solo via localStorage.s2s.debug: gli eventi di vita
|
||||
// (connect, close, errore) e l'handshake iniziale sono loggati always-on;
|
||||
// i messaggi bulk (input_audio_buffer.append) vengono solo contati per non
|
||||
// spammare la Console.
|
||||
// ---------------------------------------------------------------------------
|
||||
(function _wsDebugBanner() {
|
||||
console.info(
|
||||
"%c🟡 [S2S-WS] client caricato (ws/s2s-ws-client.js v2026-09-24 23:12) — handshake, stato, messaggi chiave e tool-call loggati; PREFETCH APIPILOT strumenti AD OGNI INIZIO SESSIONE + post-login phone (cache per-utente, cambio phone = refresh=true automatico; invalid. cache globale __s2sInvalidateApipilotCache(); anti-leak token; anti-spam audio).",
|
||||
"background:#fef3c7;color:#92400e;font-weight:700;padding:4px 8px;border-radius:4px;"
|
||||
);
|
||||
})();
|
||||
/** @param {number} status 0=connect,1=ok,2=warn,3=err @param {string} tag @param {string} msg @param {unknown[]} extra */
|
||||
function _wsLog(status, tag, msg, ...extra) {
|
||||
const palette = [
|
||||
["color:#1d4ed8;font-weight:700;", "background:#dbeafe;color:#1e3a8a;font-weight:800;padding:1px 6px;border-radius:3px;"], // CONNECT info
|
||||
["color:#047857;font-weight:700;", "background:#d1fae5;color:#065f46;font-weight:800;padding:1px 6px;border-radius:3px;"], // OK
|
||||
["color:#b45309;font-weight:700;", "background:#fef3c7;color:#92400e;font-weight:800;padding:1px 6px;border-radius:3px;"], // WARN
|
||||
["color:#b91c1c;font-weight:700;", "background:#fee2e2;color:#7f1d1d;font-weight:800;padding:1px 6px;border-radius:3px;"], // ERR
|
||||
];
|
||||
const [labelFg, badgeBg] = palette[Math.max(0, Math.min(3, status))];
|
||||
const badge = `%c[S2S-WS:${tag}]`;
|
||||
console.log(`${badge}%c ${msg}`, badgeBg, labelFg, ...extra);
|
||||
}
|
||||
/** Contatore per audio chunk bulk, per evitare spam. */
|
||||
let _wsMicChunkCount = 0;
|
||||
let _wsMicChunkLastLogTs = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🔧 APIPILOT PREFETCH AD INIZIO SESSIONE (2026-09-24)
|
||||
// Ogni volta che s2s-ws-client avvia una connessione Realtime (metodo connect())
|
||||
// viene scaldata la cache ApiPilot lato server chiamando /api/apipilot/tools.
|
||||
// Cache TTL lato client 240 s (= TTL server 300 s meno 60 s margine).
|
||||
// Il prefetch è FIRE-AND-FORGET: non blocca MAI l'handshake WS, non fallisce
|
||||
// connect() anche se il proxy restituisce 5xx.
|
||||
// ---------------------------------------------------------------------------
|
||||
/** @type {{at:number, data:any, key:string|null}|null} */
|
||||
let _apipilotClientCache = null;
|
||||
const _APIPILOT_CLIENT_TTL_MS = 240_000; // 4 minuti
|
||||
/** Contatore sessioni totali (per log), incrementato ad ogni connect(). */
|
||||
let _wsSessionCounter = 0;
|
||||
/** Chiave cache per utente: ultimi 8 cifre phoneNumber o fingerprint token. */
|
||||
function _apipilotUserKey() {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const phone = (typeof window !== "undefined" && window.voceroUserPhoneNumber) || "";
|
||||
if (phone && phone.length >= 8) return `P:${phone.slice(-8)}`;
|
||||
// @ts-ignore
|
||||
const tk = typeof window !== "undefined" && typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
|
||||
if (tk && tk.length >= 6) return `T:${tk.slice(-6)}`;
|
||||
return "anon";
|
||||
} catch (_) { return "anon"; }
|
||||
}
|
||||
/** Invalida la cache ApiPilot lato client. Esporta globalmente per main.js (dopo login phone). */
|
||||
function _invalidateApipilotClientCache() {
|
||||
_apipilotClientCache = null;
|
||||
console.info(
|
||||
"%c🔧 [S2S-WS:APIPILOT] Cache client invalidata (nuovo login utente o refresh=true forzato)",
|
||||
"background:#fde68a;color:#92400e;font-weight:700;padding:1px 6px;border-radius:3px;"
|
||||
);
|
||||
}
|
||||
|
||||
// Stesso identico marker e regole del lato server Python (server.py#L555-L556).
|
||||
// Lo ripetiamo identico in JS per garantire:
|
||||
// (1) stessa classifica dedup
|
||||
// (2) stesso formato regole marker AI
|
||||
// (3) dedup by marker stringa <<<APIPILOT_TOOLS_LIST_BEGIN>>> in effectiveReasoningInstructions
|
||||
// (così non appendiamo due volte se sia server che client provano entrambi).
|
||||
const APIPILOT_PASS1_LIST_MARKER_JS = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>";
|
||||
|
||||
/**
|
||||
* Restituisce il testo del blocco PASSATA-1 da iniettare nel prompt instructions LLM.
|
||||
* FORMATO IDENTICO al Python _resolve_apipilot_reasoning_context_block(tools=[...]).
|
||||
* - Inizio con APIPILOT_PASS1_LIST_MARKER_JS
|
||||
* - Fine con <<<APIPILOT_TOOLS_LIST_END>>>
|
||||
* - Lingua: SPAGNOLO (stesso di profile inject e istruzioni AI)
|
||||
* - Cap description a 500 chars
|
||||
* - Max 100 strumenti
|
||||
* @param {any[]|null|undefined} tools Lista da cache oppure null (usa cache corrente)
|
||||
* @returns {string} Blocco di testo PASSATA-1 (stringa vuota se lista vuota / nessuno strumento / nessuna cache)
|
||||
*/
|
||||
function buildApipilotPass1PromptBlock(tools) {
|
||||
let list = Array.isArray(tools) ? tools : null;
|
||||
if (!list) {
|
||||
// Fallback: usa cache client locale corrente (se disponibile)
|
||||
const c = _apipilotClientCache;
|
||||
if (c && c.data && Array.isArray(c.data.tools)) list = c.data.tools;
|
||||
}
|
||||
if (!Array.isArray(list) || list.length === 0) return "";
|
||||
const capped = list.slice(0, 100);
|
||||
/** @type {string[]} */
|
||||
const entries = [];
|
||||
for (const t of capped) {
|
||||
if (!t || typeof t !== "object") continue;
|
||||
const name = typeof t.name === "string" ? t.name : "";
|
||||
if (!name) continue;
|
||||
const desc = typeof t.description === "string" ? t.description : "";
|
||||
const params = (t.parameters && typeof t.parameters === "object") ? t.parameters : null;
|
||||
const requiresAuth = Boolean(t.requires_auth);
|
||||
const req = (params && Array.isArray(params.required))
|
||||
? params.required.filter((x) => typeof x === "string")
|
||||
: [];
|
||||
let descCut = desc.slice(0, 500);
|
||||
if (desc.length > 500) descCut += "…";
|
||||
let snippet = `- name: ${name}\n requires_auth: ${requiresAuth ? "true" : "false"}`;
|
||||
if (req.length) snippet += `\n required_parameters: [${req.slice(0, 40).join(", ")}]`;
|
||||
if (descCut) snippet += `\n description: ${descCut}`;
|
||||
entries.push(snippet);
|
||||
}
|
||||
if (!entries.length) return "";
|
||||
console.warn("APIPILOT_PASS1_LIST_MARKER_JS", APIPILOT_PASS1_LIST_MARKER_JS);
|
||||
const markers = (
|
||||
`${APIPILOT_PASS1_LIST_MARKER_JS}\n` +
|
||||
"### Herramientas dinámicas disponibles (ApiPilot AquaPortal)\n" +
|
||||
"\n" +
|
||||
"Cada herramienta tiene un campo `name` (identificador único, NO lo modifiques, " +
|
||||
"NO camelCase, NO traducir) y un campo `description` que describe el caso de uso.\n" +
|
||||
"\n" +
|
||||
"REGLAS EJECUCIÓN OBLIGATORIAS:\n" +
|
||||
"1. Si la petición DEL USUARIO (transcript) coincide claramente con la `description` " +
|
||||
"de EXACTAMENTE una (1) herramienta de la lista inferior → DEBES emitir EXCLUSIVAMENTE " +
|
||||
"una sola riga come PRIMERA línea de TU RESPUESTA, con questo formato sintattico:\n" +
|
||||
' <<<APIPILOT_CALL:NAME=nome_herramienta,ARGUMENTS={"parametro1":"valor1","parametro2":"valor2"}>>>\n' +
|
||||
"2. La parte ARGUMENTS={…} DEBE ser JSON válido (virgolette doppie, virgole, senza " +
|
||||
"commenti). Includi SOLAMENTE i campi elencati in `required_parameters:` (se ci sono).\n" +
|
||||
"3. Subito DOPO la riga marker NON aggiungere altro testo (niente spiegazioni, " +
|
||||
"niente anticipazioni). Il motore eseguirà lo strumento e ti risomministrerà il " +
|
||||
"risultato in una seconda chiamata.\n" +
|
||||
"4. Se NESSUNA `description` corrisponde, NON emettere marker e rispondi normalmente.\n" +
|
||||
"5. Se più di una descrizione corrisponde, scegli la più specifica e usa il marker.\n" +
|
||||
"\n" +
|
||||
"Lista herramientas (name / description):\n" +
|
||||
`${entries.join("\n")}\n` +
|
||||
"<<<APIPILOT_TOOLS_LIST_END>>>"
|
||||
);
|
||||
console.warn("markers", markers);
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlla se le istruzioni in input contengono già il marker PASS1 (dedup).
|
||||
* @param {string|undefined|null} text
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function instructionsAlreadyHasApipilotPass1(text) {
|
||||
return typeof text === "string" && text.includes(APIPILOT_PASS1_LIST_MARKER_JS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Espone globalmente una funzione helper per:
|
||||
* - main.js: costruire effectiveReasoningInstructions appendendo la lista.
|
||||
* - altri moduli che volessero leggerla.
|
||||
* Il check dedup è fatto automaticamente dal chiamante prima di appendere.
|
||||
* @returns {{block: string, count: number, marker: string, alreadyPresent:(t:string)=>boolean}}
|
||||
*/
|
||||
function getApipilotPass1ContextInfo() {
|
||||
const c = _apipilotClientCache;
|
||||
const tools = (c && c.data && Array.isArray(c.data.tools)) ? c.data.tools : [];
|
||||
const block = buildApipilotPass1PromptBlock(tools);
|
||||
return {
|
||||
block,
|
||||
count: tools.length,
|
||||
marker: APIPILOT_PASS1_LIST_MARKER_JS,
|
||||
alreadyPresent: instructionsAlreadyHasApipilotPass1,
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
Object.defineProperty(window, "__s2sInvalidateApipilotCache", {
|
||||
value: _invalidateApipilotClientCache,
|
||||
writable: true, configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "__s2sGetApipilotPass1ContextInfo", {
|
||||
value: getApipilotPass1ContextInfo,
|
||||
writable: true, configurable: true,
|
||||
});
|
||||
}
|
||||
} catch (_) { /* noop */ }
|
||||
|
||||
/**
|
||||
* @typedef {Object} WsClientOptions
|
||||
* @property {string} [sessionUrl] URL to POST for the session handshake (returns
|
||||
@@ -275,6 +456,12 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
*/
|
||||
async connect() {
|
||||
if (this._ws) throw new Error("Already connected");
|
||||
// Contatore sessioni totali: utile nel tracer per correlare gli eventi
|
||||
// di [S2S-WS:*] a un numero di sessione crescente.
|
||||
_wsSessionCounter += 1;
|
||||
const sessionIdShort = `#${_wsSessionCounter}`;
|
||||
this._sessionSeq = _wsSessionCounter;
|
||||
_wsLog(0, "SESSION", `${sessionIdShort} handshake iniziato — status: creating-session`);
|
||||
|
||||
let connectUrl;
|
||||
if (this._directUrl) {
|
||||
@@ -293,6 +480,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
// keeps a freed slot from being spent on someone who stepped away, and the
|
||||
// click is a fresh gesture (re-arms the AudioContext on iOS).
|
||||
if (waited) {
|
||||
_wsLog(1, "QUEUE", `${sessionIdShort} tuo turno — attesa click join per confermare slot`);
|
||||
await this._awaitJoin(grant);
|
||||
if (this._closed) throw _codedError("connect aborted", "aborted");
|
||||
}
|
||||
@@ -308,10 +496,101 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
this.options.micStream = await this._acquireMic();
|
||||
}
|
||||
|
||||
// Spin up the AudioContext + worklets in parallel with the WS dial.
|
||||
// Spin up the AudioContext + worklets in parallel with the WS dial,
|
||||
// E IN PARALLELO scaldiamo ApiPilot (strumenti dinamici AquaPortal).
|
||||
// Il prefetch è non bloccante: se fallisce, non fallisce connect().
|
||||
const audioReady = this._setupAudio();
|
||||
const wsReady = this._openWebSocket(connectUrl);
|
||||
await Promise.all([audioReady, wsReady]);
|
||||
const apipilotReady = this._prefetchApipilotTools(sessionIdShort);
|
||||
await Promise.all([audioReady, wsReady, apipilotReady.catch(() => void 0)]);
|
||||
_wsLog(1, "SESSION", `${sessionIdShort} connessione stabilita + audio pipeline + apipilot OK`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔧 PRE-FETCH strumenti ApiPilot ad ogni inizio di sessione Realtime.
|
||||
* - Proxy sul server: GET api/apipilot/tools?include_unauthorized=true
|
||||
* - Il wrapper FETCH globale traccia come [FETCH:APIPILOT:TOOLS] in Console.
|
||||
* - Cache TTL locale 240 s per evitare chiamate ridondanti se l'utente
|
||||
* chiude e riapre sessioni in rapida successione (server TTL=300 s).
|
||||
* - SE window.__s2sApipilot=false → salta subito.
|
||||
* - MAI fallisce: sempre catch interno.
|
||||
* @param {string} sessionIdShort Log tag "sessione #N"
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _prefetchApipilotTools(sessionIdShort) {
|
||||
try {
|
||||
// @ts-ignore: __s2sApipilot è settato da main.js dopo fetchConfig()
|
||||
const enabled = typeof window !== "undefined" && !!window.__s2sApipilot;
|
||||
if (!enabled) {
|
||||
_wsLog(2, "APIPILOT", `${sessionIdShort} feature flag apipilot=false sul server — salto prefetch strumenti`);
|
||||
return;
|
||||
}
|
||||
const currentUser = _apipilotUserKey();
|
||||
const now = Date.now();
|
||||
const cached = _apipilotClientCache;
|
||||
// HIT cache solo se: (1) ancora fresca TTL (2) stesso utente key (non cambio phone/token tra sessioni)
|
||||
if (cached && (now - cached.at <= _APIPILOT_CLIENT_TTL_MS) && cached.key === currentUser) {
|
||||
const ageSec = Math.round((now - cached.at) / 1000);
|
||||
const n =
|
||||
cached.data && typeof cached.data.count === "number"
|
||||
? cached.data.count
|
||||
: Array.isArray(cached.data && cached.data.tools)
|
||||
? cached.data.tools.length
|
||||
: "?";
|
||||
_wsLog(1, "APIPILOT", `${sessionIdShort} HIT cache client strumenti (user=${currentUser}, age=${ageSec}s, N=${n}, TTL=${Math.round(_APIPILOT_CLIENT_TTL_MS/1000)}s)`);
|
||||
return;
|
||||
}
|
||||
// Propaga header x-demo-session se disponibile in window (per auth fingerprint lato server S2S).
|
||||
/** @type {Record<string,string>} */
|
||||
const extraHeaders = {};
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (typeof window.__s2sDemoSessionToken === "string" && window.__s2sDemoSessionToken.length > 4) {
|
||||
// @ts-ignore
|
||||
extraHeaders["x-demo-session"] = window.__s2sDemoSessionToken;
|
||||
}
|
||||
} catch (_) { /* noop */ }
|
||||
// Usa refresh=true SE è cambiato l'utente (cache.key diversa o null) oppure la cache è stata invalidata globalmente.
|
||||
const needRefresh = !cached || cached.key !== currentUser || (now - cached.at > _APIPILOT_CLIENT_TTL_MS);
|
||||
const qsRefresh = needRefresh ? "&refresh=true" : "";
|
||||
const t0 = performance.now();
|
||||
_wsLog(0, "APIPILOT", `${sessionIdShort} MISS cache (user=${currentUser}, refresh=${needRefresh ? "Y" : "N"}) → GET api/apipilot/tools (proxy a S2S_APIPILOT_TOOLS_URL server)...`);
|
||||
const res = await fetch(`api/apipilot/tools?include_unauthorized=true${qsRefresh}`, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
headers: Object.keys(extraHeaders).length ? extraHeaders : undefined,
|
||||
});
|
||||
const dt = performance.now() - t0;
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const j = await res.json().catch(() => ({}));
|
||||
const ok = j && j.success && j.data;
|
||||
if (!ok) {
|
||||
throw new Error(`envelope non corretto: success=${Boolean(j && j.success)}`);
|
||||
}
|
||||
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
|
||||
const count = typeof j.data.count === "number" ? j.data.count : tools.length;
|
||||
// Aggiorna cache locale (in caso di hit la prossima sessione non chiamerà).
|
||||
_apipilotClientCache = { at: Date.now(), data: { count, tools: tools.slice(0, 100) }, key: currentUser };
|
||||
const names = tools
|
||||
.slice(0, 5)
|
||||
.map((t) => (typeof t.name === "string" ? t.name : "?"))
|
||||
.join(", ");
|
||||
_wsLog(
|
||||
1,
|
||||
"APIPILOT",
|
||||
`${sessionIdShort} READY strumenti APIPILOT caricati: N=${count}, t=${Math.round(dt)}ms, user=${currentUser} [primi 5: ${names || "(nessuno)"}]`
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
_wsLog(
|
||||
3,
|
||||
"APIPILOT",
|
||||
`${sessionIdShort} prefetch FALLITO: ${msg} — diagnostica lato server log [APIPILOT-DISCOVERY]`
|
||||
);
|
||||
// NON rilanciare: connect() deve comunque andare a buon fine.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,30 +660,40 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
async _postSession() {
|
||||
const url = this._sessionUrl;
|
||||
console.log("[ws] POST", url);
|
||||
_wsLog(0, "SESSION", `POST /session → ${url}`);
|
||||
const t0 = performance.now();
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const dur = (performance.now() - t0).toFixed(1);
|
||||
if (response.status === 402) {
|
||||
// The session proxy refused: today's per-tier time budget is spent. Surface
|
||||
// it as a typed error so the UI shows the limit modal, not a crash.
|
||||
const body = await response.json().catch(() => ({}));
|
||||
_wsLog(3, "SESSION", `/session HTTP 402: limite giornaliero esaurito (tier=${body?.tier ?? "?"}) dur=${dur} ms`);
|
||||
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
|
||||
}
|
||||
if (response.status === 503) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (body?.state === "at_capacity") {
|
||||
_wsLog(2, "QUEUE:FULL", `/session HTTP 503 state=at_capacity (coda piena) dur=${dur} ms`);
|
||||
throw _codedError("The queue is full — try again shortly.", "queue-full");
|
||||
}
|
||||
_wsLog(3, "SESSION", `/session HTTP 503 (fallback sconosciuto) dur=${dur} ms`);
|
||||
throw new Error("/session failed (503)");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
_wsLog(3, "SESSION", `/session fallita HTTP ${response.status}: ${text.slice(0, 180)} dur=${dur} ms`);
|
||||
throw new Error(`/session failed (${response.status}): ${text}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.state === "queued") {
|
||||
_wsLog(
|
||||
2,
|
||||
"QUEUE:WAIT",
|
||||
`/session → stato=queued queueId=${json.queue_id ?? "?"} position=${json.position ?? "?"} pollEvery=${json.poll_interval_s ?? "?"}s dur=${dur} ms`
|
||||
);
|
||||
return {
|
||||
state: "queued",
|
||||
queueId: json.queue_id,
|
||||
@@ -412,7 +701,13 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
pollIntervalS: json.poll_interval_s,
|
||||
};
|
||||
}
|
||||
return { state: "granted", grant: this._parseGrant(json) };
|
||||
const grant = this._parseGrant(json);
|
||||
_wsLog(
|
||||
1,
|
||||
"SESSION",
|
||||
`/session OK → GRANT sessionId=${grant.sessionId?.slice(0, 10)}… pendingTimeout=${grant.pendingTimeoutS}s dur=${dur} ms`
|
||||
);
|
||||
return { state: "granted", grant };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -426,8 +721,15 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
const intervalMs = Math.max(1, ticket.pollIntervalS || 2) * 1000;
|
||||
this._queueId = ticket.queueId;
|
||||
this._emitQueue(ticket.position);
|
||||
_wsLog(
|
||||
2,
|
||||
"QUEUE:POLL",
|
||||
`Inizio poll coda queueId=${ticket.queueId.slice(0, 10)}… initialPos=${ticket.position} intervalMs=${intervalMs}`
|
||||
);
|
||||
let pollNum = 0;
|
||||
|
||||
while (true) {
|
||||
pollNum += 1;
|
||||
await this._queueSleep(intervalMs);
|
||||
if (this._closed) throw _codedError("queue wait aborted", "aborted");
|
||||
|
||||
@@ -442,9 +744,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
|
||||
if (response.status === 402) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
_wsLog(3, "QUEUE:LIMIT", `poll #${pollNum} HTTP 402 — limite giornaliero (tier=${body?.tier ?? "?"})`);
|
||||
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
|
||||
}
|
||||
if (response.status === 404) {
|
||||
_wsLog(3, "QUEUE:EXPIRED", `poll #${pollNum} HTTP 404 — ticket scaduto queueId=${this._queueId.slice(0, 10)}…`);
|
||||
throw _codedError("Queue timed out", "queue-expired");
|
||||
}
|
||||
if (!response.ok) continue; // 502/503 — transient, retry
|
||||
@@ -452,10 +756,14 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
const json = await response.json().catch(() => null);
|
||||
if (!json) continue;
|
||||
if (json.state === "queued") {
|
||||
if (this._debug || pollNum === 1 || pollNum % 10 === 0) {
|
||||
_wsLog(2, "QUEUE:POLL", `poll #${pollNum} position=${json.position ?? "?"}`);
|
||||
}
|
||||
this._emitQueue(json.position);
|
||||
continue;
|
||||
}
|
||||
// Reached the front and claimed a slot.
|
||||
_wsLog(1, "QUEUE:FRONT", `poll #${pollNum} — RAGGIUNTA la prima posizione. Assegno slot compute.`);
|
||||
this._queueId = "";
|
||||
return this._parseGrant(json);
|
||||
}
|
||||
@@ -573,6 +881,23 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
/** @param {string} connectUrl */
|
||||
_openWebSocket(connectUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Non loggiamo l'URL completo (potrebbe contenere session_token in
|
||||
// chiaro nella querystring). Mostriamo solo host + path + un hash della
|
||||
// parte segreta per audit senza leak token.
|
||||
let maskedUrl = connectUrl;
|
||||
try {
|
||||
const u = new URL(connectUrl, window.location.href);
|
||||
const secretPortions = [];
|
||||
u.searchParams.forEach((v, k) => {
|
||||
const lower = k.toLowerCase();
|
||||
const isSecret = /token|session|auth|jwt|secret|key/i.test(lower) || String(v).length > 16;
|
||||
secretPortions.push(`${k}=${isSecret ? `✂️…${String(v).slice(-4)} (${String(v).length} chars)` : v}`);
|
||||
});
|
||||
maskedUrl = `${u.protocol}//${u.host}${u.pathname} ? ${secretPortions.join(" & ") || "(nessun query param)"}`;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
_wsLog(0, "CONNECT", `new WebSocket() → ${maskedUrl}`);
|
||||
const ws = new WebSocket(connectUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
this._ws = ws;
|
||||
@@ -580,11 +905,13 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
const onceOpen = () => {
|
||||
ws.removeEventListener("open", onceOpen);
|
||||
ws.removeEventListener("error", onceErr);
|
||||
_wsLog(1, "CONNECT", `WebSocket APERTO su ${maskedUrl} readyState=${ws.readyState}`);
|
||||
resolve();
|
||||
};
|
||||
const onceErr = (e) => {
|
||||
ws.removeEventListener("open", onceOpen);
|
||||
ws.removeEventListener("error", onceErr);
|
||||
_wsLog(3, "CONNECT", `WebSocket IMPOSSIBILE da aprire: event.type=${e?.type ?? "error"} url=${maskedUrl}`);
|
||||
reject(new Error(`WebSocket failed to open: ${e?.type ?? "error"}`));
|
||||
};
|
||||
ws.addEventListener("open", onceOpen);
|
||||
@@ -594,6 +921,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
ws.addEventListener("close", (e) => this._onWsClose(e));
|
||||
ws.addEventListener("error", (e) => {
|
||||
console.error("[ws] socket error", e);
|
||||
_wsLog(3, "ERR", `WebSocket error: event.type=${e?.type ?? "error"} wasClean=${"wasClean" in e ? String(/** @type {CloseEvent} */ (e).wasClean) : "?"}`, e);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -647,9 +975,33 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
|
||||
const type = event?.type;
|
||||
if (typeof type !== "string") return;
|
||||
// Always-on tracing for high-signal events (lifecycle, transcript/tool calls,
|
||||
// errors). For bulk streams (audio/text deltas) we log only count / first
|
||||
// + last so the console doesn't scroll into uselessness.
|
||||
const isHighSignal = /^(session\.|error$|conversation\.item\.created|conversation\.item\.input_audio_transcription\.completed|response\.created|response\.output_item\.done|response\.audio_transcript\.done|response\.output_audio_transcript\.done|response\.done|response\.function_call_arguments\.done|rate_limits\.updated)/i.test(
|
||||
type,
|
||||
);
|
||||
if (isHighSignal) {
|
||||
const isError = type === "error";
|
||||
const extra = type.startsWith("conversation.item.input_audio_transcription")
|
||||
? ` item=${event.item_id} ci=${event.content_index} — ${String(event.delta ?? event.transcript ?? "").slice(0, 180)}`
|
||||
: type.startsWith("response.")
|
||||
? ` resp=${String(event.response_id ?? event.response?.id ?? "").slice(0, 10)} status=${event.response?.status ?? "-"} ${String(event.transcript ?? event.text ?? event.delta ?? "").slice(0, 200)}`
|
||||
: type === "response.function_call_arguments.done"
|
||||
? ` tool=${event.name} callId=${String(event.call_id ?? "").slice(0, 10)} argsPreview=${String(event.arguments ?? "").slice(0, 220)}`
|
||||
: type === "error"
|
||||
? ` code=${String(event.code ?? "-")} message=${String(event.message ?? "").slice(0, 200)}`
|
||||
: type === "rate_limits.updated"
|
||||
? ` ${String(JSON.stringify(event.rate_limits ?? [])).slice(0, 220)}`
|
||||
: "";
|
||||
_wsLog(isError ? 3 : (type.startsWith("response.") && /(text_done|audio_transcript\.done|output_item\.done|output_audio_transcript\.done|function_call_arguments)/i.test(type) ? 1 : 0),
|
||||
"MSG",
|
||||
`← ${type}${extra}`
|
||||
);
|
||||
}
|
||||
// Opt-in event tracing for diagnosing turn/transcript issues. Enable with
|
||||
// `localStorage.setItem("s2s.debug", "1")` in the browser console.
|
||||
if (this._debug) {
|
||||
if (this._debug && !isHighSignal) {
|
||||
const extra = type.startsWith("conversation.item.input_audio_transcription")
|
||||
? ` item=${event.item_id} ci=${event.content_index} ${event.delta ?? event.transcript ?? ""}`
|
||||
: type.startsWith("response.")
|
||||
@@ -1008,6 +1360,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
/** @param {CloseEvent} ev */
|
||||
_onWsClose(ev) {
|
||||
console.log("[ws] socket closed:", ev.code, ev.reason);
|
||||
_wsLog(
|
||||
(ev.code === 1000 || ev.code === 1001) ? 1 : (ev.code ? 3 : 2),
|
||||
"CLOSE",
|
||||
`WebSocket CHIUSO code=${ev.code ?? "?"} reason=${String(ev.reason ?? "(nessuna)").slice(0, 160)} wasClean=${ev.wasClean}`
|
||||
);
|
||||
if (this._status === "closed" || this._status === "error") return;
|
||||
if (ev.code === 1000) {
|
||||
this._setStatus("closed");
|
||||
@@ -1037,6 +1394,38 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
output: { voice: this.options.voice },
|
||||
},
|
||||
};
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2026-09-25 INIETTA API-PILOT HERRAMIENTAS DINÁMICAS (PASADA-1) nel session.update.
|
||||
// 3 strati di assicurazione (in ordine di priorità crescente, dedup by marker):
|
||||
// (A) main.js effectiveReasoningInstructions() ha già appeso la lista.
|
||||
// (B) NOSTRO server.py proxy realtime ri-inietta (se routing lo attraversa).
|
||||
// (C) QUI s2s-ws-client: ultimo punto di catch-all prima dell'invio al
|
||||
// canale, GARANTISCE che la lista sia SEMPRE presente in istruzioni
|
||||
// (anche se qualcuno fa updateSession() con text sovrascritto poco dopo).
|
||||
{
|
||||
const currentInstr = (typeof session.instructions === "string") ? session.instructions : "";
|
||||
if (!instructionsAlreadyHasApipilotPass1(currentInstr)) {
|
||||
try {
|
||||
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
|
||||
? window.__s2sGetApipilotPass1ContextInfo()
|
||||
: null;
|
||||
const blk = (info && typeof info.block === "string") ? info.block : buildApipilotPass1PromptBlock(null);
|
||||
if (blk && blk.trim()) {
|
||||
session.instructions = currentInstr
|
||||
? `${currentInstr}\n\n${blk}`
|
||||
: blk;
|
||||
const lenBlk = blk.length;
|
||||
const cntTools = (info && typeof info.count === "number") ? info.count : "?";
|
||||
_wsLog(
|
||||
1, "APIPILOT",
|
||||
`_sendSessionUpdate: APPENDED PASS-1 instructions (N=${cntTools}, +${lenBlk} chars). origLen=${currentInstr.length} newLen=${session.instructions.length}`,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
/* never-fail */
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tools are declared here; the backend already accepts them in
|
||||
// session.update and emits response.function_call_arguments.done when the
|
||||
// model decides to call one. Only include the keys when we actually have
|
||||
@@ -1049,8 +1438,9 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
reportWsDebug("B", "s2s-ws-client.js:_sendSessionUpdate", "sending initial session.update", {
|
||||
toolCount: this._tools.length,
|
||||
toolNames: this._tools.map((tool) => tool.name),
|
||||
hasInstructions: !!this.options.instructions,
|
||||
instructionLength: (this.options.instructions || "").length,
|
||||
hasInstructions: !!session.instructions,
|
||||
instructionLength: (session.instructions || "").length,
|
||||
apipilotPass1Present: instructionsAlreadyHasApipilotPass1(session.instructions),
|
||||
});
|
||||
// #endregion
|
||||
this._send({ type: "session.update", session });
|
||||
@@ -1061,14 +1451,35 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
updateSession(patch) {
|
||||
/** @type {Record<string, any>} */
|
||||
const session = { type: "realtime" };
|
||||
if (patch.instructions) session.instructions = patch.instructions;
|
||||
if (patch.instructions) {
|
||||
let instr = patch.instructions;
|
||||
// ── 2026-09-25 catch-all apipilot PASS-1 anche per patch live. ──
|
||||
if (typeof instr === "string" && !instructionsAlreadyHasApipilotPass1(instr)) {
|
||||
try {
|
||||
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
|
||||
? window.__s2sGetApipilotPass1ContextInfo()
|
||||
: null;
|
||||
const blk = (info && typeof info.block === "string") ? info.block : buildApipilotPass1PromptBlock(null);
|
||||
if (blk && blk.trim()) {
|
||||
const origLen = instr.length;
|
||||
instr = `${instr}\n\n${blk}`;
|
||||
_wsLog(
|
||||
1, "APIPILOT",
|
||||
`updateSession: APPENDED PASS-1 on live patch (N=${(info && typeof info.count === "number") ? info.count : "?"}, +${blk.length} chars). origLen=${origLen} newLen=${instr.length}`,
|
||||
);
|
||||
}
|
||||
} catch (_) { /* never-fail */ }
|
||||
}
|
||||
session.instructions = instr;
|
||||
}
|
||||
if (patch.voice) session.audio = { output: { voice: patch.voice } };
|
||||
if (Object.keys(session).length > 1) {
|
||||
// #region debug-point C:update-session
|
||||
reportWsDebug("C", "s2s-ws-client.js:updateSession", "sending patch session.update", {
|
||||
hasInstructions: !!patch.instructions,
|
||||
instructionLength: (patch.instructions || "").length,
|
||||
hasInstructions: !!session.instructions,
|
||||
instructionLength: (session.instructions || "").length,
|
||||
hasVoice: !!patch.voice,
|
||||
apipilotPass1Present: typeof session.instructions === "string" && instructionsAlreadyHasApipilotPass1(session.instructions),
|
||||
});
|
||||
// #endregion
|
||||
this._send({ type: "session.update", session });
|
||||
@@ -1365,14 +1776,46 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
/** @param {Record<string, unknown>} event */
|
||||
_send(event) {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
||||
// Anti-spam bulk-rate limiter per eventi audio (~25 al secondo).
|
||||
// Loggiamo un resoconto ogni 250 chunk (circa 10s a 25Hz) invece di ogni singolo.
|
||||
if (event.type === "input_audio_buffer.append") {
|
||||
_wsMicChunkCount += 1;
|
||||
const now = performance.now();
|
||||
if (_wsMicChunkCount === 1 || now - _wsMicChunkLastLogTs >= 2500 || _wsMicChunkCount % 250 === 0) {
|
||||
_wsMicChunkLastLogTs = now;
|
||||
_wsLog(0, "SEND:BULK", `→ input_audio_buffer.append chunk #${_wsMicChunkCount} audio_len=${String(event.audio ?? "").length} chars (base64)`);
|
||||
}
|
||||
let payloadLength = 0;
|
||||
let stringifyMs = 0;
|
||||
let payload = "";
|
||||
if (event.type === "conversation.item.create") {
|
||||
const startedAt = performance.now();
|
||||
payload = JSON.stringify(event);
|
||||
stringifyMs = performance.now() - startedAt;
|
||||
payloadLength = payload.length;
|
||||
// #region debug-point A:conversation-item-create
|
||||
reportWsDebug("A", "s2s-ws-client.js:_send", "sending conversation.item.create payload", {
|
||||
payloadLength,
|
||||
stringifyMs: Math.round(stringifyMs),
|
||||
});
|
||||
// #endregion
|
||||
}
|
||||
if (this._debug && (event.type === "session.update" || event.type === "conversation.item.create")) {
|
||||
const preview = (payload || JSON.stringify(event)).slice(0, 400);
|
||||
console.debug(`[Vocero WS OUT] type=${event.type} payload=${preview}`);
|
||||
}
|
||||
this._ws.send(payload || JSON.stringify(event));
|
||||
return;
|
||||
}
|
||||
// Messaggi non bulk: log sempre [S2S-WS:SEND] con anteprima del payload.
|
||||
let payloadLength = 0;
|
||||
let stringifyMs = 0;
|
||||
let payload = "";
|
||||
const startedAt = performance.now();
|
||||
payload = JSON.stringify(event);
|
||||
stringifyMs = performance.now() - startedAt;
|
||||
payloadLength = payload.length;
|
||||
if (event.type === "conversation.item.create") {
|
||||
const startedAt = performance.now();
|
||||
payload = JSON.stringify(event);
|
||||
stringifyMs = performance.now() - startedAt;
|
||||
payloadLength = payload.length;
|
||||
// #region debug-point A:conversation-item-create
|
||||
reportWsDebug("A", "s2s-ws-client.js:_send", "sending conversation.item.create payload", {
|
||||
payloadLength,
|
||||
@@ -1380,11 +1823,17 @@ export class S2sWsRealtimeClient extends EventTarget {
|
||||
});
|
||||
// #endregion
|
||||
}
|
||||
const isToolish = /tool|function|response\.create/i.test(event.type || "");
|
||||
const preview = payload.slice(0, isToolish ? 600 : 260);
|
||||
_wsLog(
|
||||
isToolish ? 1 : 0,
|
||||
"SEND",
|
||||
`→ ${String(event.type ?? "?")} payload=${payloadLength} bytes stringify=${Math.round(stringifyMs)} ms — ${preview}${payload.length > (isToolish ? 600 : 260) ? "…" : ""}`
|
||||
);
|
||||
if (this._debug && (event.type === "session.update" || event.type === "conversation.item.create")) {
|
||||
const preview = (payload || JSON.stringify(event)).slice(0, 400);
|
||||
console.debug(`[Vocero WS OUT] type=${event.type} payload=${preview}`);
|
||||
console.debug(`[Vocero WS OUT] type=${event.type} payload=${payload.slice(0, 400)}`);
|
||||
}
|
||||
this._ws.send(payload || JSON.stringify(event));
|
||||
this._ws.send(payload);
|
||||
}
|
||||
|
||||
async close() {
|
||||
|
||||
Reference in New Issue
Block a user