From 179f5ac7dbff36a3b89a928b3be6247aff201e65 Mon Sep 17 00:00:00 2001 From: Alvaro Parker Date: Tue, 6 Oct 2026 17:23:20 -0300 Subject: [PATCH] refactor(ui): delete legacy static website and refresh documentation --- .gitignore | 2 +- README.md | 7 +- docs/client/architecture.md | 11 +- docs/client/context-injection.md | 4 +- docs/client/design.md | 6 +- docs/client/glossary.md | 2 +- docs/client/reasoning.md | 21 +- docs/client/tool-calling.md | 5 +- docs/client/troubleshooting.md | 18 +- docs/client/usage.md | 35 +- s2s-client/.gitignore | 4 +- s2s-client/CHANGELOG.md | 5 + s2s-client/Dockerfile | 3 +- s2s-client/README.md | 40 +- s2s-client/index.html | 700 --- s2s-client/main.js | 3653 ----------- s2s-client/package-lock.json | 42 - s2s-client/package.json | 12 - s2s-client/scripts/vendor-dependencies.mjs | 18 - s2s-client/server.py | 22 +- s2s-client/style.css | 5506 ----------------- s2s-client/tests/test_server.py | 16 +- s2s-client/ui/account.js | 189 - s2s-client/ui/chat.js | 835 --- s2s-client/ui/dom.js | 37 - s2s-client/ui/markdown.js | 56 - s2s-client/vendor/dompurify/LICENSE | 202 - s2s-client/vendor/dompurify/LICENSE-MPL | 373 -- s2s-client/vendor/dompurify/purify.es.mjs | 2464 -------- s2s-client/vendor/dompurify/purify.es.mjs.map | 1 - s2s-client/vendor/marked/LICENSE | 44 - s2s-client/vendor/marked/marked.esm.js | 80 - s2s-client/vendor/marked/marked.esm.js.map | 7 - s2s-client/worklets/audio-playback.js | 186 - s2s-client/worklets/mic-capture.js | 159 - s2s-client/ws/codec.js | 57 - s2s-client/ws/orb-visualizer.js | 98 - s2s-client/ws/s2s-ws-client.js | 1896 ------ s2s-frontend/src/styles/legacy-theme.css | 9 +- 39 files changed, 102 insertions(+), 16723 deletions(-) delete mode 100644 s2s-client/index.html delete mode 100644 s2s-client/main.js delete mode 100644 s2s-client/package-lock.json delete mode 100644 s2s-client/package.json delete mode 100644 s2s-client/scripts/vendor-dependencies.mjs delete mode 100644 s2s-client/style.css delete mode 100644 s2s-client/ui/account.js delete mode 100644 s2s-client/ui/chat.js delete mode 100644 s2s-client/ui/dom.js delete mode 100644 s2s-client/ui/markdown.js delete mode 100644 s2s-client/vendor/dompurify/LICENSE delete mode 100644 s2s-client/vendor/dompurify/LICENSE-MPL delete mode 100644 s2s-client/vendor/dompurify/purify.es.mjs delete mode 100644 s2s-client/vendor/dompurify/purify.es.mjs.map delete mode 100644 s2s-client/vendor/marked/LICENSE delete mode 100644 s2s-client/vendor/marked/marked.esm.js delete mode 100644 s2s-client/vendor/marked/marked.esm.js.map delete mode 100644 s2s-client/worklets/audio-playback.js delete mode 100644 s2s-client/worklets/mic-capture.js delete mode 100644 s2s-client/ws/codec.js delete mode 100644 s2s-client/ws/orb-visualizer.js delete mode 100644 s2s-client/ws/s2s-ws-client.js 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 - - - - - - - - - - - - -
-
-
-
-
- Vocero - - -
-
-
- - - - - -
- - - - - - -
- -
-
- - -
- - - - -
- - - - -

Toca para empezar

- - - -
- - - - - -
-
-
-

Conversación

-
- - -
-
-
-
-
-
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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