refactor(ui): delete legacy static website and refresh documentation

This commit is contained in:
2026-10-06 17:23:20 -03:00
parent f91001169c
commit 179f5ac7db
39 changed files with 102 additions and 16723 deletions
+1 -1
View File
@@ -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/
+4 -3
View File
@@ -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 .
+4 -7
View File
@@ -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
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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 |
+10 -11
View File
@@ -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 |
+3 -2
View File
@@ -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:
+10 -8
View File
@@ -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.
+17 -18
View File
@@ -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,
+1 -3
View File
@@ -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
+5
View File
@@ -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/`.
+2 -1
View File
@@ -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
+18 -22
View File
@@ -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
-700
View File
@@ -1,700 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate, max-age=0" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>Vocero · Conversación de voz</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<!-- ═══════════════════════════════════════════════════════════════════
ANTI-FOUC VEIL (S2S_THEME flash fix) — MUST stay INLINE and
BEFORE the <link rel="stylesheet" href="style.css"> tag below.
Problem reported by user 2026-09-10:
"When I refresh, the previous purple theme briefly appears."
The earlier guard `html:not([data-theme]) [data-theme-only]` only
hid the two parallel widget branches, but the UNTHEMED DEFAULTS in
style.css (:root purple palette, body background #07080d, topbar
glass, #app deep-space colours, purple orb border gradients) were
still rendering for the ~1-2 frames between body paint commit and
fetchConfig() returning api/config.theme → setAttribute("data-theme").
The user saw a dark-purple flash before the white Vocero theme
applied — exactly the symptom described.
FIX (3 layers, fail-safe):
1. While html has NO data-theme attribute (pre-fetchConfig),
render <body> COMPLETELY INVISIBLE + background:white +
border:none (the two most visible paint leaks fixed first).
Display:none would block CSS transitions from being recorded;
visibility:hidden keeps layout but forbids painting entirely,
which is exactly what we want.
2. Safety timeout: if fetchConfig() fails to resolve (e.g.
static hosting / no server / offline) the veil auto-lifts
after 2500 ms so the user is NOT stuck on a blank page.
3. Immediately after root.setAttribute("data-theme", …) in
fetchConfig() we also add class="theme-ready" which the veil
additionally checks so the user sees a 1-frame lift, not a
delayed paint. The visibility guard is a double-OR so it
expires on EITHER signal.
═══════════════════════════════════════════════════════════════════ -->
<style id="s2s-anti-fouc-veil">
html:not([data-theme]) body,
html:not(.theme-ready) body {
visibility: hidden !important;
background: #ffffff !important;
color: #0f172a !important;
opacity: 0 !important;
transition: none !important;
animation: none !important;
}
html:not([data-theme]),
html:not(.theme-ready) {
background: #ffffff !important;
}
/* Also kill the purple orb + any glass chrome during the veil window,
because even when <body> is visibility:hidden some browsers do a
first compositing pass that leaks a few pixels of background. */
html:not([data-theme]) #main-circle,
html:not([data-theme]) .orb-wrap,
html:not([data-theme]) .stage,
html:not([data-theme]) .topbar,
html:not([data-theme]) #app,
html:not(.theme-ready) #main-circle,
html:not(.theme-ready) .orb-wrap,
html:not(.theme-ready) .stage,
html:not(.theme-ready) .topbar,
html:not(.theme-ready) #app {
background: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
border: none !important;
color: transparent !important;
}
</style>
<script>
// Fail-safe veil lift: if fetchConfig() fails to reply (static hosting,
// offline, api/config 500) the user never sees <body> stuck invisible.
// setTimeout (not rAF) because we want a real wall-clock timeout.
window.__s2sVeilLiftTimeout = window.setTimeout(function () {
var r = document.documentElement;
if (!r.hasAttribute("data-theme")) r.setAttribute("data-theme", "dark");
r.classList.add("theme-ready");
}, 2500);
// Also try to clear the fail-safe as soon as DOMContentLoaded fires so
// we don't accidentally fire the lift AFTER a normal-config path.
document.addEventListener("DOMContentLoaded", function () {
var r = document.documentElement;
if (r.hasAttribute("data-theme") && !r.classList.contains("theme-ready")) {
r.classList.add("theme-ready");
if (window.__s2sVeilLiftTimeout) window.clearTimeout(window.__s2sVeilLiftTimeout);
}
});
</script>
<link rel="stylesheet" href="style.css?v=202609291430" />
<script>
document.addEventListener("DOMContentLoaded", () => {
const l = document.querySelector('link[rel="stylesheet"]');
if (l && !l.href.includes("v=202609291430")) {
l.href = l.href.replace(/v=\d+/, "v=202609291430");
}
});
</script>
</head>
<!--
`booting` disables transitions/animations until the first paint commits.
Without this the orb visibly fades/scales in on the very first frame
because `state-idle` and the generic `.ind` defaults differ. The
bootstrap script at the end of main.js strips the class after one rAF.
-->
<body class="booting">
<div id="talk-unlocker" class="talk-unlocker" hidden data-debug-only-never>
<div class="talk-unlocker-hint">
<div class="talk-unlocker-spinner" aria-hidden="true"></div>
<p class="talk-unlocker-title">Preparando Vocero…</p>
<p class="talk-unlocker-sub">Toca en cualquier lugar para empezar a hablar</p>
</div>
</div>
<div id="app">
<header class="topbar">
<div class="brand">
<div class="ident">
<div class="ident-head">
<a class="ident-title">Vocero</a>
<!-- Subtitle shown below Vocero title (theme=white only) -->
<p class="ident-subtitle" hidden data-theme-only="white">Agente conversacional</p>
</div>
</div>
</div>
<!-- User avatar circle (theme=white only): grid item next to the brand, not
absolute-positioned → eliminates overlap with title/subtitle entirely.
Same element id so main.js syncVoceroHeaderUser() still fills the text. -->
<button id="ident-user-name" class="ident-user-name" type="button" hidden disabled data-theme-only="white" aria-label="Ver datos del usuario" aria-haspopup="dialog" aria-controls="user-profile-dialog"></button>
<!-- Left sidebar (reports) toggle: always visible, pinned left side of the
topbar control cluster. Clicking slides in the offcanvas sidebar on
mobile; it's a no-op hover hint on desktop where sidebar is always
shown. -->
<button id="reports-sidebar-toggle" class="icon-btn" title="Reportes programados" aria-label="Abrir panel de reportes" aria-expanded="false" aria-controls="reports-sidebar" hidden data-reports-sidebar-toggle data-debug-only="true" data-theme-only="dark">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z" fill="none"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<span id="reports-sidebar-badge" class="chat-badge" aria-hidden="true"></span>
</button>
<div class="topbar-right" hidden data-debug-only="true">
<!-- HF login chip / sign-in pill. Populated by ui/account.js; only
shown when the deploy runs behind a load balancer with OAuth. -->
<div id="account" class="account" hidden></div>
<button id="about-btn-m" class="icon-btn about-btn-mobile" title="Acerca de este espacio" aria-label="Acerca de este espacio">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</button>
<button id="tools-btn" class="icon-btn" title="Herramientas" aria-label="Herramientas">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</button>
<button id="rag-btn" class="icon-btn" title="Contexto RAG personalizado" aria-label="Contexto RAG personalizado">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
</button>
<button id="settings-btn" class="icon-btn" title="Configuración" aria-label="Configuración">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .66.39 1.25 1 1.51H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
</header>
<a class="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">Enviar comentarios</a>
<!-- Theme=white only: Status card between topbar and stage
Ready to converse / Reports, analysis, data and more.
with light-blue chart icon and chevron-right arrow. -->
<div class="status-card" hidden data-theme-only="white" aria-label="Estado de la sesión">
<div class="status-card-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<line x1="6" y1="20" x2="6" y2="12"/>
<line x1="11" y1="20" x2="11" y2="6"/>
<line x1="16" y1="20" x2="16" y2="15"/>
<line x1="21" y1="20" x2="21" y2="10"/>
</svg>
</div>
<div class="status-card-text">
<h2 class="status-card-title">Listo para conversar</h2>
<p class="status-card-sub">Informes, análisis, datos y mucho más.</p>
</div>
<button class="status-card-arrow icon-btn" type="button" aria-label="Ver más detalles" title="Detalles">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<main class="stage">
<!-- DARK THEME orb (purple gradient + purple icons): shown only on native dark theme, hidden in white -->
<div class="orb-wrap" data-theme-only="dark">
<div id="mic-gate" class="mic-gate">
<svg id="mic-gate-arc" class="mic-gate-arc" viewBox="0 0 100 100" aria-hidden="true">
<path id="mga-track" class="mga-track" fill="none" />
<path id="mga-fill" class="mga-fill" fill="none" />
<path id="mga-hit" class="mga-hit" fill="none" />
<circle id="mga-handle" class="mga-handle" r="3" />
</svg>
<button id="mic-btn" class="side-btn" type="button" aria-label="Silenciar micrófono" title="Silenciar micrófono" aria-hidden="true">
<svg class="mic-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
<svg class="mic-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M9 5a3 3 0 0 1 6 0v4"/><path d="M9 10v1a3 3 0 0 0 5.1 2.1"/><path d="M19 10a7 7 0 0 1-1.24 3.97"/><path d="M5 10a7 7 0 0 0 11 5.67"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
</button>
</div>
<button
id="main-circle"
class="circle state-idle"
type="button"
aria-label="Iniciar conversación de voz"
>
<span class="circle-glow" aria-hidden="true"></span>
<span class="circle-ring" aria-hidden="true"></span>
<span class="circle-ring-outer" aria-hidden="true"></span>
<span class="circle-core">
<span class="circle-indicator" aria-hidden="true">
<svg class="ind ind-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="12" rx="3" fill="currentColor" stroke="none"/>
<path d="M5 10a7 7 0 0 0 14 0"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="8" y1="22" x2="16" y2="22"/>
</svg>
<svg class="ind ind-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="13"/><line x1="12" y1="16" x2="12" y2="16"/></svg>
<span class="ind ind-spinner"></span>
<span class="ind ind-thinking">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
<span class="ind ind-bars">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</span>
<svg class="ind ind-voice" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 10v4a1 1 0 0 0 1 1h3l5 4V5L7 9H4a1 1 0 0 0-1 1z" fill="currentColor" stroke="none"/>
<path class="wave wave-1" d="M16 8a5 5 0 0 1 0 8"/>
<path class="wave wave-2" d="M19 5a9 9 0 0 1 0 14"/>
</svg>
</span>
</span>
</button>
<button id="stop-btn" class="side-btn" type="button" aria-label="Finalizar" title="Finalizar" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
</div>
<!-- WHITE THEME mic stage (replaces the purple orb)
- Blue mic circular button (116 px, deep blue, white mic)
- Two waveform banks left + right (audio-reactive CSS bars)
- Listening status text
- Mute and stop controls below.
Mirrors the screenshot center UI exactly. -->
<div class="white-mic-stage" hidden data-theme-only="white" aria-label="Control de voz">
<!-- Waveform left side: 7 bars mirror right side, heights driven by JS vars -->
<div class="white-waveform white-waveform-left" aria-hidden="true">
<span class="w-bar wb-1" style="--bar-h: 0.4"></span>
<span class="w-bar wb-2" style="--bar-h: 0.65"></span>
<span class="w-bar wb-3" style="--bar-h: 0.9"></span>
<span class="w-bar wb-4" style="--bar-h: 1"></span>
<span class="w-bar wb-5" style="--bar-h: 0.7"></span>
<span class="w-bar wb-6" style="--bar-h: 0.55"></span>
<span class="w-bar wb-7" style="--bar-h: 0.3"></span>
</div>
<!-- Starts a voice session; becomes an indicator while connected. -->
<button id="white-mic-circle"
class="white-mic-circle state-idle"
type="button"
aria-label="Iniciar conversación de voz">
<span class="wmc-halo" aria-hidden="true"></span>
<span class="wmc-ring" aria-hidden="true"></span>
<span class="wmc-core" aria-hidden="true"></span>
<svg class="wmc-icon wmc-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="12" rx="3" fill="currentColor" stroke="none"/>
<path d="M5 10a7 7 0 0 0 14 0"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="8" y1="22" x2="16" y2="22"/>
</svg>
<span class="wmc-icon wmc-spinner" aria-hidden="true"></span>
</button>
<!-- Waveform right side: 7 bars mirroring left -->
<div class="white-waveform white-waveform-right" aria-hidden="true">
<span class="w-bar wb-1" style="--bar-h: 0.3"></span>
<span class="w-bar wb-2" style="--bar-h: 0.55"></span>
<span class="w-bar wb-3" style="--bar-h: 0.7"></span>
<span class="w-bar wb-4" style="--bar-h: 1"></span>
<span class="w-bar wb-5" style="--bar-h: 0.9"></span>
<span class="w-bar wb-6" style="--bar-h: 0.65"></span>
<span class="w-bar wb-7" style="--bar-h: 0.4"></span>
</div>
<!-- Status caption below mic circle: listening, thinking, etc. -->
<p id="white-mic-caption" class="white-mic-caption" role="status">Toca para empezar</p>
<p id="white-mic-subcaption" class="white-mic-subcaption" hidden></p>
<div id="white-session-controls" class="white-session-controls" role="group" aria-label="Controles de la conversación" hidden>
<button id="white-mute-btn" class="white-mute-btn" type="button" hidden disabled aria-label="Silenciar micrófono" title="Silenciar micrófono">
<svg class="mic-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/>
</svg>
<svg class="mic-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="2" y1="2" x2="22" y2="22"/><path d="M9 5a3 3 0 0 1 6 0v4"/><path d="M9 10v1a3 3 0 0 0 5.1 2.1"/><path d="M19 10a7 7 0 0 1-1.24 3.97"/><path d="M5 10a7 7 0 0 0 11 5.67"/><line x1="12" y1="19" x2="12" y2="22"/>
</svg>
<span id="white-mute-label">Silenciar micrófono</span>
</button>
<button id="white-stop-btn"
class="white-stop-btn"
type="button"
hidden
aria-label="Detener"
title="Detener sesión de voz">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
<span>Detener</span>
</button>
</div>
</div>
<p id="circle-caption" class="circle-caption" role="status">Toca para empezar</p>
<p id="circle-subcaption" class="circle-subcaption" hidden></p>
<div id="queue-actions" class="queue-actions" hidden>
<button id="join-queue-btn" class="join-queue-btn" type="button" hidden>
Entrar ahora
</button>
<button id="leave-queue-btn" class="leave-queue-btn" type="button" hidden>
Salir de la cola
</button>
</div>
</main>
<!-- Theme=white only: summary, data lookup and draft-response quick
actions below the mic area, each with an icon. -->
<div class="quick-actions" hidden data-theme-only="white" aria-label="Acciones rápidas">
<button class="quick-action-btn" type="button" data-action="summary">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/>
<line x1="8" y1="17" x2="14" y2="17"/>
</svg>
<span>Resumen</span>
</button>
<button class="quick-action-btn" type="button" data-action="query-data">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="6" y1="20" x2="6" y2="12"/>
<line x1="11" y1="20" x2="11" y2="6"/>
<line x1="16" y1="20" x2="16" y2="15"/>
<line x1="21" y1="20" x2="21" y2="10"/>
</svg>
<span>Consultar datos</span>
</button>
<button class="quick-action-btn" type="button" data-action="draft-response">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="2" x2="22" y2="6"/>
<path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"/>
</svg>
<span>Crear respuesta</span>
</button>
</div>
<!-- Text chat ALWAYS VISIBLE, fixed between the stage and footer.
The panel is now a direct child of the flex-column #app, so it
no longer needs a topbar toggle for a right-side overlay. -->
<div id="chat-panel" class="chat-panel chat-panel-inline" role="region" aria-label="Transcripción de la conversación">
<div class="chat-panel-inner">
<header class="chat-panel-header">
<h3>Conversación</h3>
<div class="chat-header-actions" role="group" aria-label="Acciones de la conversación">
<span id="chat-status" class="chat-status-pill" hidden data-theme-only="white" data-status="idle" role="status" aria-live="polite">
<span class="chat-status-dot" aria-hidden="true"></span>
<span id="chat-status-label">Sin sesión</span>
</span>
<button id="chat-export-btn" class="chat-export-btn icon-btn"
type="button"
aria-label="Descargar conversación"
title="Descargar conversación (JSON + TXT)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
</div>
</header>
<div id="chat-history" class="chat-history" role="log" aria-live="polite" aria-atomic="false"></div>
</div>
</div>
</div>
<!-- Ephemeral chat bubbles anchored to the top-right -->
<div id="bubble-stack" class="bubble-stack" aria-live="polite" aria-atomic="false"></div>
<!-- Webcam self-view, shown bottom-left while the camera tool is enabled.
HIDDEN BY DEFAULT TODAY: remove hidden and restore the main.js listener to reactivate. -->
<div id="cam-pip" class="cam-pip" aria-hidden="true" hidden>
<video id="cam-video" class="cam-video" autoplay playsinline muted></video>
<span class="cam-flash" aria-hidden="true"></span>
<span class="cam-label">cámara</span>
</div>
<!-- Left sidebar: scheduled reports.
Desktop: fixed left, 320px, always visible (below topbar + above sticky footer).
Mobile : off-canvas left overlay, toggled by #reports-sidebar-toggle button in topbar. -->
<aside id="reports-sidebar" class="reports-sidebar" aria-label="Reportes programados" hidden data-theme-only="dark">
<div id="reports-sidebar-backdrop" class="reports-sidebar-backdrop" data-close-reports-sidebar></div>
<div class="reports-sidebar-inner">
<header class="reports-sidebar-header">
<h3>Reportes</h3>
<button id="reports-sidebar-close" class="icon-btn" aria-label="Cerrar panel de reportes" data-close-reports-sidebar>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</header>
<!-- MOVED from inside .ident in the topbar — same #id so main.js syncVoceroHeaderUser()
continues to work with zero JS changes to reports rendering logic. -->
<div id="reports-hint" class="reports-hint" hidden data-theme-only="dark"></div>
<!-- Debug-only: Spanish suggestions panel (gated behind ?debug=true).
Shown as the bottom block inside the left sidebar. -->
<div id="reports-suggestions" class="reports-suggestions" hidden data-debug-only="true"></div>
</div>
</aside>
<dialog id="about-modal" class="modal about-modal">
<div class="modal-content">
<header class="modal-header">
<h2>Acerca de</h2>
<button id="about-close" class="icon-btn" aria-label="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</header>
<!-- General intro to the speech-to-speech project -->
<div class="about-intro">
</div>
<!-- The backend, shown as the path a turn actually travels -->
<div class="about-pipeline">
<p class="pipeline-title">Cómo funciona</p>
<ol class="pipeline">
<li class="pipe-endpoint">Tú hablas</li>
<li class="pipe-stage">
<span class="pipe-tag">VAD</span>
<span class="pipe-job">detecta el habla</span>
</li>
<li class="pipe-stage">
<span class="pipe-tag">STT</span>
<span class="pipe-job">la transcribe</span>
<a class="pipe-model" href="https://huggingface.co/nvidia/parakeet-tdt-1.1b" target="_blank" rel="noopener">nvidia/parakeet-tdt-1.1b<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg></a>
</li>
<li class="pipe-stage">
<span class="pipe-tag">VLM</span>
<span class="pipe-job">analiza y redacta la respuesta <span class="pipe-note">· mediante <a class="cerebras-credit" href="https://cerebras.ai" target="_blank" rel="noopener"><svg class="cerebras-mark" viewBox="0 0 50 50" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M29.4186 5.62713C24.2806 5.62713 19.353 7.6682 15.7199 11.3013C12.0868 14.9345 10.0457 19.862 10.0457 25C10.0457 30.1381 12.0868 35.0656 15.7199 38.6988C19.353 42.3319 24.2806 44.373 29.4186 44.373V47.2917C17.1061 47.2917 7.12695 37.3105 7.12695 24.998C7.12695 12.6855 17.104 2.7063 29.4165 2.7063V5.62505L29.4186 5.62713ZM39.3186 13.2875C37.7794 11.9809 35.9971 10.9915 34.0741 10.3761C32.1512 9.76067 30.1256 9.53147 28.1137 9.70166C26.1019 9.87185 24.1435 10.4381 22.3513 11.3677C20.559 12.2974 18.9683 13.5722 17.6704 15.1189C16.3726 16.6655 15.3933 18.4534 14.7888 20.3798C14.1844 22.3062 13.9667 24.3332 14.1483 26.344C14.33 28.3548 14.9073 30.3099 15.8472 32.0968C16.787 33.8838 18.0709 35.4673 19.6249 36.7563L17.7478 38.9938C15.9126 37.4545 14.3987 35.5687 13.2924 33.4442C12.1862 31.3197 11.5092 28.9981 11.3003 26.6119C11.0914 24.2258 11.3545 21.8219 12.0747 19.5374C12.7949 17.253 13.9581 15.1328 15.4978 13.298C17.0373 11.4628 18.9233 9.94877 21.0479 8.84247C23.1726 7.73618 25.4944 7.05923 27.8807 6.8503C30.267 6.64136 32.6711 6.90453 34.9557 7.62477C37.2403 8.34501 39.3607 9.50822 41.1957 11.048L39.3186 13.2875ZM34.6207 15.0459C31.9818 13.6829 28.9113 13.4172 26.0776 14.3068C23.2438 15.1964 20.876 17.1691 19.4895 19.7958C18.1029 22.4224 17.8099 25.4903 18.6741 28.332C19.5384 31.1736 21.4899 33.5589 24.104 34.9688L22.7374 37.5521C19.4721 35.7617 17.0418 32.7592 15.9707 29.1927C14.8996 25.6261 15.2737 21.7815 17.0123 18.4883C18.7509 15.1952 21.7146 12.7176 25.2637 11.5903C28.8129 10.463 32.663 10.7763 35.9832 12.4625L34.6207 15.0459ZM29.4165 17.7896C27.5042 17.7896 25.6702 18.5493 24.318 19.9015C22.9658 21.2537 22.2061 23.0877 22.2061 25C22.2061 26.9124 22.9658 28.7464 24.318 30.0986C25.6702 31.4508 27.5042 32.2105 29.4165 32.2105V35.1313C26.7296 35.1313 24.1526 34.0639 22.2527 32.1639C20.3527 30.2639 19.2853 27.687 19.2853 25C19.2853 22.3131 20.3527 19.7362 22.2527 17.8362C24.1526 15.9362 26.7296 14.8688 29.4165 14.8688V17.7896Z" fill="#F15A29"/><path d="M32.0981 22.5749C31.7875 22.2404 31.4295 21.9534 31.0356 21.7228C30.6925 21.519 30.3014 21.4097 29.9023 21.4062C29.371 21.4062 28.896 21.5041 28.4773 21.6978C28.0725 21.8802 27.7089 22.1425 27.4081 22.469C27.1074 22.7955 26.8758 23.1795 26.7273 23.5978C26.5731 24.0207 26.4981 24.4645 26.4981 24.9124C26.4981 25.3666 26.5731 25.8082 26.7273 26.227C26.877 26.6449 27.1091 27.0286 27.4096 27.3553C27.7102 27.682 28.0733 27.9451 28.4773 28.1291C28.8939 28.3228 29.3731 28.4207 29.9023 28.4207C30.3523 28.4207 30.771 28.3249 31.1564 28.1395C31.5481 27.952 31.8856 27.6707 32.146 27.3228L34.0794 29.4187C33.7877 29.7103 33.4544 29.9624 33.0752 30.1749C32.374 30.566 31.6108 30.8338 30.8189 30.9666C30.4648 31.0207 30.1585 31.0499 29.9023 31.0499C29.0617 31.0552 28.2271 30.9069 27.4398 30.6124C26.6954 30.3371 26.0144 29.914 25.4377 29.3687C24.8643 28.8208 24.4078 28.1624 24.096 27.4332C23.7572 26.6366 23.5883 25.778 23.6002 24.9124C23.6002 23.9874 23.7669 23.1478 24.096 22.3916C24.4085 21.6624 24.8627 21.0041 25.4356 20.4562C26.0148 19.9124 26.696 19.4895 27.4398 19.2145C28.2271 18.92 29.0617 18.7717 29.9023 18.777C30.6419 18.777 31.3856 18.9187 32.1356 19.202C32.8877 19.4895 33.5627 19.952 34.1023 20.5541L32.0981 22.5749Z" fill="#fff"/></svg>Cerebras</a></span></span>
<a class="pipe-model" href="https://huggingface.co/google/gemma-4-31B-it" target="_blank" rel="noopener">google/gemma-4-31B-it<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg></a>
</li>
<li class="pipe-stage">
<span class="pipe-tag">TTS</span>
<span class="pipe-job">la convierte en voz</span>
<a class="pipe-model" href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" target="_blank" rel="noopener">Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg></a>
</li>
<li class="pipe-endpoint">Vocero responde</li>
</ol>
</div>
</div>
</dialog>
<dialog id="settings-modal" class="modal">
<form method="dialog" class="modal-content">
<header class="modal-header">
<h2>Configuración</h2>
<button class="icon-btn" value="close" aria-label="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</header>
<div class="tab-panels">
<div class="tab-panel active" role="tabpanel">
<label class="field" id="conn-field">
<span id="conn-label">URL del servidor de voz</span>
<input id="lb-url" type="text" autocomplete="off" spellcheck="false" placeholder="http://localhost:8080" />
<small id="conn-hint">
URL de tu servidor de voz, por ejemplo
<code>http://localhost:8080</code> (la aplicación añade <code>/v1/realtime</code>).
</small>
</label>
<div class="field-row">
<label class="field">
<span>Voz</span>
<select id="voice">
<option value="Aiden" selected>Aiden</option>
<option value="Ryan">Ryan</option>
<option value="Dylan">Dylan</option>
<option value="Eric">Eric</option>
<option value="Ono_Anna">Ono_Anna</option>
<option value="Serena">Serena</option>
<option value="Sohee">Sohee</option>
<option value="Uncle_Fu">Uncle_Fu</option>
<option value="Vivian">Vivian</option>
</select>
</label>
</div>
<div class="field">
<span class="field-head">
Puerta de ruido
<span id="gate-value" class="field-value">Desactivada</span>
</span>
<div class="gate">
<div class="gate-track" aria-hidden="true">
<div id="gate-meter-fill" class="gate-meter-fill"></div>
<input
id="noise-gate"
type="range"
min="-66"
max="-3"
step="1"
value="-50"
aria-label="Umbral de la puerta de ruido"
/>
</div>
<div class="gate-ends">
<span>Desactivada</span>
<span>−3 dB</span>
</div>
</div>
<small>
Silencia el micrófono por debajo del umbral para evitar enviar ruido.
Desliza hasta la izquierda para desactivarla. La barra muestra tu audio durante la llamada.
</small>
</div>
<label class="field">
<span>Instrucciones</span>
<textarea id="instructions" rows="5" placeholder="Introduce las instrucciones de Vocero para esta conversación…"></textarea>
<small id="primary-prompt-meta" hidden></small>
</label>
<div class="field">
<button id="restart-conversation" type="button" class="btn primary wide" disabled>
Reiniciar conversación con esta configuración
</button>
<small id="restart-hint">Conéctate primero y vuelve aquí para aplicar cambios durante la conversación.</small>
</div>
</div>
</div>
<footer class="modal-footer">
<button id="settings-save" type="submit" class="btn primary" value="save">Guardar</button>
</footer>
</form>
</dialog>
<dialog id="tools-modal" class="modal">
<div class="modal-content">
<header class="modal-header">
<h2>Herramientas</h2>
<button id="tools-close" class="icon-btn" aria-label="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</header>
<p class="tools-intro">Permite que el asistente actúe durante la conversación. Los cambios se aplican de inmediato.</p>
<div class="tool-list">
<div class="tool-row" id="tool-web-row">
<div class="tool-info">
<span class="tool-name">Búsqueda web</span>
<span class="tool-desc">Busca información en Google mediante Serper.</span>
</div>
<label class="switch">
<input id="tool-web" type="checkbox" />
<span class="switch-track" aria-hidden="true"></span>
</label>
</div>
<label class="field tools-key">
<span>Clave de la API de búsqueda</span>
<input id="search-key" type="password" autocomplete="off" spellcheck="false" />
<small id="tool-web-hint"></small>
</label>
<!-- Camera tool row — HIDDEN BY DEFAULT TODAY.
To reactivate later, remove hidden from the div and small,
then restore the toolCamSwitch listener and enableCamera functions in main.js. -->
<div class="tool-row tool-row-sep" id="tool-cam-row" hidden>
<div class="tool-info">
<span class="tool-name">Cámara</span>
<span class="tool-desc">Permite que el asistente vea a través de tu cámara.</span>
</div>
<label class="switch">
<input id="tool-cam" type="checkbox" />
<span class="switch-track" aria-hidden="true"></span>
</label>
</div>
<small id="tool-cam-hint" class="tool-hint" hidden>La vista previa aparece abajo a la izquierda mientras la cámara está activa.</small>
<div class="tool-row tool-row-sep">
<div class="tool-info">
<span class="tool-name">Herramientas MCP cargadas</span>
<span class="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
</div>
</div>
<small id="agent-tools-hint" class="tool-hint"></small>
<div id="agent-tools-list" class="agent-tools-list"></div>
</div>
</div>
</dialog>
<dialog id="rag-modal" class="modal">
<div class="modal-content">
<header class="modal-header">
<h2>Contexto RAG personalizado</h2>
<button id="rag-close" class="icon-btn" aria-label="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</header>
<p class="tools-intro">
Pega aquí texto, documentos o fragmentos RAG que quieras tener disponibles
durante la sesión de voz actual. El texto se añade a las
instrucciones del modelo y al campo <code>user_context</code> de la solicitud a
<code>/api/reasoning</code>.
</p>
<label class="field">
<span>Contexto RAG (texto libre)</span>
<textarea
id="rag-textarea"
rows="12"
spellcheck="false"
placeholder="Pega o escribe el contexto RAG que el modelo debe usar al responder. Por ejemplo: datos del cliente, políticas o fragmentos de documentos."
></textarea>
<small id="rag-hint" class="tool-hint">
No hay contexto RAG cargado. Pulsa &quot;Enviar a la sesión&quot; para aplicar los cambios.
</small>
</label>
<footer class="modal-footer">
<button id="rag-clear" class="btn secondary" type="button" value="clear">Vaciar</button>
<div style="flex: 1"></div>
<button id="rag-submit" class="btn primary" type="button" value="submit">Enviar a la sesión</button>
</footer>
</div>
</dialog>
<!-- Shown when the per-day conversation budget is spent (mid-call or at
start). Title / message / CTA / note are filled per tier by
ui/account.js. The hero is the HF mark — a friendly smiling face. -->
<dialog id="limit-modal" class="modal limit-modal">
<div class="modal-content limit-card">
<button id="limit-close" class="icon-btn limit-close" aria-label="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="limit-badge" aria-hidden="true">
<svg class="hf-logo" viewBox="0 0 95 88" fill="none" aria-hidden="true"><path fill="#FFD21E" d="M47.21 76.5a34.75 34.75 0 1 0 0-69.5 34.75 34.75 0 0 0 0 69.5Z"/><path fill="#FF9D0B" d="M81.96 41.75a34.75 34.75 0 1 0-69.5 0 34.75 34.75 0 0 0 69.5 0Zm-73.5 0a38.75 38.75 0 1 1 77.5 0 38.75 38.75 0 0 1-77.5 0Z"/><path fill="#3A3B45" d="M58.5 32.3c1.28.44 1.78 3.06 3.07 2.38a5 5 0 1 0-6.76-2.07c.61 1.15 2.55-.72 3.7-.32ZM34.95 32.3c-1.28.44-1.79 3.06-3.07 2.38a5 5 0 1 1 6.76-2.07c-.61 1.15-2.56-.72-3.7-.32Z"/><path fill="#FF323D" d="M46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z"/><path fill="#3A3B45" fill-rule="evenodd" d="M39.43 54a8.7 8.7 0 0 1 5.3-4.49c.4-.12.81.57 1.24 1.28.4.68.82 1.37 1.24 1.37.45 0 .9-.68 1.33-1.35.45-.7.89-1.38 1.32-1.25a8.61 8.61 0 0 1 5 4.17c3.73-2.94 5.1-7.74 5.1-10.7 0-2.34-1.57-1.6-4.09-.36l-.14.07c-2.31 1.15-5.39 2.67-8.77 2.67s-6.45-1.52-8.77-2.67c-2.6-1.29-4.23-2.1-4.23.29 0 3.05 1.46 8.06 5.47 10.97Z" clip-rule="evenodd"/><path fill="#FF9D0B" d="M70.71 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM24.21 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM17.52 48c-1.62 0-3.06.66-4.07 1.87a5.97 5.97 0 0 0-1.33 3.76 7.1 7.1 0 0 0-1.94-.3c-1.55 0-2.95.59-3.94 1.66a5.8 5.8 0 0 0-.8 7 5.3 5.3 0 0 0-1.79 2.82c-.24.9-.48 2.8.8 4.74a5.22 5.22 0 0 0-.37 5.02c1.02 2.32 3.57 4.14 8.52 6.1 3.07 1.22 5.89 2 5.91 2.01a44.33 44.33 0 0 0 10.93 1.6c5.86 0 10.05-1.8 12.46-5.34 3.88-5.69 3.33-10.9-1.7-15.92-2.77-2.78-4.62-6.87-5-7.77-.78-2.66-2.84-5.62-6.25-5.62a5.7 5.7 0 0 0-4.6 2.46c-1-1.26-1.98-2.25-2.86-2.82A7.4 7.4 0 0 0 17.52 48Zm0 4c.51 0 1.14.22 1.82.65 2.14 1.36 6.25 8.43 7.76 11.18.5.92 1.37 1.31 2.14 1.31 1.55 0 2.75-1.53.15-3.48-3.92-2.93-2.55-7.72-.68-8.01.08-.02.17-.02.24-.02 1.7 0 2.45 2.93 2.45 2.93s2.2 5.52 5.98 9.3c3.77 3.77 3.97 6.8 1.22 10.83-1.88 2.75-5.47 3.58-9.16 3.58-3.81 0-7.73-.9-9.92-1.46-.11-.03-13.45-3.8-11.76-7 .28-.54.75-.76 1.34-.76 2.38 0 6.7 3.54 8.57 3.54.41 0 .7-.17.83-.6.79-2.85-12.06-4.05-10.98-8.17.2-.73.71-1.02 1.44-1.02 3.14 0 10.2 5.53 11.68 5.53.11 0 .2-.03.24-.1.74-1.2.33-2.04-4.9-5.2-5.21-3.16-8.88-5.06-6.8-7.33.24-.26.58-.38 1-.38 3.17 0 10.66 6.82 10.66 6.82s2.02 2.1 3.25 2.1c.28 0 .52-.1.68-.38.86-1.46-8.06-8.22-8.56-11.01-.34-1.9.24-2.85 1.31-2.85Z"/><path fill="#FFD21E" d="M38.6 76.69c2.75-4.04 2.55-7.07-1.22-10.84-3.78-3.77-5.98-9.3-5.98-9.3s-.82-3.2-2.69-2.9c-1.87.3-3.24 5.08.68 8.01 3.91 2.93-.78 4.92-2.29 2.17-1.5-2.75-5.62-9.82-7.76-11.18-2.13-1.35-3.63-.6-3.13 2.2.5 2.79 9.43 9.55 8.56 11-.87 1.47-3.93-1.71-3.93-1.71s-9.57-8.71-11.66-6.44c-2.08 2.27 1.59 4.17 6.8 7.33 5.23 3.16 5.64 4 4.9 5.2-.75 1.2-12.28-8.53-13.36-4.4-1.08 4.11 11.77 5.3 10.98 8.15-.8 2.85-9.06-5.38-10.74-2.18-1.7 3.21 11.65 6.98 11.76 7.01 4.3 1.12 15.25 3.49 19.08-2.12Z"/><path fill="#FF9D0B" d="M77.4 48c1.62 0 3.07.66 4.07 1.87a5.97 5.97 0 0 1 1.33 3.76 7.1 7.1 0 0 1 1.95-.3c1.55 0 2.95.59 3.94 1.66a5.8 5.8 0 0 1 .8 7 5.3 5.3 0 0 1 1.78 2.82c.24.9.48 2.8-.8 4.74a5.22 5.22 0 0 1 .37 5.02c-1.02 2.32-3.57 4.14-8.51 6.1-3.08 1.22-5.9 2-5.92 2.01a44.33 44.33 0 0 1-10.93 1.6c-5.86 0-10.05-1.8-12.46-5.34-3.88-5.69-3.33-10.9 1.7-15.92 2.78-2.78 4.63-6.87 5.01-7.77.78-2.66 2.83-5.62 6.24-5.62a5.7 5.7 0 0 1 4.6 2.46c1-1.26 1.98-2.25 2.87-2.82A7.4 7.4 0 0 1 77.4 48Zm0 4c-.51 0-1.13.22-1.82.65-2.13 1.36-6.25 8.43-7.76 11.18a2.43 2.43 0 0 1-2.14 1.31c-1.54 0-2.75-1.53-.14-3.48 3.91-2.93 2.54-7.72.67-8.01a1.54 1.54 0 0 0-.24-.02c-1.7 0-2.45 2.93-2.45 2.93s-2.2 5.52-5.97 9.3c-3.78 3.77-3.98 6.8-1.22 10.83 1.87 2.75 5.47 3.58 9.15 3.58 3.82 0 7.73-.9 9.93-1.46.1-.03 13.45-3.8 11.76-7-.29-.54-.75-.76-1.34-.76-2.38 0-6.71 3.54-8.57 3.54-.42 0-.71-.17-.83-.6-.8-2.85 12.05-4.05 10.97-8.17-.19-.73-.7-1.02-1.44-1.02-3.14 0-10.2 5.53-11.68 5.53-.1 0-.19-.03-.23-.1-.74-1.2-.34-2.04 4.88-5.2 5.23-3.16 8.9-5.06 6.8-7.33-.23-.26-.57-.38-.98-.38-3.18 0-10.67 6.82-10.67 6.82s-2.02 2.1-3.24 2.1a.74.74 0 0 1-.68-.38c-.87-1.46 8.05-8.22 8.55-11.01.34-1.9-.24-2.85-1.31-2.85Z"/><path fill="#FFD21E" d="M56.33 76.69c-2.75-4.04-2.56-7.07 1.22-10.84 3.77-3.77 5.97-9.3 5.97-9.3s.82-3.2 2.7-2.9c1.86.3 3.23 5.08-.68 8.01-3.92 2.93.78 4.92 2.28 2.17 1.51-2.75 5.63-9.82 7.76-11.18 2.13-1.35 3.64-.6 3.13 2.2-.5 2.79-9.42 9.55-8.55 11 .86 1.47 3.92-1.71 3.92-1.71s9.58-8.71 11.66-6.44c2.08 2.27-1.58 4.17-6.8 7.33-5.23 3.16-5.63 4-4.9 5.2.75 1.2 12.28-8.53 13.36-4.4 1.08 4.11-11.76 5.3-10.97 8.15.8 2.85 9.05-5.38 10.74-2.18 1.69 3.21-11.65 6.98-11.76 7.01-4.31 1.12-15.26 3.49-19.08-2.12Z"/></svg>
</div>
<h2 id="limit-title" class="limit-title">Por ahora hemos terminado</h2>
<p id="limit-msg" class="limit-msg"></p>
<a id="limit-cta" class="btn primary wide limit-cta" href="#"></a>
<p id="limit-note" class="limit-note"></p>
</div>
</dialog>
<!-- User profile dialog — opened by clicking the circular avatar badge in
the brand header. Styling: large glass hero with initials + rows of
user metadata populated via main.js populateUserProfileDialog(). -->
<dialog id="user-profile-dialog" class="modal user-profile-modal" aria-labelledby="user-profile-title">
<div class="modal-content user-profile-card">
<button id="user-profile-close" class="icon-btn user-profile-close" aria-label="Cerrar perfil de usuario">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<!-- Hero: big glass avatar circle + title + subtitle (email fallback). -->
<div class="user-profile-hero">
<div id="user-profile-avatar" class="user-profile-avatar" aria-hidden="true">AV</div>
<h2 id="user-profile-title" class="user-profile-title"></h2>
<p id="user-profile-subtitle" class="user-profile-subtitle"></p>
</div>
<!-- Metadata: a 1-col definition-list-like grid. Empty rows are hidden
automatically via JS when the field has no value, so we never show
blank labels. -->
<div id="user-profile-metadata" class="user-profile-metadata" aria-label="Información del usuario"></div>
</div>
</dialog>
<footer class="app-footer" aria-label="Versión de la aplicación">
<span class="app-footer-text">Azurian Vocero en tiempo real ver. Beta 2.1</span>
</footer>
<script type="module" src="main.js?v=202609102207"></script>
</body>
</html>
-3653
View File
File diff suppressed because it is too large Load Diff
-42
View File
@@ -1,42 +0,0 @@
{
"name": "vocero-ui",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vocero-ui",
"dependencies": {
"dompurify": "3.4.16",
"marked": "18.0.14"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/dompurify": {
"version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/marked": {
"version": "18.0.14",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.14.tgz",
"integrity": "sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
}
}
}
-12
View File
@@ -1,12 +0,0 @@
{
"name": "vocero-ui",
"private": true,
"type": "module",
"scripts": {
"vendor": "node scripts/vendor-dependencies.mjs"
},
"dependencies": {
"dompurify": "3.4.16",
"marked": "18.0.14"
}
}
@@ -1,18 +0,0 @@
import { copyFile, mkdir } from "node:fs/promises";
const root = new URL("../", import.meta.url);
const assets = [
["marked/lib/marked.esm.js", "marked/marked.esm.js"],
["marked/lib/marked.esm.js.map", "marked/marked.esm.js.map"],
["marked/LICENSE", "marked/LICENSE"],
["dompurify/dist/purify.es.mjs", "dompurify/purify.es.mjs"],
["dompurify/dist/purify.es.mjs.map", "dompurify/purify.es.mjs.map"],
["dompurify/LICENSE", "dompurify/LICENSE"],
["dompurify/LICENSE-MPL", "dompurify/LICENSE-MPL"],
];
for (const [source, destination] of assets) {
const target = new URL(`vendor/${destination}`, root);
await mkdir(new URL("./", target), { recursive: true });
await copyFile(new URL(`node_modules/${source}`, root), target);
}
+2 -20
View File
@@ -1394,7 +1394,6 @@ if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET:
SERPER_URL = "https://google.serper.dev/search"
# Cap results so the tool output stays small enough to feed back to the model.
MAX_RESULTS = 5
HERE = os.path.dirname(os.path.abspath(__file__))
app = FastAPI(title="s2s-demo")
@@ -3481,7 +3480,7 @@ async def reasoning(req: ReasoningRequest):
elif (not phone) and (not needs_reports) and (not episode_id) and (not S2S_LATEST_SUMMARY_TEXT):
logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: skipped (phoneNumber missing from request body AND no episode_id). "
"Browser-side issue: check main.js passes voceroUserPhoneNumber and/or episode_id in JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)"
"Browser-side issue: check VoiceController passes phoneNumber and/or episode_id in the JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)"
)
elif not can_inject:
logger.info(
@@ -3758,7 +3757,7 @@ async def reasoning(req: ReasoningRequest):
# ────────────────────────────────────────────────────────────────────
# Mode A: NODE_RED forwards the complete payload; the flow decides behavior.
# Stage 1 (log-only) returns {answer: "", log_only: true}, allowing main.js
# Stage 1 (log-only) returns {answer: "", log_only: true}, allowing the browser
# to leave speech generation to the S2S backend. Later stages can return
# {answer: "...", log_only: false}. Expected response fields include answer,
# log_only, usage, debug and latency_ms.
@@ -6293,18 +6292,6 @@ async def _queue_id(request: Request) -> str:
return (data or {}).get("queueId", "") if isinstance(data, dict) else ""
@app.middleware("http")
async def no_cache_static_assets(request: Request, call_next):
response = await call_next(request)
path = request.url.path or ""
nocache_ext = (".html", ".js", ".mjs", ".css", ".wasm", ".worklet.js")
if any(path.endswith(ext) for ext in nocache_ext) or path == "/" or path.endswith("/"):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
# Preserve the former proposal URLs after moving the document out of the client.
@app.get("/project", include_in_schema=False)
@app.get("/project/", include_in_schema=False)
@@ -6316,8 +6303,3 @@ async def project_documentation():
# The existing FastAPI /docs page remains registered ahead of this mount.
app.mount("/docs", StaticFiles(directory=os.path.join(ROOT_DIR, "docs"), html=True), name="documentation")
# Static front-end. Registered last so the /api routes win. `html=True` serves
# index.html at "/". The repo is public anyway, so serving the dir is fine.
app.mount("/", StaticFiles(directory=HERE, html=True), name="static")
-5506
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -53,11 +53,25 @@ class BackendContractTests(unittest.TestCase):
self.assertFalse(response.json()["allowDirect"])
self.assertNotIn("auth", response.json())
paths = self.client.get("/openapi.json").json()["paths"]
for path in ("/api/me", "/api/session/heartbeat", "/api/session/end"):
for method, path in (
("GET", "/api/me"), ("POST", "/api/session/heartbeat"), ("POST", "/api/session/end"),
):
self.assertNotIn(path, paths)
self.assertEqual(self.client.request(method, path).status_code, 404)
self.assertFalse(any(getattr(route, "path", "").startswith("/oauth") for route in server.app.routes))
self.upstream.assert_not_called()
def test_only_documentation_is_served_as_static_content(self):
for path in ("/", "/index.html", "/main.js", "/style.css", "/server.py", "/requirements.txt"):
self.assertEqual(self.client.get(path).status_code, 404)
self.assertEqual(self.client.get("/docs").status_code, 200)
self.assertEqual(self.client.get("/docs/README.md").status_code, 200)
for path in ("/project/", "/public/docs.html"):
response = self.client.get(path, follow_redirects=False)
self.assertEqual(response.status_code, 307)
self.assertEqual(response.headers["location"], "/docs/proposals/vocero-architecture.html")
self.upstream.assert_not_called()
def test_session_grant_passes_through_without_extra_fields_or_cookies(self):
self.respond(httpx.Response(200, json=GRANT))
response = self.client.post("/api/session", json={})
-189
View File
@@ -1,189 +0,0 @@
// @ts-check
/**
* Account — the HF login chip and the daily-limit modal.
*
* Reads `/api/me` to learn the current tier (anonymous / signed-in / PRO) and
* remaining daily talk-time, renders a sign-in pill or a signed-in chip with a
* small popover (tier, remaining, sign out, upgrade), and shows the limit modal
* when a conversation is refused or cut. The time metering itself lives in
* main.js (heartbeat loop) + the server; this module is just the surface.
*
* Inert unless the deploy is in LB mode (`/api/me` → `{enabled:true}`).
*/
import { $, escHtml } from "./dom.js";
const PRO_URL = "https://huggingface.co/subscribe/pro";
// Official multi-color Hugging Face logo, used in the badge + sign-in CTA.
const HF_MARK = `<svg class="hf-logo" viewBox="0 0 95 88" fill="none" aria-hidden="true"><path fill="#FFD21E" d="M47.21 76.5a34.75 34.75 0 1 0 0-69.5 34.75 34.75 0 0 0 0 69.5Z"/><path fill="#FF9D0B" d="M81.96 41.75a34.75 34.75 0 1 0-69.5 0 34.75 34.75 0 0 0 69.5 0Zm-73.5 0a38.75 38.75 0 1 1 77.5 0 38.75 38.75 0 0 1-77.5 0Z"/><path fill="#3A3B45" d="M58.5 32.3c1.28.44 1.78 3.06 3.07 2.38a5 5 0 1 0-6.76-2.07c.61 1.15 2.55-.72 3.7-.32ZM34.95 32.3c-1.28.44-1.79 3.06-3.07 2.38a5 5 0 1 1 6.76-2.07c-.61 1.15-2.56-.72-3.7-.32Z"/><path fill="#FF323D" d="M46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z"/><path fill="#3A3B45" fill-rule="evenodd" d="M39.43 54a8.7 8.7 0 0 1 5.3-4.49c.4-.12.81.57 1.24 1.28.4.68.82 1.37 1.24 1.37.45 0 .9-.68 1.33-1.35.45-.7.89-1.38 1.32-1.25a8.61 8.61 0 0 1 5 4.17c3.73-2.94 5.1-7.74 5.1-10.7 0-2.34-1.57-1.6-4.09-.36l-.14.07c-2.31 1.15-5.39 2.67-8.77 2.67s-6.45-1.52-8.77-2.67c-2.6-1.29-4.23-2.1-4.23.29 0 3.05 1.46 8.06 5.47 10.97Z" clip-rule="evenodd"/><path fill="#FF9D0B" d="M70.71 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM24.21 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM17.52 48c-1.62 0-3.06.66-4.07 1.87a5.97 5.97 0 0 0-1.33 3.76 7.1 7.1 0 0 0-1.94-.3c-1.55 0-2.95.59-3.94 1.66a5.8 5.8 0 0 0-.8 7 5.3 5.3 0 0 0-1.79 2.82c-.24.9-.48 2.8.8 4.74a5.22 5.22 0 0 0-.37 5.02c1.02 2.32 3.57 4.14 8.52 6.1 3.07 1.22 5.89 2 5.91 2.01a44.33 44.33 0 0 0 10.93 1.6c5.86 0 10.05-1.8 12.46-5.34 3.88-5.69 3.33-10.9-1.7-15.92-2.77-2.78-4.62-6.87-5-7.77-.78-2.66-2.84-5.62-6.25-5.62a5.7 5.7 0 0 0-4.6 2.46c-1-1.26-1.98-2.25-2.86-2.82A7.4 7.4 0 0 0 17.52 48Zm0 4c.51 0 1.14.22 1.82.65 2.14 1.36 6.25 8.43 7.76 11.18.5.92 1.37 1.31 2.14 1.31 1.55 0 2.75-1.53.15-3.48-3.92-2.93-2.55-7.72-.68-8.01.08-.02.17-.02.24-.02 1.7 0 2.45 2.93 2.45 2.93s2.2 5.52 5.98 9.3c3.77 3.77 3.97 6.8 1.22 10.83-1.88 2.75-5.47 3.58-9.16 3.58-3.81 0-7.73-.9-9.92-1.46-.11-.03-13.45-3.8-11.76-7 .28-.54.75-.76 1.34-.76 2.38 0 6.7 3.54 8.57 3.54.41 0 .7-.17.83-.6.79-2.85-12.06-4.05-10.98-8.17.2-.73.71-1.02 1.44-1.02 3.14 0 10.2 5.53 11.68 5.53.11 0 .2-.03.24-.1.74-1.2.33-2.04-4.9-5.2-5.21-3.16-8.88-5.06-6.8-7.33.24-.26.58-.38 1-.38 3.17 0 10.66 6.82 10.66 6.82s2.02 2.1 3.25 2.1c.28 0 .52-.1.68-.38.86-1.46-8.06-8.22-8.56-11.01-.34-1.9.24-2.85 1.31-2.85Z"/><path fill="#FFD21E" d="M38.6 76.69c2.75-4.04 2.55-7.07-1.22-10.84-3.78-3.77-5.98-9.3-5.98-9.3s-.82-3.2-2.69-2.9c-1.87.3-3.24 5.08.68 8.01 3.91 2.93-.78 4.92-2.29 2.17-1.5-2.75-5.62-9.82-7.76-11.18-2.13-1.35-3.63-.6-3.13 2.2.5 2.79 9.43 9.55 8.56 11-.87 1.47-3.93-1.71-3.93-1.71s-9.57-8.71-11.66-6.44c-2.08 2.27 1.59 4.17 6.8 7.33 5.23 3.16 5.64 4 4.9 5.2-.75 1.2-12.28-8.53-13.36-4.4-1.08 4.11 11.77 5.3 10.98 8.15-.8 2.85-9.06-5.38-10.74-2.18-1.7 3.21 11.65 6.98 11.76 7.01 4.3 1.12 15.25 3.49 19.08-2.12Z"/><path fill="#FF9D0B" d="M77.4 48c1.62 0 3.07.66 4.07 1.87a5.97 5.97 0 0 1 1.33 3.76 7.1 7.1 0 0 1 1.95-.3c1.55 0 2.95.59 3.94 1.66a5.8 5.8 0 0 1 .8 7 5.3 5.3 0 0 1 1.78 2.82c.24.9.48 2.8-.8 4.74a5.22 5.22 0 0 1 .37 5.02c-1.02 2.32-3.57 4.14-8.51 6.1-3.08 1.22-5.9 2-5.92 2.01a44.33 44.33 0 0 1-10.93 1.6c-5.86 0-10.05-1.8-12.46-5.34-3.88-5.69-3.33-10.9 1.7-15.92 2.78-2.78 4.63-6.87 5.01-7.77.78-2.66 2.83-5.62 6.24-5.62a5.7 5.7 0 0 1 4.6 2.46c1-1.26 1.98-2.25 2.87-2.82A7.4 7.4 0 0 1 77.4 48Zm0 4c-.51 0-1.13.22-1.82.65-2.13 1.36-6.25 8.43-7.76 11.18a2.43 2.43 0 0 1-2.14 1.31c-1.54 0-2.75-1.53-.14-3.48 3.91-2.93 2.54-7.72.67-8.01a1.54 1.54 0 0 0-.24-.02c-1.7 0-2.45 2.93-2.45 2.93s-2.2 5.52-5.97 9.3c-3.78 3.77-3.98 6.8-1.22 10.83 1.87 2.75 5.47 3.58 9.15 3.58 3.82 0 7.73-.9 9.93-1.46.1-.03 13.45-3.8 11.76-7-.29-.54-.75-.76-1.34-.76-2.38 0-6.71 3.54-8.57 3.54-.42 0-.71-.17-.83-.6-.8-2.85 12.05-4.05 10.97-8.17-.19-.73-.7-1.02-1.44-1.02-3.14 0-10.2 5.53-11.68 5.53-.1 0-.19-.03-.23-.1-.74-1.2-.34-2.04 4.88-5.2 5.23-3.16 8.9-5.06 6.8-7.33-.23-.26-.57-.38-.98-.38-3.18 0-10.67 6.82-10.67 6.82s-2.02 2.1-3.24 2.1a.74.74 0 0 1-.68-.38c-.87-1.46 8.05-8.22 8.55-11.01.34-1.9-.24-2.85-1.31-2.85Z"/><path fill="#FFD21E" d="M56.33 76.69c-2.75-4.04-2.56-7.07 1.22-10.84 3.77-3.77 5.97-9.3 5.97-9.3s.82-3.2 2.7-2.9c1.86.3 3.23 5.08-.68 8.01-3.92 2.93.78 4.92 2.28 2.17 1.51-2.75 5.63-9.82 7.76-11.18 2.13-1.35 3.64-.6 3.13 2.2-.5 2.79-9.42 9.55-8.55 11 .86 1.47 3.92-1.71 3.92-1.71s9.58-8.71 11.66-6.44c2.08 2.27-1.58 4.17-6.8 7.33-5.23 3.16-5.63 4-4.9 5.2.75 1.2 12.28-8.53 13.36-4.4 1.08 4.11-11.76 5.3-10.97 8.15.8 2.85 9.05-5.38 10.74-2.18 1.69 3.21-11.65 6.98-11.76 7.01-4.31 1.12-15.26 3.49-19.08-2.12Z"/></svg>`;
/** @param {number} sec @returns {string} "m:ss" */
function fmt(sec) {
const s = Math.max(0, Math.round(sec));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
export class Account {
constructor() {
/** @type {HTMLElement} */
this._root = $("#account");
/** @type {HTMLDialogElement} */
this._modal = $("#limit-modal");
this._modalTitle = $("#limit-title");
this._modalMsg = $("#limit-msg");
this._modalNote = $("#limit-note");
/** @type {HTMLAnchorElement} */
this._modalCta = /** @type {any} */ ($("#limit-cta"));
/** @type {{enabled:boolean, auth?:boolean, loggedIn?:boolean, username?:string, avatar?:string, tier?:string, remainingSec?:number|null, limitSec?:number|null, loginUrl?:string|null, logoutUrl?:string|null}} */
this._me = { enabled: false };
this._popoverOpen = false;
$("#limit-close").addEventListener("click", () => this._modal.close());
this._modal.addEventListener("click", (e) => {
if (e.target === this._modal) this._modal.close();
});
// Close the popover on an outside click.
document.addEventListener("click", (e) => {
if (this._popoverOpen && !this._root.contains(/** @type {Node} */ (e.target))) {
this._closePopover();
}
});
}
get tier() {
return this._me.tier || "anon";
}
/** Fetch `/api/me` and (re)render the chip. Safe to call repeatedly (load,
* after the OAuth redirect, after a conversation ends). */
async refresh() {
try {
const res = await fetch("api/me");
this._me = res.ok ? await res.json() : { enabled: false };
} catch {
this._me = { enabled: false };
}
this._render();
}
_render() {
const me = this._me;
if (!me.enabled) {
this._root.hidden = true;
this._root.innerHTML = "";
return;
}
this._root.hidden = false;
if (!me.loggedIn) {
// Signed-out: a sign-in pill (only when OAuth is actually available).
if (me.auth && me.loginUrl) {
this._root.innerHTML = `<a class="signin-pill" href="${escHtml(me.loginUrl)}" title="Inicia sesión para obtener más tiempo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg><span>Iniciar sesión</span></a>`;
} else {
this._root.innerHTML = "";
this._root.hidden = true;
}
return;
}
// Signed-in: avatar + handle chip that toggles a popover.
const isPro = me.tier === "pro";
// Org members get unlimited usage too, but aren't PRO — don't brand them so.
const isUnlimited = isPro || me.tier === "org";
const avatar = me.avatar
? `<img class="account-avatar" src="${escHtml(me.avatar)}" alt="" />`
: `<span class="account-avatar account-avatar-fallback">${escHtml((me.username || "?")[0].toUpperCase())}</span>`;
const remaining =
isUnlimited || me.remainingSec == null
? "Sin límite"
: `${fmt(me.remainingSec)} restantes hoy`;
const tierLabel = isPro ? "PRO" : isUnlimited ? "Equipo" : "Gratis";
this._root.innerHTML = `
<button id="account-chip" class="account-chip" aria-haspopup="true" aria-expanded="false">
${avatar}
<span class="account-handle">${escHtml(me.username || "tú")}</span>
${isPro
? '<span class="account-pro">PRO</span>'
: me.tier === "org"
? '<span class="account-pro account-team">EQUIPO</span>'
: ""}
</button>
<div id="account-pop" class="account-pop" hidden>
<div class="account-pop-row account-pop-name">${escHtml(me.username || "tú")}</div>
<div class="account-pop-row account-pop-meta">
<span class="account-tier">${tierLabel}</span>
<span class="account-remaining">${escHtml(remaining)}</span>
</div>
${isUnlimited ? "" : `<a class="account-pop-link" href="${PRO_URL}" target="_blank" rel="noopener">Pasar a PRO</a>`}
<a class="account-pop-link account-signout" href="${escHtml(me.logoutUrl || "#")}">Cerrar sesión</a>
</div>`;
const chip = $("#account-chip");
chip.addEventListener("click", (e) => {
e.stopPropagation();
this._popoverOpen ? this._closePopover() : this._openPopover();
});
}
_openPopover() {
const pop = document.getElementById("account-pop");
const chip = document.getElementById("account-chip");
if (!pop || !chip) return;
pop.hidden = false;
chip.setAttribute("aria-expanded", "true");
this._popoverOpen = true;
}
_closePopover() {
const pop = document.getElementById("account-pop");
const chip = document.getElementById("account-chip");
if (pop) pop.hidden = true;
if (chip) chip.setAttribute("aria-expanded", "false");
this._popoverOpen = false;
}
/**
* Show the limit modal for a tier — used both when a conversation is refused
* at start (402) and when a live one is cut (heartbeat `expired`).
* @param {string} [tier]
*/
showLimit(tier = this.tier) {
const canSignIn = this._me.auth && this._me.loginUrl;
this._modalTitle.textContent = "¡Gracias por conversar!";
if (tier === "anon") {
this._modalMsg.textContent =
"Las conversaciones de invitados duran 5 minutos. Inicia sesión con Hugging Face para obtener 10 minutos gratis al día. Los miembros PRO pueden conversar sin límite.";
this._modalNote.textContent = "Tus minutos gratis se renuevan mañana.";
if (canSignIn) {
this._modalCta.innerHTML = `${HF_MARK}<span>Iniciar sesión con Hugging Face</span>`;
this._modalCta.href = /** @type {string} */ (this._me.loginUrl);
this._modalCta.hidden = false;
} else {
this._modalCta.hidden = true;
}
} else {
// Signed-in, non-PRO.
this._modalMsg.textContent =
"Ya has usado tus 10 minutos de hoy. Pasa a PRO para conversar sin límite y apoyar la IA de código abierto.";
this._modalNote.textContent = "También puedes volver mañana. Tus minutos se renuevan cada día.";
this._modalCta.innerHTML = "<span>Pasar a PRO</span>";
this._modalCta.href = PRO_URL;
this._modalCta.hidden = false;
}
if (!this._modal.open) this._modal.showModal();
}
/** Show a warm "we're at capacity" message when even the waiting line is full.
* Reuses the limit modal shell; no call-to-action, just reassurance. */
showBusy() {
this._modalTitle.textContent = "¡Estamos al límite de abrazos! 🤗";
this._modalMsg.textContent =
"Todos los lugares y la cola están ocupados. ¡Demasiado cariño! Tómate un café y vuelve en un minuto.";
this._modalNote.textContent = "Normalmente se libera un lugar en pocos minutos.";
this._modalCta.hidden = true;
if (!this._modal.open) this._modal.showModal();
}
}
-835
View File
@@ -1,835 +0,0 @@
// @ts-check
/**
* ChatView — owns the whole conversation surface: the slide-in history panel,
* the ephemeral on-orb bubbles, and all the transcript/tool/streaming
* bookkeeping. main.js wires the realtime client's events straight to the
* `on*` methods here and otherwise doesn't touch chat state.
*
* Two parallel surfaces share one shape (see `_buildMessageEl`):
* - ephemeral bubbles (`.bubble` / `.bubble-*`) fade on a timer
* - persistent history (`.hist-msg` / `.hist-*`) the durable panel log
*
* Keying:
* - user transcripts by the server's `item_id` — a speculative continuation
* REUSES it, so both segments land in one row/bubble; deltas are CUMULATIVE
* (each carries the full sentence so far), so we replace text wholesale.
* - assistant transcripts by `response_id`, so a cancelled speculative reply
* can be marked interrupted without erasing what was already shown.
*/
import { $, escHtml, DEBUG } from "./dom.js";
import { renderMarkdown } from "./markdown.js";
/**
* @param {string} hypothesisId
* @param {string} location
* @param {string} msg
* @param {Record<string, unknown>} [data]
*/
function reportChatDebug(hypothesisId, location, msg, data = {}) {
void hypothesisId;
void location;
void msg;
void data;
}
const WRENCH_PATH = `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>`;
const CHAT_BUBBLE_SVG = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_SVG}<span class="chat-empty-title">Aún no hay mensajes</span><span class="chat-empty-hint">Toca el círculo y empieza a hablar</span></div>`;
/**
* @param {string} prefix
* @param {string} [sep]
* @returns {string} safe filename prefix (a-z 0-9 _ - no spaces / slashes / unicode),
* sanitised so any OS accepts it.
*/
function _safeName(prefix, sep = "-") {
const base = String(prefix || "").toLowerCase().trim()
.normalize("NFKD").replace(/[^\x20-\x7E]/g, "")
.replace(/[^a-z0-9]+/gi, sep || "-")
.replace(new RegExp(`^${sep}+|${sep}+$`, "g"), "");
return base || "vocero";
}
export class ChatView {
constructor() {
// In the V2 layout the chat panel lives inline in #app, shown permanently
// below the orb — there is NO toggle button, close button, backdrop, or
// unread badge any more. The strict $() helper THROWS when the selector is
// missing, so for the optional overlay-era refs we MUST use plain
// document.querySelector (returns null when absent). Only the
// always-present inline refs go through $().
/** @type {HTMLButtonElement | null} */
this._chatBtn = /** @type {HTMLButtonElement | null} */ (document.querySelector("#chat-btn"));
/** @type {HTMLSpanElement | null} */
this._chatBadge = /** @type {HTMLSpanElement | null} */ (document.querySelector("#chat-badge"));
/** @type {HTMLElement} */
this._chatPanel = $("#chat-panel");
/** @type {HTMLElement | null} */
this._chatPanelBackdrop = /** @type {HTMLElement | null} */ (document.querySelector("#chat-panel-backdrop"));
/** @type {HTMLButtonElement | null} */
this._chatPanelClose = /** @type {HTMLButtonElement | null} */ (document.querySelector("#chat-panel-close"));
/** @type {HTMLElement} */
this._chatHistory = $("#chat-history");
/** @type {HTMLElement} */
this._bubbleStack = $("#bubble-stack");
/** @type {HTMLButtonElement | null} Export conversation download button in header. */
this._exportBtn = /** @type {HTMLButtonElement | null} */ (document.getElementById("chat-export-btn"));
if (this._exportBtn) {
this._exportBtn.addEventListener("click", () => this.exportConversation());
}
// Inline chat is always "open" (permanently visible on screen).
this._panelOpen = true;
// ── Always-bottom scroll bookkeeping ─────────────────────────────────
// Active timer for 4-step retry chain (0/30/100/250ms snap-to-bottom).
/** @type {number | null} */
this._scrollTimerId = null;
// Current step index inside a retry chain (0..3).
this._scrollStep = 0;
// ── User transcript state (keyed by item_id) ───────────────────────────
/** @type {Map<string, HTMLElement>} */
this._userHistByItem = new Map();
/** @type {HTMLElement | null} */
this._activeUserBubble = null;
this._activeUserItemId = "";
// Monotonic counter for synthesizing unique keys when the server omits an
// item_id / response_id, so id-less messages never collapse onto each other.
this._anonSeq = 0;
// ── Assistant transcript state (keyed by response_id) ──────────────────
/** @type {Map<string, { bubble: HTMLElement, hist: HTMLElement }>} */
this._asstByResp = new Map();
/** Original transcript text is retained independently of rendered markup. @type {WeakMap<HTMLElement, string>} */
this._messageText = new WeakMap();
// ── Ephemeral bubble auto-dismiss ──────────────────────────────────────
// Per-element expiry (epoch ms). A bubble fades once its expiry passes —
// but only in stack order (see _reapBubbles). Refreshing the expiry keeps a
// bubble alive while it updates (e.g. the live user utterance).
/** @type {WeakMap<HTMLElement, number>} */
this._bubbleExpiry = new WeakMap();
// Single pending reaper handle: one timer for the whole stack (not one per
// bubble) so dismissal is strictly oldest-first regardless of per-bubble delays.
this._reaperHandle = 0;
// ── New inline-chat handlers: only wire when old overlay buttons exist.
if (this._chatBtn) this._chatBtn.addEventListener("click", () => (this._panelOpen ? this._closePanel() : this._openPanel()));
if (this._chatPanelClose) this._chatPanelClose.addEventListener("click", () => this._closePanel());
if (this._chatPanelBackdrop) this._chatPanelBackdrop.addEventListener("click", () => this._closePanel());
document.addEventListener("keydown", (e) => {
// Old behaviour (ESC closes overlay) is a no-op on the inline permanent
// panel; only execute it when a close button actually exists.
if (e.key === "Escape" && this._chatPanelClose && this._panelOpen) this._closePanel();
});
}
// ── User name / initials helpers ────────────────────────────────────────
/** @returns {string} display name for the current user (e.g. "Alessandro").
* Reuses extractVoceroUserName + initialsForName exposed on window by main.js
* so we don't duplicate the nested-object lookup logic (profile/user/customer
* keys, first+last name fallback, etc.). Falls back to "Usuario" (Spanish)
* when no profile has been resolved yet — never returns "" because the
* white-theme chat label is rendered into a pseudo-element that would show
* a lonely timestamp otherwise.
*/
_resolveUserName() {
const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null;
const profile =
/** @type {Record<string, unknown> | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null;
const full = ex ? ex(profile) : "";
if (!full) return "Usuario";
// Return ONLY the first token so the narrow chat label line fits:
// "Alessandro Valenti" → "Alessandro", matches the screenshot style.
const first = String(full).trim().split(/\s+/)[0] || full;
return first;
}
/** @returns {string} 2-letter uppercase initials, e.g. "Alessandro Valenti" → "AV". */
_resolveUserInitials() {
const mk = typeof window._s2sInitialsFor === "function" ? window._s2sInitialsFor : null;
const profile =
/** @type {Record<string, unknown> | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null;
const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null;
const full = ex ? ex(profile) : "";
if (mk && full) {
const init = mk(full);
if (init) return init;
}
return "AV";
}
/** @returns {string} "HH:MM" 24h using the current locale, no seconds. */
_nowStamp() {
try {
return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
} catch (_) {
const d = new Date();
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
}
/** Assistant display name — fixed Spanish label, same as the topbar brand. */
get _assistantName() {
return "Vocero";
}
// ── Panel ───────────────────────────────────────────────────────────────
_openPanel() {
this._panelOpen = true;
if (this._chatPanel) this._chatPanel.classList.add("open");
if (this._chatBadge) this._chatBadge.classList.remove("visible");
this._scrollToBottom();
}
_closePanel() {
// Closing disabled on the new inline-permanent layout; guard so if the
// overlay UI is still in use by another variant it continues to work.
if (!this._chatPanelClose) return;
this._panelOpen = false;
if (this._chatPanel) this._chatPanel.classList.remove("open");
}
// Scroll the chat history to place the LAST message's FINAL LINE fully above
// the sticky footer bar.
//
// DESIGN NOTES (2026-09-04, after user report "scroll non scrolla
// sufficientemente"):
// 1. NO early-return on "already queued". The previous coalescing flag was
// the root cause of tail-clipping: the 1st incoming delta would queue a
// rAF, every subsequent delta (up to ~30/frame) was SKIPPED, then the
// rAF ran against the scrollHeight of the 1st delta only — so the tail
// words appended later were never accounted for.
// 2. Instead we CANCEL any pending timer chain and RESCHEDULE from scratch
// every single time `_scrollToBottom()` is called. This means the very
// last append/update call always "wins" the chain — so we scroll
// against the final grown content, not an intermediate snapshot.
// 3. We run 4 scroll attempts staggered at [0ms, 30ms, 100ms, 250ms].
// Layout is async: line-wrap, font metrics, the scroll anchoring reset,
// and tool-row height expansions all resolve in later micro/task frames.
// Attempt 0 = immediate post-append; 30 = after one layout-pass; 100 =
// after any Chrome scroll-anchor reflow; 250 = after React-like deferred
// re-renders (if any). At 250ms we are guaranteed stable scrollHeight.
// 4. Instead of scrollTop = scrollHeight we use scrollTo({top: 9999999}).
// The browser clamps automatically; using a giant literal avoids any
// race where scrollHeight was read before a layout commit propagated.
_scrollToBottom() {
if (!this._chatHistory) return;
const h = this._chatHistory;
// Cancel any in-flight attempt chain and start fresh — guarantees the LAST
// caller wins (so if 100 tokens arrive in 1 frame we don't run 100 chains,
// we only run ONE chain: the one scheduled from the 100th token append).
if (this._scrollTimerId != null) {
clearTimeout(this._scrollTimerId);
this._scrollTimerId = null;
}
// Also reset any per-chain tracking so we re-run all 4 steps from the new
// point-in-time content height.
this._scrollStep = 0;
const snap = () => {
// Use Number.MAX_SAFE_INTEGER: browsers clamp to the real max
// (scrollHeight - clientHeight) automatically. This avoids ANY race
// where we read scrollHeight at an intermediate layout commit, then
// text wraps to more lines later and the scroll delta is short by a
// few pixels (the exact bug reported by the user where the last line
// was ½ visible behind the floating footer bar).
const BIG = Number.MAX_SAFE_INTEGER;
try { h.scrollTo({ top: BIG, behavior: "instant" }); }
catch (_) {
// Fallback for browsers without instant behavior option support
h.scrollTop = BIG;
}
};
const step = () => {
snap();
this._scrollStep++;
// 5 attempts, staggered: next delay in ms per step index
// step 0 (already run above): immediate
// step 1: +30ms after one layout pass
// step 2: +70ms after line-wrap reflow (most-common "one word left on
// line 7" tail-clipping gets fixed here)
// step 3: +150ms after Chrome scroll-anchor reflow
// step 4: +250ms final after deferred tool-row / image layout
// stop: step 5 reached → exit.
const nextDelay = [30, 70, 150, 250][this._scrollStep];
if (nextDelay != null) {
this._scrollTimerId = setTimeout(step, nextDelay);
} else {
// Step 4 done → release guard. The id is kept so a new incoming call
// can cancel a mid-flight chain cleanly.
this._scrollTimerId = null;
}
};
// Attempt 0 — IMMEDIATELY (same microtask as append — catches 90% of cases).
step();
}
_markUnread() {
// New inline layout: the chat is always visible so no "unread badge" UX.
// If we're running the old overlay variant we still show it.
if (this._panelOpen) {
this._scrollToBottom();
return;
}
if (this._chatBadge) this._chatBadge.classList.add("visible");
}
// ── Shared rendering ──────────────────────────────────────────────────────
/**
* Build a role-labelled message element. Ephemeral bubbles and persistent
* history rows share the same shape and differ only in their class prefix
* (`bubble`/`bubble-*` vs `hist-msg`/`hist-*`).
*
* White-theme label + avatar data attributes (populated on both the outer
* wrapper and the inner body):
* wrapper .{hist-msg|bubble}.user → data-user-name / data-user-initials / data-timestamp
* wrapper .{hist-msg|bubble}.assistant → data-asst-name / data-timestamp
* inner .{hist|bubble}-body → data-user-name | data-asst-name + data-timestamp
*
* The .{hist|bubble}-body::before pseudo-element in style.css uses attr()
* to render the "Name HH:MM" label aligned to the bubble corner; the
* wrapper .user::before pseudo uses attr(data-user-initials) to paint the
* circular avatar's 2-letter monogram.
*
* @param {{ container: string, prefix: string, role: "user"|"assistant", text: string, partial?: boolean }} o
* @returns {HTMLElement}
*/
_buildMessageEl({ container, prefix, role, text, partial = false }) {
const el = document.createElement("div");
el.className = `${container} ${role}`;
const label = role === "user" ? "Tú" : "Asistente";
const stamp = this._nowStamp();
if (role === "user") {
const uName = this._resolveUserName();
const uInit = this._resolveUserInitials();
el.setAttribute("data-user-name", uName);
el.setAttribute("data-user-initials", uInit);
el.setAttribute("data-timestamp", stamp);
} else {
el.setAttribute("data-asst-name", this._assistantName);
el.setAttribute("data-timestamp", stamp);
}
el.innerHTML = `<div class="${prefix}-role">${label}</div><div class="${prefix}-body${partial ? " partial" : ""}"></div>`;
const body = /** @type {HTMLDivElement} */ (el.lastElementChild);
if (body) {
this._renderMessageBody(body, text, role === "assistant");
if (role === "user") {
body.setAttribute("data-user-name", el.getAttribute("data-user-name") || "");
} else {
body.setAttribute("data-asst-name", this._assistantName);
}
body.setAttribute("data-timestamp", stamp);
}
return el;
}
/** @param {HTMLElement} body @param {string} text @param {boolean} assistant */
_renderMessageBody(body, text, assistant) {
this._messageText.set(body, text);
body.classList.toggle("markdown-content", assistant);
if (assistant) body.replaceChildren(renderMarkdown(text));
else body.textContent = text;
}
// ── Ephemeral bubbles ───────────────────────────────────────────────────
/** @param {"user"|"assistant"|"tool"} role @param {string} text @returns {HTMLElement} */
_spawnBubble(role, text) {
let el;
if (role === "tool") {
el = document.createElement("div");
el.className = "bubble tool";
el.innerHTML = `<svg class="bubble-tool-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${WRENCH_PATH}</svg><span class="bubble-tool-text">${escHtml(text)}</span>`;
} else {
el = this._buildMessageEl({ container: "bubble", prefix: "bubble", role, text });
}
this._bubbleStack.appendChild(el);
// Cap the stack at 3, but never evict the bubble the caller is still
// actively updating (the live user bubble) — drop the next-oldest instead.
const visible = /** @type {HTMLElement[]} */ ([...this._bubbleStack.querySelectorAll(".bubble:not(.out)")]);
if (visible.length > 3) {
this._dismissBubble(visible.find((b) => b !== this._activeUserBubble) ?? visible[0]);
}
requestAnimationFrame(() => el.classList.add("in"));
return el;
}
/** @param {HTMLElement} el @param {string} text */
_updateBubbleText(el, text) {
const t = /** @type {HTMLElement | null} */ (el.querySelector(".bubble-body"));
if (t) this._renderMessageBody(t, text, el.classList.contains("assistant"));
}
/** @param {HTMLElement} el */
_dismissBubble(el) {
if (!el || el.classList.contains("out")) return; // idempotent
this._bubbleExpiry.delete(el);
el.classList.remove("in");
el.classList.add("out");
const remove = () => el.remove();
el.addEventListener("transitionend", remove, { once: true });
// Fallback: transitionend never fires if the bubble's visual state didn't
// change (dismissed pre-paint) or the tab is backgrounded. The transition
// is 0.3s, so force removal a little after.
setTimeout(remove, 400);
}
/**
* Fade bubbles whose expiry has passed — strictly oldest-first. We walk the
* stack top (oldest) to bottom (newest) and stop at the first bubble still
* alive: nothing newer may leave while an older bubble is still on screen. A
* bubble that keeps updating pushes its own expiry forward, so it (and
* everything behind it) stays put until it finally goes quiet.
*/
_reapBubbles() {
this._reaperHandle = 0;
const now = Date.now();
const visible = /** @type {HTMLElement[]} */ ([...this._bubbleStack.querySelectorAll(".bubble:not(.out)")]);
let nextWake = Infinity;
for (const el of visible) {
const exp = this._bubbleExpiry.get(el) ?? now; // no expiry recorded → treat as due
if (exp <= now) {
this._dismissBubble(el);
} else {
// Oldest survivor isn't due yet; stop so nothing newer leaves before it.
nextWake = exp;
break;
}
}
if (nextWake !== Infinity) {
this._reaperHandle = setTimeout(() => this._reapBubbles(), Math.max(50, nextWake - Date.now()));
}
}
/**
* (Re)arm a bubble's auto-dismiss by pushing its expiry out by `delay`.
* Calling it again resets the countdown — so a bubble that keeps updating
* stays on screen and only fades once it goes quiet. Removal is ordered by the
* shared reaper, so the oldest bubble always disappears first.
* @param {HTMLElement} el @param {number} [delay]
*/
_bumpDismiss(el, delay = 4000) {
this._bubbleExpiry.set(el, Date.now() + delay);
if (!this._reaperHandle) this._reaperHandle = setTimeout(() => this._reapBubbles(), delay);
}
// ── History ───────────────────────────────────────────────────────────────
/** Render the empty-state placeholder into the history panel. */
renderEmptyState() {
this._chatHistory.innerHTML = EMPTY_STATE_HTML;
}
/** Reset the panel to the empty state and clear the unread badge. */
clear() {
this.renderEmptyState();
if (this._chatBadge) this._chatBadge.classList.remove("visible");
}
/** @param {"user"|"assistant"} role @param {string} text @param {boolean} partial @returns {HTMLElement} */
_appendHistMsg(role, text, partial) {
const empty = this._chatHistory.querySelector(".chat-empty");
if (empty) empty.remove();
const el = this._buildMessageEl({ container: "hist-msg", prefix: "hist", role, text, partial });
this._chatHistory.appendChild(el);
this._scrollToBottom();
return el;
}
/** @param {HTMLElement | null} el @param {string} text @param {boolean} partial */
_updateHistMsg(el, text, partial) {
if (!el) return;
const body = /** @type {HTMLElement | null} */ (el.querySelector(".hist-body"));
if (!body) return;
this._renderMessageBody(body, text, el.classList.contains("assistant"));
body.classList.toggle("partial", partial);
this._scrollToBottom();
}
/**
* Append a tool-call row to the conversation. We only add it once the tool
* has run, so the expandable toggle carries BOTH the call input and its result.
* @param {string} name @param {string} argsJson @param {string} output
*/
_appendHistTool(name, argsJson, output) {
const empty = this._chatHistory.querySelector(".chat-empty");
if (empty) empty.remove();
let pretty = argsJson;
try { pretty = JSON.stringify(JSON.parse(argsJson), null, 2); } catch {}
const el = document.createElement("div");
el.className = "hist-msg tool";
el.innerHTML = `
<div class="hist-role">Llamada a herramienta</div>
<button class="hist-tool-header" aria-expanded="false">
<svg class="hist-tool-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${WRENCH_PATH}</svg>
<span class="hist-tool-name">${escHtml(name)}</span>
<svg class="hist-tool-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="hist-tool-body">
<div class="hist-tool-label">Entrada</div>
<div class="hist-tool-block">${escHtml(pretty)}</div>
<div class="hist-tool-label">Salida</div>
<div class="hist-tool-block hist-tool-output">${escHtml(output || "(sin salida)")}</div>
</div>
`;
const header = /** @type {HTMLButtonElement} */ (el.querySelector(".hist-tool-header"));
const body = /** @type {HTMLDivElement} */ (el.querySelector(".hist-tool-body"));
header.addEventListener("click", () => {
const expanded = header.getAttribute("aria-expanded") === "true";
header.setAttribute("aria-expanded", String(!expanded));
body.classList.toggle("open", !expanded);
});
this._chatHistory.appendChild(el);
this._scrollToBottom();
}
/** Tag an assistant history row as interrupted (user barged in mid-reply).
* @param {HTMLElement | null} hist */
_markHistInterrupted(hist) {
if (!hist || hist.querySelector(".hist-note")) return;
hist.classList.add("interrupted");
const note = document.createElement("div");
note.className = "hist-note";
note.textContent = "Interrumpido";
hist.appendChild(note);
}
/** Render a captured webcam frame in the transcript (the camera tool result).
* @param {string} dataUrl */
_appendHistImage(dataUrl) {
const empty = this._chatHistory.querySelector(".chat-empty");
if (empty) empty.remove();
const el = document.createElement("div");
el.className = "hist-msg tool";
el.innerHTML = `<div class="hist-role">Captura</div><img class="hist-image" alt="Captura de la cámara enviada al modelo" />`;
const img = /** @type {HTMLImageElement} */ (el.querySelector("img"));
img.src = dataUrl;
this._chatHistory.appendChild(el);
this._scrollToBottom();
}
/**
* Reset all streaming bookkeeping for session start / teardown. Pass
* `dismiss` to also fade any bubbles still on screen.
* @param {{ dismiss?: boolean }} [opts]
*/
reset(opts) {
if (opts?.dismiss) {
if (this._activeUserBubble) this._dismissBubble(this._activeUserBubble);
for (const { bubble } of this._asstByResp.values()) this._dismissBubble(bubble);
}
this._userHistByItem.clear();
this._activeUserBubble = null;
this._activeUserItemId = "";
this._asstByResp.clear();
}
// ── Export / download conversation ───────────────────────────────────────
/**
* Serialize the current persistent chat history (.hist-msg rows inside
* #chat-history) into a structured JSON object. Covers:
* - user / assistant text messages (role, text, timestamp, name, initials)
* - tool-call expandable rows (role=tool, name, args, output)
* - webcam / snapshot images (role=image, dataUrl)
* Empty state rows (.chat-empty) and ephemeral orb bubbles are ignored.
* @returns {{
* exportedAt: string,
* messageCount: number,
* messages: Array<{ type: string; timestamp?: string; [k: string]: unknown }>,
* }}
*/
_serializeConversation() {
/** @type {Array<{ type: string; timestamp?: string; [k: string]: unknown }>} */
const messages = [];
if (!this._chatHistory) return { exportedAt: new Date().toISOString(), messageCount: 0, messages };
const rows = /** @type {HTMLElement[]} */ ([...this._chatHistory.querySelectorAll(":scope > .hist-msg")]);
for (const row of rows) {
const ts = row.getAttribute("data-timestamp") || undefined;
if (row.classList.contains("user") || row.classList.contains("assistant")) {
const body = /** @type {HTMLElement | null} */ (row.querySelector(".hist-body"));
if (!body) continue;
const role = row.classList.contains("user") ? "user" : "assistant";
const out = /** @type {{ type: string; text: string; timestamp?: string; name?: string; initials?: string; interrupted?: boolean }} */ ({
type: role,
text: this._messageText.get(body) ?? body.textContent ?? "",
});
if (ts) out.timestamp = ts;
if (role === "user") {
const n = row.getAttribute("data-user-name");
const i = row.getAttribute("data-user-initials");
if (n) out.name = n;
if (i) out.initials = i;
}
if (row.classList.contains("interrupted")) out.interrupted = true;
messages.push(out);
} else if (row.classList.contains("tool")) {
const img = /** @type {HTMLImageElement | null} */ (row.querySelector("img.hist-image"));
if (img) {
messages.push({ type: "image", timestamp: ts, dataUrl: img.src || "" });
} else {
const nameEl = /** @type {HTMLElement | null} */ (row.querySelector(".hist-tool-name"));
const blocks = /** @type {HTMLElement[]} */ ([...row.querySelectorAll(".hist-tool-block")]);
const args = blocks[0]?.textContent || "";
const output = blocks[1]?.textContent || "";
messages.push({
type: "tool",
timestamp: ts,
name: nameEl?.textContent || "",
args,
output,
});
}
}
}
return {
exportedAt: new Date().toISOString(),
messageCount: messages.length,
messages,
};
}
/**
* Pretty-print the JSON conversation structure to a human-readable plain
* text log (one entry per "HH:MM Name: text"). Tool calls are rendered
* as indented blocks (tool name followed by input/output sections).
* @param {ReturnType<ChatView["_serializeConversation"]>} conv
* @returns {string}
*/
_conversationToText(conv) {
const lines = [];
const date = (() => { try { return new Date(conv.exportedAt).toLocaleString(); } catch { return conv.exportedAt; } })();
lines.push(`Conversación Vocero — ${date}`);
lines.push(`Mensajes totales: ${conv.messageCount}`);
lines.push("—".repeat(52));
lines.push("");
for (const m of conv.messages) {
if (m.type === "user" || m.type === "assistant") {
const label = m.type === "user" ? (m.name ? String(m.name) : "Usuario") : "Vocero";
const head = [m.timestamp, label].filter(Boolean).join(" ");
lines.push(`${head}${m.interrupted ? " [interrumpida]" : ""}:`);
lines.push(String(m.text || "").trim());
lines.push("");
} else if (m.type === "tool") {
const head = [m.timestamp, `HERRAMIENTA: ${String(m.name || "").trim()}`].filter(Boolean).join(" ");
lines.push(head);
const args = String(m.args || "").trim();
const output = String(m.output || "").trim();
if (args) { lines.push(" Entrada:"); lines.push(" " + args.split("\n").join("\n ")); }
if (output) { lines.push(" Salida:"); lines.push(" " + output.split("\n").join("\n ")); }
lines.push("");
} else if (m.type === "image") {
const head = [m.timestamp, "CAPTURA (imagen de la cámara enviada al modelo)"].filter(Boolean).join(" ");
lines.push(head);
lines.push(" " + String(m.dataUrl || "").slice(0, 80) + (String(m.dataUrl || "").length > 80 ? "…" : ""));
lines.push("");
}
}
return lines.join("\n");
}
/**
* Trigger a browser "download" for the given string content. Uses the
* classic Blob+URL+a.click pattern — no backend call, fully client-side,
* works offline and never leaks the conversation content anywhere.
* @param {string} content
* @param {string} filename
* @param {string} mime
*/
_downloadBlob(content, filename, mime) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
setTimeout(() => a.remove(), 0);
} finally {
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
}
/**
* Public entry-point wired to the #chat-export-btn in the chat header.
* Serializes the current conversation and produces TWO downloads:
* 1. vocero-conversacion-YYYYMMDD-HHMMSS.json (structured data)
* 2. vocero-conversacion-YYYYMMDD-HHMMSS.txt (human-readable log)
* UI feedback: briefly swaps the download icon for a ✓ completion tick then
* restores the original SVG so the user gets a visual ack that the export
* completed (idempotent / errors are logged to console — never throws).
*/
exportConversation() {
try {
const conv = this._serializeConversation();
const profile = /** @type {Record<string, unknown> | null} */ (/** @type {unknown} */ (window.voceroUserProfile)) || null;
const ex = typeof window._s2sExtractName === "function" ? window._s2sExtractName : null;
const userName = ex && profile ? String(ex(profile) || "").trim() : "";
const stampBase = (() => {
const d = new Date();
const pad = (n, w = 2) => String(n).padStart(w, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
})();
const prefix = _safeName(`vocero-conversacion${userName ? "-" + userName : ""}`, "-");
const jsonName = `${prefix}-${stampBase}.json`;
const txtName = `${prefix}-${stampBase}.txt`;
const jsonText = JSON.stringify(conv, null, 2);
const plainText = this._conversationToText(conv);
if (conv.messageCount === 0) {
// Don't offer downloads of empty logs; flash the button briefly to
// signal "pressed" without spamming the user with empty files.
if (this._exportBtn) {
this._exportBtn.classList.add("chat-export-empty");
setTimeout(() => this._exportBtn?.classList.remove("chat-export-empty"), 600);
}
if (typeof window !== "undefined" && typeof console !== "undefined") {
console.info("[ui/chat.js:exportConversation] No messages yet — export skipped.");
}
return;
}
this._downloadBlob(jsonText, jsonName, "application/json;charset=utf-8");
this._downloadBlob(plainText, txtName, "text/plain;charset=utf-8");
// Quick visual OK: paint the button green for 650ms. Avoids modifying
// innerHTML (we'd otherwise lose the SVG on mobile fast-clicks).
if (this._exportBtn) {
this._exportBtn.classList.add("chat-export-ok");
setTimeout(() => this._exportBtn?.classList.remove("chat-export-ok"), 650);
}
} catch (err) {
if (typeof window !== "undefined" && typeof console !== "undefined") {
console.error("[ui/chat.js:exportConversation] Conversation export failed:", err);
}
}
}
// ── Client event handlers ─────────────────────────────────────────────────
/**
* A streamed transcript delta (user or assistant).
* @param {{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }} d
*/
onTranscript(d) {
if (DEBUG) console.debug(`[ui] transcript role=${d.role} partial=${d.partial} item=${d.itemId} resp=${d.responseId} text=${JSON.stringify(d.text)}`);
if (d.role === "user") {
// Group by item_id: a speculative continuation reuses the same id, so it
// updates the same row/bubble. A missing id falls back to the active item
// (same utterance) or a fresh unique key, never a shared sentinel that
// would collapse distinct turns into one row.
const id = d.itemId || this._activeUserItemId || `_u${++this._anonSeq}`;
const text = d.text;
let hist = this._userHistByItem.get(id);
if (!hist) {
hist = this._appendHistMsg("user", text, d.partial);
this._userHistByItem.set(id, hist);
} else {
this._updateHistMsg(hist, text, d.partial);
}
// One ephemeral bubble per active item. Purely timer-based: the timer is
// refreshed on every delta, so it stays while the user keeps talking and
// fades a few seconds after they stop — no dependency on a response ever
// arriving, so it can never get stuck.
if (this._activeUserItemId !== id || !this._activeUserBubble) {
this._activeUserBubble = this._spawnBubble("user", text);
this._activeUserItemId = id;
} else {
this._updateBubbleText(this._activeUserBubble, text);
}
this._bumpDismiss(this._activeUserBubble, 6000);
this._markUnread();
} else if (d.role === "assistant") {
// Assistant transcript arrives once, as the full text, keyed by
// response_id so a cancelled speculative response can be removed later. A
// missing id gets a unique key so two id-less replies never collide.
const rid = d.responseId || `_a${++this._anonSeq}`;
const entry = this._asstByResp.get(rid);
if (!entry) {
const bubble = this._spawnBubble("assistant", d.text);
this._asstByResp.set(rid, { bubble, hist: this._appendHistMsg("assistant", d.text, false) });
this._bumpDismiss(bubble);
} else {
this._updateBubbleText(entry.bubble, d.text);
this._updateHistMsg(entry.hist, d.text, false);
this._bumpDismiss(entry.bubble);
}
this._markUnread();
}
}
/**
* A response closed (completed or cancelled).
* @param {{ responseId: string; status: string; audible?: boolean; transcript?: string }} detail
*/
onResponseFinished(detail) {
const { responseId, status, audible, transcript } = detail;
if (DEBUG) console.debug(`[ui] response-finished resp=${responseId} status=${status} audible=${audible} known=${this._asstByResp.has(responseId)}`);
// Without an id we can't target a specific response; the bubble will
// auto-dismiss on its own timer regardless.
if (!responseId) return;
const entry = this._asstByResp.get(responseId);
if (status === "cancelled") {
// Keep every transcript that was received — mark it interrupted rather
// than erasing it. If the `*.transcript.done` never fired, build the row
// from the text carried in response.done.
let hist = entry?.hist ?? null;
if (!hist && transcript) {
hist = this._appendHistMsg("assistant", transcript, false);
} else if (hist && transcript) {
this._updateHistMsg(hist, transcript, false);
}
if (hist) this._markHistInterrupted(hist);
this._asstByResp.delete(responseId);
return;
}
// Any other terminal close (completed / failed / incomplete / …): just
// release the map entry. The bubble already auto-dismisses on its timer and
// the history row persists as the conversation log. Crucially we do NOT
// touch user state here — that lifecycle is fully independent.
this._asstByResp.delete(responseId);
// ALWAYS re-anchor to the last line when a response ends, since the final
// punctuation line + any trailing tool-result row pushes the tail further
// down than the last streamed delta did.
this._scrollToBottom();
}
/** The model called a tool — show an ephemeral "running" bubble.
* @param {string} name */
onToolCall(name) {
this._bumpDismiss(this._spawnBubble("tool", name));
this._markUnread();
this._scrollToBottom();
}
/** The tool finished — append its call+result row (and any captured image).
* @param {string} name @param {string} argsJson @param {string} output @param {string} [image] */
onToolResult(name, argsJson, output, image) {
const startedAt = performance.now();
this._appendHistTool(name, argsJson, output);
if (image) this._appendHistImage(image);
// Tool rows can be tall (big JSON payloads, tables). Force a scroll after
// insert so the start of the result isn't hidden off-screen above the
// sticky footer; we want the LAST line of the answer always in view.
this._scrollToBottom();
this._markUnread();
// #region debug-point C:chat-tool-render
reportChatDebug("C", "ui/chat.js:onToolResult", "rendered tool result row", {
name,
argsLength: argsJson.length,
outputLength: output.length,
renderMs: Math.round(performance.now() - startedAt),
});
// #endregion
if (image) this._appendHistImage(image); // show the captured frame below the call
this._markUnread();
}
}
-37
View File
@@ -1,37 +0,0 @@
// @ts-check
/** Small shared helpers used across the UI modules: a strict query selector,
* HTML escaping for text we drop into innerHTML, error-string trimming, and
* the opt-in debug flag. */
/** Opt-in tracing: `localStorage.setItem("s2s.debug", "1")` then reload. */
export const DEBUG = (() => {
try {
return localStorage.getItem("s2s.debug") === "1";
} catch {
return false;
}
})();
/**
* Query a single element, throwing if it's missing (so a broken selector fails
* loudly at startup rather than as a later null-deref).
* @template {HTMLElement} T
* @param {string} selector
* @returns {T}
*/
export function $(selector) {
const el = document.querySelector(selector);
if (!el) throw new Error(`Missing element: ${selector}`);
return /** @type {T} */ (el);
}
/** @param {string} s @returns {string} */
export function escHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** Trim a long error message to fit the orb caption. @param {string} text */
export function truncateError(text) {
if (text.length <= 90) return text;
return text.slice(0, 87) + "…";
}
-56
View File
@@ -1,56 +0,0 @@
// @ts-check
import { Marked } from "../vendor/marked/marked.esm.js";
import DOMPurify from "../vendor/dompurify/purify.es.mjs";
import { escHtml } from "./dom.js";
const markdown = new Marked({
gfm: true,
breaks: true,
renderer: {
// Treat embedded HTML as text. Replies can format Markdown, not UI controls.
html: ({ text }) => escHtml(text),
checkbox: ({ checked }) => checked ? "☑ " : "☐ ",
},
});
/** Parse assistant text into passive, sanitized message content. @param {string} text */
export function renderMarkdown(text) {
if (!DOMPurify.isSupported) return document.createTextNode(text);
try {
const html = markdown.parse(text, { async: false });
const fragment = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
"p", "br", "strong", "em", "del", "a", "img", "ul", "ol", "li",
"h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "code", "hr",
"table", "thead", "tbody", "tr", "th", "td",
],
ALLOWED_ATTR: ["href", "title", "src", "alt", "start", "align"],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
RETURN_DOM_FRAGMENT: true,
});
for (const link of fragment.querySelectorAll("a")) {
if (!link.hasAttribute("href")) {
link.replaceWith(...link.childNodes);
} else {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
}
for (const image of fragment.querySelectorAll("img")) image.loading = "lazy";
for (const table of fragment.querySelectorAll("table")) {
const wrapper = document.createElement("div");
wrapper.className = "markdown-table";
wrapper.tabIndex = 0;
wrapper.setAttribute("role", "region");
wrapper.setAttribute("aria-label", "Tabla de la respuesta");
table.replaceWith(wrapper);
wrapper.appendChild(table);
}
for (const code of fragment.querySelectorAll("pre")) code.tabIndex = 0;
return fragment;
} catch (err) {
console.warn("[ui] Markdown rendering failed", err);
return document.createTextNode(text);
}
}
-202
View File
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-373
View File
@@ -1,373 +0,0 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
-44
View File
@@ -1,44 +0,0 @@
# License information
## Contribution License Agreement
If you contribute code to this project, you are implicitly allowing your code
to be distributed under the MIT license. You are also implicitly verifying that
all code is your original work. `</legalese>`
## Marked
Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/)
Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## Markdown
Copyright © 2004, John Gruber
http://daringfireball.net/
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-186
View File
@@ -1,186 +0,0 @@
// @ts-check
/**
* AudioWorkletProcessor that plays back Float32 mono samples received from
* the main thread, upsampling whatever incoming rate the server uses
* (typically 24 kHz PCM16) to the AudioContext rate (typically 48 kHz).
*
* Lifecycle / messaging:
*
* main -> worklet:
* { kind: "config", inputRate: 24000 } one-shot at startup
* { kind: "audio", samples: Float32Array } (transferable) per chunk
* { kind: "clear" } wipe queue (barge-in)
*
* worklet -> main:
* { kind: "stats", queuedMs, played } every ~250 ms
* { kind: "underrun" } every time the queue
* runs dry mid-playback
*
* Underrun strategy: output silence. We do NOT hold the last sample (that
* tends to produce audible clicks/buzzes when long gaps appear between
* TTS chunks). A short ramp-out + ramp-in at boundaries would be nicer but
* the server's 30 ms cadence makes underruns visible only at end of turn.
*/
const STATS_INTERVAL_FRAMES = 12000;
const FADE_FRAMES = 32;
const START_BUFFER_MS = 90;
class AudioPlaybackProcessor extends AudioWorkletProcessor {
constructor() {
super();
this._inputRate = 24000;
this._stepRatio = this._inputRate / sampleRate;
this._queue = [];
this._readIdx = 0;
this._fracPos = 0;
this._playing = false;
this._framesSinceStats = 0;
this._totalPlayed = 0;
this._fadeIn = 0;
this._fadeOut = 0;
this._lastSample = 0;
this._startBuffered = false;
this.port.onmessage = (e) => {
const data = e.data;
if (!data || typeof data !== "object") return;
switch (data.kind) {
case "config":
if (typeof data.inputRate === "number" && data.inputRate > 0) {
this._inputRate = data.inputRate;
this._stepRatio = this._inputRate / sampleRate;
}
break;
case "audio":
if (data.samples instanceof Float32Array && data.samples.length > 0) {
this._queue.push(data.samples);
if (!this._playing && this._queuedMs() >= START_BUFFER_MS) {
this._playing = true;
this._startBuffered = true;
this._fadeIn = FADE_FRAMES;
this._fadeOut = 0;
}
}
break;
case "clear":
this._queue.length = 0;
this._readIdx = 0;
this._fracPos = 0;
this._fadeOut = FADE_FRAMES;
this._startBuffered = false;
break;
}
};
}
_queuedSamples() {
let total = -this._readIdx;
for (const buf of this._queue) total += buf.length;
return Math.max(0, total);
}
_queuedMs() {
return (this._queuedSamples() / this._inputRate) * 1000;
}
/** Linear-interp read at the current fractional position. */
_readInterpolated() {
if (this._queue.length === 0) return null;
const head = this._queue[0];
const idx = this._readIdx;
const frac = this._fracPos;
let a = head[idx];
let b;
if (idx + 1 < head.length) {
b = head[idx + 1];
} else if (this._queue.length > 1) {
b = this._queue[1][0];
} else {
b = a;
}
return a + (b - a) * frac;
}
/** Advance the read position by `stepRatio`; pop consumed buffers. */
_advance() {
this._fracPos += this._stepRatio;
while (this._fracPos >= 1) {
this._fracPos -= 1;
this._readIdx += 1;
}
while (this._queue.length > 0 && this._readIdx >= this._queue[0].length) {
this._readIdx -= this._queue[0].length;
this._queue.shift();
}
}
process(_, outputs) {
const channels = outputs[0];
if (!channels || channels.length === 0) return true;
const out = channels[0];
const stereo = channels.length > 1 ? channels[1] : null;
for (let i = 0; i < out.length; i++) {
let sample = 0;
if (!this._playing && this._queue.length > 0 && this._queuedMs() >= START_BUFFER_MS) {
this._playing = true;
this._startBuffered = true;
this._fadeIn = FADE_FRAMES;
this._fadeOut = 0;
}
if (this._playing) {
const v = this._readInterpolated();
if (v === null) {
// Underrun: try to ramp out cleanly to avoid clicks.
sample = this._lastSample * Math.max(0, 1 - 1 / FADE_FRAMES);
this._lastSample = sample;
if (Math.abs(sample) < 1e-4) {
this._playing = false;
this._startBuffered = false;
this._lastSample = 0;
this.port.postMessage({ kind: "underrun" });
}
} else {
sample = v;
this._lastSample = v;
this._advance();
}
if (this._fadeIn > 0) {
const gain = 1 - this._fadeIn / FADE_FRAMES;
sample *= gain;
this._fadeIn -= 1;
}
if (this._fadeOut > 0) {
const gain = this._fadeOut / FADE_FRAMES;
sample *= gain;
this._fadeOut -= 1;
if (this._fadeOut === 0) {
this._playing = false;
this._startBuffered = false;
this._lastSample = 0;
}
}
this._totalPlayed += 1;
}
out[i] = sample;
if (stereo) stereo[i] = sample;
}
this._framesSinceStats += out.length;
if (this._framesSinceStats >= STATS_INTERVAL_FRAMES) {
this._framesSinceStats = 0;
this.port.postMessage({ kind: "stats", queuedMs: this._queuedMs(), played: this._totalPlayed });
}
return true;
}
}
registerProcessor("audio-playback", AudioPlaybackProcessor);
-159
View File
@@ -1,159 +0,0 @@
// @ts-check
/**
* AudioWorkletProcessor that resamples the AudioContext rate (typically 48 kHz)
* down to 16 kHz, packs the result as little-endian Int16 PCM, and posts it
* back to the main thread in fixed-size chunks.
*
* The Hugging Face speech-to-speech WebSocket route expects the
* `input_audio_buffer.append` payload at 16 kHz PCM16 mono.
*
* Design notes:
* - 48 -> 16 is an exact 3:1 ratio so we use a 3-tap boxcar average as a
* cheap low-pass before decimating. Good enough for voice STT; we lose
* a tiny bit of >8 kHz content which the pipeline discards anyway.
* - Output frames are emitted at the cadence dictated by `chunkMs`
* (default 40 ms = 640 samples = 1280 bytes). The OpenAI Realtime
* server batches incoming audio so the cadence is flexible; 20-100 ms
* is the sweet spot.
* - Float -> Int16 saturates to [-1, 1] before scaling.
* - Optional noise gate: per-chunk RMS decides open/closed against a
* threshold; the gain ramps (fast attack, hold, slow release) so word
* onsets aren't clipped and quiet tails don't click. The gate only
* affects the audio we SEND; the main-thread visualiser taps the raw
* mic separately. We post the chunk RMS up every frame so the Settings
* mic meter can show the live level against the threshold.
*/
const TARGET_RATE = 16000;
const DEFAULT_CHUNK_MS = 40;
// Gate envelope timing (fixed; only the threshold is user-tunable).
const GATE_ATTACK_MS = 5; // open almost instantly so word onsets survive
const GATE_HOLD_MS = 250; // stay open this long after the level drops back under
const GATE_RELEASE_MS = 80; // then fade closed over this long (no click)
class MicCaptureProcessor extends AudioWorkletProcessor {
constructor(options) {
super();
const chunkMs = options?.processorOptions?.chunkMs ?? DEFAULT_CHUNK_MS;
this._inputRate = sampleRate;
this._ratio = this._inputRate / TARGET_RATE;
this._chunkSamples16k = Math.round((TARGET_RATE * chunkMs) / 1000);
this._scratch = new Float32Array(0);
this._decimated = new Float32Array(this._chunkSamples16k);
this._enabled = true;
// Noise gate state. Disabled by default (pure passthrough).
this._gateEnabled = false;
this._thresholdLin = 0; // linear amplitude; signal RMS must exceed this to open
this._gateGain = 1; // smoothed gain currently applied
this._holdRemaining = 0; // samples left before the gate may start closing
this._attackCoef = Math.exp(-1 / ((GATE_ATTACK_MS / 1000) * TARGET_RATE));
this._releaseCoef = Math.exp(-1 / ((GATE_RELEASE_MS / 1000) * TARGET_RATE));
this._holdSamples = Math.round((GATE_HOLD_MS / 1000) * TARGET_RATE);
this.port.onmessage = (e) => {
const data = e.data;
if (data?.kind === "enable") this._enabled = !!data.value;
else if (data?.kind === "gate") {
this._gateEnabled = !!data.enabled;
// dB -> linear amplitude. When off, threshold 0 keeps the gate open.
this._thresholdLin = data.enabled ? Math.pow(10, data.thresholdDb / 20) : 0;
}
};
}
/**
* Append `incoming` to the internal scratch buffer, then emit as many
* full output chunks as we have material for.
* @param {Float32Array} incoming
*/
_ingest(incoming) {
if (incoming.length === 0) return;
const next = new Float32Array(this._scratch.length + incoming.length);
next.set(this._scratch, 0);
next.set(incoming, this._scratch.length);
this._scratch = next;
this._maybeEmit();
}
_maybeEmit() {
const r = this._ratio;
const n = this._chunkSamples16k;
const needIn = Math.ceil(n * r);
const dec = this._decimated;
while (this._scratch.length >= needIn) {
// 1. Decimate to 16 kHz floats and accumulate energy for the gate/meter.
let sumSq = 0;
if (Math.abs(r - 3) < 1e-6) {
// 48 kHz -> 16 kHz fast path with boxcar lowpass.
for (let i = 0; i < n; i++) {
const idx = i * 3;
const s = (this._scratch[idx] + this._scratch[idx + 1] + this._scratch[idx + 2]) / 3;
dec[i] = s;
sumSq += s * s;
}
} else {
// Generic path: linear interpolation. Slower but works at any rate
// (e.g. some Windows boxes report sampleRate=44100).
for (let i = 0; i < n; i++) {
const srcPos = i * r;
const idx = Math.floor(srcPos);
const frac = srcPos - idx;
const a = this._scratch[idx];
const b = this._scratch[idx + 1] ?? a;
const s = a + (b - a) * frac;
dec[i] = s;
sumSq += s * s;
}
}
const rms = Math.sqrt(sumSq / n);
// 2. Decide the gate target for this chunk, then ramp sample-by-sample.
let target = 1;
if (this._gateEnabled) {
if (rms >= this._thresholdLin) {
this._holdRemaining = this._holdSamples; // re-arm the hold
} else if (this._holdRemaining > 0) {
this._holdRemaining -= n; // coasting through the hold window
} else {
target = 0;
}
}
// 3. Apply the (smoothed) gain and pack to Int16.
const out = new Int16Array(n);
let gain = this._gateGain;
for (let i = 0; i < n; i++) {
const coef = target > gain ? this._attackCoef : this._releaseCoef;
gain = target + (gain - target) * coef;
const s = dec[i] * gain;
const clamped = s < -1 ? -1 : s > 1 ? 1 : s;
out[i] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff;
}
this._gateGain = gain;
// Shift the scratch buffer to keep only the trailing unused samples.
const consumed = Math.floor(n * r);
this._scratch = this._scratch.slice(consumed);
// Live input level for the Settings meter (raw RMS, pre-gate).
this.port.postMessage({ kind: "level", rms });
if (this._enabled) {
this.port.postMessage(out.buffer, [out.buffer]);
}
// When disabled (mic muted) we silently consume input so the worklet
// stays alive and the buffer never grows unbounded.
}
}
process(inputs) {
const input = inputs[0];
if (!input || input.length === 0 || !input[0]) return true;
const mono = input[0];
if (mono.length > 0) this._ingest(mono);
return true;
}
}
registerProcessor("mic-capture", MicCaptureProcessor);
-57
View File
@@ -1,57 +0,0 @@
// @ts-check
/**
* Pure, stateless helpers for the WebSocket realtime client: base64 <-> PCM
* conversion for the audio frames on the wire, transcript extraction from a
* `response.done` payload, and a tiny URL helper. Kept separate from the client
* so the protocol/state logic stays readable.
*/
/** @param {string} url */
export function trimTrailingSlash(url) {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
/**
* Pull the assistant transcript out of a `response.done` payload. The text
* lives in `response.output[].content[].transcript` (audio) or `.text`. Used as
* the source of truth for interrupted replies, where the dedicated
* `*.transcript.done` event may never arrive.
* @param {any} response
* @returns {string}
*/
export function extractResponseTranscript(response) {
const output = response?.output;
if (!Array.isArray(output)) return "";
/** @type {string[]} */
const parts = [];
for (const item of output) {
for (const part of item?.content ?? []) {
const text = part?.transcript ?? part?.text;
if (typeof text === "string" && text.trim()) parts.push(text.trim());
}
}
return parts.join(" ").trim();
}
/** @param {ArrayBuffer} buf */
export function base64FromArrayBuffer(buf) {
const bytes = new Uint8Array(buf);
// Chunked encoding so we don't blow up the call stack on long buffers.
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, /** @type {number[]} */ (
/** @type {unknown} */ (bytes.subarray(i, i + chunk))
));
}
return btoa(binary);
}
/** @param {string} b64 */
export function base64ToBytes(b64) {
const binary = atob(b64);
const len = binary.length;
const out = new Uint8Array(len);
for (let i = 0; i < len; i++) out[i] = binary.charCodeAt(i);
return out;
}
-98
View File
@@ -1,98 +0,0 @@
// @ts-check
/**
* Orb spectrum visualiser. Each animation frame it reads two AnalyserNodes (the
* mic input and the TTS output) and maps the low-frequency speech energy onto
* the orb's CSS custom properties:
* - `--bar0`..`--bar4` the 5-band level meter
* - `--ai-audio-level` the global "Reachy talks" glow / scale pulse
*
* The bottom of the FFT is where speech energy lives, so the band edges stay
* low — that keeps the bars dancing on voice rather than on noise. While the AI
* is speaking we source the bars from the OUTPUT analyser so the orb pulses with
* Reachy's voice instead of sitting dead while the user is silent.
*/
// Exported so the client can size its AnalyserNodes to match our buffer.
export const VIS_FFT_SIZE = 256;
const VIS_BAND_COUNT = 5;
const VIS_BAND_EDGES = [2, 5, 9, 16, 28, 52];
const VIS_ATTACK = 0.6; // weight for new sample on upswing (snappy)
const VIS_RELEASE = 0.18; // weight for new sample on decay (gentle fade)
export class OrbVisualiser {
/**
* @param {AnalyserNode} micAnalyser
* @param {AnalyserNode} outAnalyser
* @param {() => boolean} isAiSpeaking Source the bars from the AI output when
* true, otherwise from the mic.
*/
constructor(micAnalyser, outAnalyser, isAiSpeaking) {
this._mic = micAnalyser;
this._out = outAnalyser;
this._isAiSpeaking = isAiSpeaking;
this._buf = new Uint8Array(micAnalyser.frequencyBinCount);
this._bands = new Float32Array(VIS_BAND_COUNT);
this._aiLevel = 0;
/** @type {number | null} */
this._frame = null;
}
/** Begin the rAF loop (idempotent). */
start() {
if (this._frame !== null) return;
const root = document.documentElement;
const tick = () => {
this._frame = requestAnimationFrame(tick);
this._update(root);
};
this._frame = requestAnimationFrame(tick);
}
/** Stop the loop and clear the CSS vars so the orb returns to rest. */
stop() {
if (this._frame !== null) {
cancelAnimationFrame(this._frame);
this._frame = null;
}
const root = document.documentElement;
for (let i = 0; i < VIS_BAND_COUNT; i++) root.style.removeProperty(`--bar${i}`);
root.style.removeProperty("--ai-audio-level");
}
/** @param {HTMLElement} root */
_update(root) {
// Mic bars: split FFT into 5 log-ish bands, smooth, write CSS vars.
const source = this._isAiSpeaking() ? this._out : this._mic;
source.getByteFrequencyData(this._buf);
for (let b = 0; b < VIS_BAND_COUNT; b++) {
const lo = VIS_BAND_EDGES[b];
const hi = VIS_BAND_EDGES[b + 1];
let sum = 0;
let n = 0;
for (let i = lo; i < hi && i < this._buf.length; i++) {
sum += this._buf[i];
n += 1;
}
const target = n > 0 ? sum / (n * 255) : 0;
const prev = this._bands[b];
const k = target > prev ? VIS_ATTACK : VIS_RELEASE;
const next = prev + (target - prev) * k;
this._bands[b] = next;
root.style.setProperty(`--bar${b}`, next.toFixed(3));
}
// Global AI audio level: peak of the output analyser, used by the CSS to
// make the orb's glow / scale react to Reachy's voice.
this._out.getByteFrequencyData(this._buf);
let peak = 0;
const limit = Math.min(this._buf.length, VIS_BAND_EDGES[VIS_BAND_COUNT]);
for (let i = 0; i < limit; i++) {
if (this._buf[i] > peak) peak = this._buf[i];
}
const aiTarget = peak / 255;
const k = aiTarget > this._aiLevel ? VIS_ATTACK : VIS_RELEASE;
this._aiLevel = this._aiLevel + (aiTarget - this._aiLevel) * k;
root.style.setProperty("--ai-audio-level", this._aiLevel.toFixed(3));
}
}
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -4320,8 +4320,7 @@ html[data-theme="white"] .markdown-content a {
/* ─── Webcam preview (camera tool) — CURRENTLY HIDDEN BY DEFAULT ─────────────
* Floating self-view, bottom-left. Mirrored for the user; the frame sent to
* the model is drawn un-mirrored. Markup is retained for future reactivation.
* To enable it, remove hidden from cam-pip in index.html and restore the main.js listener. */
* the model is drawn un-mirrored. Camera controls are omitted from the React view. */
.cam-pip {
position: fixed;
left: 20px;
@@ -4577,12 +4576,12 @@ html[data-theme="white"] .markdown-content a {
light mobile S2S_THEME="white")
═══════════════════════════════════════════════════════════════════════ */
/* Anti-FOUC veil: until fetchConfig() resolves, <html> does not carry any
/* Anti-FOUC veil: until configuration resolves, <html> does not carry any
`data-theme` attribute yet. Keep every parallel UI block tagged with
`data-theme-only` INVISIBLE in that window so the user never sees the
purple orb flash-and-disappear on `S2S_THEME=white` deploys, nor the
light-blue mic flash on dark deploys.
As soon as fetchConfig runs (main.js fetchConfig → root.setAttribute)
Once App.tsx applies the resolved theme,
the html tag receives either data-theme="dark" or data-theme="white"
→ this guard stops applying → the correctly gated branch paints. */
html:not([data-theme]) [data-theme-only] {
@@ -4650,7 +4649,7 @@ html[data-theme="white"] .bubble-stack {
Light mobile "Vocero app" theme matching the provided screenshot:
pure white background, deep blue accents, card-based UI, avatar+timestamp
per chat bubble, rounded input with clip+send.
Activated on <html data-theme="white"> (main.js sets the attr from
Activated on <html data-theme="white"> (App.tsx sets the attribute from
/api/config response). All selectors below are scoped to that attribute
so the native dark theme is 100% preserved when the flag is off.
═══════════════════════════════════════════════════════════════════════ */