pretheme
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+584
-126
File diff suppressed because it is too large
Load Diff
+1520
-136
File diff suppressed because it is too large
Load Diff
+127
-30
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user