This commit is contained in:
2026-09-10 15:57:48 -04:00
parent d58be9613f
commit 413ba08c21
13 changed files with 4649 additions and 334 deletions
+156
View File
@@ -0,0 +1,156 @@
# Changelog — s2s-client (Azurian Vocero)
Todos los cambios relevantes del proyecto están documentados en este archivo.
Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/).
---
## [2026-09-07]
### Añadido — Soporte `episode_id` (inject de episodio podcast)
Nuevo parámetro query string `?episode_id=` junto a `?phoneNumber=` que
habilita la carga y la inyección automática de un episodio podcast como
contexto PRIMARIO en el prompt LLM.
**URL de ejemplo:**
```
/?phoneNumber=%2B447432477433&episode_id=episode-19569f77-a06e-5ce5-a52f-6299dbd67d03
```
**Archivos modificados:**
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)):
1. Añadida la constante `PODCAST_EPISODE_HINT` ([L426-L429](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429)) para el hint semántico en el prompt.
2. Añadida la función `_resolve_episode_context(raw_value)` ([L475-L504](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504)): normaliza la respuesta API (dict/list/str, posible wrapper `{"result": …}`), la serializa en JSON (tope 24.000 chars) y la envuelve con los marcadores `<<<PODCAST_EPISODE_BEGIN/END>>>`.
3. Añadida la función asíncrona `_fetch_podcast_episode(episode_id)` ([L1010-L1078](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078)): GET directo a
`https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}`
(timeout 15s, follow redirects, manejo de errores tolerante — nunca rompe el flujo, devuelve `None` en caso de fallo).
4. Extendida `_augment_instructions_with_profile(...)` ([L1137-L1181](file:///home/duxiter/s2s/s2s-client/server.py#L1137-L1181)) con el nuevo parámetro `episode_context_text`; el orden de inject ahora es (más específico → más genérico): **episode → profile → reports → summary**.
5. Extendida `_patch_client_session_update_with_profile(...)` ([L1184-L1306](file:///home/duxiter/s2s/s2s-client/server.py#L1184-L1306)) con soporte a `episode_context_text` y logging dedicado `[INJECT-EPISODE]` / `[DEBUG-EPISODE]`.
6. WebSocket connect handler ([L1791-L1959](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959)):
- lee `episode_id` desde query params junto a `phoneNumber`;
- ejecuta fetch episodio + resolve ANTES de profile/reports/summary;
- guarda `injected_episode_context` e `inject_episode_id` en el `session_ctx` compartido.
7. `_client_to_upstream` ([L2649-L2661](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661)): pasa `ctx_episode_context` al patch de `session.update`.
8. Endpoint `POST /api/reasoning`:
- extendido el modelo `ReasoningRequest` con el campo `episode_id: str | None = None` ([L1309-L1323](file:///home/duxiter/s2s/s2s-client/server.py#L1309-L1323));
- en el handler ([L1411-L1577](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577)) fetch episodio independiente de phone;
- `inject_episode_text` se hace **prepend** sobre `user_context_parts` (primero de todos);
- reenvío a NODE_RED / OPENAI con logging extendido (`episodeIdLen`).
- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)):
1. Lectura desde query string: `const voceroEpisodeId = pageParams.get("episode_id")` ([L676-L680](file:///home/duxiter/s2s/s2s-client/main.js#L676-L680)).
2. `fetchOpenAiReasoningAnswer` ([L1337-L1364](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364)): campo `episode_id` añadido al body POST `/api/reasoning`, logKey y `console.info` extendidos.
3. Construcción URL WebSocket ([L2323-L2342](file:///home/duxiter/s2s/s2s-client/main.js#L2323-L2342)): `episode_id` añadido a query params tanto con la API `URL.searchParams` como en el fallback string (concat con `&`).
---
### Añadido — `S2S_START_SESSION_MODE`: 3 modos de inicio de sesión
Variable de entorno leída por el servidor y propagada al cliente vía `GET /api/config.startSessionMode`.
Objetivo: controlar si y cómo se enciende automáticamente el micrófono y si el modelo saluda
primero (flujo similar al wake-word). Valores soportados:
| Valor | Descripción |
|---|---|
| *(vacío) / default* | Inicio manual: solo tap sobre el Orb / botón Start |
| **`TALK`** | Después de 2s overlay a pantalla completa "Toca en cualquier lugar para empezar" (policy safe para Safari/iOS `getUserMedia`). Click/touch/tecla dentro de la user gesture → `doStart()`. El cliente WS inyecta bootstrap `"hola vocero"` + `requestResponse()`. |
| **`TALKFIRST`** | Después de 2s **intenta `doStart()` DIRECTAMENTE SIN click**, precargando `pendingWakeWordConfirmationText = "hola vocero"` (misma variable de la wake-word real). Si los permisos de mic ya están "allow always" funciona sin click. Si falla (policy Safari/iOS) → fallback sobre el mismo unlocker de TALK con mensaje "permiso micrófono". |
**Archivos modificados:**
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)):
1. Lectura env: `S2S_START_SESSION_MODE = os.environ.get(...)`, `.strip().upper()` ([L150](file:///home/duxiter/s2s/s2s-client/server.py#L150)).
2. Banner `[STARTUP]` extendido con el campo `startSessionMode=` ([L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212)).
3. Endpoint `GET /api/config` → nuevo campo `startSessionMode` + log `[INTERNAL] /api/config` ([L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400)).
- **`index.html`** ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39)):
- Añadido nuevo overlay `#talk-unlocker` a pantalla completa (z-index 9999), `hidden` por defecto, con tarjeta "Preparando Vocero… / Toca en cualquier lugar para empezar a hablar" + spinner degradado.
- **`style.css`** ([style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215)):
- Clases `.talk-unlocker`, `.talk-unlocker-hint`, `.talk-unlocker-spinner`, `.talk-unlocker-title`, `.talk-unlocker-sub` + animaciones fade-in y pop-up de tarjeta.
- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)):
1. Variable global `startSessionMode = ""` + comentario ([L628-L632](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632)).
2. Parse dentro de `fetchConfig()` → uppercase, log en `reportDebug("B",…)` ([L2309-L2320](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320)).
3. Paso a cliente WS → `startWithResponse: startSessionMode === "TALK"` ([L2683-L2692](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692)).
4. Bloque scheduling auto-start ([L2976-L3083](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083)):
- `bootstrapWithAutoStart()`: `await fetchConfig()` → `maybeScheduleAutoStart()`;
- `setTimeout(2000 ms)` warmup para finalizar fetch de episode/profile/reports;
- `showTalkUnlocker(fallbackHint)`: gestiona labels personalizadas en caso de fallback TALKFIRST;
- **`tryTalkFirstDirect()` (TALKFIRST)**: set `pendingWakeWordConfirmationText="hola vocero"` + `doStart()` + try/catch con teardown y fallback unlocker;
- listener unlocker click/touchstart/keydown → `startTalkFromGesture()` con `createResumedAudioContext()` dentro de la gesture (para iOS silent-free).
5. `onClientStatus()` ([L2878-L2932](file:///home/duxiter/s2s/s2s-client/main.js#L2878-L2932)) añadido `hideTalkUnlocker()` en `connecting/queued/your-turn/connected`.
- **`ws/s2s-ws-client.js`** ([s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js)):
1. Constructor: nuevas propiedades `_startWithResponse`, `_startWithResponseText` (default `"hola vocero"`), `_initialResponseSent` guardia one-shot ([L163-L178](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L163-L178)).
2. Handler `session.updated` ([L688-L713](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713)): **Solo para `TALK`** (no TALKFIRST, que en cambio usa `pendingWakeWordConfirmationText`): `this.sendUserText("hola vocero") + this.requestResponse()` — mismo code path identico de la wake-word.
3. `requestResponse` / `_createResponseNow` restaurados limpios (solo payload opcional `image`; ningún shaped `response.create`).
---
### Modificado — `#reports-sidebar-toggle` visible solo con `?debug=true`
El botón "reports" en la topbar (toggle del panel lateral reports) ahora es
`data-debug-only="true"` — por lo tanto solo se muestra si la URL contiene
`?debug=true`, de manera análoga al cluster `.topbar-right`.
**Archivos modificados:**
- **`index.html`** → `#reports-sidebar-toggle` ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L47-L50)): añadido `data-debug-only="true"`.
- **`main.js`** → `applyDebugOnlyVisibilityGate()` ([L48-L109](file:///home/duxiter/s2s/s2s-client/main.js#L48-L109)): extendido para iterar *todos* los elementos `[data-debug-only="true"]` (no solo `.topbar-right`); línea 1227: `reportsSidebarToggle.hidden = false` condicionado a `qs.get("debug") === "true"` ([L1227-L1246](file:///home/duxiter/s2s/s2s-client/main.js#L1227-L1246)) + log rojo `🚫 FORZADO HIDDEN` cuando falta debug=true.
- **`style.css`** → regla de seguridad ya presente: `[data-debug-only]:not([data-debug-enabled]) { display:none !important; visibility:hidden; opacity:0 }` ([L532-L537](file:///home/duxiter/s2s/s2s-client/style.css#L532-L537)).
---
## [2026-09-04]
### Modificado — Chat siempre visible + integración seamless
Reorganizado el layout para mostrar el chat textual de forma permanente e
integrado visualmente con el Orb en la parte superior del stage.
- Eliminado botón toggle chat en la topbar (`#chat-btn`) y relativo overlay.
- Stage con el Orb en la parte superior; nuevo `#chat-panel` a continuación (historial
+ form input fijo sobre el footer sticky).
- Actualizada lógica en `main.js` y `ui/chat.js` para el envío de texto mediante
`client.sendUserText` y auto-scroll sin controles de visibilidad del panel.
- Versión banner deploy: **2026-09-03 14:40**.
### Resuelto — Estabilidad integración chat mobile
- Error `Missing element: #chat-btn` resuelto con selectores non-strict
(`document.querySelector`) para elementos opcionales eliminados.
- Refactorización estética "seamless": chat sin marcos, sombras ni fondos
(`background:transparent`, `box-shadow:none`).
- **Layout mobile:**
- padding incrementado en `#app` (56px) y `.chat-history` (96px) para evitar
superposiciones con el footer sticky;
- márgenes fijos en las burbujas: `margin-right:22px` (Assistant), `margin-left:6px` (User).
- **Auto-scroll streaming:** implementado `_scrollToBottom` con cadena de retry
múltiples (`requestAnimationFrame` + `setTimeout`) para compensar el line-wrap
asíncrono durante la llegada de los tokens; `overflow-anchor:none` + `scroll-behavior:auto !important`.
### Añadido — Avatar usuario con iniciales + modal de perfil
- Sustituido el texto del nombre de usuario en el header por avatar circular 40×40px
(gradient de marca) conteniendo las iniciales calculadas (ej. "AV").
- Nueva `<dialog id="user-profile-dialog">` en `index.html` que se abre al click
sobre el avatar: muestra email, teléfono, rol, empresa, con soporte a shapes
anidadas del payload de perfil.
- Cierre del modal: tecla ESC, click fuera de la tarjeta, botón `×` dedicado.
---
## [Notas de formato]
- **Tope campo prompt episodio:** 24.000 caracteres (transcript largos).
- **Tope profile / summary:** 1.600 caracteres cada uno.
- **Tope reports list:** 12.000 caracteres.
- **Orden inject prompt LLM (WS session.update + reasoning):**
1. Episodio podcast actual (PRIMARIO)
2. Perfil llamador (identidad)
3. Lista reports / scripts podcast programados
4. Summary última conversación
- Todos los bloques están marcados con `<<<NOMBRE_BLOQUE_BEGIN/END>>>` para inspección de logs.
+156
View File
@@ -0,0 +1,156 @@
# Changelog — s2s-client (Azurian Vocero)
Tutte le modifiche rilevanti al progetto sono documentate in questo file.
Formato basato su [Keep a Changelog](https://keepachangelog.com/it/1.1.0/).
---
## [2026-09-07]
### Aggiunto — Supporto `episode_id` (podcast episode inject)
Nuovo parametro query string `?episode_id=` affiancato a `?phoneNumber=` che
abilita il caricamento e l'iniezione automatica di un episodio podcast come
contesto PRIMARIO nel prompt LLM.
**URL di esempio:**
```
/?phoneNumber=%2B447432477433&episode_id=episode-19569f77-a06e-5ce5-a52f-6299dbd67d03
```
**File modificati:**
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)):
1. Aggiunta costante `PODCAST_EPISODE_HINT` ([L426-L429](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429)) per l'hint semantico nel prompt.
2. Aggiunta funzione `_resolve_episode_context(raw_value)` ([L475-L504](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504)): normalizza la risposta API (dict/list/str, eventuale wrapper `{"result": …}`), la serializza in JSON (cap 24.000 chars) e la avvolge con i marker `<<<PODCAST_EPISODE_BEGIN/END>>>`.
3. Aggiunta funzione asincrona `_fetch_podcast_episode(episode_id)` ([L1010-L1078](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078)): GET plain verso
`https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}`
(timeout 15s, follow redirects, error handling soft — non rompe mai il flusso, restituisce `None` su fail).
4. Estesa `_augment_instructions_with_profile(...)` ([L1137-L1181](file:///home/duxiter/s2s/s2s-client/server.py#L1137-L1181)) con nuovo parametro `episode_context_text`; l'ordine di inject ora è (più specifico → più generico): **episode → profile → reports → summary**.
5. Estesa `_patch_client_session_update_with_profile(...)` ([L1184-L1306](file:///home/duxiter/s2s/s2s-client/server.py#L1184-L1306)) con supporto a `episode_context_text` e logging dedicato `[INJECT-EPISODE]` / `[DEBUG-EPISODE]`.
6. WebSocket connect handler ([L1791-L1959](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959)):
- legge `episode_id` da query params assieme a `phoneNumber`;
- esegue fetch episodio + resolve PRIMA di profile/reports/summary;
- salva `injected_episode_context` e `inject_episode_id` nel `session_ctx` condiviso.
7. `_client_to_upstream` ([L2649-L2661](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661)): passa `ctx_episode_context` al patch di `session.update`.
8. Endpoint `POST /api/reasoning`:
- esteso modello `ReasoningRequest` con campo `episode_id: str | None = None` ([L1309-L1323](file:///home/duxiter/s2s/s2s-client/server.py#L1309-L1323));
- nel handler ([L1411-L1577](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577)) fetch episodio indipendente da phone;
- `inject_episode_text` viene **prepend** a `user_context_parts` (prima di tutti);
- inoltro a NODE_RED / OPENAI con logging esteso (`episodeIdLen`).
- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)):
1. Lettura da query string: `const voceroEpisodeId = pageParams.get("episode_id")` ([L676-L680](file:///home/duxiter/s2s/s2s-client/main.js#L676-L680)).
2. `fetchOpenAiReasoningAnswer` ([L1337-L1364](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364)): campo `episode_id` aggiunto al body POST `/api/reasoning`, logKey e `console.info` estesi.
3. Costruzione URL WebSocket ([L2323-L2342](file:///home/duxiter/s2s/s2s-client/main.js#L2323-L2342)): `episode_id` appeso a query params sia con API `URL.searchParams` sia nel fallback stringa (concat con `&`).
---
### Aggiunto — `S2S_START_SESSION_MODE`: 3 modalità di start sessione
Env var letta dal server e propagata al client via `GET /api/config.startSessionMode`.
Scopo: governare se e come il microfono si auto-accende e se il modello saluta
per primo (flow simile a wake-word). Valori supportati:
| Valore | Descrizione |
|---|---|
| *(vuoto) / default* | Start manuale: solo tap sull'Orb / pulsante Start |
| **`TALK`** | Dopo 2s overlay fullscreen "Tocca ovunque per iniziare" (policy safe per Safari/iOS `getUserMedia`). Click/touch/tasto dentro la user gesture → `doStart()`. WS client inietta bootstrap `"hola vocero"` + `requestResponse()`. |
| **`TALKFIRST`** | Dopo 2s **tenta `doStart()` DIRETTAMENTE SENZA click**, pre-caricando `pendingWakeWordConfirmationText = "hola vocero"` (stessa variabile della wake-word vera). Se i permessi mic sono già "allow always" funziona senza click. Se fallisce (policy Safari/iOS) → fallback sullo stesso unlocker di TALK con messaggio "permesso microfono". |
**File modificati:**
- **`server.py`** ([server.py](file:///home/duxiter/s2s/s2s-client/server.py)):
1. Lettura env: `S2S_START_SESSION_MODE = os.environ.get(...)`, `.strip().upper()` ([L150](file:///home/duxiter/s2s/s2s-client/server.py#L150)).
2. Banner `[STARTUP]` esteso con campo `startSessionMode=` ([L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212)).
3. Endpoint `GET /api/config` → nuovo campo `startSessionMode` + log `[INTERNAL] /api/config` ([L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400)).
- **`index.html`** ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39)):
- Aggiunto nuovo `#talk-unlocker` overlay fullscreen (z-index 9999), `hidden` di default, con card "Preparando Vocero… / Toca en cualquier lugar para empezar a hablar" + spinner gradient.
- **`style.css`** ([style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215)):
- Classi `.talk-unlocker`, `.talk-unlocker-hint`, `.talk-unlocker-spinner`, `.talk-unlocker-title`, `.talk-unlocker-sub` + animazioni fade-in e pop-up card.
- **`main.js`** ([main.js](file:///home/duxiter/s2s/s2s-client/main.js)):
1. Variabile globale `startSessionMode = ""` + commento ([L628-L632](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632)).
2. Parse dentro `fetchConfig()` → uppercase, log in `reportDebug("B",…)` ([L2309-L2320](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320)).
3. Passaggio a WS client → `startWithResponse: startSessionMode === "TALK"` ([L2683-L2692](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692)).
4. Blocco scheduling auto-start ([L2976-L3083](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083)):
- `bootstrapWithAutoStart()`: `await fetchConfig()` → `maybeScheduleAutoStart()`;
- `setTimeout(2000 ms)` warmup per finire fetch di episode/profile/reports;
- `showTalkUnlocker(fallbackHint)`: gestisce label custom nel caso fallback TALKFIRST;
- **`tryTalkFirstDirect()` (TALKFIRST)**: set `pendingWakeWordConfirmationText="hola vocero"` + `doStart()` + try/catch con teardown e fallback unlocker;
- listener unlocker click/touchstart/keydown → `startTalkFromGesture()` con `createResumedAudioContext()` dentro la gesture (per iOS silent-free).
5. `onClientStatus()` ([L2878-L2932](file:///home/duxiter/s2s/s2s-client/main.js#L2878-L2932)) aggiunto `hideTalkUnlocker()` in `connecting/queued/your-turn/connected`.
- **`ws/s2s-ws-client.js`** ([s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js)):
1. Costruttore: nuove proprietà `_startWithResponse`, `_startWithResponseText` (default `"hola vocero"`), `_initialResponseSent` guardia one-shot ([L163-L178](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L163-L178)).
2. Handler `session.updated` ([L688-L713](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713)): **Solo per `TALK`** (non TALKFIRST, che usa invece `pendingWakeWordConfirmationText`): `this.sendUserText("hola vocero") + this.requestResponse()` — stesso identico code path della wake-word.
3. `requestResponse` / `_createResponseNow` ripristinati puliti (solo payload opzionale `image`; nessun shaped `response.create`).
---
### Modificato — `#reports-sidebar-toggle` visibile solo con `?debug=true`
Il bottone "reports" nella topbar (toggle del pannello laterale reports) è ora
`data-debug-only="true"` — viene quindi mostrato solo se l'URL contiene
`?debug=true`, analogamente al cluster `.topbar-right`.
**File modificati:**
- **`index.html`** → `#reports-sidebar-toggle` ([index.html](file:///home/duxiter/s2s/s2s-client/index.html#L47-L50)): aggiunto `data-debug-only="true"`.
- **`main.js`** → `applyDebugOnlyVisibilityGate()` ([L48-L109](file:///home/duxiter/s2s/s2s-client/main.js#L48-L109)): esteso per iterare *tutti* gli elementi `[data-debug-only="true"]` (non solo `.topbar-right`); riga 1227: `reportsSidebarToggle.hidden = false` gated a `qs.get("debug") === "true"` ([L1227-L1246](file:///home/duxiter/s2s/s2s-client/main.js#L1227-L1246)) + log rosso `🚫 FORZATO HIDDEN` quando manca debug=true.
- **`style.css`** → regola di sicurezza già presente: `[data-debug-only]:not([data-debug-enabled]) { display:none !important; visibility:hidden; opacity:0 }` ([L532-L537](file:///home/duxiter/s2s/s2s-client/style.css#L532-L537)).
---
## [2026-09-04]
### Modificato — Chat sempre visibile + integrazione seamless
Riorganizzato il layout per mostrare la chat testuale in modo permanente e
integrato visivamente con l'Orb nella parte superiore dello stage.
- Rimosso pulsante toggle chat nella topbar (`#chat-btn`) e relativo overlay.
- Stage con l'Orb nella parte superiore; nuovo `#chat-panel` a seguire (cronologia
+ form input fisso sopra il footer sticky).
- Aggiornata logica in `main.js` e `ui/chat.js` per l'invio testo tramite
`client.sendUserText` e auto-scroll senza controlli di visibilità pannello.
- Versione banner deploy: **2026-09-03 14:40**.
### Risolto — Stabilità integrazione chat mobile
- Errore `Missing element: #chat-btn` risolto con selettori non-strict
(`document.querySelector`) per elementi opzionali rimossi.
- Refactoring estetico "seamless": chat senza cornici, ombre o sfondi
(`background:transparent`, `box-shadow:none`).
- **Layout mobile:**
- padding incrementato in `#app` (56px) e `.chat-history` (96px) per evitare
sovrapposizioni col footer sticky;
- margini fissi bolle: `margin-right:22px` (Assistant), `margin-left:6px` (User).
- **Auto-scroll streaming:** implementato `_scrollToBottom` con catena di retry
multipli (`requestAnimationFrame` + `setTimeout`) per compensare il line-wrap
asincrono durante l'arrivo dei token; `overflow-anchor:none` + `scroll-behavior:auto !important`.
### Aggiunto — Avatar utente iniziali + modale profilo
- Sostituito testo nome utente nell'header con avatar circolare 40×40px
(gradient brand) contenente le iniziali calcolate (es. "AV").
- Nuova `<dialog id="user-profile-dialog">` in `index.html` che si apre al click
sull'avatar: mostra email, telefono, ruolo, azienda, con supporto a shape
annidate del payload profilo.
- Chiusura modale: tasto ESC, click fuori card, pulsante `×` dedicato.
---
## [Note di formato]
- **Cap campo prompt episodio:** 24.000 caratteri (transcript lunghi).
- **Cap profilo / summary:** 1.600 caratteri ciascuno.
- **Cap reports list:** 12.000 caratteri.
- **Ordine inject prompt LLM (WS session.update + reasoning):**
1. Episodio podcast corrente (PRIMARIO)
2. Profilo chiamante (identity)
3. Lista reports / script podcast schedulati
4. Summary ultima conversazione
- Tutti i blocchi sono marcati con `<<<NOME_BLOCCO_BEGIN/END>>>` per ispezione log.
+484
View File
@@ -0,0 +1,484 @@
# Inject pipeline — caller context & reference material
Documento ufficiale di tutti i blocchi di contesto che il server inietta
automaticamente nel prompt LLM **all'inizio di ogni sessione**, sia per il
canale **WebSocket realtime** (`/v1/realtime`, patch di `session.update`) sia
per l'endpoint **HTTP reasoning** (`POST /api/reasoning`, augmentation di
`instructions` e `user_context`).
Tutto il sistema è **generico / deploy-agnostic** per scelta architetturale:
nessun campo viene hardcodato. Ogni blocco accetta payload arbitrari e li
serializza in JSON direttamente. Qualsiasi errore di rete / JSON non
interrompe mai il flusso: il blocco problematico viene solo saltato con
una riga `[INJECT-*] ⏭️ skipped` nel log server.
---
## 0. Master flag: `S2S_INJECT_PROFILE`
Tutta la pipeline è sotto **un unico master flag** lato server.
| Valore env `S2S_INJECT_PROFILE` | Comportamento |
|---|---|
| `false` / non impostato | **Pure passthrough.** Nessuna fetch upstream, nessuna modifica a `session.update` o al body `/api/reasoning`. Il proxy passa tutto invariato. |
| `true` / `1` / `yes` / `on` | Pipeline inject attiva. I singoli blocchi poi dipendono dagli env specifici e dai query params della URL del client. |
Perché esiste: su deploy generici (nessuna integrazione con AzurianLab /
Aquachile / sistemi interni) il client rimane un puro speech-to-speech
passthrough, senza dipendere da endpoint esterni.
---
## 1. Blocco episodio podcast — `episode_id`
**Priorità massima.** Quando c'è, viene considerato il riferimento PRINCIPALE
e più fidato per tutta la sessione.
### Trigger (quando si attiva)
Una qualunque delle due:
1. URL pagina browser → query string `?episode_id=episode-xxx`
2. Body `POST /api/reasoning` → campo `episode_id: "episode-xxx"`
### Sorgente dati
GET plain verso l'endpoint Aquachile AzurianLab:
```
https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}
```
Parametri fetch:
- timeout 15 s
- `follow_redirects: true`
- **Nessuna auth** (endpoint pubblico)
Formato risposta accettato:
- `Content-Type: application/json` → oggetto arbitrario o array
- `{"result": <payload>}` → wrapper supportato, il contenuto viene "spacchettato"
- `text/plain` → usato come stringa raw
### Cap
24.000 caratteri dopo serializzazione JSON (transcript lunghi).
Se supera → troncato con `...` append.
### Wrap applicato
```
< PODCAST_EPISODE_HINT >
<<<PODCAST_EPISODE_BEGIN>>>
{ <TUTTI I CAMPI DELL'EPISODIO, JSON> }
<<<PODCAST_EPISODE_END>>>. This is the episode the caller is currently interacting with — answer with reference to it first.
```
**Hint testuale** (in testa, letto dal modello come primissima frase del blocco):
> Podcast episode context loaded from the page `episode_id` query param (current episode the user is referencing). Treat the episode fields (title, description, transcript, script, show notes, guests, audio metadata, etc.) as the **PRIMARY trusted reference material** for this session.
### Campi supportati (esempi — lista NON esaustiva, qualsiasi campo extra viene incluso)
- metadati base: `id`, `title`, `slug`, `description`, `summary`, `publishedAt`, `durationSeconds`
- contenuti: `transcript`, `podcastScript`, `script`, `chapters[]`, `showNotes`
- persone: `host`, `guests[]`, `author`, `guestsInfo[]`
- audio: `audioUrl`, `audioFile`, `waveform`, `sampleRate`
- classificazione: `tags[]`, `topics[]`, `category`, `season`, `episodeNumber`
- riferimenti incrociati: `reportId`, `scheduleId`, `company`, `product`
### Riferimenti codice
- fetch upstream: [_fetch_podcast_episode](file:///home/duxiter/s2s/s2s-client/server.py#L1010-L1078)
- wrap + serialize + cap 24k: [_resolve_episode_context](file:///home/duxiter/s2s/s2s-client/server.py#L475-L504)
- hint costante: [PODCAST_EPISODE_HINT](file:///home/duxiter/s2s/s2s-client/server.py#L426-L429)
---
## 2. Blocco profilo chiamante — `phoneNumber`
Contesto identità del chiamante. Viene usato per il modello quando risponde
(riferirsi all'utente per nome, conoscere la sua azienda, ruolo, ecc.).
### Trigger
1. URL browser → `?phoneNumber=%2B<numero-internazionale>`
2. Body `/api/reasoning` → `phoneNumber: "+…"`
### Sorgente dati
```
GET VOCERO_USER_PHONE_AUTH_URL?phoneNumber=<phone>
```
Se è configurato anche `S2S_LOGIN_BY_PHONE`, PRIMA della profile fetch:
```
POST S2S_LOGIN_BY_PHONE body { "phoneNumber": "..." }
```
Da questa risposta il proxy estrae un bearer token e lo inoltra come
`Authorization: Bearer <token>` sulle chiamate successive (profile, reports,
summary).
Formato risposta: JSON arbitrario, wrapper `{"result": ...}` supportato.
### Cap
1.600 caratteri dopo JSON serialize.
### Wrap
```
Caller profile resolved from the page phoneNumber:
{ <profilo JSON troncato> }.
Use this as trusted caller context when relevant.
```
### Riferimenti codice
- login by phone: [_login_by_phone](file:///home/duxiter/s2s/s2s-client/server.py#L812-L890)
- fetch profile: [_fetch_user_profile_for_phone](file:///home/duxiter/s2s/s2s-client/server.py#L1081-L1134)
- wrap serialize: [_resolve_user_context_text](file:///home/duxiter/s2s/s2s-client/server.py#L432-L444)
---
## 3. Blocco lista reports / script schedulati
Riferimento "storico" dei contenuti preparati prima della sessione. Non sono
contesto live, ma materiale che il modello deve sapere esiste (e a cui
riferirsi se l'utente chiede di report pregressi, script, podcast schedulati).
### Trigger
Solo env: `S2S_REPORT_LIST` deve essere valorizzato a un URL valido.
**Funziona anche SENZA `phoneNumber`** (lista reports generica per tutti).
Se `phoneNumber` è presente lo passa come query param all'endpoint.
### Sorgente dati
```
GET S2S_REPORT_LIST[?phoneNumber=<phone>]
```
Wrapper `{"result": [...]}` supportato.
### Cap
12.000 caratteri sul blocco COMPLETO (ogni podcast script può essere lungo).
### Wrap + filtri + sort
```
Scheduled reports / podcast scripts available for this caller before the current session.
Each entry is a scheduled report; use the podcastScript contents as trusted reference material
when the user asks about scheduled topics, reports, or prior content.
<<<REPORTS_LIST_BEGIN>>>
[
{
"sourceScheduleLabel": "...",
"date": "2026-09-06T…",
"podcastScript": "…"
},
… ordinati per data DESC (nuovi prima) …
]
<<<REPORTS_LIST_END>>>
```
Regole per ogni entry:
1. **Solo dict** vengono tenuti (stringhe/int scartati).
2. Per ogni entry estrae 3 campi con fallback a nested shape:
- Label: `sourceScheduleLabel` oppure `sourceSchedule.label`
- Date: `date` oppure `sourceSchedule.date`
- Script: `podcastScript`
3. Ordinamento **data DESC** (nuovi prima) con parser robusto:
- ISO datetime / date strings
- `Date` / `DateTime` Python (se deserializzati)
- Unix timestamp int/float
- Lexicografico DESC come fallback per stringhe ISO-ish
- Valori nulli / non-parse-abili finiscono in fondo.
### Riferimenti codice
- fetch: [_fetch_reports_list](file:///home/duxiter/s2s/s2s-client/server.py#L668-L745)
- parse data sort key: [_parse_date_key](file:///home/duxiter/s2s/s2s-client/server.py#L507-L515)
- wrap + filtro + sort: [_resolve_reports_context](file:///home/duxiter/s2s/s2s-client/server.py#L518-L666)
---
## 4. Blocco summary ultima conversazione
Sintesi della chiamata/chat IMMEDIATAMENTE precedente a questa. Usato dal
modello per "ricordarsi" cosa si è detto l'ultima volta senza conservare
storico lunghissimo nel prompt.
### Trigger
Entrambe:
- env `S2S_LATEST_SUMMARY` impostato
- `phoneNumber` presente in URL/body
### Sorgente dati
```
GET S2S_LATEST_SUMMARY?phoneNumber=<phone>
```
Wrapper `{"result": <valore>}` supportato.
Accetta SIA JSON che plain text (diversi backend summary ritornano testo semplice).
### Cap
1.600 caratteri.
### Wrap
```
Latest conversation summary for this caller (most recent before the current session):
<<<LATEST_SUMMARY_BEGIN>>>
<summary, stringa o JSON serializzato>
<<<LATEST_SUMMARY_END>>>.
Use this as trusted history when the user references prior calls.
```
### Riferimenti codice
- fetch: [_fetch_latest_summary_for_phone](file:///home/duxiter/s2s/s2s-client/server.py#L893-L971)
- wrap: [_resolve_latest_summary_context](file:///home/duxiter/s2s/s2s-client/server.py#L446-L472)
---
## 5. Blocco `user_context` custom dal browser (SOLO `/api/reasoning`)
**Canale solo HTTP reasoning.**
Non è nel WS: il WS non ha campo user_context nel protocollo realtime
OpenAI (tutto passa per `session.instructions`).
Composizione lato client in [main.js fetchOpenAiReasoningAnswer](file:///home/duxiter/s2s/s2s-client/main.js#L1337-L1364):
```js
const profileCtx = voceroUserContextText();
const ragCtx = customRagContextText();
const user_context = [profileCtx, ragCtx]
.map(s => s.trim()).filter(Boolean).join("\n\n");
```
### Dove va a finire
Nel body a monte sia NODE_RED sia OPENAI, questo blocco viene **APPESO DOPO**
tutti i blocchi iniettati server-side:
```
user_context finale =
[1. episode] ← solo se episode_id
[2. profile] ← solo se phoneNumber + env profilatura
[3. reports] ← solo se env S2S_REPORT_LIST
[4. summary] ← solo se phoneNumber + S2S_LATEST_SUMMARY
+ browser user_context (profileCtx + ragCtx)
```
---
## Ordine finale nel prompt LLM
Questa è la sequenza **letterale** con cui i blocchi vengono concatenati
dentro `session.instructions` (WS) e in `instructions` / `user_context`
(reasoning HTTP).
```
1. EPISODIO PODCAST ← PRIORITÀ PIÙ ALTA (primary trusted reference)
2. PROFILO CHIAMANTE ← identity / caller profile
3. REPORTS SCHEDULATI ← scheduled / prior content
4. SUMMARY ULTIMA CALL ← history più recente
5. Base instructions (prompt predefinito o primary prompt)
6. (solo reasoning) user_context custom inviato dal browser
```
Perché questo ordine:
- più in alto nel prompt = più peso / attenzione dal modello.
- l'episodio attuale è il materiale che l'utente ha esplicitamente richiesto
aprendo la pagina con `?episode_id=`, quindi merita massimo peso.
- il profilo chiamante va sempre prima di "contenuti generici" (reports e
summary) perché serve per coerenza nel riferirsi all'utente.
- reports = contenuti schedulati PRIMA della sessione corrente → più vecchi
dell'ultima chiamata, quindi messi PRIMA del summary ultima chiamata che
è il contenuto più "vivo".
Implementazione ordine: [_augment_instructions_with_profile](file:///home/duxiter/s2s/s2s-client/server.py#L1149-L1159)
```python
contexts = [c for c in [episode_ctx, profile_ctx, reports_ctx, summary_ctx] if c]
```
---
## Punti di innesco (quando viene calcolato / applicato)
### Canale WebSocket — `/v1/realtime`
**MOMENTO 1 (solo una volta):** apertura WS.
Nel handler [@app.websocket("/v1/realtime")](file:///home/duxiter/s2s/s2s-client/server.py#L1791-L1959)
appena la WS viene accettata:
1. parsa `ws.url.query` per `phoneNumber` e `episode_id`
2. esegue login (se attivo) → profile → episode → reports → summary
3. salva TUTTI i blocchi già wrappati e serializzati nel dict `session_ctx`:
```python
session_ctx = {
"injected_episode_context": "...",
"injected_profile_context": "...",
"injected_reports_context": "...",
"injected_summary_context": "...",
...
}
```
**MOMENTO 2 (ogni `session.update` client→upstream):** nel task
[_client_to_upstream](file:///home/duxiter/s2s/s2s-client/server.py#L2649-L2661)
ogni frame in uscita viene passato a:
```python
_patch_client_session_update_with_profile(
frame_text,
user_context_text = profile_ctx,
reports_context_text = reports_ctx,
summary_context_text = summary_ctx,
episode_context_text = episode_ctx, # ← nuovo
)
```
Se il frame NON è `session.update` → ritorna **identico** e non viene loggato
(per evitare spam).
Se è `session.update` → patch del campo `payload["session"]["instructions"]`.
### Canale HTTP — `POST /api/reasoning`
**Ogni chiamata** ricostruisce i blocchi da zero nel
[handler `/api/reasoning`](file:///home/duxiter/s2s/s2s-client/server.py#L1411-L1577):
1. legge `req.phoneNumber` + `req.episode_id`
2. fetch episodio (se episode_id) + login + profile + reports + summary
3. costruisce `user_context_parts` e `effective_instructions`
4. inoltra al backend (NODE_RED o OPENAI)
---
## Quando un blocco viene SKIPPATO (niente inject)
Tutti i blocchi hanno 5 possibili ragioni di skip. Nessuna rompe mai il
flusso: ogni caso produce una riga `[INJECT-*] ⏭️ skipped` nei log e basta.
| Causa | Log |
|---|---|
| Master flag `S2S_INJECT_PROFILE=false` | `[INJECT-PROFILE] ⏭️ skipped (S2S_INJECT_PROFILE=false)` |
| Parametro mancante (es. `phoneNumber` non in URL) | `[INJECT-*] ⏭️ skipped (<parametro> missing)` |
| Endpoint env non configurato | `[INJECT-*] ⏭️ skipped (env for ... empty)` |
| Upstream HTTP ≠ 200 | `[INJECT-*] ⛔ upstream HTTP <status_code>` |
| Upstream ha ritornato vuoto / null / `{}` / `[]` | `[INJECT-*] ⏭️ upstream returned empty` |
| Eccezione di rete (timeout, DNS, SSL) | `[INJECT-*] ⛔ fetch unreachable error=<exc>` |
---
## Marcatori log / marker nel prompt
Ogni blocco è **auto-documentante** per ispezione dei log con coppie di
marker:
```
<<<PODCAST_EPISODE_BEGIN>>> / <<<PODCAST_EPISODE_END>>>
<<<REPORTS_LIST_BEGIN>>> / <<<REPORTS_LIST_END>>>
<<<LATEST_SUMMARY_BEGIN>>> / <<<LATEST_SUMMARY_END>>>
```
Quando `S2S_DEBUG_PROMPT=true` viene loggato il contenuto COMPLETO di ogni
blocco con i tag `[DEBUG-EPISODE]`, `[DEBUG-REPORTS]`, `[DEBUG-SUMMARY]` sia
all'atto del load (WS connect) sia dopo l'augment delle instructions (prima
di inoltrare a monte). **WARNING:** questo env può loggare PII.
---
## Tabella riepilogo: trigger per ogni blocco
| # | Blocco | Trigger | env richiesto | parametro | cap |
|---|---|---|---|---|---|
| 1 | Episodio podcast | `episode_id` in URL o body | (nessuno — URL hardcodato Aquachile) | `episode_id=` | 24 k |
| 2 | Profilo chiamante | `phoneNumber` | `VOCERO_USER_PHONE_AUTH_URL` | `phoneNumber=` | 1,6 k |
| 3 | Reports schedulati | sempre se env | `S2S_REPORT_LIST` | opzionale `phoneNumber=` | 12 k |
| 4 | Summary ultima call | `phoneNumber` | `S2S_LATEST_SUMMARY` | `phoneNumber=` | 1,6 k |
| 5 | Browser user_context | solo reasoning | (nessuno) | body `user_context` | illimitato |
---
## Error handling strategico
Perché tutti gli errori sono silenziosi (log + skip) e non abortiscono:
> Se il backend dei profili è giù per manutenzione 5 minuti non vogliamo
> che il Voice Agent smetta di funzionare. Semplicemente l'assistente non
> conosce l'identità del chiamante per 5 minuti, ma parla normalmente.
L'utente non si accorge di niente. L'operatore legge i log e vede le righe
`⛔`; può risolvere offline.
Se volessi forzare il fail (raro, su deploy dedicato), si trasformerebbero
le `return None` finali in `raise HTTPException(...)` — ma per default
non è il comportamento voluto.
---
## Appendice A — Start-session modes (`S2S_START_SESSION_MODE`)
Oltre alla inject pipeline lato server, è presente una feature lato client che
governa **come e se la sessione vocale inizia in automatico** e se il modello
saluta per primo (in modo identico alla wake-word "Hola Vocero").
### Valori supportati
| `S2S_START_SESSION_MODE` | Chi inizia? | Richiede click utente? | Trigger greet iniziale |
|---|---|---|---|
| *(vuoto) / default* | Utente, tap manuale Orb | ✅ | No |
| `TALK` | Auto dopo 2s con overlay unlocker | ✅ (1 click policy-safe su Safari/iOS) | WS client: `sendUserText("hola vocero") + requestResponse()` dopo `session.updated` |
| `TALKFIRST` | Auto dopo 2s **SENZA click** (se possibile) | ❌ se mic già "allow always"; fallback unlocker se `getUserMedia` fallisce | **Stesso identico codice della wake-word vera:** `pendingWakeWordConfirmationText = "hola vocero"` (main.js `case "connected"` → `sendUserText + requestResponse`) |
### Warmup 2s
Il timer non parte prima di 2000 ms per lasciare terminare:
- `fetchVoceroUserProfile()` (identity chiamante)
- `fetchVoceroReportsList()` (storico script podcast)
- fetch episode (`episode_id`) se presente
- `GET /api/config` (con la quale il client apprende `startSessionMode`)
Senza warmup le injection lato server potrebbero ancora non essere completate e
il greet iniziale arriverebbe "cieco" (senza contesto episodio/profilo).
### Fallback di `TALKFIRST`
Nel 99% dei casi `getUserMedia()` fuori da una user gesture fallisce
(NotAllowedError / NotReadableError su Safari, Chrome con policy strict, iOS).
In tal caso:
1. si chiude il client WS (`teardown()`)
2. stato torna `idle`
3. si mostra `#talk-unlocker` con la dicitura:
- **"Se requiere permiso de micrófono"**
- **"Toca en cualquier lugar para permitir el audio y empezar"**
4. `pendingWakeWordConfirmationText` NON viene svuotato → quando l'utente
toccherà, `case "connected"` triggerà comunque la greet come wake-word.
### Riferimenti codice
| Componente | File | Range righe |
|---|---|---|
| Lettura env + banner startup + `/api/config` exposure | [server.py](file:///home/duxiter/s2s/s2s-client/server.py) | [L150](file:///home/duxiter/s2s/s2s-client/server.py#L150), [L196-L212](file:///home/duxiter/s2s/s2s-client/server.py#L196-L212), [L1383-L1400](file:///home/duxiter/s2s/s2s-client/server.py#L1383-L1400) |
| Overlay HTML unlocker | [index.html](file:///home/duxiter/s2s/s2s-client/index.html#L32-L39) | L32-L39 |
| Stili overlay | [style.css](file:///home/duxiter/s2s/s2s-client/style.css#L145-L215) | L145-L215 |
| Variabile globale + parse config client | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L628-L632) | L628-L632 |
| `fetchConfig()` parsing + `reportDebug("B")` | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2309-L2320) | L2309-L2320 |
| `new S2sWsRealtimeClient({startWithResponse})` | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2683-L2692) | L2683-L2692 |
| Scheduling + `tryTalkFirstDirect()` + unlocker gesture | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2976-L3083) | L2976-L3083 |
| `onClientStatus("connected")` → wake-word inject flow | [main.js](file:///home/duxiter/s2s/s2s-client/main.js#L2897-L2914) | L2897-L2914 |
| WS client → `TALK` mode sendUserText("hola vocero") + `requestResponse()` | [s2s-ws-client.js](file:///home/duxiter/s2s/s2s-client/ws/s2s-ws-client.js#L688-L713) | L688-L713 |
---
## Riferimenti file
| File | Ruolo |
|---|---|
| [server.py](file:///home/duxiter/s2s/s2s-client/server.py) | Tutta la pipeline inject: fetch, wrap, patch, handler WS + reasoning |
| [main.js](file:///home/duxiter/s2s/s2s-client/main.js) | Passaggio client `?phoneNumber` + `?episode_id` → WS query params e POST `/api/reasoning` body |
| [CHANGELOG.md](file:///home/duxiter/s2s/s2s-client/CHANGELOG.md) | History rilasci / modifiche alla inject pipeline |
+77 -5
View File
@@ -16,6 +16,76 @@ Browser voice-chat UI for the
backend. The browser streams mic audio over a WebSocket using the OpenAI
Realtime **GA** protocol and plays back the assistant's audio as it arrives.
---
## 🆕 Novedades / Actualizaciones (Azurian Vocero realtime ver.Beta 2.1)
> Versión del navegador que debes ver cargada en consola: **`2026-09-04 11:20`**.
> Si no aparece, haz **borrado de datos del sitio + recarga dura (Ctrl+Shift+R)** o abre una pestaña en modo incógnito.
### 🎨 Rediseño visual completo (tema aurora + glassmorphism)
- **Tema de fondo aurora**: gradiente radial violeta/rosa/cian sobre lienzo oscuro con malla sutil + animación `auroraShift` lenta.
- **Cromado uniforme glass**: todos los elementos flotantes (tarjeta brand, panel user profile, botones icono, botón enviar chat, panel informes sidebar) usan *glass blur + saturate + borde gradiente brand 1px* (técnica mask `padding-box / border-box` doble linear-gradient).
- **Tokens de marca 3 colores brand** (violeta `#8b5cf6`, rosa `#ec4899`, cian `#22d3ee`) mezclados con `color-mix(in srgb, brand X%, neutral)` → ningún color hardcodeado deriva; todo el UI sigue estos tres tonos.
### 🧭 Estructura (orden vertical de arriba → abajo)
1. **`header.topbar`** brand "Vocero" → avatar circular iniciales nombre usuario.
- 🟦 **Avatar user**: círculo 40×40px con gradiente brand, initials mayúsculas bold 800, hover lift/glow + foco teclado. Al hacer clic → diálogo modal perfil usuario (rejilla 2 columnas email/teléfono/username/rol/empresa/userid/país/fecha creación/último login; links `mailto:`/`tel:`; chips mono para códigos; mobile ≤480px apilado 1 col).
- 🟦 **Botón toggle chat (icono speech bubble) → ELIMINADO PERMANENTEMENTE**. La chat ahora siempre está visible inline; el botón toggle ya no existe en el DOM.
2. **`main.stage`** (arriba del todo, flexible): orbe/globode voz animado (3 anillos: core, ring, conic spin 3 velocidades + highlight specular + glows breathe `breatheOrb`) + caption `TAP OR SAY "HOLA VOCERO"` micro-bob uppercase gradient.
- ✅ Antes había una zona vacía sobre el orbe → eliminada, ahora el stage empieza en la parte superior (`justify-content: flex-start`).
3. **`<section id="chat-panel" class="chat-panel chat-panel-inline">` SIEMPRE VISIBLE, 100% ancho, SOTTO stage, SOPRA footer sticky** (rellena la zona vacía inferior).
- ✅ Antes: overlay lateral derecho. **Ahora: bloque inline dentro de `#app` como hijo flex-col**.
- 🟦 Sin marco / sin riquadro (fondo aurora seamless, 0 borde, 0 shadow, 0 backdrop blur).
- Cabecera `CONVERSACIÓN` h3 uppercase 13px gradiente.
- `#chat-history` scroll interno. Burbujas de mensaje glass con **código de colores por rol**: usuario (tint 10% voice-user cyan + borde 44% cyan + glow) / assistant (tint 10% voice-assistant violet/pink + borde 42% + glow).
- Distancias seguras anti-borde: `.hist-msg.user margin-left:6px auto dx` / `.hist-msg.assistant margin-right:22px auto sx` → **nunca las burbujas tocan el borde del viewport** (fix confirmado).
- Formulario input texto `#chat-form` (arriba del botón enviar ✈️) **actualmente oculto (`hidden`)** para modo voz-puro; elimina ese atributo para reactivarlo. El handler submit que envía `client.sendUserText(text)` al websocket ya está conectado.
4. **Footer sticky `position: sticky; bottom:0; z:3; backdrop-blur`** "Azurian Vocero realtime ver.Beta 2.1" siempre dentro del viewport, incluso cuando el documento crece y haces scroll (no requiere scroll hacia abajo para verlo). Separador superior línea gradiente brand.
### 📜 Sidebar de informes a la IZQUIERDA (antes topbar inline)
- Desktop **≥ 1024px**: sidebar fija left 320px siempre visible; `#app padding-left 320px` reserva sitio.
- Tablet/Mobile **< 1024**: offcanvas `translateX(-105%)` slide-in con backdrop blur; toggle mediante botón icono reportes `#reports-sidebar-toggle` en topbar con badge contador.
- `#reports-hint` siempre visible; `.reports-suggestions` y `.topbar-right` con puerta triple de visibilidad (atributo HTML hidden + CSS rule `[data-debug-only]:not([data-debug-enabled]) !important display:none` + JS `applyDebugOnlyVisibilityGate()` ejecutado 6 veces por lifecycle) → solo aparecen cuando el query param `?debug=true` está presente en la URL.
### ⬇️ Auto-scroll chat SIEMPRE hacia la última respuesta del asistente
Arquitectura anti-clipping definitiva (versión 11:20 fix):
1. **CSS sobrante intencional** (`96 → 160px chat-history padding-bottom`, `40→160px chat-panel clamp`, `56→120px #app padding-bottom + safe-area-inset-bottom`) → el último párrafo siempre está por ENCIMA de la línea del footer glass flotante.
2. **Unidades móviles seguras `svh / svw`** (smallest viewport): evita oscilaciones layout cuando aparece/desaparece la barra URL del móvil.
3. **JS cancel-and-reschedule** cada vez que `_scrollToBottom()` se llama: si llegan 100 tokens streaming en 1 frame, el último cancela todas las cadenas pendientes y se ejecuta desde cero → **no hay coalescing flag SKIP** (esta era la causa original del scroll insuficiente).
4. **5 reintentos escalonados `0 / +30 / +70 / +150 / +250ms`**:
- 0 ms (inmediato): 90% de los casos.
- 30 ms: tras 1 layout-pass.
- 70 ms: **corrige el wrapping de la última palabra en una línea nueva** (bug más común).
- 150 ms: después de reflow Chrome scroll-anchor.
- 250 ms: después de expandir filas tool / imágenes grandes.
5. **Snap con `Number.MAX_SAFE_INTEGER`**: el navegador clampa automáticamente al máximo real (`scrollHeight - clientHeight`); no hay race de leer scrollHeight demasiado pronto.
6. `overflow-anchor: none; scroll-behavior: auto !important;` → sin smooth, sin Chrome scroll-anchor-luchando contra nosotros.
7. Llamadas scroll explicitas extra en: `onResponseFinished` (fin de respuesta assistant, crítico), `onToolCall`, `onToolResult`.
### 🛡️ Protecciones anti-error de refs eliminadas
- En el refactor de chat eliminamos 4 nodos DOM (`#chat-btn`, `#chat-badge`, `#chat-panel-backdrop`, `#chat-panel-close`).
- **Antes**: el constructor de `ChatView` usaba `$("#...")` strict → lanzaba `Error("Missing element: #chat-btn")` al arrancar.
- **Ahora**: esas 4 refs opcionales usan `document.querySelector("#...")` → devuelve `null` cuando no existen, sin throw; todos los listeners guarded `if (el)`. Los 3 IDs obligatorios (`#chat-panel`, `#chat-history`, `#bubble-stack`) siguen con strict para fallar fuerte si alguien los borra por error.
### 🔄 Actualizaciones de versión / cache bust
Para evitar caché HTTP agresiva en navegadores móviles (ESM sin bundler + service worker = nunca refrescan URLs iguales), todas las referencias a recursos estáticos usan query param `?v=YYYYMMDDHHMM` sincronizado en **6 puntos**:
1. `index.html <link rel="stylesheet" href="style.css?v=...">`
2. `index.html <script type="module" src="main.js?v=...">`
3-6. Los **4 imports ESM dentro de main.js** (`ws/s2s-ws-client`, `ui/dom`, `ui/chat`, `ui/account`).
Además hay un **script inline en `<head>`** tras el style.css que al `DOMContentLoaded` fuerza a reescribir el href del stylesheet con la última versión si por algún motivo cargó la antigua (anti-cache móvil).
### Deploy
```bash
cd /home/duxiter/s2s
docker compose restart s2s_ui
```
Limpia siempre datos del sitio en el navegador (Application → Storage → Clear site data) y recarga dura `Ctrl+Shift+R` al finalizar.
---
## Quick start (local)
1. **Start the speech-to-speech backend** in realtime mode (from the repo root;
@@ -149,21 +219,23 @@ NOT collide with the WebRTC variant.
## Files
> 🇪🇸 **Notas adicionales (Azurian Vocero)**: `index.html` ahora incluye (1) botón toggle lateral izquierdo reportes `#reports-sidebar-toggle` con badge contador `#reports-sidebar-badge` visible solo <1024px, (2) `<dialog id="user-profile-dialog">` modal perfil usuario que se abre al hacer clic en el avatar circular del encabezado, (3) panel chat `<section id="chat-panel">` permanente dentro de `#app` entre `</main>` y footer sticky (el panel overlay derecho antiguo y el botón toggle chat superior fueron ELIMINADOS completamente).
| File | Role |
|------|------|
| `index.html` | Single page, orb + settings modal (identical UI to the WebRTC app) |
| `main.js` | State machine, settings, tools, camera, noise-gate UI wiring |
| `ui/chat.js` | `ChatView`: history panel, ephemeral bubbles, transcript/tool streaming |
| `main.js` | State machine, settings, tools, camera, noise-gate UI wiring + chat-form submit handler (`client.sendUserText`) + user profile dialog open/close + reports sidebar open/close + 3-layer debug gate |
| `ui/chat.js` | `ChatView`: history panel, ephemeral bubbles, transcript/tool streaming; always-inline 100% width chat panel w/ 5-step retry auto-scroll to last assistant line |
| `ui/account.js` | `Account`: HF login chip + popover, daily-limit modal |
| `ui/dom.js` | Shared helpers: `$`, `escHtml`, `truncateError`, `DEBUG` |
| `ui/dom.js` | Shared helpers: strict `$(selector)` that throws on missing, `document.querySelector` fallback, `escHtml`, `truncateError`, `DEBUG` from URL |
| `auth.py` | HF OAuth + per-request identity (tier, hashed keys) |
| `limiter.py` | SQLite per-day talk-time budget (chunked server-clock reservation) |
| `ws/s2s-ws-client.js` | WebSocket handshake + OpenAI Realtime GA protocol |
| `ws/codec.js` | base64 <-> PCM helpers + transcript extraction (pure) |
| `ws/orb-visualizer.js` | `OrbVisualiser`: FFT bands -> orb CSS custom properties |
| `ws/orb-visualizer.js` | `OrbVisualiser`: FFT bands -> orb CSS custom properties (violet/pink/cyan brand gradient) |
| `worklets/mic-capture.js` | AudioWorklet: 48 kHz Float32 -> 16 kHz Int16 PCM, posts ~40 ms chunks |
| `worklets/audio-playback.js` | AudioWorklet: 24 kHz Float32 ring buffer -> 48 kHz, linear interp, fade in/out |
| `style.css` | Orb animations, layout, dark theme (verbatim from the WebRTC app) |
| `style.css` | Orb animations, layout, dark aurora gradient theme, glass chat bubbles, sticky footer, reports sidebar desktop left 320px / mobile offcanvas, user profile 2-col grid modal |
## Audio pipeline notes
+95 -17
View File
@@ -13,7 +13,15 @@
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"
/>
<link rel="stylesheet" href="style.css?v=20260902001" />
<link rel="stylesheet" href="style.css?v=202609041120" />
<script>
document.addEventListener("DOMContentLoaded", () => {
const l = document.querySelector('link[rel="stylesheet"]');
if (l && !l.href.includes("v=202609041120")) {
l.href = l.href.replace(/v=\d+/, "v=202609041120");
}
});
</script>
</head>
<!--
`booting` disables transitions/animations until the first paint commits.
@@ -22,17 +30,32 @@
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">Azurian Vocero realtime ver.Beta 2.0</a>
<a class="ident-title">Vocero</a>
</div>
<div id="ident-user-name" class="ident-user-name" hidden></div>
</div>
</div>
<div class="topbar-right">
<!-- 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="Reports programados" aria-label="Abrir panel de reportes" aria-expanded="false" aria-controls="reports-sidebar" hidden data-reports-sidebar-toggle data-debug-only="true">
<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>
@@ -45,10 +68,6 @@
<button id="rag-btn" class="icon-btn" title="Custom RAG context" aria-label="Custom RAG context">
<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="chat-btn" class="icon-btn" title="Conversation history" aria-label="Conversation history">
<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span id="chat-badge" class="chat-badge" aria-hidden="true"></span>
</button>
<button id="settings-btn" class="icon-btn" title="Settings" aria-label="Settings">
<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>
@@ -128,6 +147,31 @@
</div>
</main>
<!-- Chat di testo SEMPRE VISIBILE (fissa in basso, tra stage e footer).
Nota: ora è un blocco diretto del flex-col #app, non più un overlay
destro → non c'è più bisogno del bottone toggle in topbar. -->
<section id="chat-panel" class="chat-panel chat-panel-inline" aria-label="Chat de texto">
<div class="chat-panel-inner">
<header class="chat-panel-header">
<h3>Conversación</h3>
</header>
<div id="chat-history" class="chat-history" role="log" aria-live="polite" aria-atomic="false"></div>
<!-- Text input row HIDDEN per user request 2026-09-04 (restore removing hidden tag below) -->
<form id="chat-form" class="chat-form" autocomplete="off" spellcheck="false" hidden style="display:none!important">
<input id="chat-input"
class="chat-input"
type="text"
name="message"
placeholder="Escribe aquí tu mensaje a Vocero…"
aria-label="Enviar mensaje de texto a Vocero"
maxlength="500"
/>
<button id="chat-send" type="submit" class="chat-send-btn icon-btn" aria-label="Enviar mensaje">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</form>
</div>
</section>
</div>
<!-- Ephemeral chat bubbles anchored to the top-right -->
@@ -141,19 +185,26 @@
<span class="cam-label">camera</span>
</div>
<!-- Conversation history panel (overlay) -->
<div id="chat-panel" class="chat-panel">
<div id="chat-panel-backdrop" class="chat-panel-backdrop"></div>
<div class="chat-panel-inner">
<header class="chat-panel-header">
<h3>Conversation</h3>
<button id="chat-panel-close" class="icon-btn" aria-label="Close">
<!-- Left sidebar: reports programados.
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>
<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>
<div id="chat-history" class="chat-history"></div>
<!-- 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></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>
</div>
</aside>
<dialog id="about-modal" class="modal about-modal">
<div class="modal-content">
@@ -395,6 +446,33 @@
</div>
</dialog>
<script type="module" src="main.js?v=20260902001"></script>
<!-- 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="Close user profile">
<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). -->
<section 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>
</section>
<!-- 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. -->
<section id="user-profile-metadata" class="user-profile-metadata" aria-label="Informazioni utente"></section>
</div>
</dialog>
<footer class="app-footer" aria-label="App version footer">
<span class="app-footer-text">Azurian Vocero realtime ver.Beta 2.1</span>
</footer>
<script type="module" src="main.js?v=202609041120"></script>
</body>
</html>
+1000 -11
View File
File diff suppressed because it is too large Load Diff
+584 -126
View File
File diff suppressed because it is too large Load Diff
+1520 -136
View File
File diff suppressed because it is too large Load Diff
+127 -30
View File
@@ -38,23 +38,35 @@ const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_
export class ChatView {
constructor() {
/** @type {HTMLButtonElement} */
this._chatBtn = $("#chat-btn");
/** @type {HTMLSpanElement} */
this._chatBadge = $("#chat-badge");
/** @type {HTMLDivElement} */
// 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 {HTMLDivElement} */
this._chatPanelBackdrop = $("#chat-panel-backdrop");
/** @type {HTMLButtonElement} */
this._chatPanelClose = $("#chat-panel-close");
/** @type {HTMLDivElement} */
/** @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 {HTMLDivElement} */
/** @type {HTMLElement} */
this._bubbleStack = $("#bubble-stack");
this._panelOpen = false;
this._scrollQueued = false;
// 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>} */
@@ -80,11 +92,14 @@ export class ChatView {
// bubble) so dismissal is strictly oldest-first regardless of per-bubble delays.
this._reaperHandle = 0;
this._chatBtn.addEventListener("click", () => (this._panelOpen ? this._closePanel() : this._openPanel()));
this._chatPanelClose.addEventListener("click", () => this._closePanel());
this._chatPanelBackdrop.addEventListener("click", () => this._closePanel());
// ── 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) => {
if (e.key === "Escape" && this._panelOpen) this._closePanel();
// 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();
});
}
@@ -92,33 +107,104 @@ export class ChatView {
_openPanel() {
this._panelOpen = true;
this._chatPanel.classList.add("open");
this._chatBadge.classList.remove("visible");
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;
this._chatPanel.classList.remove("open");
if (this._chatPanel) this._chatPanel.classList.remove("open");
}
// Coalesce scroll-to-bottom: a burst of cumulative transcript deltas would
// otherwise queue one rAF per delta, all writing the same scrollTop.
// 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._panelOpen || this._scrollQueued) return;
this._scrollQueued = true;
requestAnimationFrame(() => {
this._scrollQueued = false;
this._chatHistory.scrollTop = this._chatHistory.scrollHeight;
});
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;
}
this._chatBadge.classList.add("visible");
if (this._chatBadge) this._chatBadge.classList.add("visible");
}
// ── Shared rendering ──────────────────────────────────────────────────────
@@ -230,7 +316,7 @@ export class ChatView {
/** Reset the panel to the empty state and clear the unread badge. */
clear() {
this.renderEmptyState();
this._chatBadge.classList.remove("visible");
if (this._chatBadge) this._chatBadge.classList.remove("visible");
}
/** @param {"user"|"assistant"} role @param {string} text @param {boolean} partial @returns {HTMLElement} */
@@ -419,6 +505,10 @@ export class ChatView {
// 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.
@@ -426,6 +516,7 @@ export class ChatView {
onToolCall(name) {
this._bumpDismiss(this._spawnBubble("tool", name));
this._markUnread();
this._scrollToBottom();
}
/** The tool finished — append its call+result row (and any captured image).
@@ -433,6 +524,12 @@ export class ChatView {
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,
+40
View File
@@ -162,6 +162,20 @@ export class S2sWsRealtimeClient extends EventTarget {
this._joinTimer = 0;
/** @type {NoiseGate} Mic noise gate; off by default. */
this._noiseGate = options.noiseGate ?? { enabled: false, thresholdDb: -45 };
/** @type {boolean} If true, right after the initial session.updated ack we inject
* the wake-word-style bootstrap phrase (default "hola vocero") as if the user
* had spoken it, then trigger `requestResponse()` — so the assistant
* behaves EXACTLY as it does when the "Hola Vocero" hot-word is detected
* (same code path, same greeting style). Used when S2S_START_SESSION_MODE=TALK. */
this._startWithResponse = options.startWithResponse === true;
/** @type {string} Phrase injected into the first turn (mirrors the wake-word
* confirmation flow. Defaults to "hola vocero". Used ONLY on first session start.
* Overridable via options.startWithResponseText for test/CI flows. */
this._startWithResponseText = (options.startWithResponseText || "").trim() || "hola vocero";
/** @type {boolean} Guards the initial bootstrap turn from firing more than once
* (e.g. after a live `updateSession()` that re-emits session.updated later
* while the session is already running). */
this._initialResponseSent = false;
/** @type {WebSocket | null} */
this._ws = null;
/** @type {AudioContext | null} */
@@ -671,6 +685,32 @@ export class S2sWsRealtimeClient extends EventTarget {
`tools=${toolNames || "none"}`
);
}
// Primo ack del server: se startup mode=TALK vogliamo il comportamento
// IDENTICO a quando arriva la frase di wake word "Hola Vocero".
// Quindi NON mandiamo shaped response.create con istruzioni di greet
// hardcodate, ma invece:
// 1. injectiamo "hola vocero" nella conversazione come se l'utente
// l'avesse detto (sendUserText → conversation.item.create user)
// 2. requestResponse() VUOTO → il modello risponde esattamente come
// nel codice wake-word in main.js.
// Stesso identico risultato, nessuna istruzione custom.
if (this._startWithResponse && !this._initialResponseSent) {
this._initialResponseSent = true;
const phrase = this._startWithResponseText.trim() || "hola vocero";
// #region debug-point B:start-session-initial-response
reportWsDebug("B", "s2s-ws-client.js:session.updated",
"emitting initial turn via wake-word style sendUserText + requestResponse() (S2S_START_SESSION_MODE=TALK)",
{
toolCount: Array.isArray(event.session?.tools) ? event.session.tools.length : 0,
phrase,
});
// #endregion
if (this._debug) {
console.debug(`[Vocero WS] bootstrap turn: sendUserText(${JSON.stringify(phrase)}) → requestResponse()`);
}
this.sendUserText(phrase);
this.requestResponse();
}
// Acknowledged by server, nothing to do.
break;