diff --git a/.gitignore b/.gitignore
index 2f79ea2..98f6366 100644
--- a/.gitignore
+++ b/.gitignore
@@ -131,7 +131,7 @@ s2s-reasoning-engine/lib/flows_cred.json
s2s-reasoning-engine/.config.*
# ─────────────────────────────────────────────────────────────
-# s2s-client static artifacts generated at runtime by browser / proxy logs
+# Client backend and speech-service runtime artifacts
# ─────────────────────────────────────────────────────────────
s2s-client/.cache/
s2s-client/uploads/
diff --git a/README.md b/README.md
index de06c9e..a149127 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ documentation, source identifiers, comments, and internal instructions use Engli
| Component | Purpose |
|---|---|
| [s2s-frontend](s2s-frontend) | React, TypeScript, and Tailwind browser UI, served by Vite with hot reload |
-| [s2s-client](s2s-client) | FastAPI backend, realtime WebSocket proxy, search and ApiPilot bridges; retained static client |
+| [s2s-client](s2s-client) | FastAPI backend, realtime WebSocket proxy, search and ApiPilot bridges |
| [speech_to_speech](speech_to_speech) | Queue-driven VAD, speech recognition, language model, and speech synthesis pipeline |
| [s2s-reasoning-engine](s2s-reasoning-engine) | Optional Node-RED HTTP reasoning and conversation logging flows |
@@ -36,8 +36,9 @@ line or Node.js 24+, npm, Bash 4.4+, `setsid`, and `curl`. For staging hostnames
set `S2S_DEV_ALLOWED_HOSTS` and forward the Vite HMR WebSocket through the existing
HTTPS reverse proxy. See [development setup](s2s-frontend/README.md).
-The existing Docker image still serves the retained static client. To build it,
-use the repository root as the Docker build context so documentation is included:
+The Docker image serves backend routes and shared documentation. The React
+frontend runs separately through Vite. To build the backend image, use the
+repository root as the Docker build context so documentation is included:
```bash
docker build -f s2s-client/Dockerfile -t s2s-client .
diff --git a/docs/client/architecture.md b/docs/client/architecture.md
index a5e9463..a3b1141 100644
--- a/docs/client/architecture.md
+++ b/docs/client/architecture.md
@@ -42,17 +42,15 @@ audio. It is not an unconditional byte-for-byte tunnel.
| Source | Responsibility |
|---|---|
-| [server.py](../../s2s-client/server.py) | Configuration, static serving, WebSocket relay, context loading, ApiPilot, reasoning, search, queue and usage routes |
+| [server.py](../../s2s-client/server.py) | Configuration, documentation serving, WebSocket relay, context loading, ApiPilot, reasoning, search and queue routes |
| [start_ui.sh](../../start_ui.sh) | Dependency setup, ports, readiness, reload processes, and shutdown |
| [vite.config.ts](../../s2s-frontend/vite.config.ts) | Public development endpoint, HMR, allowed hosts, and backend routing |
| [App.tsx](../../s2s-frontend/src/App.tsx) | React layout, voice controls, dialogs, profile and reports sidebar |
| [controller.ts](../../s2s-frontend/src/realtime/controller.ts) | Application state, settings, context, queue, tools and external reasoning |
| [client.ts](../../s2s-frontend/src/realtime/client.ts) | Realtime protocol, session lifecycle, audio and transcript events |
-| [components](../../s2s-frontend/src/components) | Chat, account, settings and voice components |
+| [components](../../s2s-frontend/src/components) | Chat, caller profile, settings and voice components |
| [audio](../../s2s-frontend/src/audio) | TypeScript capture/playback worklets, PCM codec, and visualizer |
| [legacy-theme.css](../../s2s-frontend/src/styles/legacy-theme.css) | Retained themes and responsive visual styles alongside Tailwind |
-| [auth.py](../../s2s-client/auth.py) | Hugging Face OAuth and caller identity/tier |
-| [limiter.py](../../s2s-client/limiter.py) | SQLite daily talk-time reservations and reconciliation |
| [flows.json](../../s2s-reasoning-engine/flows.json) | Node-RED reasoning and assistant-response logging |
## Server routes
@@ -68,15 +66,14 @@ audio. It is not an unconditional byte-for-byte tunnel.
| `POST /api/apipilot/execute` | Validated tool execution returning a `success/data/error` envelope |
| `POST /api/search` | Serper web search proxy |
| `/api/session`, `/api/queue/*` | Load-balancer session and waiting-queue proxies |
-| `/api/me`, `/api/session/heartbeat`, `/api/session/end` | Identity, reservation extension and usage reconciliation |
The public root serves the React UI. `/__vite_hmr` carries Vite hot updates;
`/v1/realtime` remains the speech connection. `/docs` remains the FastAPI API
explorer, while project documentation files are served in the `/docs/`
subdirectories. Markdown remains
source text, and directories without `index.html` are not browsable pages.
-The existing static client remains in `s2s-client` for direct FastAPI and Docker
-usage; its static responses retain the existing no-cache headers.
+FastAPI serves backend and documentation routes only. The frontend source and
+assets live in `s2s-frontend` and are served by Vite.
## Audio path
diff --git a/docs/client/context-injection.md b/docs/client/context-injection.md
index d429c8f..2a5105b 100644
--- a/docs/client/context-injection.md
+++ b/docs/client/context-injection.md
@@ -3,8 +3,8 @@
Context reaches the assistant through browser-built instructions, server-patched
realtime session instructions, and HTTP reasoning context. The implementation is
in [server.py](../../s2s-client/server.py),
-[main.js](../../s2s-client/main.js), and the
-[WebSocket client](../../s2s-client/ws/s2s-ws-client.js).
+[context.ts](../../s2s-frontend/src/lib/context.ts), and the
+[WebSocket client](../../s2s-frontend/src/realtime/client.ts).
## Independent controls
diff --git a/docs/client/design.md b/docs/client/design.md
index f85fcb5..f2a6fb5 100644
--- a/docs/client/design.md
+++ b/docs/client/design.md
@@ -1,8 +1,8 @@
# Design language
This guide describes the current browser UI. Use
-[style.css](../../s2s-client/style.css) as the token/selector reference and
-[index.html](../../s2s-client/index.html) for the theme-specific structure.
+[legacy-theme.css](../../s2s-frontend/src/styles/legacy-theme.css) as the token/selector
+reference and [App.tsx](../../s2s-frontend/src/App.tsx) for the theme-specific structure.
Earlier documentation described a monochrome-only dark UI; the current design
also uses aurora gradients, glass surfaces, and a separate light theme.
@@ -36,7 +36,7 @@ selector overrides rather than a second stylesheet.
| Listening/user speaking | Cyan (`--listening`) |
| Assistant speaking | Violet (`--speaking`) |
| Processing | Amber (`--processing`) |
-| Ready/authenticated | Green (`--success`) |
+| Ready/success | Green (`--success`) |
| Error | Red (`--error`) |
`--voice-user`, `--voice-assistant` and `--voice-tool` connect transcript roles
diff --git a/docs/client/glossary.md b/docs/client/glossary.md
index 6697e05..914cb79 100644
--- a/docs/client/glossary.md
+++ b/docs/client/glossary.md
@@ -34,7 +34,7 @@ names shown in a deployment are configuration examples, not definitions.
| Queue | Users waiting for an available compute slot; waiting is not yet an active conversation |
| Ticket | A held queue position returned by the load balancer |
| Position | The displayed place in the queue; distinct from a time estimate |
-| Claim | The transition from a waiting ticket to an allocated realtime session and usage reservation |
+| Claim | The transition from a waiting ticket to an allocated realtime session |
| At capacity | A full waiting queue that cannot accept another ticket |
| Barge-in | User speech interrupts an active assistant response, coordinating playback clearing and backend cancellation |
| Log-only reasoning | External transcript processing that observes/logs the turn without replacing native speech output |
diff --git a/docs/client/reasoning.md b/docs/client/reasoning.md
index ab093dd..c336734 100644
--- a/docs/client/reasoning.md
+++ b/docs/client/reasoning.md
@@ -15,15 +15,15 @@ The mode is normalized case-insensitively. `NODERED` and `NODE-RED` resolve to
## Shared instruction assembly
-In [main.js](../../s2s-client/main.js), `effectiveReasoningInstructions()` starts
-with `pinnedPrompt || settings.instructions`. `/api/config` pins
+In [context.ts](../../s2s-frontend/src/lib/context.ts), `buildInstructions()` starts
+with `config.primaryPrompt || settings.instructions`. `/api/config` pins
`S2S_PRIMARY_PROMPT` when non-empty; otherwise the settings prompt or
`DEFAULT_INSTRUCTIONS` is used. The current server does not fetch a remote agent
profile to choose this base prompt.
The browser adds caller context when `injectProfile` is enabled, custom RAG text
when the operator has applied it, and an available ApiPilot catalog block.
-`effectiveInstructions()` adds `OPENAI_RENDER_HINT` only in `OPENAI` mode.
+`speechInstructions()` adds the speech-rendering hint only in `OPENAI` mode.
The WebSocket client packages these instructions in `session.update.session`;
the local proxy may then add server-resolved context. Details belong in
[context injection](context-injection.md).
@@ -49,8 +49,8 @@ S2S_REASONING_TYPE=NODE_RED
S2S_REASONING_NODE_RED_URL=http://localhost:1881
```
-`maybeRunOpenAiReasoning()` handles both external modes despite its historical
-function name. It runs after a complete user transcript and posts a body with
+`VoiceController.reason()` in [controller.ts](../../s2s-frontend/src/realtime/controller.ts)
+handles both external modes. It runs after a complete user transcript and posts a body with
`transcript`, `instructions`, `language`, `user_context`, `phoneNumber`, and
`episode_id`. The server builds context and forwards the reasoning fields to
Node-RED with a 30-second timeout.
@@ -84,10 +84,9 @@ browser's native `session.tools` catalog as OpenAI Chat Completions `tools`.
For a non-log-only answer, the browser cancels/suppresses the active native
response and appends the external assistant text. It uses browser
-`SpeechSynthesis` when available. Its compatibility fallback sends
-`[VOCERO_RENDER_REPLY]` plus the answer to the realtime backend and requests a
-response. The render hint asks the model to read only the answer after that
-marker. This instruction is model behavior, not a guarantee of exact playback.
+`SpeechSynthesis` when available. The React client adds the answer to the realtime
+conversation as an assistant item; without browser speech synthesis it remains
+visible as text.
External reasoning adds an HTTP operation to each completed transcript and has
separate provider token usage. It is not the speech pipeline's `responses-api`
@@ -99,8 +98,8 @@ realtime backend instead.
| Goal | Source |
|---|---|
| Change the base agent prompt | `S2S_PRIMARY_PROMPT` or browser settings when unpinned |
-| Change browser context assembly | `effectiveReasoningInstructions()` in `main.js` |
+| Change browser context assembly | `buildInstructions()` in `s2s-frontend/src/lib/context.ts` |
| Change HTTP provider messages | `reasoning()` in `server.py` |
-| Change external-answer rendering | `maybeRunOpenAiReasoning()` in `main.js` |
+| Change external-answer rendering | `VoiceController.reason()` and `RealtimeClient.renderExternalAnswer()` |
| Change Node-RED processing | `s2s-reasoning-engine/flows.json` |
| Change native model/tool generation | `speech_to_speech/LLM` and realtime handlers |
diff --git a/docs/client/tool-calling.md b/docs/client/tool-calling.md
index d9b27f4..ec5767e 100644
--- a/docs/client/tool-calling.md
+++ b/docs/client/tool-calling.md
@@ -48,8 +48,9 @@ For a native call, the backend emits:
```
`arguments` is a JSON-encoded string. The
-[WebSocket client](../../s2s-client/ws/s2s-ws-client.js) parses it and dispatches
-`toolcall`; [main.js](../../s2s-client/main.js) runs the supported executor.
+[WebSocket client](../../s2s-frontend/src/realtime/client.ts) validates the event and
+dispatches `tool`; [controller.ts](../../s2s-frontend/src/realtime/controller.ts)
+parses the arguments and runs the supported executor.
Web search uses `POST /api/search`, keeping the deployment's Serper key on the
server. Tool output returns to the model as:
diff --git a/docs/client/troubleshooting.md b/docs/client/troubleshooting.md
index 2e9c5c4..39ba6fc 100644
--- a/docs/client/troubleshooting.md
+++ b/docs/client/troubleshooting.md
@@ -78,7 +78,7 @@ code before claiming a root cause is resolved.
`S2S_DEBUG_PROMPT=true` enables complete prompt logs, including caller information
and conversation text. Use summaries when full payloads are unnecessary. The
browser's application functions are module-scoped; do not assume names such as
-`effectiveInstructions()` are available directly on `window` in DevTools.
+`buildInstructions()` are available directly on `window` in DevTools.
## Short gaps or clipped words around tool results
@@ -119,15 +119,17 @@ microphone/audio permissions. If automatic startup fails, use the unlocker
gesture and inspect the permission error rather than increasing the timer
without evidence.
-## Read-only source checks
+## Local validation
-From the repository root, useful syntax checks are:
+From the repository root, run the frontend checks and local backend fixtures:
```bash
-node --check s2s-client/main.js
-node --check s2s-client/ws/s2s-ws-client.js
-python -m py_compile s2s-client/server.py
+npm --prefix s2s-frontend run check
+npm --prefix s2s-frontend run build
+.venv/bin/python -m unittest discover -s s2s-client/tests -v
```
-Syntax checks do not validate microphone access, network services, model output,
-or audio quality. Collect a fresh browser/backend reproduction for those paths.
+These checks cover frontend contracts and local session/queue/WebSocket
+fixtures. They do not validate deployed services, real microphone hardware,
+model output, or audio quality. Use a local browser/backend reproduction for
+those paths before deployment.
diff --git a/docs/client/usage.md b/docs/client/usage.md
index d533aa6..6cafee3 100644
--- a/docs/client/usage.md
+++ b/docs/client/usage.md
@@ -70,7 +70,7 @@ See [LLM](../speech/llm.md), [STT](../speech/stt.md), and [TTS](../speech/tts.md
|---|---|---|---|
| `SPEECH_TO_SPEECH_URL` set | Direct to pinned URL | Locked | Disabled |
| `S2S_PROXY_TARGET` set, no direct URL | Same-origin `/v1/realtime` proxy | Locked | Disabled |
-| `LOAD_BALANCER_URL` set, neither above set | Per-session compute URL from `/api/session` | Hidden | Queue enabled; metering requires `SPACE_ID` too |
+| `LOAD_BALANCER_URL` set, neither above set | Per-session compute URL from `/api/session` | Hidden | Allocation, queue polling and cancellation |
| None set | User-provided realtime URL | Editable | Disabled |
Direct URLs may be full `ws(s)://.../v1/realtime` addresses or host values that
@@ -84,8 +84,9 @@ server; it is not a universally available local service.
## Docker
-This existing image serves the retained static client. It does not run the
-React/Vite development launcher.
+This image serves FastAPI backend routes and shared documentation. Run the
+React frontend separately with Vite and point `S2S_BACKEND_URL` at the exposed
+backend address. The image does not run the development launcher or serve a UI.
Use the project root as the build context:
@@ -144,24 +145,22 @@ The warmup is a timer, not proof that all upstream context requests have
finished. Browser permission and audio policies can still require a gesture.
The greeting uses the same `hola vocero` input as wake-word handling.
-## Queue and daily usage
+## Queue and session lifecycle
-Load-balancer mode proxies session acquisition and queue polling. Waiting does
-not reserve conversation time; a daily budget is reserved when a compute slot
-is granted. The server enables metering only with both `LOAD_BALANCER_URL` and
-`SPACE_ID` and with neither pinned direct/proxy mode active.
+Load-balancer mode proxies `POST /api/session`. A free compute slot returns a
+grant containing `session_id`, `connect_url`, and `pending_timeout_s`. Otherwise
+the browser receives a queue ticket and polls `GET /api/queue/{queue_id}` until
+a slot is granted. The browser offers a timed join action after waiting.
-| Environment setting | Default | Purpose |
-|---|---|---|
-| `LIMIT_ANON_SEC` | 300 | Anonymous seconds per UTC day |
-| `LIMIT_FREE_SEC` | 600 | Signed-in non-PRO seconds per UTC day |
-| `UNLIMITED_ORGS` | Adds to built-in organizations | Additional unlimited organization members |
-| `USAGE_HASH_SECRET` | Random if unset | Identity-key hashing and anonymous cookie signing |
+`DELETE /api/queue/{queue_id}` cancels a waiting ticket. On session stop or page
+exit, the browser uses `POST /api/queue/end` through `sendBeacon`, falling back
+to a keepalive fetch. Expired tickets and missed join deadlines require a new
+attempt. Full queues return `503` with `state: "at_capacity"`.
-PRO members are unlimited. Built-in unlimited organizations are `cerebras`,
-`HuggingFaceM4`, `smolagents`, and `pollen-robotics`; organization matching is
-case-insensitive. See [auth.py](../../s2s-client/auth.py) and
-[limiter.py](../../s2s-client/limiter.py) for the current enforcement behavior.
+Once connected, session teardown closes the microphone, audio graph and
+WebSocket. The 30-second inactivity timeout remains active when the assistant
+is not speaking. Caller-profile lookup and upstream authorization operate
+independently of allocation and use their existing configured endpoints.
For external transcript processing, configure the selected
[reasoning mode](reasoning.md). For connection, context and audio diagnostics,
diff --git a/s2s-client/.gitignore b/s2s-client/.gitignore
index 8a9283c..fea38d8 100644
--- a/s2s-client/.gitignore
+++ b/s2s-client/.gitignore
@@ -6,7 +6,7 @@
.idea/
*.swp
-# Static HTTP server logs (we don't ship them)
+# Server logs
*.log
# Python
@@ -14,8 +14,6 @@ __pycache__/
*.pyc
.venv/
venv/
-node_modules/
-!package-lock.json
# Local-only replica deploy script (never tracked)
deploy_replica.py
diff --git a/s2s-client/CHANGELOG.md b/s2s-client/CHANGELOG.md
index 8ccb771..e06be9b 100644
--- a/s2s-client/CHANGELOG.md
+++ b/s2s-client/CHANGELOG.md
@@ -9,6 +9,11 @@ the implementation at the recorded date; current operating instructions live in
### Changed
+- Removed the legacy browser site in favor of the React application in
+ `s2s-frontend`. FastAPI now serves only backend and documentation routes.
+- Retired daily talk-time accounting and the associated Hugging Face account
+ UI. Load-balancer allocation, queue lifecycle, caller profiles, upstream
+ authentication, and voice-session cleanup remain available.
- Consolidated technical documentation under the root `docs/` directory and
replaced translated changelog copies with this English history.
- Moved the agent-profile and reference-audio examples into `docs/examples/`.
diff --git a/s2s-client/Dockerfile b/s2s-client/Dockerfile
index d75920d..5e7729c 100644
--- a/s2s-client/Dockerfile
+++ b/s2s-client/Dockerfile
@@ -1,5 +1,6 @@
# Build from the project root: docker build -f s2s-client/Dockerfile -t s2s-client .
-# Serve the client, same-origin API proxies, and shared project documentation.
+# Serve backend APIs, the WebSocket proxy, and shared project documentation.
+# Run the React/Vite frontend separately; this image contains no browser UI.
# HF Spaces (sdk: docker) routes traffic to $PORT, default 7860.
FROM python:3.11-slim
diff --git a/s2s-client/README.md b/s2s-client/README.md
index eff930a..142e979 100644
--- a/s2s-client/README.md
+++ b/s2s-client/README.md
@@ -7,45 +7,41 @@ sdk: docker
app_port: 7860
pinned: false
short_description: Voice chat over WebSocket against a HF speech-to-speech
-hf_oauth: true
---
-# Realtime voice backend and retained static client
+# Realtime voice backend
-FastAPI server and retained static voice-chat UI for an OpenAI Realtime-compatible
-speech-to-speech backend. The browser streams microphone audio over WebSocket,
-plays assistant audio, displays transcripts, and offers Spanish controls.
+FastAPI APIs and WebSocket relay for the [React voice frontend](../s2s-frontend).
+This service handles configuration, caller context, upstream authentication,
+search, reasoning, ApiPilot tools, and optional load-balancer allocation.
The root `start_ui.sh` now serves the [React frontend](../s2s-frontend) with
Vite on public port `8420`, proxying backend requests here on loopback port
-`8421`. This directory's static UI remains available when FastAPI is run
-directly or through the existing Docker image.
+`8421`. The browser UI lives entirely in `s2s-frontend`; running FastAPI directly
+or through Docker exposes only backend routes and shared documentation.
The complete [usage guide](../docs/client/usage.md) covers local startup,
-connection modes, Docker, settings, and usage limits. See the
+connection modes, Docker, settings, and queue behavior. See the
[documentation index](../docs/README.md), [architecture](../docs/client/architecture.md),
and [change history](CHANGELOG.md) for implementation details and history.
-The retained static client's assistant replies render Markdown with Marked and
-DOMPurify. Their pinned, checked-in browser modules are served from `vendor/`, including upstream
-licenses, so the UI does not require a CDN or a Node.js runtime. To refresh the
-browser dependencies with Node.js 20 or newer after updating `package.json`:
-
-```bash
-cd s2s-client
-npm install --ignore-scripts
-npm run vendor
-```
-
-Commit `package-lock.json` and the generated `vendor/` files together. Existing
-locked versions can be restored with `npm ci --ignore-scripts`.
-
Build from the repository root so the container includes the documentation:
```bash
docker build -f s2s-client/Dockerfile -t s2s-client .
```
+Run the backend regression tests from the repository root after installing
+`requirements.txt` in `.venv`:
+
+```bash
+.venv/bin/python -m unittest discover -s s2s-client/tests -v
+```
+
+Tests isolate environment configuration, use HTTP fixtures for allocation and
+caller authentication, and start a loopback WebSocket service to verify audio
+frames, keepalive, and teardown. No deployed services or database are needed.
+
FastAPI's API explorer remains at `/docs`. Project documents are served as files below
`/docs/client/`, `/docs/speech/`, `/docs/adr/`, `/docs/examples/`, and
`/docs/proposals/`. The historical `/project/` and `/public/docs.html` URLs
diff --git a/s2s-client/index.html b/s2s-client/index.html
deleted file mode 100644
index 19120f7..0000000
--- a/s2s-client/index.html
+++ /dev/null
@@ -1,700 +0,0 @@
-
-
-
-
-
-
-
-
- Vocero · Conversación de voz
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Preparando Vocero…
-
Toca en cualquier lugar para empezar a hablar
-
-
-
-
-
-
-
-
Vocero
-
-
Agente conversacional
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Enviar comentarios
-
-
-
-
-
-
-
-
-
-
-
-
-
Listo para conversar
-
Informes, análisis, datos y mucho más.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Toca para empezar
-
-
-
-
-
-
-
-
-
-
- Silenciar micrófono
-
-
-
- Detener
-
-
-
-
- Toca para empezar
-
-
-
-
- Entrar ahora
-
-
- Salir de la cola
-
-
-
-
-
-
-
-
-
-
-
-
-
- Resumen
-
-
-
-
-
-
-
-
- Consultar datos
-
-
-
-
-
-
- Crear respuesta
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- cámara
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Permite que el asistente actúe durante la conversación. Los cambios se aplican de inmediato.
-
-
-
-
-
-
-
-
-
-
- Pega aquí texto, documentos o fragmentos RAG que quieras tener disponibles
- durante la sesión de voz actual. El texto se añade a las
- instrucciones del modelo y al campo user_context de la solicitud a
- /api/reasoning.
-
-
-
- Contexto RAG (texto libre)
-
-
- No hay contexto RAG cargado. Pulsa "Enviar a la sesión" para aplicar los cambios.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Por ahora hemos terminado
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/s2s-client/main.js b/s2s-client/main.js
deleted file mode 100644
index d5c299f..0000000
--- a/s2s-client/main.js
+++ /dev/null
@@ -1,3653 +0,0 @@
-// @ts-check
-/**
- * Minimal voice conversation app, talking to a Hugging Face speech-to-speech
- * backend over **WebSocket** (drop-in alternative to the WebRTC variant).
- *
- * Click the orb -> we ask for the mic, POST a session on the LB, open a
- * WebSocket on the routed compute endpoint, push session.update + mic
- * audio, play back the TTS audio. The orb visually reflects the live
- * state (idle, connecting, listening, user-speaking, processing,
- * ai-speaking).
- *
- * The only meaningful difference vs. the WebRTC main.js is that the
- * client owns its own AudioContext (no `attachOutputTrack`), so we hand
- * it the MediaStream directly.
- *
- * @typedef {"idle" | "connecting" | "queued" | "your-turn" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState
- */
-
-import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js?v=202609041120";
-import { $, escHtml, truncateError, DEBUG } from "./ui/dom.js?v=202609041120";
-import { ChatView } from "./ui/chat.js?v=202609041120";
-import { Account } from "./ui/account.js?v=202609041120";
-
-// ---------------------------------------------------------------------------
-// 🔎 DEBUG BANNER (always-on): identify the exact main.js version loaded.
-// If this line is missing from the console, the browser is using an older cached file.
-console.info(
- "%c🟢 VOCERO main.js loaded. version=2026-09-24 23:12 (ALWAYS-ON FETCH WRAPPER+WS DEBUG; APIPILOT: demo flag=true + AUTOMATIC PREFETCH FOR EVERY WS SESSION + PREFETCH AFTER PHONE LOGIN (refresh=true invalidates the anonymous server cache); per-user client cache; phone changes invalidate automatically; chat export; scroll fix).",
- "background:#1e293b;color:#86efac;font-weight:700;padding:4px 8px;border-radius:4px;"
-);
-console.info(
- " Current URL location.href=", location.href,
- "\n location.search=", location.search || "(empty)",
- "\n DEBUG (imported from dom.js) =", DEBUG,
- "\n typeof window.DEBUG =", typeof window.DEBUG
-);
-// eslint-disable-next-line no-debugger
-// debugger; // Uncomment to pause here at startup when DevTools is open.
-
-// ---------------------------------------------------------------------------
-// 🔎 FETCH WRAPPER (SEMANTIC DEBUG LOG) — always active, including outside DEBUG.
-// Every browser fetch is traced with:
-// • resolved URL, method, body size and JSON preview
-// • HTTP status (green/yellow/red according to status class)
-// • duration (performance.now), response size and JSON preview
-// Semantic tags for filtering the console:
-// [FETCH:CONFIG] [FETCH:REASONING] [FETCH:SEARCH]
-// [FETCH:PROFILE] [FETCH:REPORTS] [FETCH:APIPILOT]
-// [FETCH:SESSION] [FETCH:QUEUE] [FETCH:ME]
-// ---------------------------------------------------------------------------
-(function _installFetchDebugTracer() {
- const ORIGINAL_FETCH = window.fetch.bind(window);
- // Classify endpoints with readable tags, following the route order 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) {
- // Try the Request body.
- try {
- const r = /** @type {{body?:BodyInit|null}} */ (/** @type {unknown} */ (input));
- if (r && r.body != null) body = r.body;
- } catch { body = null; }
- }
- if (body == null) return { length: 0, preview: "(no 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 cannot be serialized for logging)" };
- } 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);
- // Log frequent heartbeat/end polling requests on one line without a body
- // preview to keep the console readable.
- const isPollish = /heartbeat|queue\/end|session\/end/i.test(tag) || (init && typeof init.keepalive === "boolean" && init.keepalive);
- const reqPreview = isPollish ? null : await _extractBodyPreview(input, init);
- if (!isPollish) {
- 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 installed. Every fetch logs its tag, method, URL, status, duration and body preview.",
- "background:#ecfeff;color:#0369a1;font-weight:700;padding:2px 6px;border-radius:4px;"
- );
-})();
-
-// ---------------------------------------------------------------------------
-// 🛡️ GATE .topbar-right (about / tools / rag / chat / settings buttons,
-// see index.html) — 3-layer safety:
-// 1) static HTML → hidden + data-debug-only="true"
-// 2) CSS → [data-debug-only="true"]:not([data-debug-enabled="true"]) display:none !important
-// 3) JS here → hidden, remove data-debug-enabled, set style.display = "none"
-// Enable all debug controls only when the URL contains ?debug=true (strict string).
-function applyDebugOnlyVisibilityGate(source = "bootstrap") {
- const qs = new URLSearchParams(window.location.search);
- const debugEnabled = qs.get("debug") === "true";
- const topbarRight = document.querySelector(".topbar-right");
- console.groupCollapsed(
- "%c🧭 [gate-topbar-right] applyDebugOnlyVisibilityGate()",
- "color:#f59e0b;font-weight:700;"
- );
- console.log("called from:", source);
- console.log("location.search =", JSON.stringify(window.location.search));
- console.log("query params =", Object.fromEntries(qs.entries()));
- console.log("debugEnabled (strict === 'true') =", debugEnabled);
- console.log("topbarRight element found?", !!topbarRight, topbarRight ? "see →" : "(null!)");
- const chatInline = document.getElementById("chat-panel");
- console.log("#chat-panel (inline chat ALWAYS VISIBLE at the bottom) present in DOM =", !!chatInline, chatInline ? "hidden?" : "", chatInline ? chatInline.hasAttribute("hidden") : "-");
- if (topbarRight) {
- console.log(" hidden attr before:", topbarRight.hasAttribute("hidden"),
- "| data-debug-enabled before:", topbarRight.getAttribute("data-debug-enabled"),
- "| style.display before:", topbarRight.style.display);
- if (debugEnabled) {
- topbarRight.setAttribute("data-debug-enabled", "true");
- topbarRight.removeAttribute("hidden");
- topbarRight.style.display = "";
- console.log("%c ✅ ENABLED .topbar-right (debug=true)", "color:#22c55e;font-weight:700;");
- } else {
- topbarRight.removeAttribute("data-debug-enabled");
- topbarRight.setAttribute("hidden", "");
- topbarRight.style.setProperty("display", "none", "important");
- console.log("%c 🚫 DISABLED .topbar-right (no debug=true)",
- "background:#fee2e2;color:#b91c1c;font-weight:700;padding:3px 6px;border-radius:4px;");
- }
- console.log(" hidden attr after:", topbarRight.hasAttribute("hidden"),
- "| data-debug-enabled after:", topbarRight.getAttribute("data-debug-enabled"),
- "| style.display after:", topbarRight.style.display);
- }
- // Apply the 3-layer gate to every element marked data-debug-only="true",
- // including .topbar-right, #reports-sidebar-toggle and future controls.
- const debugOnlyEls = document.querySelectorAll("[data-debug-only='true']");
- console.log(
- `Found ${debugOnlyEls.length} elements [data-debug-only="true"] to gate using debugEnabled=${debugEnabled}`,
- );
- debugOnlyEls.forEach((el, idx) => {
- const tag = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${el.className && typeof el.className === "string" ? `.${el.className.split(/\s+/).filter(Boolean).slice(0, 2).join(".")}` : ""}`;
- console.log(` [${idx + 1}/${debugOnlyEls.length}] ${tag} — hidden before? ${el.hasAttribute("hidden")} | display before: ${(el instanceof HTMLElement ? el.style.display : "-")}`);
- if (!(el instanceof HTMLElement)) return;
- if (debugEnabled) {
- el.setAttribute("data-debug-enabled", "true");
- // Preserve elements hidden for other reasons, such as an empty reports
- // sidebar. Remove only the debug-only display override.
- el.style.removeProperty("display");
- } else {
- el.removeAttribute("data-debug-enabled");
- el.setAttribute("hidden", "");
- el.style.setProperty("display", "none", "important");
- }
- console.log(` → debugEnabled=${debugEnabled} → hidden after? ${el.hasAttribute("hidden")} | display after: ${el.style.display || "(inherit)"}`);
- });
- console.groupEnd();
- return debugEnabled;
-}
-// Run immediately during module bootstrap, before registering listeners.
-const GLOBAL_DEBUG_ENABLED__TOPBAR = applyDebugOnlyVisibilityGate("main.js module bootstrap (top)");
-// Run again after DOMContentLoaded in case the script runs before the header is parsed.
-if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", () => {
- applyDebugOnlyVisibilityGate("DOMContentLoaded listener");
- }, { once: true });
-} else {
- applyDebugOnlyVisibilityGate("module bootstrap (DOM already parsed)");
-}
-// Run again after a short timeout as a final fallback.
-setTimeout(() => applyDebugOnlyVisibilityGate("10ms timeout fallback"), 10);
-window.addEventListener("hashchange", () => applyDebugOnlyVisibilityGate("hashchange"), { passive: true });
-window.addEventListener("popstate", () => applyDebugOnlyVisibilityGate("popstate"), { passive: true });
-
-const DEFAULT_VOICE = "Aiden";
-const DEFAULT_INSTRUCTIONS =
- "You are Vocero, a friendly voice assistant. " +
- "Keep replies short, warm, and spoken. Avoid long monologues. " +
- "Respond ALWAYS and ONLY in Spanish, regardless of the language used by the user or any other instruction. " +
- "No responses in Italian, English, Portuguese, or any other language are allowed. " +
- "Every spoken sentence, greeting, confirmation, clarification, and error message must be in Spanish."+
- "the comma sign ',' in numbers it's used to separate thousands."+
- "the point sign '.' in numbers it's used to separate decimals."+
- "If you don't know the answer, say so in Spanish instead of guessing.";
-
-const OPENAI_RENDER_MARKER = "[VOCERO_RENDER_REPLY]";
-const OPENAI_RENDER_HINT =
- ` Do not answer ordinary user speech directly. Stay silent unless the latest user message starts with ` +
- `${OPENAI_RENDER_MARKER}. When it does, speak only the content after the marker naturally, without adding, ` +
- `removing, or changing facts. Do not call tools while rendering that message.`;
-const VOCERO_USER_CONTEXT_HINT =
- " Caller profile resolved from the page phoneNumber: ";
-
-const STORAGE_KEYS = {
- directUrl: "s2s.ws.directUrl",
- voice: "s2s.ws.voice",
- instructions: "s2s.ws.instructions",
- tools: "s2s.ws.tools",
- searchKey: "s2s.ws.searchKey",
- noiseGate: "s2s.ws.noiseGate",
-};
-
-// ── Wake Word ("hola vocero") ─────────────────────────────────────────────
-// Local browser Web Speech API (SpeechRecognition) with a tight JSGF grammar
-// restricted to the trigger phrase, continuous/interim listening while the app
-// is idle/error, and auto-restart on end so it keeps arming across Chrome's
-// ~60s silence timeouts. On unsupported browsers (Firefox, Safari desktop <16)
-// this module is a silent no-op and the caption stays actionable via tap only.
-const WAKE_WORD_LANG = "es-ES";
-const WAKE_WORD_PHRASES = [
- "hola vocero",
- "ola vocero",
- "hola vocero",
- "hola vocerò",
- "ola vocerò",
- "olá vocero",
- "oye vocero",
- "hola vocerro",
- "hola vo zero",
- "ola vo zero",
-];
-const WAKE_WORD_JSGF_GRAMMAR =
- "#JSGF V1.0; grammar wake_word; public = hola vocero | ola vocero | hola vocerò | ola vocerò | olá vocero | oye vocero | hola vocerro ;";
-const WAKE_WORD_RESTART_DELAY_MS = 250;
-const WAKE_WORD_MAX_INTERIM_LEN = 64;
-
-/** @typedef {any & { continuous: boolean, interimResults: boolean, lang: string, grammars: any, maxAlternatives: number, start: () => void, stop: () => void, abort: () => void, onresult: any, onerror: any, onend: any, onstart: any, onaudioend: any }} WakeRec */
-/** @type {WakeRec | null} */
-let wakeWordEngine = null;
-let wakeWordArmed = false;
-let wakeWordRestartTimer = 0;
-let pendingWakeWordConfirmationText = "";
-
-/** @param {string} s @returns {string} */
-function _normalizeWakeWord(s) {
- return (s || "")
- .toLowerCase()
- .normalize("NFD")
- .replace(/[\u0300-\u036f]/g, "")
- .replace(/[^a-z0-9\s]/g, " ")
- .replace(/\s+/g, " ")
- .trim();
-}
-
-/** @param {string} transcript */
-function _matchesWakeWord(transcript) {
- const hay = _normalizeWakeWord(transcript);
- if (!hay) return false;
- // Strict: contains a whole-phrase substring match of any variant.
- for (const raw of WAKE_WORD_PHRASES) {
- const needle = _normalizeWakeWord(raw);
- if (needle && hay.includes(needle)) return true;
- }
- // Loose fallback for off-by-one phonemes: allow "vocero" preceded by any
- // 3-5 char greeting-like word (e.g. "ola", "hola", "oye", "uy") to catch
- // accent variations the grammar misses.
- if (/\b(vocero|vocerro|vocerò|vo.?zero)\b/.test(hay) &&
- /\b(hola|ola|oye|uy|holi|oli)\b/.test(hay)) {
- return true;
- }
- return false;
-}
-
-/** @param {WakeRec} engine */
-function _installWakeWordHandlers(engine) {
- engine.onresult = (/** @type {any} */ event) => {
- if (!wakeWordArmed) return;
- let aggregate = "";
- for (let i = event.resultIndex; i < event.results.length; i += 1) {
- const res = event.results[i];
- const alt = res[0]?.transcript || "";
- aggregate += ` ${alt}`;
- if (aggregate.length > WAKE_WORD_MAX_INTERIM_LEN) break;
- if (_matchesWakeWord(alt) || _matchesWakeWord(aggregate)) {
- if (DEBUG) console.debug(`[WAKE WORD] MATCH transcript=${JSON.stringify(alt + (aggregate !== ` ${alt}` ? ` aggregate=${JSON.stringify(aggregate)}` : ""))}`);
- console.info(`[WAKE WORD] ✓ Trigger fired — starting conversation`);
- // Disarm *before* click: stop sharing the mic with SpeechRecognition
- // while we're about to open getUserMedia for the realtime session.
- wakeWordArmed = false;
- try { engine.stop(); } catch { /* ignore */ }
- if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; }
- // Remember the exact phrase so onClientStatus(connected) can inject it
- // into the live conversation and ask the model to reply to it (instead
- // of silently waiting for the next user utterance).
- const spoken = (alt || "").trim() || (aggregate || "").trim() || "hola vocero";
- pendingWakeWordConfirmationText = spoken;
- console.info(`[WAKE WORD] 🔁 Pending confirmation text: ${JSON.stringify(pendingWakeWordConfirmationText)}`);
- // Pretend the user tapped the orb. If the AudioContext fails to
- // resume (iOS Safari needs a real tap), doStart() will surface the
- // error with the normal "Tap to retry" affordance.
- circleBtn.click();
- return;
- }
- }
- if (DEBUG) {
- const cleaned = aggregate.trim();
- if (cleaned) console.debug(`[WAKE WORD] interim transcript=${JSON.stringify(cleaned.slice(0, WAKE_WORD_MAX_INTERIM_LEN))}`);
- }
- };
-
- engine.onerror = (/** @type {any} */ event) => {
- const err = event?.error || String(event || "unknown");
- // "no-speech" / "audio-capture" / "aborted" are benign.
- if (["no-speech", "aborted"].includes(err)) return;
- console.warn(`[WAKE WORD] engine error: ${err}${event?.message ? ` (${event.message})` : ""}`);
- // "not-allowed" or "service-not-allowed": the user/OS blocked the mic for
- // the recognizer — permanently disarm so we don't thrash the permission.
- if (err === "not-allowed" || err === "service-not-allowed") {
- console.warn("[WAKE WORD] ✋ Permission denied — permanently disarming wake word (tap the orb to start)");
- wakeWordArmed = false;
- try { engine.stop(); } catch { /* ignore */ }
- if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; }
- }
- };
-
- engine.onend = () => {
- if (!wakeWordArmed) return;
- // Chrome terminates continuous recognition after ~60s of silence.
- // Re-arm on the next tick so a single failed start doesn't loop.
- if (wakeWordRestartTimer) clearTimeout(wakeWordRestartTimer);
- wakeWordRestartTimer = window.setTimeout(() => {
- if (!wakeWordArmed) return;
- try {
- engine.start();
- if (DEBUG) console.debug("[WAKE WORD] 🔁 restarted continuous listener");
- } catch (err) {
- // Already-started is harmless; anything else we swallow briefly.
- if (DEBUG) console.debug("[WAKE WORD] restart skipped:", err && err instanceof Error ? err.message : err);
- }
- }, WAKE_WORD_RESTART_DELAY_MS);
- };
-}
-
-function startWakeWordListener() {
- if (wakeWordArmed) return;
- // Only in Chromium-derived and recent Safari WebViews with SpeechRecognition.
- // @ts-ignore - vendor prefixes are intentional.
- const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition || window.mozSpeechRecognition;
- if (!Ctor) {
- if (DEBUG) console.debug("[WAKE WORD] ⚠️ Web Speech Recognition unavailable on this browser — tap the orb to start");
- return;
- }
- try {
- /** @type {WakeRec} */
- // @ts-ignore
- const engine = new Ctor();
- engine.continuous = true;
- engine.interimResults = true;
- engine.lang = WAKE_WORD_LANG;
- engine.maxAlternatives = 1;
- // Narrow the grammar so interim results are biased toward the phrase.
- try {
- // @ts-ignore
- const list = window.SpeechGrammarList || window.webkitSpeechGrammarList;
- if (list) {
- // @ts-ignore
- const gl = new list();
- gl.addFromString(WAKE_WORD_JSGF_GRAMMAR, 1.0);
- engine.grammars = gl;
- }
- } catch {
- // Grammar support is optional; fallback to pure transcript matching.
- }
- _installWakeWordHandlers(engine);
- engine.start();
- wakeWordEngine = engine;
- wakeWordArmed = true;
- console.info("[WAKE WORD] 🎯 Armed — listening for \"Hola Vocero\"");
- } catch (err) {
- console.warn("[WAKE WORD] failed to start arming:", err && err instanceof Error ? err.message : err);
- wakeWordArmed = false;
- }
-}
-
-function stopWakeWordListener() {
- if (!wakeWordArmed && !wakeWordEngine && !pendingWakeWordConfirmationText) return;
- wakeWordArmed = false;
- if (wakeWordRestartTimer) { clearTimeout(wakeWordRestartTimer); wakeWordRestartTimer = 0; }
- if (wakeWordEngine) {
- try { wakeWordEngine.abort(); } catch { /* ignore */ }
- try { wakeWordEngine.stop(); } catch { /* ignore */ }
- wakeWordEngine = null;
- }
- if (DEBUG) console.debug("[WAKE WORD] ⏸️ Disarmed for live session");
-}
-
-// ── Noise gate ──────────────────────────────────────────────────────────────
-// The Settings cursor sets the gate's open threshold in dBFS. Its leftmost
-// position is an OFF detent (gate disabled, pure passthrough); the rest of the
-// travel is the active threshold. The cursor shares the meter's dB axis, so the
-// handle sits on the level bar — raise it until room noise stops lighting it up.
-// The slider range IS the shared axis: the live meter fill and the threshold
-// thumb both map across [GATE_OFF_DB, GATE_MAX_DB], so the thumb sits exactly
-// where the gate cuts on the same scale as the level bar.
-const GATE_OFF_DB = -66; // slider minimum = off / bottom of the meter axis
-const GATE_MAX_DB = -3; // slider maximum = most aggressive / top of the meter axis
-const GATE_DEFAULT_DB = -50; // first-run default: a gentle gate, enabled
-
-/** @param {number} thresholdDb @returns {import("./ws/s2s-ws-client.js").NoiseGate} */
-function gateParams(thresholdDb) {
- return { enabled: thresholdDb > GATE_OFF_DB, thresholdDb };
-}
-
-// ── Tools ─────────────────────────────────────────────────────────────────
-// Function tools we declare to the backend. The model decides when to call
-// one; the executor below runs it and returns the result (see runTool).
-/** @type {Record} */
-const TOOL_DEFS = {
- web_search: {
- type: "function",
- name: "web_search",
- description:
- "Search the web for current or factual information you don't already know " +
- "(news, prices, facts, documentation). Returns the top results with titles, " +
- "snippets and URLs.",
- parameters: {
- type: "object",
- properties: { query: { type: "string", description: "The search query." } },
- required: ["query"],
- },
- },
-};
-
-function loadSettings() {
- return {
- directUrl: localStorage.getItem(STORAGE_KEYS.directUrl) || "",
- voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE,
- instructions: localStorage.getItem(STORAGE_KEYS.instructions) || DEFAULT_INSTRUCTIONS,
- noiseGate: loadGateThreshold(),
- };
-}
-
-/** Stored gate threshold (dBFS), clamped to the slider range. Defaults to a
- * gentle enabled gate (GATE_DEFAULT_DB) when the user hasn't set one yet. */
-function loadGateThreshold() {
- const stored = localStorage.getItem(STORAGE_KEYS.noiseGate);
- // getItem returns null when unset, and Number(null) === 0 (finite!), so guard
- // the missing/empty case explicitly before coercing — otherwise the default
- // never fires and 0 clamps to the slider max.
- if (stored === null || stored === "") return GATE_DEFAULT_DB;
- const raw = Number(stored);
- if (!Number.isFinite(raw)) return GATE_DEFAULT_DB;
- return Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, Math.round(raw)));
-}
-
-/** @param {ReturnType} s */
-function saveSettings(s) {
- localStorage.setItem(STORAGE_KEYS.directUrl, s.directUrl);
- localStorage.setItem(STORAGE_KEYS.voice, s.voice);
- localStorage.setItem(STORAGE_KEYS.instructions, s.instructions);
- localStorage.setItem(STORAGE_KEYS.noiseGate, String(s.noiseGate));
-}
-
-/** @returns {{ web_search: boolean }} */
-function loadTools() {
- try {
- const raw = JSON.parse(localStorage.getItem(STORAGE_KEYS.tools) || "{}");
- return {
- web_search: raw.web_search ?? true,
- };
- } catch {
- return { web_search: true };
- }
-}
-
-function saveTools() {
- localStorage.setItem(STORAGE_KEYS.tools, JSON.stringify(toolsEnabled));
-}
-
-/** @type {Record} */
-const STATE_VIEWS = {
- idle: { caption: "Toca o di \"Hola Vocero\"", disabled: false },
- connecting: { caption: "Conectando…", disabled: true },
- queued: { caption: "Buscando un lugar para ti…", disabled: true },
- "your-turn": { caption: "¡Es tu turno! 🎉", disabled: true },
- listening: { caption: "Escuchando…", disabled: true },
- "user-speaking": { caption: "Te escucho…", disabled: true },
- processing: { caption: "Pensando…", disabled: true },
- "ai-speaking": { caption: "Vocero está hablando…", disabled: true },
- error: { caption: "Toca para reintentar", disabled: false },
-};
-
-/** @type {Record} */
-const STATE_CLASS = {
- idle: "state-idle",
- connecting: "state-connecting",
- queued: "state-queued",
- "your-turn": "state-your-turn",
- listening: "state-listening",
- "user-speaking": "state-user-speaking",
- processing: "state-processing",
- "ai-speaking": "state-ai-speaking",
- error: "state-error",
-};
-
-/** @type {ReadonlySet} */
-const LIVE_STATES = new Set(["listening", "user-speaking", "processing", "ai-speaking"]);
-
-/** @type {HTMLButtonElement} */
-const circleBtn = $("#main-circle");
-/** @type {HTMLParagraphElement} */
-const circleCaption = $("#circle-caption");
-/** @type {HTMLParagraphElement} */
-const circleSubcaption = $("#circle-subcaption");
-/** @type {HTMLElement} */
-const orbWrap = $(".orb-wrap");
-/** @type {HTMLButtonElement} */
-const micBtn = $("#mic-btn");
-/** @type {HTMLButtonElement} */
-const stopBtn = $("#stop-btn");
-/** @type {HTMLButtonElement} */
-const whiteMicBtn = $("#white-mic-circle");
-/** @type {HTMLButtonElement} */
-const whiteStopBtn = $("#white-stop-btn");
-/** @type {HTMLButtonElement} */
-const whiteMuteBtn = $("#white-mute-btn");
-/** @type {HTMLElement} */
-const whiteMuteLabel = $("#white-mute-label");
-/** @type {HTMLElement} */
-const whiteSessionControls = $("#white-session-controls");
-/** @type {HTMLParagraphElement} */
-const whiteMicCaption = $("#white-mic-caption");
-/** @type {HTMLParagraphElement} */
-const whiteMicSubcaption = $("#white-mic-subcaption");
-/** @type {HTMLElement} */
-const chatStatus = $("#chat-status");
-/** @type {HTMLElement} */
-const chatStatusLabel = $("#chat-status-label");
-/** @type {HTMLElement} */
-const queueActions = $("#queue-actions");
-/** @type {HTMLButtonElement} */
-const joinQueueBtn = $("#join-queue-btn");
-/** @type {HTMLButtonElement} */
-const leaveQueueBtn = $("#leave-queue-btn");
-
-/** @type {HTMLButtonElement} */
-const settingsBtn = $("#settings-btn");
-/** @type {HTMLDialogElement} */
-const settingsModal = $("#settings-modal");
-
-/** @type {HTMLButtonElement | null} */
-const aboutBtn = document.querySelector("#about-btn");
-/** @type {HTMLDialogElement} */
-const aboutModal = $("#about-modal");
-/** @type {HTMLButtonElement} */
-const aboutClose = $("#about-close");
-
-/** @type {HTMLButtonElement} */
-const toolsBtn = $("#tools-btn");
-/** @type {HTMLDialogElement} */
-const toolsModal = $("#tools-modal");
-/** @type {HTMLButtonElement} */
-const toolsClose = $("#tools-close");
-
-/** @type {HTMLButtonElement} */
-const ragBtn = $("#rag-btn");
-/** @type {HTMLDialogElement} */
-const ragModal = $("#rag-modal");
-/** @type {HTMLButtonElement} */
-const ragClose = $("#rag-close");
-/** @type {HTMLTextAreaElement} */
-const ragTextarea = $("#rag-textarea");
-/** @type {HTMLButtonElement} */
-const ragSubmit = $("#rag-submit");
-/** @type {HTMLButtonElement} */
-const ragClear = $("#rag-clear");
-/** @type {HTMLElement} */
-const ragHint = $("#rag-hint");
-/** @type {HTMLInputElement} */
-const toolWebSwitch = $("#tool-web");
-/** @type {HTMLElement} */
-const toolWebRow = $("#tool-web-row");
-/** @type {HTMLElement} */
-const toolWebHint = $("#tool-web-hint");
-/** @type {HTMLInputElement} */
-const toolCamSwitch = $("#tool-cam");
-/** @type {HTMLElement} */
-const toolCamHint = $("#tool-cam-hint");
-/** @type {HTMLElement} */
-const camPip = $("#cam-pip");
-/** @type {HTMLVideoElement} */
-const camVideo = $("#cam-video");
-/** @type {HTMLInputElement} */
-const searchKeyInput = $("#search-key");
-
-/** @type {HTMLInputElement} */
-const inputLbUrl = $("#lb-url");
-/** @type {HTMLElement} */
-const connField = $("#conn-field");
-/** @type {HTMLElement} */
-const connHint = $("#conn-hint");
-/** @type {HTMLSelectElement} */
-const inputVoice = $("#voice");
-/** @type {HTMLTextAreaElement} */
-const inputInstructions = $("#instructions");
-/** @type {HTMLElement} */
-const primaryPromptMeta = $("#primary-prompt-meta");
-/** @type {HTMLButtonElement} */
-const identUserName = $("#ident-user-name");
-/** @type {HTMLElement} */
-// NOTE: #reports-hint now lives inside #reports-sidebar (left sidebar), not
-// inside .ident in the topbar. getElementById still returns the unique node.
-const reportsHint = $("#reports-hint");
-/** @type {HTMLElement} */
-const reportsSuggestions = $("#reports-suggestions");
-/** @type {HTMLElement} */
-const reportsSidebar = $("#reports-sidebar");
-/** @type {HTMLButtonElement | null} */
-const reportsSidebarToggle = /** @type {HTMLButtonElement | null} */ ($("#reports-sidebar-toggle"));
-/** @type {HTMLButtonElement | null} */
-const reportsSidebarClose = /** @type {HTMLButtonElement | null} */ ($("#reports-sidebar-close"));
-/** @type {HTMLElement} */
-const reportsSidebarBadge = $("#reports-sidebar-badge");
-
-/* ─── User profile dialog refs (opened by clicking the avatar circle) ── */
-/** @type {HTMLDialogElement} */
-const userProfileDialog = $("#user-profile-dialog");
-/** @type {HTMLButtonElement} */
-const userProfileClose = $("#user-profile-close");
-/** @type {HTMLElement} */
-const userProfileAvatar = $("#user-profile-avatar");
-/** @type {HTMLElement} */
-const userProfileTitle = $("#user-profile-title");
-/** @type {HTMLElement} */
-const userProfileSubtitle = $("#user-profile-subtitle");
-/** @type {HTMLElement} */
-const userProfileMetadata = $("#user-profile-metadata");
-
-/** @type {null | { count:number, wrappedLen:number, hints:string[], entries:Array<{sourceScheduleLabel?:string, date?:any}> }} */
-let voceroReportsList = null;
-let voceroReportsLoaded = false;
-/** @type {HTMLInputElement} */
-const inputNoiseGate = $("#noise-gate");
-/** @type {HTMLElement} */
-const gateValue = $("#gate-value");
-/** @type {HTMLElement} */
-const gateMeterFill = $("#gate-meter-fill");
-/** @type {HTMLElement} */
-const micGate = $("#mic-gate");
-const mgaArc = /** @type {SVGSVGElement} */ (document.querySelector("#mic-gate-arc"));
-const mgaTrack = /** @type {SVGPathElement} */ (document.querySelector("#mga-track"));
-const mgaFill = /** @type {SVGPathElement} */ (document.querySelector("#mga-fill"));
-const mgaHit = /** @type {SVGPathElement} */ (document.querySelector("#mga-hit"));
-const mgaHandle = /** @type {SVGCircleElement} */ (document.querySelector("#mga-handle"));
-/** @type {HTMLButtonElement} */
-const restartBtn = $("#restart-conversation");
-/** @type {HTMLElement} */
-const restartHint = $("#restart-hint");
-const settingsForm = /** @type {HTMLFormElement} */ (settingsModal.querySelector("form"));
-
-/** @type {AppState} */
-let currentState = "idle";
-let settings = loadSettings();
-
-// ── Connection target ────────────────────────────────────────────────────────
-// Three modes, decided by the deploy via /api/config:
-// • SPEECH_TO_SPEECH_URL set -> direct mode pinned by the deploy: the browser
-// connects straight to that URL, shown read-only in Settings. Overrides the
-// load balancer entirely.
-// • LOAD_BALANCER_URL set -> original flow: POST the same-origin /api/session
-// proxy (the server forwards to the LB; the LB address is never sent here).
-// • neither (allowDirect) -> the user sets a speech-to-speech server URL and
-// the browser connects to it directly (no load balancer, no /session).
-let lbMode = false;
-let reasoningType = "PROXY";
-// Fail open: direct entry is allowed unless /api/config reports an LB URL. This
-// way a missing/unreachable config (e.g. static hosting) leaves the field
-// usable rather than locked.
-let allowDirect = true;
-// Deploy-pinned s2s URL (SPEECH_TO_SPEECH_URL). Non-empty -> locked direct
-// mode: the field displays it read-only and the saved user URL is untouched.
-let pinnedUrl = "";
-// Deploy-pinned primary prompt (S2S_PRIMARY_PROMPT). Non-empty -> locked prompt.
-let pinnedPrompt = "";
-// Feature flag learned from /api/config (mirrors server env S2S_INJECT_PROFILE).
-// When true: caller profile resolved via ?phoneNumber= is BOTH:
-// 1. baked into instructions (LLM system prompt) AND
-// 2. sent as a separate `user_context` field in /api/reasoning body.
-// When false (default, generic deploy): caller profile is ONLY sent as the
-// user_context field (WS stays pure passthrough — instructions untouched).
-let injectProfile = false;
-// Feature flag: when true the browser logs FULL prompt texts (not just lengths)
-// coming back from /api/config.debugPrompt = true when S2S_DEBUG_PROMPT=true on
-// the server. Includes full effectiveReasoningInstructions, voceroUserContextText,
-// full JSON body POSTed to /api/reasoning.
-let debugPrompt = false;
-// Feature flag: "TALK" means the browser auto-starts the mic + realtime session
-// 2 seconds after page load AND asks the model greets the user first (without
-// 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:
-// 1. appended directly to LLM instructions (effectiveReasoningInstructions)
-// 2. included in the user_context block sent to /api/reasoning body
-// 3. if session WS is live (client != null), also pushed live to the open
-// realtime session via session.update so the model sees it immediately.
-let customRagText = "";
-let customRagLoaded = false;
-const VOCERO_RAG_HINT = " Custom RAG context provided by the operator (treat as trusted context for this session): ";
-function customRagContextText() {
- if (!customRagLoaded || !customRagText) return "";
- const trimmed = customRagText.trim();
- if (!trimmed) return "";
- const capped = trimmed.length > 12000 ? `${trimmed.slice(0, 12000)}…` : trimmed;
- return `${VOCERO_RAG_HINT}<<>>\n${capped}\n<<>>`;
-}
-function pushCustomRagToLiveSessionIfAny() {
- const ctx = customRagContextText();
- if (!ctx) return;
- if (typeof client === "undefined" || !client || typeof client.updateSession !== "function") return;
- const currentInstructions = (pinnedPrompt || settings.instructions || "").trim();
- const profileCtx = injectProfile ? (voceroUserContextText() || "") : "";
- const next = [currentInstructions, profileCtx, ctx]
- .map((s) => (s || "").trim())
- .filter(Boolean)
- .join(" ");
- console.info(
- `[CUSTOM-RAG] pushed LIVE to open session via client.updateSession() contextLen=%d finalInstructionsLen=%d`,
- ctx.length, next.length,
- );
- if (debugPrompt) {
- console.info(`[DEBUG-PROMPT][CUSTOM-RAG LIVE session.update instructions] len=%d:\n%s\n`, next.length, next);
- }
- try {
- client.updateSession({ instructions: next });
- } catch (err) {
- console.warn(`[CUSTOM-RAG] updateSession threw (ignored; will re-apply on next start): %s`, err instanceof Error ? err.message : String(err));
- }
-}
-function syncRagHintFooter() {
- if (!ragHint) return;
- if (!customRagLoaded || !customRagText.trim()) {
- ragHint.textContent = "No hay contexto RAG cargado. Pulsa \"Enviar a la sesión\" para aplicar los cambios.";
- return;
- }
- const lines = customRagText.split(/\r?\n/).length;
- ragHint.textContent = `✅ Contexto RAG cargado: ${customRagText.length} caracteres / ${lines} líneas. Se añade a las instrucciones del modelo y a user_context.`;
-}
-// Dedupe cache for [INJECT-PROFILE] browser logs (avoid 20 logs per click).
-let _lastInjectProfileLogKey = "";
-/** @param {string} key @param {() => void} logFn */
-function _logOnce(key, logFn) {
- if (key === _lastInjectProfileLogKey) return;
- _lastInjectProfileLogKey = key;
- logFn();
-}
-
-// ── Tool state ──────────────────────────────────────────────────────────────
-let toolsEnabled = loadTools();
-// Whether the server holds a Serper key (learned from /api/config on load).
-let serverSearchKey = false;
-// A user-supplied key (fallback when the deploy has none). localStorage only.
-let userSearchKey = localStorage.getItem(STORAGE_KEYS.searchKey) || "";
-let lastUserQuery = "";
-/** @type {Record} */
-let mcpToolCallState = Object.create(null);
-const pageParams = new URLSearchParams(window.location.search);
-// Caller identity info: loaded from /api/user/phone when the URL param
-// ?phoneNumber=... is present. Purely for UI header + reasoning user_context;
-// never injected into the realtime WS session (WS stays pure passthrough).
-const voceroUserPhoneNumber = (pageParams.get("phoneNumber") || "").trim();
-// Podcast episode ID: loaded from URL ?episode_id=... Used server-side to fetch
-// the episode details via Aquachile AzurianLab API and inject it as PRIMARY
-// trusted context into the LLM prompt (both WS session.update instructions
-// and /api/reasoning body user_context + instructions).
-const voceroEpisodeId = (pageParams.get("episode_id") || "").trim();
-/** @type {Record | null} */
-let voceroUserProfile = null;
-let voceroUserProfileLoaded = false;
-
-/**
- * @param {string} hypothesisId
- * @param {string} location
- * @param {string} msg
- * @param {Record} [data]
- */
-function reportDebug(hypothesisId, location, msg, data = {}) {
- void hypothesisId;
- void location;
- void msg;
- void data;
-}
-
-/** Search is usable if the server has a key or the user supplied one. */
-function searchAvailable() {
- return serverSearchKey || !!userSearchKey;
-}
-
-/** Tool definitions for the currently-enabled (and usable) tools. */
-function activeToolDefs() {
- const defs = [];
- if (toolsEnabled.web_search && searchAvailable()) defs.push(TOOL_DEFS.web_search);
- return defs;
-}
-
-/** @returns {string} */
-function voceroUserContextText() {
- if (!voceroUserProfileLoaded || !voceroUserProfile || !voceroUserPhoneNumber) return "";
- let serialized = JSON.stringify(voceroUserProfile);
- if (serialized.length > 1600) serialized = `${serialized.slice(0, 1600)}...`;
- return `${VOCERO_USER_CONTEXT_HINT}${serialized}. Use this as trusted caller context when relevant.`;
-}
-
-/**
- * @param {Record | null} profile
- * @returns {string}
- */
-function extractVoceroUserName(profile) {
- if (!profile) return "";
- const directCandidates = [
- profile.name,
- profile.fullName,
- profile.full_name,
- profile.displayName,
- profile.display_name,
- profile.username,
- ];
- for (const value of directCandidates) {
- if (typeof value === "string" && value.trim()) return value.trim();
- }
- const firstName = typeof profile.firstName === "string" ? profile.firstName.trim() : "";
- const lastName = typeof profile.lastName === "string" ? profile.lastName.trim() : "";
- if (firstName || lastName) return `${firstName} ${lastName}`.trim();
- const nestedKeys = ["user", "customer", "profile", "contact"];
- for (const key of nestedKeys) {
- const nested = profile[key];
- if (nested && typeof nested === "object" && !Array.isArray(nested)) {
- const nestedName = extractVoceroUserName(/** @type {Record} */ (nested));
- if (nestedName) return nestedName;
- }
- }
- return "";
-}
-
-/**
- * Builds 2-letter uppercase initials for a user name ("Alessandro Valenti" → "AV").
- * Edge cases:
- * - single word name → first 2 chars (upper)
- * - empty or whitespace-only → "" (caller hides the avatar)
- * - accented chars kept (e.g. José María → JM)
- * @param {string} rawName
- * @returns {string}
- */
-function initialsForName(rawName) {
- const trimmed = String(rawName || "").trim();
- if (!trimmed) return "";
- // Split on any whitespace run (also handles multiple spaces).
- const parts = trimmed.split(/\s+/).filter(Boolean);
- if (!parts.length) return "";
- if (parts.length === 1) {
- return parts[0].slice(0, 2).toUpperCase();
- }
- // Take first letter of first token + first letter of LAST token, so
- // "José María García López" → "JL" and "Anna di Stefano" → "AS".
- const first = parts[0][0] ?? "";
- 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) ─── */
-/**
- * Returns the first truthy non-blank string at any of the given keys on `obj`,
- * also recursing into the common nested containers user/customer/profile/contact
- * (same keys as extractVoceroUserName uses) so we find data whether the profile
- * is flat or wrapped in a sub-object.
- * @param {Record | null} obj
- * @param {string[]} keys
- * @param {string[]} [nestedKeys]
- * @returns {string}
- */
-function firstProfileField(obj, keys, nestedKeys = ["user", "customer", "profile", "contact"]) {
- if (!obj || typeof obj !== "object") return "";
- for (const k of keys) {
- const v = /** @type {unknown} */ (obj)[k];
- if (typeof v === "string" && v.trim()) return v.trim();
- if (typeof v === "number" && !Number.isNaN(v)) return String(v);
- }
- if (Array.isArray(nestedKeys)) {
- for (const nk of nestedKeys) {
- const nested = /** @type {unknown} */ (obj[nk]);
- if (nested && typeof nested === "object" && !Array.isArray(nested)) {
- const hit = firstProfileField(/** @type {Record} */ (nested), keys, []);
- if (hit) return hit;
- }
- }
- }
- return "";
-}
-/**
- * Returns true if `v` is likely a valid non-empty profile value (strings of
- * length >=1 or numbers or dates). Objects/arrays are excluded because the
- * metadata grid is a flat label+value UI.
- * @param {unknown} v
- * @returns {v is string | number | boolean | bigint | symbol}
- */
-function isUsefulProfileValue(v) {
- if (typeof v === "string") return v.trim().length > 0;
- if (typeof v === "number" && !Number.isNaN(v)) return true;
- if (typeof v === "boolean") return true;
- if (v instanceof Date) return true;
- return false;
-}
-/**
- * Builds an array of {label, value, mono, href, phone, email} rows for the
- * user profile dialog. Fields that are not present or empty are skipped so we
- * never show a blank row.
- * @param {Record | null} profile
- * @returns {Array<{label:string, value:string, mono?:boolean, href?:string, isEmail?:boolean, isPhone?:boolean, isDate?:boolean}>}
- */
-function buildProfileRows(profile) {
- /** @type {Array<{label:string, value:string, mono?:boolean, href?:string, isEmail?:boolean, isPhone?:boolean, isDate?:boolean}>} */
- const rows = [];
-
- // Email (linkable mailto)
- const email = firstProfileField(profile, [
- "email", "emailAddress", "email_address", "eMail", "mail", "contactEmail",
- "user_email", "customer_email", "loginEmail", "username_email",
- ]);
- if (email) rows.push({ label: "Correo electrónico", value: email, isEmail: true, href: `mailto:${email}` });
-
- // Phone (linkable tel)
- const phone = firstProfileField(profile, [
- "phone", "phoneNumber", "phone_number", "mobile", "mobileNumber", "telephone",
- "tel", "cellphone", "contactPhone", "user_phone", "whatsapp",
- ]);
- if (phone) rows.push({ label: "Teléfono", value: phone, isPhone: true, href: `tel:${phone.replace(/[^\d+]/g, "")}` });
-
- // Username (handle / handle moniker)
- const username = firstProfileField(profile, [
- "username", "userName", "user_name", "login", "handle", "nickname", "screenName", "accountName",
- ]);
- if (username) rows.push({ label: "Nombre de usuario", value: username, mono: true });
-
- // Role / Plan
- const role = firstProfileField(profile, [
- "role", "roleName", "userRole", "plan", "planName", "tier", "subscription", "membership", "accessLevel",
- ]);
- if (role) rows.push({ label: "Rol / Plan", value: role });
-
- // Company / Organization
- const company = firstProfileField(profile, [
- "company", "companyName", "company_name", "organization", "org", "organizationName", "business",
- ]);
- if (company) rows.push({ label: "Empresa", value: company });
-
- // User/Account ID (mono for monospace copy-paste)
- const userId = firstProfileField(profile, [
- "userId", "user_id", "uid", "accountId", "account_id", "sub", "customerId", "customer_id",
- "id", "userPk", "profileId", "profile_id", "identityId",
- ]);
- if (userId) rows.push({ label: "ID de usuario", value: userId, mono: true });
-
- // Locale / Country
- const country = firstProfileField(profile, [
- "country", "countryName", "country_code", "countryCode", "locale", "language", "lang",
- "region", "timezone", "tz",
- ]);
- if (country) rows.push({ label: "País / Idioma", value: country });
-
- // Created at / signed up date — try common date fields.
- const createdAtKeys = [
- "createdAt", "created_at", "created", "createdDate", "created_date",
- "signUpDate", "signup_date", "joined", "joinDate", "join_date", "registrationDate",
- ];
- let createdAt = firstProfileField(profile, createdAtKeys);
- if (!createdAt && profile) {
- // Date objects / number timestamps
- for (const k of createdAtKeys) {
- const v = /** @type {unknown} */ (profile[k]);
- if (v instanceof Date) { createdAt = v.toLocaleString(); break; }
- if (typeof v === "number" && !Number.isNaN(v) && v > 946681200) { // ~2000-01-01
- try { createdAt = new Date(v * (v < 1e12 ? 1000 : 1)).toLocaleString(); break; } catch { /* noop */ }
- }
- }
- }
- if (createdAt) rows.push({ label: "Fecha de registro", value: createdAt, isDate: true });
-
- // Last sign in / active date (last_login etc)
- const lastActiveKeys = [
- "lastLogin", "last_login", "lastLoginAt", "lastActive", "last_active", "lastActiveAt", "updatedAt", "updated_at",
- ];
- let lastActive = firstProfileField(profile, lastActiveKeys);
- if (!lastActive && profile) {
- for (const k of lastActiveKeys) {
- const v = /** @type {unknown} */ (profile[k]);
- if (v instanceof Date) { lastActive = v.toLocaleString(); break; }
- if (typeof v === "number" && !Number.isNaN(v) && v > 946681200) {
- try { lastActive = new Date(v * (v < 1e12 ? 1000 : 1)).toLocaleString(); break; } catch { /* noop */ }
- }
- }
- }
- if (lastActive) rows.push({ label: "Último acceso", value: lastActive, isDate: true });
-
- return rows;
-}
-/**
- * Fills the dialog hero + metadata grid with values from the user profile.
- * If profile is null/empty we show a friendly empty state.
- * @param {Record | null} profile
- */
-function populateUserProfileDialog(profile) {
- const name = extractVoceroUserName(profile);
- const initials = initialsForName(name);
- if (userProfileAvatar) {
- userProfileAvatar.textContent = initials || "??";
- userProfileAvatar.setAttribute("aria-label", name ? `Avatar: ${name}` : "Usuario no identificado");
- }
- if (userProfileTitle) {
- userProfileTitle.textContent = name || "Usuario no identificado";
- }
- if (userProfileSubtitle) {
- const email = firstProfileField(profile, ["email", "emailAddress", "email_address", "eMail", "mail"]);
- const phone = firstProfileField(profile, ["phone", "phoneNumber", "phone_number", "mobile", "telephone", "tel"]);
- const parts = [email, phone].filter(Boolean);
- userProfileSubtitle.textContent = parts.length ? parts.join(" · ") : (name ? "No hay información de contacto disponible." : "Conéctate a Vocero para ver los datos del usuario.");
- }
- if (userProfileMetadata) {
- userProfileMetadata.innerHTML = "";
- const rows = buildProfileRows(profile);
- if (!rows.length) {
- userProfileMetadata.hidden = true;
- return;
- }
- userProfileMetadata.hidden = false;
- for (const row of rows) {
- const wrapper = document.createElement("div");
- wrapper.className = "user-profile-row";
-
- const label = document.createElement("div");
- label.className = "user-profile-row-label";
- label.textContent = row.label;
-
- const value = document.createElement("div");
- value.className = "user-profile-row-value";
-
- /** @type {Node} */
- let node;
- if (row.href) {
- const a = document.createElement("a");
- a.href = row.href;
- if (row.isEmail) { a.rel = "noopener"; a.target = "_blank"; }
- if (row.isPhone) a.rel = "nofollow noopener";
- if (row.mono) { const m = document.createElement("code"); m.textContent = row.value; a.appendChild(m); }
- else a.textContent = row.value;
- node = a;
- } else if (row.mono) {
- const m = document.createElement("code");
- m.textContent = row.value;
- node = m;
- } else {
- node = document.createTextNode(row.value);
- }
- value.appendChild(node);
-
- wrapper.appendChild(label);
- wrapper.appendChild(value);
- userProfileMetadata.appendChild(wrapper);
- }
- }
-}
-/**
- * Open the profile dialog, populate it from the current voceroUserProfile,
- * and optionally restore focus to the avatar when it closes (a11y).
- */
-function openUserProfileDialog() {
- if (!userProfileDialog || !extractVoceroUserName(voceroUserProfile)) return;
- populateUserProfileDialog(/** @type {Record} */ (voceroUserProfile));
- try {
- userProfileDialog.showModal();
- } catch {
- // If dialog is already open / unsupported we fall back to an inline style
- // (shouldn't happen on any modern browser).
- userProfileDialog.setAttribute("open", "");
- }
-}
-
-function syncVoceroHeaderUser() {
- const name = extractVoceroUserName(voceroUserProfile);
- const initials = initialsForName(name);
- identUserName.hidden = s2sTheme !== "white" || !name;
- identUserName.disabled = !name;
- if (name) {
- // Show avatar with the 2-letter initials. Full name is reachable via hover
- // tooltip (title) and is used by screen readers via aria-label.
- identUserName.textContent = initials;
- identUserName.title = `Usuario: ${name}`;
- identUserName.setAttribute("aria-label", `Ver datos de ${name}`);
- } else {
- identUserName.textContent = "";
- identUserName.removeAttribute("title");
- identUserName.setAttribute("aria-label", "Ver datos del usuario");
- }
-
- if (!reportsHint) return;
- // Keep the topbar-right in sync every time we re-render reports (another
- // safety net alongside the bootstrap / DOMContentLoaded / 10ms fallback).
- if (typeof applyDebugOnlyVisibilityGate === "function") {
- applyDebugOnlyVisibilityGate("syncVoceroHeaderUser pre-check");
- }
-
- const hasList =
- voceroReportsLoaded &&
- Array.isArray(voceroReportsList?.entries) &&
- voceroReportsList.entries.some((e) => e && typeof e === "object" && e.sourceScheduleLabel);
- if (!hasList) {
- reportsHint.hidden = true;
- reportsHint.innerHTML = "";
- return;
- }
- const rawEntries = /** @type {Array<{sourceScheduleLabel:string}>} */ (
- voceroReportsList.entries.filter((e) => e && typeof e === "object" && e.sourceScheduleLabel)
- );
- // Deduplicate by label (preserve insertion order = first occurrence = most recent
- // since the server pre-sorts DESC). Empty or null labels already filtered above.
- const seen = new Set();
- /** @type {Array<{sourceScheduleLabel:string}>} */
- const entries = [];
- for (const e of rawEntries) {
- const key = String(e.sourceScheduleLabel).trim();
- if (!key) continue;
- if (seen.has(key)) continue;
- seen.add(key);
- entries.push({ sourceScheduleLabel: key });
- }
- if (!entries.length) {
- reportsHint.hidden = true;
- reportsHint.innerHTML = "";
- if (reportsSuggestions) { reportsSuggestions.innerHTML = ""; reportsSuggestions.hidden = true; }
- if (reportsSidebar) reportsSidebar.hidden = true;
- if (reportsSidebarToggle) reportsSidebarToggle.hidden = true;
- if (reportsSidebarBadge) { reportsSidebarBadge.removeAttribute("data-count"); reportsSidebarBadge.textContent = ""; }
- return;
- }
- // Total count reflects the unique labels shown in the UI (not the raw server
- // repeated list). Use unique count unless caller explicitly set count.
- const count = entries.length;
- const totalLen = voceroReportsList.wrappedLen ?? 0;
- const titleRow = document.createElement("div");
- titleRow.className = "reports-summary";
- const titleText = document.createElement("span");
- titleText.className = "reports-summary-text";
- titleText.textContent = "Reportes cargados";
- const badge1 = document.createElement("span");
- badge1.className = "reports-summary-badge";
- badge1.textContent = `${count} programados`;
- const badge2 = document.createElement("span");
- badge2.className = "reports-summary-badge";
- badge2.textContent = `Bloque LLM ${totalLen.toLocaleString()} caracteres`;
- titleRow.appendChild(titleText);
- titleRow.appendChild(badge1);
- titleRow.appendChild(badge2);
- const ul = document.createElement("ul");
- ul.className = "reports-list";
- const labelTintFor = (rawLabel) => {
- const l = String(rawLabel || "").toLowerCase();
- if (l.includes("mortalidad") || l.includes("riesgo") || l.includes("kpi")) return "mortality";
- if (l.includes("métrica") || l.includes("metricas") || l.includes("medici")) return "metrics";
- if (l.includes("diario") || l.includes("diaria") || l.includes("informe")) return "daily";
- if (l.includes("producto") || l.includes("servicio")) return "products";
- if (l.includes("synapse") || l.includes("ejecucion") || l.includes("calendario") || l.includes(" ti ") || l.endsWith("ti")) return "synapse";
- if (l.includes("supply") || l.includes("cadena") || l.includes("log") || l.includes("envío") || l.includes("envio") || l.includes("chile")) return "supply";
- if (l.includes("financiero") || l.includes("finanzas") || l.includes("costo") || l.includes("presupuesto") || l.includes("venta")) return "finance";
- return "";
- };
- for (const e of entries) {
- const li = document.createElement("li");
- const tint = labelTintFor(e.sourceScheduleLabel);
- if (tint) li.setAttribute("data-tint", tint);
- li.textContent = String(e.sourceScheduleLabel);
- ul.appendChild(li);
- }
- // Build dynamic suggestion prompts in Spanish based on the actual labels so
- // the user knows what they can ask the assistant about. Max 4 suggestions;
- // pick first N entries by priority.
- const suggestions = /** @type {string[]} */ ([]);
- const labelPool = entries.map((e) => String(e.sourceScheduleLabel));
- // Try to match contextual templates by keyword first (intent-based)
- const hasKeyword = (kws) => labelPool.some((l) => kws.some((k) => l.toLowerCase().includes(k.toLowerCase())));
- const pickAnyLabel = (kws) =>
- kws ? (labelPool.find((l) => kws.some((k) => l.toLowerCase().includes(k.toLowerCase()))) || null) : null;
- const firstLabel = labelPool[0];
- const secondLabel = labelPool[1] || firstLabel;
- const thirdLabel = labelPool[2] || firstLabel;
- // Generic base templates always valid; keep intent- & keyword-driven ones higher priority
- const intentTemplates = [
- { when: hasKeyword(["riesgo", "riesgos", "mortalidad", "porcentaje", "kpi"]),
- text: `Dame un resumen ejecutivo de los riesgos operativos y KPIs del reporte de ${pickAnyLabel(["riesgo", "riesgos", "mortalidad", "porcentaje", "kpi"]) || firstLabel}.` },
- { when: hasKeyword(["diario", "diaria", "informe"]),
- text: `Cuéntame los puntos clave del informe diario más reciente (${pickAnyLabel(["diario", "diaria", "informe"]) || firstLabel}).` },
- { when: hasKeyword(["métrica", "métricas", "metricas", "medición", "medicion"]),
- text: `Compara las métricas de la última semana del reporte ${pickAnyLabel(["métrica", "métricas", "metricas", "medición", "medicion"]) || secondLabel}.` },
- { when: hasKeyword(["producto", "productos", "servicio", "servicios"]),
- text: `Qué hay de nuevo en productos y servicios según el reporte ${pickAnyLabel(["producto", "productos", "servicio", "servicios"]) || thirdLabel}?` },
- { when: hasKeyword(["supply", "supply chain", "cadena", "logística", "logistica", "envío", "envio", "chile", "latin"]),
- text: `Hay novedades en la cadena de suministro o logística en el reporte ${pickAnyLabel(["supply", "cadena", "logística", "logistica", "envío", "envio", "chile", "latin"]) || thirdLabel}?` },
- { when: hasKeyword(["synapse", "ejecucion", "ejecuciones", "calendario", "ti", "it"]),
- text: `¿Qué ejecuciones del calendario Synapse TI están reportadas? (${pickAnyLabel(["synapse", "ejecucion", "ejecuciones", "calendario", "ti", "it"]) || thirdLabel}).` },
- { when: hasKeyword(["podcast", "briefing", "mañana", "matutino", "morning"]),
- text: `Lee el podcast briefing matutino de hoy (${pickAnyLabel(["podcast", "briefing", "mañana", "matutino", "morning"]) || firstLabel}).` },
- { when: hasKeyword(["financiero", "finanzas", "costo", "costos", "presupuesto", "venta", "ventas"]),
- text: `Análisis financiero: costos, presupuesto y ventas del reporte ${pickAnyLabel(["financiero", "finanzas", "costo", "costos", "presupuesto", "venta", "ventas"]) || secondLabel}.` },
- { when: hasKeyword(["ejecutivo", "ceo", "reunión", "reunion", "semanal"]),
- text: `Puntos de agenda para la reunión ejecutiva semanal según el reporte ${pickAnyLabel(["ejecutivo", "ceo", "reunión", "reunion", "semanal"]) || secondLabel}.` },
- { when: true, text: `¿Qué highlights hay en el reporte más reciente «${firstLabel}»?` },
- { when: true, text: `Resume los 3 hallazgos principales de los reportes de hoy (${count} disponibles).` },
- { when: count >= 2, text: `Compara ${firstLabel} vs ${secondLabel} y dime cuál tiene mayor prioridad.` },
- { when: count >= 3, text: `Plan de acción sugerido con base en ${firstLabel}, ${secondLabel} y ${thirdLabel}.` },
- ];
- for (const t of intentTemplates) {
- if (t.when && suggestions.length < 4) suggestions.push(t.text);
- if (suggestions.length >= 4) break;
- }
- const qs = new URLSearchParams(window.location.search);
- const debugParamRaw = qs.get("debug");
- // eslint-disable-next-line no-debugger
- // debugger; // Uncomment to pause inside every syncVoceroHeaderUser call.
- console.groupCollapsed("%c🔍 [reports-ui] syncVoceroHeaderUser() render details", "color:#60a5fa;font-weight:700;");
- console.log("location.href =", location.href);
- console.log("location.search =", JSON.stringify(window.location.search));
- console.log("all query params =", Object.fromEntries(qs.entries()));
- console.log("pageParams (legacy module-load) .get('debug') =", JSON.stringify(pageParams.get("debug")));
- console.log("fresh qs .get('debug') =", JSON.stringify(debugParamRaw), "| typeof =", typeof debugParamRaw);
- console.log("strict === 'true' ? =", debugParamRaw === "true");
- console.log("count (unique labels) =", count, "| wrappedLen =", totalLen);
- console.log("suggestions array length (before debug gate) =", suggestions.length);
- suggestions.forEach((s, i) => console.log(` sug[${i}] = ${s}`));
- const currentDebug = debugParamRaw === "true";
- const debugMode = currentDebug;
- console.log("👉 FINAL DECISION debugMode =", debugMode, " → CREATE suggestions section? ", debugMode ? "✅ YES" : "❌ NO");
- console.groupEnd();
-
- let sugSection = null;
- if (debugMode) {
- sugSection = document.createElement("div");
- sugSection.className = "reports-suggestions";
- const sugTitle = document.createElement("span");
- sugTitle.className = "reports-suggestions-title";
- sugTitle.textContent = "Sugerencias de preguntas para el asistente:";
- const sugUl = document.createElement("ul");
- sugUl.className = "reports-suggestions-list";
- for (const s of suggestions) {
- const li = document.createElement("li");
- li.textContent = s;
- sugUl.appendChild(li);
- }
- sugSection.appendChild(sugTitle);
- sugSection.appendChild(sugUl);
- console.log("%c[reports-ui] suggestions section CREATED (debugMode=true)", "color:#22c55e;font-weight:700;",
- "nItems=", sugUl.children.length, "innerHTML=", sugSection.outerHTML.slice(0, 300));
- } else {
- console.log("%c[reports-ui] suggestions section NOT CREATED (debugMode=false, no ?debug=true)",
- "color:#ef4444;font-weight:700;");
- }
-
- reportsHint.innerHTML = "";
- reportsHint.appendChild(titleRow);
- reportsHint.appendChild(ul);
- // Suggestions section lives inside the sidebar as a separate static element
- // (#reports-suggestions), NOT inside the .reports-hint card — keeps chips
- // card compact and shows suggestions below it when debug mode is on.
- if (reportsSuggestions) {
- reportsSuggestions.innerHTML = "";
- // the #reports-suggestions element itself carries the `data-debug-only`
- // attribute, so applyDebugOnlyVisibilityGate will hide/show it automatically
- // based on ?debug=true (3-layer gate + html hidden attr + CSS fallback).
- reportsSuggestions.hidden = true;
- if (sugSection) {
- // Keep inner children (titleText + ul) — but drop outer wrapper so the
- // static element's CSS (reports-suggestions class already on the el)
- // is the one styling the block.
- while (sugSection.firstChild) reportsSuggestions.appendChild(sugSection.firstChild);
- sugSection = null;
- // After populating, run the gate again to make sure static element
- // stays visible only if debug=true (3-layer gate will re-apply rules).
- if (typeof applyDebugOnlyVisibilityGate === "function") applyDebugOnlyVisibilityGate("post syncVoceroHeaderUser sug");
- }
- } else if (sugSection) {
- reportsHint.appendChild(sugSection);
- }
- reportsHint.hidden = false;
-
- // Unhide & wire the sidebar now that we have reports to show.
- // The sidebar starts with [hidden] to avoid empty flashes on boot.
- if (reportsSidebar) {
- reportsSidebar.hidden = false;
- // Ensure sidebar is painted into the DOM before reading layout.
- // On desktop >= 1024px CSS keeps it always shown; on mobile we don't
- // auto-open (that's user-driven via the toggle button).
- }
- // Topbar toggle button: shown only when we have reports AND debug=true.
- if (reportsSidebarToggle) {
- const qs = new URLSearchParams(window.location.search);
- const debugParam = qs.get("debug") === "true";
- console.info(
- "%c[reports-ui] toggle reports-sidebar-toggle: hidden? BEFORE=%s | debug=%s",
- "color:#f59e0b;font-weight:700;",
- reportsSidebarToggle.hasAttribute("hidden"),
- debugParam,
- );
- if (debugParam) {
- reportsSidebarToggle.hidden = false;
- } else {
- reportsSidebarToggle.setAttribute("hidden", "");
- reportsSidebarToggle.style.setProperty("display", "none", "important");
- reportsSidebarToggle.removeAttribute("data-debug-enabled");
- console.info("%c[reports-ui] 🚫 reports sidebar toggle FORCED HIDDEN (no debug=true)",
- "background:#fee2e2;color:#b91c1c;font-weight:700;padding:3px 6px;border-radius:4px;");
- }
- }
- // Update badge count on the toggle button (CSS shows it only if != 0).
- if (reportsSidebarBadge) {
- reportsSidebarBadge.setAttribute("data-count", String(count));
- reportsSidebarBadge.textContent = count > 99 ? "99+" : String(count);
- }
-
- // eslint-disable-next-line no-console
- console.info(
- "%c[reports-ui] RENDER COMPLETE. reportsHint.querySelectorAll('.reports-suggestions').length =",
- "color:#a855f7;font-weight:700;",
- reportsHint.querySelectorAll(".reports-suggestions").length,
- "| children in block:", reportsHint.children.length,
- "| reportsHint.hidden=", reportsHint.hidden,
- "| #reports-sidebar hidden=", reportsSidebar ? reportsSidebar.hidden : "no-sidebar-el",
- "| badge count:", reportsSidebarBadge ? reportsSidebarBadge.getAttribute("data-count") : "no-badge"
- );
-}
-
-/** Instructions used by the reasoning engine. */
-function effectiveReasoningInstructions() {
- const base = pinnedPrompt || settings.instructions;
- let instructions = base;
- // When S2S_INJECT_PROFILE=true (deploy flag) the caller profile is also
- // baked directly into the instructions prompt text, in addition to being
- // sent separately in the user_context body field.
- // When S2S_INJECT_PROFILE=false (generic deploy / default / RAG backend)
- // user_context lives only in the separate body field; instructions stay
- // clean and the WS proxy remains pure passthrough.
- const ctx = voceroUserContextText();
- let profileAppended = false;
- if (injectProfile) {
- if (ctx) {
- instructions = instructions ? `${instructions} ${ctx}` : ctx;
- profileAppended = true;
- }
- }
- // Custom operator RAG context (pasted via header RAG dialog) is ALWAYS
- // appended to instructions when non-empty (operator intent is explicit).
- const ragCtx = customRagContextText();
- let ragAppended = false;
- if (ragCtx) {
- instructions = instructions ? `${instructions} ${ragCtx}` : ragCtx;
- ragAppended = true;
- }
- // ───────────────────────────────────────────────────────────────────────────
- // 2026-09-25 INJECT API-PILOT DYNAMIC TOOLS (PASS 1).
- // Match the server.py format: <<>>, followed by
- // name+description+requires_auth+required_parameters and the closing marker.
- // Fetch tools through GET api/apipilot/tools (240 s per-user client cache in
- // s2s-ws-client.js) and append them directly to the LLM instructions.
- // Deduplicate by marker when a previous call or the Python server already
- // injected the list, avoiding duplicate or oversized prompt blocks.
- let apipilotBlock = "";
- let apipilotCount = 0;
- let apipilotAppended = false;
- try {
- // @ts-ignore: exported globally by s2s-ws-client.js, loaded before main.js.
- // Check typeof before calling the optional helper.
- const getter = typeof window !== "undefined" && window.__s2sGetApipilotPass1ContextInfo;
- if (typeof getter === "function") {
- const info = getter();
- 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 block the rest of the instructions prompt. */
- }
- const key = `effInst:${injectProfile ? "T" : "F"}:${ctx ? "hasCtx" : "noCtx"}:${ragCtx ? "hasRag" : "noRag"}:${apipilotAppended ? "apipilotY" : "apipilotN"}:${(base || "").length}`;
- _logOnce(key, () => {
- 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`,
- ragCtx.length, (base || "").length, profileAppended, instructions.length,
- );
- }
- if (injectProfile) {
- if (profileAppended) {
- console.info(
- `[INJECT-PROFILE] ✅ BROWSER effectiveReasoningInstructions APPENDED caller profile inside LLM prompt. baseLen=%d ctxLen=%d -> finalLen=%d`,
- (base || "").length, ctx.length, instructions.length,
- );
- } else {
- console.info(
- `[INJECT-PROFILE] ⏭️ BROWSER S2S_INJECT_PROFILE=true but NO profile context to inject (fetchVoceroUserProfile missing / failed / no ?phoneNumber=?). baseLen=%d`,
- (base || "").length,
- );
- }
- } else {
- console.info(
- `[INJECT-PROFILE] ⏭️ BROWSER S2S_INJECT_PROFILE=false -> instructions stay GENERIC, profile NOT baked inside LLM prompt, WS pure passthrough. Profile is ONLY in body user_context (if any). baseLen=%d`,
- (base || "").length,
- );
- }
- });
- if (debugPrompt) {
- 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 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) {
- console.info(
- `[DEBUG-PROMPT][BROWSER voceroUserContextText] FULL TEXT len=%d:\n%s\n`,
- ctx.length, ctx,
- );
- } else {
- console.info(`[DEBUG-PROMPT][BROWSER voceroUserContextText] EMPTY len=0`);
- }
- if (ragCtx) {
- console.info(
- `[DEBUG-PROMPT][BROWSER customRagContextText] FULL TEXT len=%d:\n%s\n`,
- ragCtx.length, ragCtx,
- );
- } else {
- console.info(`[DEBUG-PROMPT][BROWSER customRagContextText] EMPTY len=0`);
- }
- });
- }
- return instructions;
-}
-
-/** Instructions pushed to the realtime ASR/TTS backend. */
-function effectiveInstructions() {
- const instructions = effectiveReasoningInstructions();
- return reasoningType === "OPENAI" ? `${instructions}${OPENAI_RENDER_HINT}` : instructions;
-}
-
-/**
- * @returns {string}
- */
-function preferredResponseLanguage() {
- // Hard-pin output language to Spanish regardless of the browser locale,
- // matching DEFAULT_INSTRUCTIONS ("respond ALWAYS and ONLY in Spanish").
- // The TTS voice picker and /api/reasoning body will both use this.
- return "es-ES";
-}
-
-/**
- * @param {string} answer @returns {string}
- */
-function buildRenderOnlyPrompt(answer) {
- const clean = String(answer || "").trim();
- return clean ? `${OPENAI_RENDER_MARKER}\n${clean}` : "";
-}
-
-/**
- * @param {string} transcript
- * @returns {Promise<{ answer: string, log_only: boolean, raw: any }>}
- */
-async function fetchOpenAiReasoningAnswer(transcript) {
- const instructions = effectiveReasoningInstructions();
- const profileCtx = voceroUserContextText();
- const ragCtx = customRagContextText();
- const user_context = [profileCtx, ragCtx].map((s) => (s || "").trim()).filter(Boolean).join("\n\n");
- const phoneNumber = voceroUserPhoneNumber || null;
- const episode_id = voceroEpisodeId || null;
- const body = {
- transcript,
- instructions,
- language: preferredResponseLanguage(),
- user_context,
- phoneNumber,
- episode_id,
- };
- const logKey = `reasoningBody:${injectProfile ? "T" : "F"}:${debugPrompt ? "T" : "F"}:${phoneNumber ? "hasPhone" : "noPhone"}:${episode_id ? "hasEpisode" : "noEpisode"}:${profileCtx ? "hasProfile" : "noProfile"}:${ragCtx ? "hasRag" : "noRag"}:${instructions.length}`;
- _logOnce(logKey, () => {
- console.info(
- `[INJECT-PROFILE] BROWSER POST /api/reasoning body summary. injectProfile=%s transcriptLen=%d instructionsLen=%d userCtxLen=%d (profileCtxLen=%d ragCtxLen=%d) phoneLen=%s episodeLen=%s`,
- injectProfile,
- transcript.length,
- instructions.length,
- user_context.length,
- profileCtx.length,
- ragCtx.length,
- phoneNumber ? phoneNumber.length : "null",
- episode_id ? episode_id.length : "null",
- );
- if (ragCtx) {
- console.info(
- `[CUSTOM-RAG] included in POST /api/reasoning user_context block ragLen=%d`,
- ragCtx.length,
- );
- }
- });
- if (debugPrompt) {
- console.info(
- `[DEBUG-PROMPT][BROWSER POST /api/reasoning] FULL body JSON:\n%s\n`,
- JSON.stringify(body, null, 2),
- );
- }
- const res = await fetch("api/reasoning", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- let detail = String(res.status);
- try {
- const json = await res.json();
- if (json?.detail) detail = String(json.detail);
- } catch {}
- throw new Error(`reasoning error (${detail})`);
- }
- const json = await res.json();
- const answer = typeof json?.answer === "string" ? json.answer.trim() : "";
- const log_only = Boolean(json && json.log_only === true);
- // Log-only mode may return answer=""; that is not an error.
- if (!answer && !log_only) throw new Error("reasoning returned no answer");
- return { answer, log_only, raw: json };
-}
-
-/**
- * @param {string} transcript
- * @param {string} itemId
- */
-async function maybeRunOpenAiReasoning(transcript, itemId) {
- if (!["OPENAI", "NODE_RED"].includes(reasoningType)) return;
- const text = transcript.trim();
- if (!text) return;
- if (!client || !LIVE_STATES.has(currentState)) return;
- reportDebug("R", "main.js:maybeRunOpenAiReasoning", "starting external reasoning turn", {
- itemId,
- transcriptLength: text.length,
- currentState,
- });
- let result;
- try {
- result = await fetchOpenAiReasoningAnswer(text);
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- console.warn(`[Vocero REASONING] error itemId=${JSON.stringify(itemId)} error=${msg}`);
- reportDebug("R", "main.js:maybeRunOpenAiReasoning", "external reasoning failed", {
- itemId,
- error: msg,
- });
- return;
- }
- // ── LOG-ONLY MODE (PHASE 1) ────────────────────────────────────────────
- // Node-RED only logs the request and returns log_only=true.
- // Preserve the backend response without injecting text or speaking.
- // The native S2S backend remains responsible for the entire turn.
- if (result && result.log_only) {
- reportDebug("R", "main.js:maybeRunOpenAiReasoning", "external reasoning in LOG_ONLY mode — pass through to native S2S backend", {
- itemId,
- reasoningType,
- intent: (result.raw && result.raw.intent) || "log_only",
- });
- console.debug(
- `[Vocero REASONING LOG_ONLY] mode=log_only engine=${reasoningType} ` +
- `transcript=${JSON.stringify(text.slice(0, 160))} ` +
- `intent=${JSON.stringify((result.raw && result.raw.intent) || "log_only")}`
- );
- return;
- }
- // ── STANDARD MODE (LATER PHASES) ──────────────────────────────────────
- // External reasoning takes priority: cancel the native response, insert
- // the assistant text and speak through SpeechSynthesis.
- const answer = (result && result.answer) || "";
- if (!answer) {
- console.warn(`[Vocero REASONING] empty non-log-only answer itemId=${JSON.stringify(itemId)}`);
- return;
- }
- if (typeof client.cancelActiveResponse === "function") {
- client.cancelActiveResponse();
- } else if (typeof client.suppressAssistantAudio === "function") {
- client.suppressAssistantAudio();
- }
- reportDebug("R", "main.js:maybeRunOpenAiReasoning", "received external reasoning answer", {
- itemId,
- answerLength: answer.length,
- });
- if (typeof client.appendAssistantText === "function") {
- client.appendAssistantText(answer);
- } else {
- const renderPrompt = buildRenderOnlyPrompt(answer);
- if (renderPrompt) {
- client.sendUserText(renderPrompt);
- client.requestResponse();
- }
- }
- try {
- if ("speechSynthesis" in window) {
- const utter = new SpeechSynthesisUtterance(answer);
- const want = (preferredResponseLanguage() || "en").slice(0, 2).toLowerCase();
- const voices = window.speechSynthesis.getVoices
- ? window.speechSynthesis.getVoices()
- : [];
- const match = Array.isArray(voices) && voices.length > 0
- ? (voices.find(v => (v.lang || "").slice(0, 2).toLowerCase() === want) || voices[0])
- : null;
- if (match) utter.voice = match;
- if (want) utter.lang = want;
- utter.rate = 1.0;
- utter.pitch = 1.0;
- window.speechSynthesis.cancel();
- window.speechSynthesis.speak(utter);
- }
- } catch (ttsErr) {
- console.warn("[Vocero REASONING] speechSynthesis failed:", ttsErr);
- }
-}
-
-/** Push the active tool set to a live session so toggles apply mid-call. */
-function pushToolsToSession() {
- if (!client || !LIVE_STATES.has(currentState)) return;
- const tools = activeToolDefs();
- const toolChoice = tools.length ? "auto" : "none";
- // #region debug-point B:push-tools-session
- reportDebug("B", "main.js:pushToolsToSession", "pushing tools to live session", {
- state: currentState,
- toolNames: tools.map((tool) => tool.name),
- toolCount: tools.length,
- });
- // #endregion
- client.setTools(tools, toolChoice);
- // The hidden tool-use hint depends on whether any tool is active, so refresh
- // instructions alongside the tool set.
- const instructions = effectiveInstructions();
- // #region debug-point C:push-instructions-session
- reportDebug("C", "main.js:pushToolsToSession", "updating live session instructions", {
- instructionLength: instructions.length,
- lastUserQuery,
- });
- // #endregion
- if (typeof client.updateSession === "function") client.updateSession({ instructions });
-}
-
-// ── Chat view ───────────────────────────────────────────────────────────────
-// Owns the history panel, the ephemeral bubbles, and all transcript/tool
-// streaming state. The client's events are forwarded to its on* methods.
-const chat = new ChatView();
-
-// ── Account / limiter ─────────────────────────────────────────────────────
-// Login chip + daily-limit modal (inert unless the deploy is in LB mode). The
-// server meters conversation time; the client just heartbeats a live session
-// and tears down when the server reports the budget is spent.
-const account = new Account();
-let limiterOn = false;
-let heartbeatTimer = 0;
-let trackedSessionId = "";
-let trackedTier = "";
-// The waiting-queue ticket id while we're in line (else ""). Used to leave the
-// queue on teardown / tab-close so we don't hold a phantom place.
-let queuedTicketId = "";
-
-// ── Idle auto-close (30s of silence, never during AI speech) ─────────────
-// The user asked the voice assistant to go to sleep silently after 30s of no
-// activity. "Activity" is any meaningful event on either side: the user
-// started speaking (VAD), the assistant started speaking (TTS), a partial or
-// final transcript arrived, a tool call was requested or returned, or the
-// client moved out of a transient pre-live state (connecting / queue /
-// your-turn). Once the session is live, every tick of activity resets the
-// countdown back to 30s; if 30s expire without a single event we close the
-// socket, refund any reserved budget, and drop back to the idle orb so the
-// microphone stops listening and the daily budget isn't eaten by a forgotten
-// tab. The countdown is intentionally disabled in the pre-live states (we
-// don't want to close while the user is still waiting in queue) and during
-// `ai-speaking` — if the *system* is still talking we NEVER close the
-// session mid-reply, even on very long TTS turns (the TTS engine itself will
-// fire transcript/response-finished events that re-arm the timer afterward).
-const IDLE_TIMEOUT_MS = 30 * 1000;
-let idleCloseTimer = 0;
-let idleCloseLastActivityAtMs = 0;
-/** @type {Set} - States where a live session is open and the user could be silent. `ai-speaking` is intentionally excluded so we never kill a reply mid-TTS. */
-const IDLE_COUNT_STATES = new Set(["listening", "user-speaking", "processing"]);
-
-function _kickIdleTimer(reason = "activity") {
- idleCloseLastActivityAtMs = Date.now();
- if (!IDLE_COUNT_STATES.has(currentState)) {
- if (idleCloseTimer) { clearTimeout(idleCloseTimer); idleCloseTimer = 0; }
- return;
- }
- if (idleCloseTimer) clearTimeout(idleCloseTimer);
- idleCloseTimer = window.setTimeout(() => {
- const staleMs = Date.now() - idleCloseLastActivityAtMs;
- if (staleMs + 500 < IDLE_TIMEOUT_MS) {
- // Race with a very recent kick: re-arm rather than close.
- if (DEBUG) console.debug(`[IDLE] fire skipped (staleMs=${staleMs} < ${IDLE_TIMEOUT_MS}), re-arming`);
- _kickIdleTimer("re-arm");
- return;
- }
- console.info(`[IDLE] ⏰ Closing session after ${(staleMs / 1000).toFixed(1)}s of inactivity (timeout=${IDLE_TIMEOUT_MS / 1000}s) — reason=${reason}`);
- idleCloseTimer = 0;
- // Friendly caption flash right before teardown moves us back to the idle
- // copy (so the user understands *why* the call ended).
- setCaption("Sesión cerrada por inactividad", "muted");
- void (async () => {
- try {
- await teardown();
- } catch (err) {
- console.warn("[IDLE] teardown failed during auto-close:", err && err instanceof Error ? err.message : err);
- }
- // teardown() calls setState("idle") which restores the normal idle
- // caption. Leave a visible breadcrumb on the subcaption briefly so the
- // user can tell what happened instead of wondering why the orb reset.
- circleSubcaption.textContent = "Sesión cerrada tras 30 segundos de silencio. Toca el círculo o repite «Hola Vocero» para empezar de nuevo.";
- circleSubcaption.hidden = false;
- whiteMicSubcaption.textContent = circleSubcaption.textContent;
- whiteMicSubcaption.hidden = false;
- window.setTimeout(() => {
- circleSubcaption.hidden = true;
- whiteMicSubcaption.hidden = true;
- }, 8000);
- })();
- }, IDLE_TIMEOUT_MS);
- if (DEBUG) {
- console.debug(`[IDLE] 🔄 reset timer (${IDLE_TIMEOUT_MS / 1000}s) reason=${reason} state=${currentState}`);
- }
-}
-
-function _cancelIdleTimer() {
- if (idleCloseTimer) { clearTimeout(idleCloseTimer); idleCloseTimer = 0; }
- idleCloseLastActivityAtMs = 0;
- if (DEBUG) console.debug(`[IDLE] ⛔ cancelled timer state=${currentState}`);
-}
-
-/** @type {S2sWsRealtimeClient | null} */
-let client = null;
-/** @type {MediaStream | null} */
-let micStream = null;
-let micMuted = false;
-/** @type {Promise | null} */
-let teardownPromise = null;
-
-function syncMicControls() {
- const live = LIVE_STATES.has(currentState);
- const canMute = live && !!micStream && !!client && !teardownPromise;
- const label = micMuted ? "Activar micrófono" : "Silenciar micrófono";
- for (const button of [micBtn, whiteMuteBtn]) {
- button.disabled = !canMute;
- button.classList.toggle("muted", micMuted);
- button.setAttribute("aria-label", label);
- button.title = label;
- }
- whiteMuteBtn.hidden = !live || s2sTheme !== "white";
- whiteMuteLabel.textContent = label;
-}
-
-function syncVoiceControls() {
- const view = STATE_VIEWS[currentState];
- const live = LIVE_STATES.has(currentState);
- const disabled = view.disabled || !!teardownPromise;
- const mutedWhileListening = micMuted && (currentState === "listening" || currentState === "user-speaking");
- const label = currentState === "error" ? "Reintentar conversación de voz"
- : currentState === "idle" ? "Iniciar conversación de voz"
- : mutedWhileListening ? "Micrófono silenciado" : view.caption;
- circleBtn.disabled = disabled;
- circleBtn.setAttribute("aria-label", label);
- whiteMicBtn.disabled = disabled;
- whiteMicBtn.setAttribute("aria-label", label);
- whiteMicBtn.className = `white-mic-circle ${STATE_CLASS[currentState]}`;
- whiteStopBtn.hidden = !live || s2sTheme !== "white";
- whiteStopBtn.disabled = !live || !!teardownPromise;
- whiteSessionControls.hidden = !live || s2sTheme !== "white";
- syncMicControls();
-
- const waiting = currentState === "queued" || currentState === "your-turn";
- chatStatus.dataset.status = live ? "online" : currentState === "error" ? "error"
- : currentState === "connecting" || waiting ? "waiting" : "idle";
- chatStatusLabel.textContent = live ? "En línea"
- : currentState === "connecting" ? "Conectando…"
- : currentState === "queued" ? "En cola"
- : currentState === "your-turn" ? "Tu turno"
- : currentState === "error" ? "Sin conexión" : "Sin sesión";
-}
-
-/** @param {AppState} next */
-function setState(next) {
- const previousState = currentState;
- currentState = next;
- const view = STATE_VIEWS[next];
- circleBtn.className = `circle ${STATE_CLASS[next]}`;
- syncVoiceControls();
- if (next !== "error" || previousState !== "error") setCaption(view.caption);
-
- const live = LIVE_STATES.has(next);
- orbWrap.classList.toggle("live", live);
- micBtn.setAttribute("aria-hidden", live ? "false" : "true");
- stopBtn.setAttribute("aria-hidden", live ? "false" : "true");
- micBtn.tabIndex = live ? 0 : -1;
- stopBtn.tabIndex = live ? 0 : -1;
-
- // Queue affordances: "Leave queue" whenever we're in line; "Join now" only once
- // it's our turn (a slot is held for us). Both live under #queue-actions.
- const yourTurn = next === "your-turn";
- const inLine = next === "queued" || yourTurn;
- queueActions.hidden = !inLine;
- joinQueueBtn.hidden = !yourTurn;
- joinQueueBtn.tabIndex = yourTurn ? 0 : -1;
- leaveQueueBtn.hidden = !inLine;
- leaveQueueBtn.tabIndex = inLine ? 0 : -1;
- if (!yourTurn) stopJoinCountdown();
-
- // Warm reassurance under the terse position, only while waiting in line.
- if (next === "queued") {
- circleSubcaption.textContent =
- "¡Nos pasamos de abrazos! 🤗 Todos los lugares están ocupados, pero guardamos uno para ti en la cola. Ya estás avanzando.";
- circleSubcaption.hidden = false;
- } else {
- circleSubcaption.hidden = true;
- }
- whiteMicSubcaption.textContent = circleSubcaption.textContent;
- whiteMicSubcaption.hidden = circleSubcaption.hidden;
-
- // Wake word: armed only when idle/error (no live mic contention with the
- // realtime session), and disarmed the moment we move into any other state.
- if (next === "idle" || next === "error") {
- startWakeWordListener();
- } else {
- stopWakeWordListener();
- }
-
- // Idle auto-close: arm the countdown whenever we land in a live conversing
- // state (user/assistant turn, processing), and cancel it whenever we leave
- // those states (idle, queue, error, teardown). Moving *to* a live state is
- // itself counted as activity so the 10s window starts fresh.
- if (IDLE_COUNT_STATES.has(next)) {
- _kickIdleTimer(`state→${next}`);
- } else {
- _cancelIdleTimer();
- }
-
- updateRestartAvailability();
-}
-
-function updateRestartAvailability() {
- // Restart works from any settled state — it tears down a live call (if any)
- // and reconnects with the current settings. Only block while mid-connect or
- // while waiting in the queue (restarting from there would just re-queue).
- restartBtn.disabled =
- currentState === "connecting" || currentState === "queued" || currentState === "your-turn";
- restartHint.hidden = false;
- restartHint.textContent = LIVE_STATES.has(currentState)
- ? "Reconecta ahora con la configuración indicada."
- : "Inicia una conversación con la configuración indicada.";
-}
-
-/**
- * @param {string} text
- * @param {"" | "error" | "muted"} [kind]
- */
-function setCaption(text, kind = "") {
- const mutedWhileListening = micMuted && (currentState === "listening" || currentState === "user-speaking") && kind !== "error";
- const trimmed = mutedWhileListening ? "Micrófono silenciado" : text.trim();
- circleCaption.textContent = trimmed;
- // .idle-cta is the JS-based fallback (adds gradient CTA style) for browsers
- // that don't support :has(). The primary selector is .stage:has(.state-idle) .circle-caption.
- const addIdleCta = currentState === "idle" && !kind && trimmed;
- const parts = ["circle-caption"];
- if (kind) parts.push(kind);
- if (!trimmed) parts.push("empty");
- if (addIdleCta) parts.push("idle-cta");
- circleCaption.className = parts.join(" ");
- whiteMicCaption.textContent = trimmed;
- whiteMicCaption.classList.toggle("muted", kind === "muted" || mutedWhileListening);
- whiteMicCaption.classList.toggle("error", kind === "error");
-}
-
-function openSettings() {
- syncConnectionUi();
- inputVoice.value = settings.voice;
- inputInstructions.value = pinnedPrompt || settings.instructions;
- inputInstructions.readOnly = !!pinnedPrompt;
- primaryPromptMeta.hidden = !pinnedPrompt;
- primaryPromptMeta.textContent = pinnedPrompt
- ? "Instrucciones principales fijadas por este despliegue."
- : "";
- syncGateUi();
- updateRestartAvailability();
- settingsModal.showModal();
-}
-
-/** dB position (clamped to the slider axis) as a 0..1 fraction of the track.
- * @param {number} db */
-function dbToFraction(db) {
- const clamped = Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, db));
- return (clamped - GATE_OFF_DB) / (GATE_MAX_DB - GATE_OFF_DB);
-}
-
-/** @param {number} f @returns {number} dB at a 0..1 position on the gate axis. */
-function fractionToDb(f) {
- const clamped = Math.min(1, Math.max(0, f));
- return Math.round(GATE_OFF_DB + clamped * (GATE_MAX_DB - GATE_OFF_DB));
-}
-
-// ── Radial gate arc (around the mic button, live during a call) ─────────────
-// A 270° arc with the gap facing the orb (right). Fraction 0 (=Off) sits at the
-// bottom-ish start; 1 (=max) at the top-ish end. The level fill and the
-// threshold handle ride this same axis, mirroring the Settings widget.
-const ARC_R = 40;
-// A ~200° arc centred on the left (180°) so the wide gap faces the orb (right).
-const ARC_SPAN_DEG = 200;
-const ARC_START_DEG = 180 - ARC_SPAN_DEG / 2; // lower-left start; Off end
-
-/** Point at fraction f (0..1) and radius r, in the 0..100 viewBox.
- * @param {number} f @param {number} [r] */
-function arcPoint(f, r = ARC_R) {
- const deg = ARC_START_DEG + f * ARC_SPAN_DEG;
- const rad = (deg * Math.PI) / 180;
- return { x: 50 + r * Math.cos(rad), y: 50 + r * Math.sin(rad) };
-}
-
-/** SVG path `d` for the full 0..1 arc (clockwise). */
-function fullArcD() {
- const a = arcPoint(0);
- const b = arcPoint(1);
- const largeArc = ARC_SPAN_DEG > 180 ? 1 : 0;
- return `M ${a.x} ${a.y} A ${ARC_R} ${ARC_R} 0 ${largeArc} 1 ${b.x} ${b.y}`;
-}
-
-/** One-time geometry: track, fill (dash-revealed) and the transparent hit band. */
-function initGateArc() {
- const d = fullArcD();
- mgaTrack.setAttribute("d", d);
- mgaFill.setAttribute("d", d);
- mgaHit.setAttribute("d", d);
- // pathLength 100 lets us reveal the fill by fraction via dashoffset.
- mgaFill.setAttribute("pathLength", "100");
- mgaFill.style.strokeDasharray = "100 100";
- mgaFill.style.strokeDashoffset = "100"; // empty until levels arrive
- renderGateHandle();
-}
-
-/** Place the threshold bead on the arc at the stored threshold; flag off state. */
-function renderGateHandle() {
- const off = settings.noiseGate <= GATE_OFF_DB;
- const p = arcPoint(dbToFraction(settings.noiseGate));
- mgaHandle.setAttribute("cx", String(p.x));
- mgaHandle.setAttribute("cy", String(p.y));
- micGate.classList.toggle("gate-off", off);
-}
-
-/** Paint a 0..1 live level onto the arc fill (and the Settings meter if open).
- * Brightens the tick when the level crosses the threshold — i.e. the gate is
- * actually open — but only when gating is enabled.
- * @param {number} rms */
-function paintInputLevel(rms) {
- const db = rms > 0 ? 20 * Math.log10(rms) : GATE_OFF_DB;
- const f = dbToFraction(db);
- mgaFill.style.strokeDashoffset = String(100 * (1 - f));
- if (settingsModal.open) gateMeterFill.style.width = `${f * 100}%`;
- const enabled = settings.noiseGate > GATE_OFF_DB;
- micGate.classList.toggle("gate-open", enabled && f >= dbToFraction(settings.noiseGate));
-}
-
-/** The single place that commits a new gate threshold: updates both controls,
- * persists, and applies live to the running session.
- * @param {number} db */
-function setGateThreshold(db) {
- settings.noiseGate = Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, Math.round(db)));
- const off = settings.noiseGate <= GATE_OFF_DB;
- inputNoiseGate.value = String(settings.noiseGate);
- gateValue.textContent = off ? "Desactivada" : `${settings.noiseGate} dB`;
- renderGateHandle();
- localStorage.setItem(STORAGE_KEYS.noiseGate, String(settings.noiseGate));
- if (client && LIVE_STATES.has(currentState)) {
- client.setNoiseGate(gateParams(settings.noiseGate));
- }
-}
-
-/** Reflect the stored gate threshold into the slider, label and arc handle. */
-function syncGateUi() {
- inputNoiseGate.value = String(settings.noiseGate);
- const off = settings.noiseGate <= GATE_OFF_DB;
- gateValue.textContent = off ? "Desactivada" : `${settings.noiseGate} dB`;
- renderGateHandle();
-}
-
-// Drag along the arc band to set the threshold (a tap on the glyph still mutes).
-let gateDragging = false;
-/** @param {PointerEvent} e */
-function gatePointerToDb(e) {
- const rect = mgaArc.getBoundingClientRect();
- const cx = rect.left + rect.width / 2;
- const cy = rect.top + rect.height / 2;
- let deg = (Math.atan2(e.clientY - cy, e.clientX - cx) * 180) / Math.PI;
- if (deg < 0) deg += 360;
- // Map the on-arc angle to a fraction; angles in the right-side gap fall
- // outside [0,1] and fractionToDb clamps them to the nearest end (just-below
- // start -> Off, just-past end -> max).
- const f = (deg - ARC_START_DEG) / ARC_SPAN_DEG;
- return fractionToDb(f);
-}
-mgaHit.addEventListener("pointerdown", (e) => {
- gateDragging = true;
- mgaHit.setPointerCapture(e.pointerId);
- setGateThreshold(gatePointerToDb(e));
-});
-mgaHit.addEventListener("pointermove", (e) => {
- if (gateDragging) setGateThreshold(gatePointerToDb(e));
-});
-const endGateDrag = (/** @type {PointerEvent} */ e) => {
- if (!gateDragging) return;
- gateDragging = false;
- try { mgaHit.releasePointerCapture(e.pointerId); } catch {}
-};
-mgaHit.addEventListener("pointerup", endGateDrag);
-mgaHit.addEventListener("pointercancel", endGateDrag);
-
-settingsBtn.addEventListener("click", openSettings);
-
-// About panel: native , Esc closes for free; also close on the X and
-// on a click in the backdrop (a click whose target is the dialog itself).
-aboutBtn?.addEventListener("click", () => aboutModal.showModal());
-// Mobile twin of the (i), living in the right-hand control cluster.
-$("#about-btn-m").addEventListener("click", () => aboutModal.showModal());
-aboutClose.addEventListener("click", () => aboutModal.close());
-aboutModal.addEventListener("click", (e) => {
- if (e.target === aboutModal) aboutModal.close();
-});
-
-/* ─── User profile: native button and dialog keyboard behavior ─────── */
-if (identUserName) {
- identUserName.addEventListener("click", (e) => { e.preventDefault(); openUserProfileDialog(); });
-}
-// Close via the explicit X button (top-right of the card).
-if (userProfileClose) {
- userProfileClose.addEventListener("click", (e) => { e.preventDefault(); userProfileDialog?.close(); });
-}
-// Click in the backdrop = click whose target is the itself.
-if (userProfileDialog) {
- userProfileDialog.addEventListener("close", () => {
- if (!identUserName.hidden) identUserName.focus();
- });
- userProfileDialog.addEventListener("click", (e) => {
- if (e.target === userProfileDialog) userProfileDialog.close();
- });
-}
-
-/* ─── Reports sidebar (LEFT) handlers ────────────────────────────────────── */
-/**
- * Toggle open / close reports sidebar (off-canvas mobile overlay OR no-op desktop).
- * The actual visibility on desktop is handled entirely via CSS min-width:1024px rule (sidebar
- * always rendered inline); the JS code here is only for the mobile "slidein
- * @param {boolean} [forceOpen]
- */
-function openReportsSidebar(forceOpen) {
- if (!reportsSidebar) return;
- const open = typeof forceOpen === "boolean" ? forceOpen : !reportsSidebar.classList.contains("open");
- reportsSidebar.classList.toggle("open", open);
- if (reportsSidebarToggle) {
- reportsSidebarToggle.setAttribute("aria-expanded", open ? "true" : "false");
- }
-}
-if (reportsSidebarToggle) {
- reportsSidebarToggle.addEventListener("click", (e) => {
- e.preventDefault();
- // toggle: if already open close else open
- openReportsSidebar(!reportsSidebar.classList.contains("open"));
- });
-}
-if (reportsSidebarClose) {
- reportsSidebarClose.addEventListener("click", (e) => { e.preventDefault(); openReportsSidebar(false); });
-}
-// Close on backdrop click OR any element marked [data-close-reports-sidebar].
-if (reportsSidebar) {
- reportsSidebar.addEventListener("click", (e) => {
- const target = /** @type {HTMLElement} */ (e.target);
- if (!target) return;
- if (target.closest("[data-close-reports-sidebar]")) openReportsSidebar(false);
- });
-}
-// Close with ESC (same pattern as chat panel).
-document.addEventListener("keydown", (e) => {
- if (e.key === "Escape" && reportsSidebar?.classList.contains("open")) openReportsSidebar(false);
-}, { passive: true });
-
-function syncToolsUi() {}
-
-/**
- * @param {string} _query
- * @param {any} [_opts]
- */
-function updateMcpToolSuggestions(_query, _opts) {}
-
-toolsBtn.addEventListener("click", () => { toolsModal.showModal(); });
-toolsClose.addEventListener("click", () => toolsModal.close());
-toolsModal.addEventListener("click", (e) => {
- if (e.target === toolsModal) toolsModal.close();
-});
-
-ragBtn.addEventListener("click", () => {
- // Pre-fill the textarea with current value (if any) so user can edit.
- if (customRagLoaded && ragTextarea && !ragTextarea.value.trim()) {
- ragTextarea.value = customRagText;
- } else if (ragTextarea && !ragTextarea.value.trim() && customRagText.trim()) {
- ragTextarea.value = customRagText;
- }
- syncRagHintFooter();
- ragModal.showModal();
-});
-ragClose.addEventListener("click", () => ragModal.close());
-ragModal.addEventListener("click", (e) => {
- if (e.target === ragModal) ragModal.close();
-});
-ragClear.addEventListener("click", () => {
- if (ragTextarea) ragTextarea.value = "";
- syncRagHintFooter();
- if (ragTextarea) ragTextarea.focus();
-});
-ragSubmit.addEventListener("click", () => {
- const next = (ragTextarea ? ragTextarea.value : "").trim();
- if (!next) {
- customRagText = "";
- customRagLoaded = true;
- syncRagHintFooter();
- console.info(`[CUSTOM-RAG] submit: textarea empty -> clearing any prior custom RAG context.`);
- ragModal.close();
- return;
- }
- customRagText = next;
- customRagLoaded = true;
- syncRagHintFooter();
- const ctx = customRagContextText();
- const lines = next.split(/\r?\n/).length;
- console.info(
- `[CUSTOM-RAG] submit OK: rawLen=%d lines=%d wrappedCtxLen=%d -> will appear in instructions + user_context body.`,
- next.length, lines, ctx.length,
- );
- if (debugPrompt) {
- console.info(
- `[DEBUG-PROMPT][CUSTOM-RAG submitted raw text] len=%d lines=%d:\n%s\n`,
- next.length, lines, next,
- );
- if (ctx) {
- console.info(
- `[DEBUG-PROMPT][CUSTOM-RAG wrapped context (with CUSTOM_RAG_BEGIN/END markers)] len=%d:\n%s\n`,
- ctx.length, ctx,
- );
- }
- }
- // If a realtime session is already live (e.g. user already clicked the orb)
- // we push the new context LIVE into the session so it takes effect
- // immediately without having to restart.
- pushCustomRagToLiveSessionIfAny();
- ragModal.close();
-});
-
-toolWebSwitch.addEventListener("change", () => {
- if (toolWebSwitch.checked && !searchAvailable()) {
- toolWebSwitch.checked = false;
- return;
- }
- toolsEnabled.web_search = toolWebSwitch.checked;
- saveTools();
- pushToolsToSession();
-});
-
-searchKeyInput.addEventListener("input", () => {
- if (serverSearchKey) return;
- userSearchKey = searchKeyInput.value.trim();
- if (userSearchKey) localStorage.setItem(STORAGE_KEYS.searchKey, userSearchKey);
- else localStorage.removeItem(STORAGE_KEYS.searchKey);
-
- const avail = searchAvailable();
- toolWebSwitch.disabled = !avail;
- toolWebRow.classList.toggle("disabled", !avail);
- if (!avail && toolsEnabled.web_search) {
- toolsEnabled.web_search = false;
- toolWebSwitch.checked = false;
- saveTools();
- pushToolsToSession();
- }
- toolWebHint.textContent = userSearchKey
- ? "Se usa tu clave, guardada solo en este navegador."
- : "No hay una clave del servidor configurada. Añade tu clave de Serper para activar la búsqueda web.";
-});
-
-async function runTool(name, argsJson, callId) {
- if (!client) return { output: "" };
- let args = /** @type {Record} */ ({});
- try { args = JSON.parse(argsJson || "{}"); } catch { /* keep {} */ }
-
- // #region debug-point D:run-tool-entry
- reportDebug("D", "main.js:runTool", "tool execution started", {
- name,
- callId,
- args,
- });
- // #endregion
- try {
- let output = "";
- let image;
- if (name === "web_search") {
- const q = String(args.query || "").trim();
- output = await execWebSearch(q);
- } else {
- output = `Unknown tool: ${name}.`;
- }
- // #region debug-point E:run-tool-success
- reportDebug("E", "main.js:runTool", "tool execution completed", {
- name,
- outputLen: output.length,
- hasImage: !!image,
- });
- // #endregion
- if (client) client.sendFunctionCallOutput(callId, output);
- return { output, image };
- } catch (err) {
- const detail = truncateError(err);
- // #region debug-point E:run-tool-error
- reportDebug("E", "main.js:runTool", "tool execution failed", {
- name,
- error: detail,
- });
- // #endregion
- if (client) client.sendFunctionCallOutput(callId, `Tool error: ${detail}`);
- return { output: `Tool error: ${detail}` };
- }
-}
-async function execWebSearch(query) {
- if (!query) return "No query provided.";
- /** @type {Record} */
- const body = { query };
- // Only send a user key when there's no server key (server prefers its own).
- if (!serverSearchKey && userSearchKey) body.key = userSearchKey;
-
- const res = await fetch("api/search", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- let detail = String(res.status);
- try { const j = await res.json(); if (j.detail) detail = j.detail; } catch {}
- throw new Error(`search error (${detail})`);
- }
- const json = await res.json();
- // Date-stamp the header so the model treats these as fresh realtime facts
- // rather than its (older) training knowledge.
- const today = new Date().toISOString().slice(0, 10);
- /** @type {string[]} */
- const lines = [`Google search result from ${today}:`];
- if (json.answer) lines.push(`Answer: ${json.answer}`);
- for (const r of json.results || []) {
- lines.push(`- ${r.title}: ${r.snippet} (${r.url})`);
- }
- return lines.length > 1 ? lines.join("\n") : `${lines[0]}\nNo results found.`;
-}
-
-async function fetchVoceroUserProfile() {
- if (!voceroUserPhoneNumber) {
- console.info(
- "[INJECT-PROFILE] ⏭️ BROWSER fetchVoceroUserProfile SKIPPED: URL has NO ?phoneNumber=. " +
- "Profile context will be empty (info-user fetch + injectProfile not possible without caller phone)."
- );
- return;
- }
- try {
- const res = await fetch(`api/user/phone?phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}`);
- if (!res.ok) {
- // 503 = endpoint not configured on this deploy (generic deploy); skip
- // silently. Any other non-2xx is an error worth reporting.
- if (res.status === 503) {
- console.info(
- "[INJECT-PROFILE] ⏭️ BROWSER skipped (deploy did NOT configure VOCERO_USER_PHONE_AUTH_URL env). endpoint returned HTTP 503, proxy /api/user/phone unavailable. " +
- "Inject-profile will have empty profile context server-side if WS/reasoning also try."
- );
- voceroUserProfile = null;
- voceroUserProfileLoaded = false;
- syncVoceroHeaderUser();
- return;
- }
- const message = await res.text();
- throw new Error(message || `HTTP ${res.status}`);
- }
- const json = await res.json();
- voceroUserProfile = json && typeof json === "object" && json.result && typeof json.result === "object"
- ? json.result
- : (json && typeof json === "object" && !Array.isArray(json) ? json : null);
- voceroUserProfileLoaded = true;
- if (voceroUserProfile) {
- console.info(
- `[INJECT-PROFILE] ✅ BROWSER fetchVoceroUserProfile OK. phoneLen=%d profileKeys=%s headerUser=%s`,
- voceroUserPhoneNumber.length,
- Object.keys(voceroUserProfile).join(","),
- extractVoceroUserName(voceroUserProfile) || "(none)",
- );
- // ── 🚀 PREFETCH ApiPilot IMMEDIATELY AFTER SUCCESSFUL PHONE LOGIN
- // Force refresh=true to bypass the S2S proxy server cache (TTL 300 s)
- // now that the caller is authenticated rather than anonymous.
- // AquaPortal logs [apipilot] list_tools.start user=(non-anon) after
- // every successful phone login.
- try {
- if (window.__s2sApipilot) {
- const t0 = performance.now();
- console.info(
- "%c🔧 [APIPILOT-CLIENT] post-login phone → FORCE refresh api/apipilot/tools (bypass the 300 s S2S cache for the newly authenticated user)",
- "background:#ede9fe;color:#5b21b6;font-weight:700;padding:2px 6px;border-radius:4px;"
- );
- const demoToken = typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
- 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(`invalid envelope success=${Boolean(j && j.success)}`);
- const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
- const count = typeof j.data.count === "number" ? j.data.count : tools.length;
- const names = tools.slice(0, 6).map((t) => (typeof t.name === "string" ? t.name : "?")).join(", ");
- // Invalidate the WS client cache so the next session refreshes it.
- try {
- // @ts-ignore
- if (typeof window.__s2sInvalidateApipilotCache === "function") window.__s2sInvalidateApipilotCache();
- } catch (_) { /* noop */ }
- console.info(
- `%c🔧 [APIPILOT-CLIENT] post-login OK (dt=${dt}ms): N=${count} tools [first 6: ${names || "(none)"}]`,
- "background:#ecfdf5;color:#047857;font-weight:700;padding:2px 6px;border-radius:4px;"
- );
- }).catch((err) => {
- const dt = Math.round(performance.now() - t0);
- console.warn(
- `%c🔧 [APIPILOT-CLIENT] post-login FAIL (dt=${dt}ms): ${String(err instanceof Error ? err.message : err)} → check [APIPILOT-DISCOVERY] in the S2S server logs`,
- "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`,
- voceroUserProfile === null,
- );
- }
- syncVoceroHeaderUser();
- } catch (err) {
- voceroUserProfile = null;
- voceroUserProfileLoaded = false;
- syncVoceroHeaderUser();
- const message = err instanceof Error ? err.message : String(err);
- console.warn(
- `[INJECT-PROFILE] ⛔ BROWSER fetchVoceroUserProfile FAILED phone=%s error=%s`,
- JSON.stringify(voceroUserPhoneNumber), message,
- );
- }
-}
-
-async function fetchVoceroReportsList() {
- // NOTE: the server allows reports list without phone (global scheduled reports
- // for the company may be valid too). If a phone is present on the page URL we
- // pass it along anyway so the upstream can filter per-caller if configured.
- try {
- const qs = voceroUserPhoneNumber
- ? `?phoneNumber=${encodeURIComponent(voceroUserPhoneNumber)}`
- : "";
- const res = await fetch(`api/user/reports${qs}`);
- if (!res.ok) {
- // 503 = S2S_REPORT_LIST not configured on this deploy → silent skip.
- if (res.status === 503) {
- console.info(
- "[INJECT-REPORTS] ⏭️ BROWSER skipped (deploy did NOT configure S2S_REPORT_LIST env). endpoint returned HTTP 503, reports UI not shown.",
- );
- voceroReportsList = null;
- voceroReportsLoaded = true;
- syncVoceroHeaderUser();
- return;
- }
- const message = await res.text();
- throw new Error(message || `HTTP ${res.status}`);
- }
- const json = await res.json();
- const valid =
- json && typeof json === "object" &&
- Array.isArray(json.hints);
- if (!valid) {
- console.info(
- `[INJECT-REPORTS] ⏭️ BROWSER fetchVoceroReportsList HTTP 200 but payload missing hints array. keys=%s`,
- json && typeof json === "object" ? Object.keys(json).join(",") : "none",
- );
- voceroReportsList = null;
- voceroReportsLoaded = true;
- syncVoceroHeaderUser();
- return;
- }
- voceroReportsList = json;
- voceroReportsLoaded = true;
- if (json.skipped || !json.hints.length) {
- console.info(
- `[INJECT-REPORTS] ⏭️ BROWSER reports list empty after normalization. reason=%s count=%d`,
- json.reason || "empty hints",
- json.count ?? 0,
- );
- } else {
- console.info(
- `[INJECT-REPORTS] ✅ BROWSER fetchVoceroReportsList OK. phoneLen=%s count=%d wrappedLen=%d firstHint=%.120s`,
- voceroUserPhoneNumber ? voceroUserPhoneNumber.length : 0,
- json.count ?? json.hints.length,
- json.wrappedLen ?? 0,
- json.hints[0] || "",
- );
- if (window.S2S_DEBUG_PROMPT) {
- console.debug(
- "[DEBUG-REPORTS][BROWSER reports hints] FULL list (%d items) → %o",
- json.hints.length, json.hints,
- );
- }
- }
- syncVoceroHeaderUser();
- } catch (err) {
- voceroReportsList = null;
- voceroReportsLoaded = true;
- syncVoceroHeaderUser();
- const message = err instanceof Error ? err.message : String(err);
- console.warn(
- `[INJECT-REPORTS] ⛔ BROWSER fetchVoceroReportsList FAILED phone=%s error=%s`,
- JSON.stringify(voceroUserPhoneNumber || ""), message,
- );
- }
-}
-
-/** Learn server config (search key + connection target), then refresh the UI. */
-async function fetchConfig() {
- try {
- const res = await fetch("api/config");
- if (res.ok) {
- const json = await res.json();
- serverSearchKey = !!json.search;
- lbMode = !!json.lb;
- reasoningType = ["OPENAI","NODE_RED"].includes((json.reasoningType || "").toUpperCase())
- ? (json.reasoningType || "").toUpperCase()
- : "PROXY";
- // Lock to LB mode only when the deploy reports a load balancer.
- allowDirect = json.allowDirect ?? !lbMode;
- // Deploy-pinned direct URL (overrides the LB server-side already).
- pinnedUrl = (json.s2sUrl || "").trim();
- pinnedPrompt = (json.primaryPrompt || "").trim();
- injectProfile = !!json.injectProfile;
- debugPrompt = !!json.debugPrompt;
- // Feature flag for the server-side ApiPilot AquaPortal bridge.
- // True means S2S_APIPILOT_TOOLS_URL is configured and the frontend can
- // use /api/apipilot/tools and /api/apipilot/execute.
- const apipilotEnabled = !!json.apipilot;
- if (typeof window.__s2sApipilot === "undefined") {
- try { Object.defineProperty(window, "__s2sApipilot", { value: false, writable: true, configurable: true }); }
- 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