fix: standardize Spanish user-facing copy

This commit is contained in:
2026-10-05 10:24:33 -03:00
parent dfc1f663c7
commit cdbff5cb0e
8 changed files with 204 additions and 201 deletions
+3
View File
@@ -21,6 +21,9 @@ the implementation at the recorded date; current operating instructions live in
and new English internal tool-result wrappers.
- Renamed the reasoning engine's log-only acknowledgment field to `received`
and its debug phase to `log_only_phase1`.
- Standardized browser labels, accessibility text, account/profile dialogs,
session feedback, and built-in API errors in Spanish. The interface declares
`lang="es"`; internal instructions and diagnostics remain English.
## 2026-09-25
+1 -1
View File
@@ -177,7 +177,7 @@ def user_view(request) -> dict:
token = _field(info, "access_token")
out = {
"loggedIn": True,
"username": _field(user, "preferred_username") or _field(user, "name") or "you",
"username": _field(user, "preferred_username") or _field(user, "name") or "tú",
"avatar": _field(user, "picture"),
"tier": resolve_tier(user, token),
}
+69 -69
View File
@@ -1,12 +1,12 @@
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate, max-age=0" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>Minimal Conversation · S2S backend (WebSocket)</title>
<title>Vocero · Conversación de voz</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
@@ -152,7 +152,7 @@
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" data-theme-only="dark">
<button id="reports-sidebar-toggle" class="icon-btn" title="Reportes programados" aria-label="Abrir panel de reportes" aria-expanded="false" aria-controls="reports-sidebar" hidden data-reports-sidebar-toggle data-debug-only="true" data-theme-only="dark">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z" fill="none"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<span id="reports-sidebar-badge" class="chat-badge" aria-hidden="true"></span>
</button>
@@ -160,22 +160,22 @@
<!-- HF login chip / sign-in pill. Populated by ui/account.js; only
shown when the deploy runs behind a load balancer with OAuth. -->
<div id="account" class="account" hidden></div>
<button id="about-btn-m" class="icon-btn about-btn-mobile" title="About this space" aria-label="About this space">
<button id="about-btn-m" class="icon-btn about-btn-mobile" title="Acerca de este espacio" aria-label="Acerca de este espacio">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</button>
<button id="tools-btn" class="icon-btn" title="Tools" aria-label="Tools">
<button id="tools-btn" class="icon-btn" title="Herramientas" aria-label="Herramientas">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</button>
<button id="rag-btn" class="icon-btn" title="Custom RAG context" aria-label="Custom RAG context">
<button id="rag-btn" class="icon-btn" title="Contexto RAG personalizado" aria-label="Contexto RAG personalizado">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
</button>
<button id="settings-btn" class="icon-btn" title="Settings" aria-label="Settings">
<button id="settings-btn" class="icon-btn" title="Configuración" aria-label="Configuración">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .66.39 1.25 1 1.51H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
</header>
<a class="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">Enviar feedback</a>
<a class="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">Enviar comentarios</a>
<!-- Theme=white only: Status card between topbar and stage
Ready to converse / Reports, analysis, data and more.
@@ -210,7 +210,7 @@
<path id="mga-hit" class="mga-hit" fill="none" />
<circle id="mga-handle" class="mga-handle" r="3" />
</svg>
<button id="mic-btn" class="side-btn" type="button" aria-label="Mute" title="Mute" aria-hidden="true">
<button id="mic-btn" class="side-btn" type="button" aria-label="Silenciar micrófono" title="Silenciar micrófono" aria-hidden="true">
<svg class="mic-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
<svg class="mic-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M9 5a3 3 0 0 1 6 0v4"/><path d="M9 10v1a3 3 0 0 0 5.1 2.1"/><path d="M19 10a7 7 0 0 1-1.24 3.97"/><path d="M5 10a7 7 0 0 0 11 5.67"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
</button>
@@ -220,7 +220,7 @@
id="main-circle"
class="circle state-idle"
type="button"
aria-label="Start voice conversation"
aria-label="Iniciar conversación de voz"
>
<span class="circle-glow" aria-hidden="true"></span>
<span class="circle-ring" aria-hidden="true"></span>
@@ -256,7 +256,7 @@
</span>
</button>
<button id="stop-btn" class="side-btn" type="button" aria-label="End" title="End" aria-hidden="true">
<button id="stop-btn" class="side-btn" type="button" aria-label="Finalizar" title="Finalizar" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
</div>
@@ -324,15 +324,15 @@
</button>
</div>
<p id="circle-caption" class="circle-caption" role="status">Tap to start</p>
<p id="circle-caption" class="circle-caption" role="status">Toca para empezar</p>
<p id="circle-subcaption" class="circle-subcaption" hidden></p>
<div id="queue-actions" class="queue-actions" hidden>
<button id="join-queue-btn" class="join-queue-btn" type="button" hidden>
Join now
Entrar ahora
</button>
<button id="leave-queue-btn" class="leave-queue-btn" type="button" hidden>
Leave queue
Salir de la cola
</button>
</div>
</main>
@@ -431,7 +431,7 @@
<div id="cam-pip" class="cam-pip" aria-hidden="true" hidden>
<video id="cam-video" class="cam-video" autoplay playsinline muted></video>
<span class="cam-flash" aria-hidden="true"></span>
<span class="cam-label">camera</span>
<span class="cam-label">cámara</span>
</div>
<!-- Left sidebar: scheduled reports.
@@ -458,8 +458,8 @@
<dialog id="about-modal" class="modal about-modal">
<div class="modal-content">
<header class="modal-header">
<h2>About</h2>
<button id="about-close" class="icon-btn" aria-label="Close">
<h2>Acerca de</h2>
<button id="about-close" class="icon-btn" aria-label="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</header>
@@ -470,29 +470,29 @@
<!-- The backend, shown as the path a turn actually travels -->
<div class="about-pipeline">
<p class="pipeline-title">The pipeline</p>
<p class="pipeline-title">Cómo funciona</p>
<ol class="pipeline">
<li class="pipe-endpoint">You speak</li>
<li class="pipe-endpoint">Tú hablas</li>
<li class="pipe-stage">
<span class="pipe-tag">VAD</span>
<span class="pipe-job">detects speech</span>
<span class="pipe-job">detecta el habla</span>
</li>
<li class="pipe-stage">
<span class="pipe-tag">STT</span>
<span class="pipe-job">transcribes it</span>
<span class="pipe-job">la transcribe</span>
<a class="pipe-model" href="https://huggingface.co/nvidia/parakeet-tdt-1.1b" target="_blank" rel="noopener">nvidia/parakeet-tdt-1.1b<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg></a>
</li>
<li class="pipe-stage">
<span class="pipe-tag">VLM</span>
<span class="pipe-job">analyses and composes the reply <span class="pipe-note">· via <a class="cerebras-credit" href="https://cerebras.ai" target="_blank" rel="noopener"><svg class="cerebras-mark" viewBox="0 0 50 50" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M29.4186 5.62713C24.2806 5.62713 19.353 7.6682 15.7199 11.3013C12.0868 14.9345 10.0457 19.862 10.0457 25C10.0457 30.1381 12.0868 35.0656 15.7199 38.6988C19.353 42.3319 24.2806 44.373 29.4186 44.373V47.2917C17.1061 47.2917 7.12695 37.3105 7.12695 24.998C7.12695 12.6855 17.104 2.7063 29.4165 2.7063V5.62505L29.4186 5.62713ZM39.3186 13.2875C37.7794 11.9809 35.9971 10.9915 34.0741 10.3761C32.1512 9.76067 30.1256 9.53147 28.1137 9.70166C26.1019 9.87185 24.1435 10.4381 22.3513 11.3677C20.559 12.2974 18.9683 13.5722 17.6704 15.1189C16.3726 16.6655 15.3933 18.4534 14.7888 20.3798C14.1844 22.3062 13.9667 24.3332 14.1483 26.344C14.33 28.3548 14.9073 30.3099 15.8472 32.0968C16.787 33.8838 18.0709 35.4673 19.6249 36.7563L17.7478 38.9938C15.9126 37.4545 14.3987 35.5687 13.2924 33.4442C12.1862 31.3197 11.5092 28.9981 11.3003 26.6119C11.0914 24.2258 11.3545 21.8219 12.0747 19.5374C12.7949 17.253 13.9581 15.1328 15.4978 13.298C17.0373 11.4628 18.9233 9.94877 21.0479 8.84247C23.1726 7.73618 25.4944 7.05923 27.8807 6.8503C30.267 6.64136 32.6711 6.90453 34.9557 7.62477C37.2403 8.34501 39.3607 9.50822 41.1957 11.048L39.3186 13.2875ZM34.6207 15.0459C31.9818 13.6829 28.9113 13.4172 26.0776 14.3068C23.2438 15.1964 20.876 17.1691 19.4895 19.7958C18.1029 22.4224 17.8099 25.4903 18.6741 28.332C19.5384 31.1736 21.4899 33.5589 24.104 34.9688L22.7374 37.5521C19.4721 35.7617 17.0418 32.7592 15.9707 29.1927C14.8996 25.6261 15.2737 21.7815 17.0123 18.4883C18.7509 15.1952 21.7146 12.7176 25.2637 11.5903C28.8129 10.463 32.663 10.7763 35.9832 12.4625L34.6207 15.0459ZM29.4165 17.7896C27.5042 17.7896 25.6702 18.5493 24.318 19.9015C22.9658 21.2537 22.2061 23.0877 22.2061 25C22.2061 26.9124 22.9658 28.7464 24.318 30.0986C25.6702 31.4508 27.5042 32.2105 29.4165 32.2105V35.1313C26.7296 35.1313 24.1526 34.0639 22.2527 32.1639C20.3527 30.2639 19.2853 27.687 19.2853 25C19.2853 22.3131 20.3527 19.7362 22.2527 17.8362C24.1526 15.9362 26.7296 14.8688 29.4165 14.8688V17.7896Z" fill="#F15A29"/><path d="M32.0981 22.5749C31.7875 22.2404 31.4295 21.9534 31.0356 21.7228C30.6925 21.519 30.3014 21.4097 29.9023 21.4062C29.371 21.4062 28.896 21.5041 28.4773 21.6978C28.0725 21.8802 27.7089 22.1425 27.4081 22.469C27.1074 22.7955 26.8758 23.1795 26.7273 23.5978C26.5731 24.0207 26.4981 24.4645 26.4981 24.9124C26.4981 25.3666 26.5731 25.8082 26.7273 26.227C26.877 26.6449 27.1091 27.0286 27.4096 27.3553C27.7102 27.682 28.0733 27.9451 28.4773 28.1291C28.8939 28.3228 29.3731 28.4207 29.9023 28.4207C30.3523 28.4207 30.771 28.3249 31.1564 28.1395C31.5481 27.952 31.8856 27.6707 32.146 27.3228L34.0794 29.4187C33.7877 29.7103 33.4544 29.9624 33.0752 30.1749C32.374 30.566 31.6108 30.8338 30.8189 30.9666C30.4648 31.0207 30.1585 31.0499 29.9023 31.0499C29.0617 31.0552 28.2271 30.9069 27.4398 30.6124C26.6954 30.3371 26.0144 29.914 25.4377 29.3687C24.8643 28.8208 24.4078 28.1624 24.096 27.4332C23.7572 26.6366 23.5883 25.778 23.6002 24.9124C23.6002 23.9874 23.7669 23.1478 24.096 22.3916C24.4085 21.6624 24.8627 21.0041 25.4356 20.4562C26.0148 19.9124 26.696 19.4895 27.4398 19.2145C28.2271 18.92 29.0617 18.7717 29.9023 18.777C30.6419 18.777 31.3856 18.9187 32.1356 19.202C32.8877 19.4895 33.5627 19.952 34.1023 20.5541L32.0981 22.5749Z" fill="#fff"/></svg>Cerebras</a></span></span>
<span class="pipe-job">analiza y redacta la respuesta <span class="pipe-note">· mediante <a class="cerebras-credit" href="https://cerebras.ai" target="_blank" rel="noopener"><svg class="cerebras-mark" viewBox="0 0 50 50" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M29.4186 5.62713C24.2806 5.62713 19.353 7.6682 15.7199 11.3013C12.0868 14.9345 10.0457 19.862 10.0457 25C10.0457 30.1381 12.0868 35.0656 15.7199 38.6988C19.353 42.3319 24.2806 44.373 29.4186 44.373V47.2917C17.1061 47.2917 7.12695 37.3105 7.12695 24.998C7.12695 12.6855 17.104 2.7063 29.4165 2.7063V5.62505L29.4186 5.62713ZM39.3186 13.2875C37.7794 11.9809 35.9971 10.9915 34.0741 10.3761C32.1512 9.76067 30.1256 9.53147 28.1137 9.70166C26.1019 9.87185 24.1435 10.4381 22.3513 11.3677C20.559 12.2974 18.9683 13.5722 17.6704 15.1189C16.3726 16.6655 15.3933 18.4534 14.7888 20.3798C14.1844 22.3062 13.9667 24.3332 14.1483 26.344C14.33 28.3548 14.9073 30.3099 15.8472 32.0968C16.787 33.8838 18.0709 35.4673 19.6249 36.7563L17.7478 38.9938C15.9126 37.4545 14.3987 35.5687 13.2924 33.4442C12.1862 31.3197 11.5092 28.9981 11.3003 26.6119C11.0914 24.2258 11.3545 21.8219 12.0747 19.5374C12.7949 17.253 13.9581 15.1328 15.4978 13.298C17.0373 11.4628 18.9233 9.94877 21.0479 8.84247C23.1726 7.73618 25.4944 7.05923 27.8807 6.8503C30.267 6.64136 32.6711 6.90453 34.9557 7.62477C37.2403 8.34501 39.3607 9.50822 41.1957 11.048L39.3186 13.2875ZM34.6207 15.0459C31.9818 13.6829 28.9113 13.4172 26.0776 14.3068C23.2438 15.1964 20.876 17.1691 19.4895 19.7958C18.1029 22.4224 17.8099 25.4903 18.6741 28.332C19.5384 31.1736 21.4899 33.5589 24.104 34.9688L22.7374 37.5521C19.4721 35.7617 17.0418 32.7592 15.9707 29.1927C14.8996 25.6261 15.2737 21.7815 17.0123 18.4883C18.7509 15.1952 21.7146 12.7176 25.2637 11.5903C28.8129 10.463 32.663 10.7763 35.9832 12.4625L34.6207 15.0459ZM29.4165 17.7896C27.5042 17.7896 25.6702 18.5493 24.318 19.9015C22.9658 21.2537 22.2061 23.0877 22.2061 25C22.2061 26.9124 22.9658 28.7464 24.318 30.0986C25.6702 31.4508 27.5042 32.2105 29.4165 32.2105V35.1313C26.7296 35.1313 24.1526 34.0639 22.2527 32.1639C20.3527 30.2639 19.2853 27.687 19.2853 25C19.2853 22.3131 20.3527 19.7362 22.2527 17.8362C24.1526 15.9362 26.7296 14.8688 29.4165 14.8688V17.7896Z" fill="#F15A29"/><path d="M32.0981 22.5749C31.7875 22.2404 31.4295 21.9534 31.0356 21.7228C30.6925 21.519 30.3014 21.4097 29.9023 21.4062C29.371 21.4062 28.896 21.5041 28.4773 21.6978C28.0725 21.8802 27.7089 22.1425 27.4081 22.469C27.1074 22.7955 26.8758 23.1795 26.7273 23.5978C26.5731 24.0207 26.4981 24.4645 26.4981 24.9124C26.4981 25.3666 26.5731 25.8082 26.7273 26.227C26.877 26.6449 27.1091 27.0286 27.4096 27.3553C27.7102 27.682 28.0733 27.9451 28.4773 28.1291C28.8939 28.3228 29.3731 28.4207 29.9023 28.4207C30.3523 28.4207 30.771 28.3249 31.1564 28.1395C31.5481 27.952 31.8856 27.6707 32.146 27.3228L34.0794 29.4187C33.7877 29.7103 33.4544 29.9624 33.0752 30.1749C32.374 30.566 31.6108 30.8338 30.8189 30.9666C30.4648 31.0207 30.1585 31.0499 29.9023 31.0499C29.0617 31.0552 28.2271 30.9069 27.4398 30.6124C26.6954 30.3371 26.0144 29.914 25.4377 29.3687C24.8643 28.8208 24.4078 28.1624 24.096 27.4332C23.7572 26.6366 23.5883 25.778 23.6002 24.9124C23.6002 23.9874 23.7669 23.1478 24.096 22.3916C24.4085 21.6624 24.8627 21.0041 25.4356 20.4562C26.0148 19.9124 26.696 19.4895 27.4398 19.2145C28.2271 18.92 29.0617 18.7717 29.9023 18.777C30.6419 18.777 31.3856 18.9187 32.1356 19.202C32.8877 19.4895 33.5627 19.952 34.1023 20.5541L32.0981 22.5749Z" fill="#fff"/></svg>Cerebras</a></span></span>
<a class="pipe-model" href="https://huggingface.co/google/gemma-4-31B-it" target="_blank" rel="noopener">google/gemma-4-31B-it<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg></a>
</li>
<li class="pipe-stage">
<span class="pipe-tag">TTS</span>
<span class="pipe-job">speaks it back</span>
<span class="pipe-job">la convierte en voz</span>
<a class="pipe-model" href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" target="_blank" rel="noopener">Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg></a>
</li>
<li class="pipe-endpoint">The orb replies</li>
<li class="pipe-endpoint">Vocero responde</li>
</ol>
</div>
</div>
@@ -501,8 +501,8 @@
<dialog id="settings-modal" class="modal">
<form method="dialog" class="modal-content">
<header class="modal-header">
<h2>Settings</h2>
<button class="icon-btn" value="close" aria-label="Close">
<h2>Configuración</h2>
<button class="icon-btn" value="close" aria-label="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</header>
@@ -510,17 +510,17 @@
<div class="tab-panels">
<div class="tab-panel active" role="tabpanel">
<label class="field" id="conn-field">
<span id="conn-label">Speech-to-speech server URL</span>
<input id="lb-url" type="text" autocomplete="off" spellcheck="false" placeholder="http://localhost:port" />
<span id="conn-label">URL del servidor de voz</span>
<input id="lb-url" type="text" autocomplete="off" spellcheck="false" placeholder="http://localhost:8080" />
<small id="conn-hint">
URL of your speech-to-speech server, e.g.
<code>http://localhost:8080</code> (the app adds <code>/v1/realtime</code>).
URL de tu servidor de voz, por ejemplo
<code>http://localhost:8080</code> (la aplicación añade <code>/v1/realtime</code>).
</small>
</label>
<div class="field-row">
<label class="field">
<span>Voice</span>
<span>Voz</span>
<select id="voice">
<option value="Aiden" selected>Aiden</option>
<option value="Ryan">Ryan</option>
@@ -537,8 +537,8 @@
<div class="field">
<span class="field-head">
Noise gate
<span id="gate-value" class="field-value">Off</span>
Puerta de ruido
<span id="gate-value" class="field-value">Desactivada</span>
</span>
<div class="gate">
<div class="gate-track" aria-hidden="true">
@@ -550,37 +550,37 @@
max="-3"
step="1"
value="-50"
aria-label="Noise gate threshold"
aria-label="Umbral de la puerta de ruido"
/>
</div>
<div class="gate-ends">
<span>Off</span>
<span>Desactivada</span>
<span>−3 dB</span>
</div>
</div>
<small>
Mutes the mic below the handle so room noise isn't sent. Slide fully
left to turn it off; the bar shows your live input while in a call.
Silencia el micrófono por debajo del umbral para evitar enviar ruido.
Desliza hasta la izquierda para desactivarla. La barra muestra tu audio durante la llamada.
</small>
</div>
<label class="field">
<span>Instructions</span>
<textarea id="instructions" rows="5" placeholder="You are Vocero, a friendly voice assistant for the company Aquachile..."></textarea>
<span>Instrucciones</span>
<textarea id="instructions" rows="5" placeholder="Introduce las instrucciones de Vocero para esta conversación…"></textarea>
<small id="primary-prompt-meta" hidden></small>
</label>
<div class="field">
<button id="restart-conversation" type="button" class="btn primary wide" disabled>
Restart conversation with these settings
Reiniciar conversación con esta configuración
</button>
<small id="restart-hint">Connect first, then come back here to apply live changes.</small>
<small id="restart-hint">Conéctate primero y vuelve aquí para aplicar cambios durante la conversación.</small>
</div>
</div>
</div>
<footer class="modal-footer">
<button id="settings-save" type="submit" class="btn primary" value="save">Save</button>
<button id="settings-save" type="submit" class="btn primary" value="save">Guardar</button>
</footer>
</form>
</dialog>
@@ -588,19 +588,19 @@
<dialog id="tools-modal" class="modal">
<div class="modal-content">
<header class="modal-header">
<h2>Tools</h2>
<button id="tools-close" class="icon-btn" aria-label="Close">
<h2>Herramientas</h2>
<button id="tools-close" class="icon-btn" aria-label="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</header>
<p class="tools-intro">Let the assistant act during the conversation. Changes apply live.</p>
<p class="tools-intro">Permite que el asistente actúe durante la conversación. Los cambios se aplican de inmediato.</p>
<div class="tool-list">
<div class="tool-row" id="tool-web-row">
<div class="tool-info">
<span class="tool-name">Web search</span>
<span class="tool-desc">Look things up on Google, via Serper.</span>
<span class="tool-name">Búsqueda web</span>
<span class="tool-desc">Busca información en Google mediante Serper.</span>
</div>
<label class="switch">
<input id="tool-web" type="checkbox" />
@@ -608,7 +608,7 @@
</label>
</div>
<label class="field tools-key">
<span>Search API key</span>
<span>Clave de la API de búsqueda</span>
<input id="search-key" type="password" autocomplete="off" spellcheck="false" />
<small id="tool-web-hint"></small>
</label>
@@ -618,20 +618,20 @@
then restore the toolCamSwitch listener and enableCamera functions in main.js. -->
<div class="tool-row tool-row-sep" id="tool-cam-row" hidden>
<div class="tool-info">
<span class="tool-name">Camera</span>
<span class="tool-desc">Let the assistant see through your webcam.</span>
<span class="tool-name">Cámara</span>
<span class="tool-desc">Permite que el asistente vea a través de tu cámara.</span>
</div>
<label class="switch">
<input id="tool-cam" type="checkbox" />
<span class="switch-track" aria-hidden="true"></span>
</label>
</div>
<small id="tool-cam-hint" class="tool-hint" hidden>A live preview shows bottom-left while the camera is on.</small>
<small id="tool-cam-hint" class="tool-hint" hidden>La vista previa aparece abajo a la izquierda mientras la cámara está activa.</small>
<div class="tool-row tool-row-sep">
<div class="tool-info">
<span class="tool-name">Loaded MCP tools</span>
<span class="tool-desc">Tools published by the remote agent profile.</span>
<span class="tool-name">Herramientas MCP cargadas</span>
<span class="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
</div>
</div>
<small id="agent-tools-hint" class="tool-hint"></small>
@@ -643,36 +643,36 @@
<dialog id="rag-modal" class="modal">
<div class="modal-content">
<header class="modal-header">
<h2>Custom RAG context</h2>
<button id="rag-close" class="icon-btn" aria-label="Close">
<h2>Contexto RAG personalizado</h2>
<button id="rag-close" class="icon-btn" aria-label="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</header>
<p class="tools-intro">
Incolla qui testo, documenti, o estratti RAG che vuoi siano disponibili
per la sessione vocale corrente. Il testo viene aggiunto sia alle
istruzioni del LLM sia al campo <code>user_context</code> del body
Pega aquí texto, documentos o fragmentos RAG que quieras tener disponibles
durante la sesión de voz actual. El texto se añade a las
instrucciones del modelo y al campo <code>user_context</code> de la solicitud a
<code>/api/reasoning</code>.
</p>
<label class="field">
<span>RAG context (testo libero, puro)</span>
<span>Contexto RAG (texto libre)</span>
<textarea
id="rag-textarea"
rows="12"
spellcheck="false"
placeholder="Incolla o scrivi qui il contesto RAG che il modello deve usare durante la risposta. Es: dati cliente, policy, estratti documentali, ecc."
placeholder="Pega o escribe el contexto RAG que el modelo debe usar al responder. Por ejemplo: datos del cliente, políticas o fragmentos de documentos."
></textarea>
<small id="rag-hint" class="tool-hint">
Nessun RAG context caricato. Premi &quot;Invia nella sessione&quot; per applicare le modifiche.
No hay contexto RAG cargado. Pulsa &quot;Enviar a la sesión&quot; para aplicar los cambios.
</small>
</label>
<footer class="modal-footer">
<button id="rag-clear" class="btn secondary" type="button" value="clear">Svuota</button>
<button id="rag-clear" class="btn secondary" type="button" value="clear">Vaciar</button>
<div style="flex: 1"></div>
<button id="rag-submit" class="btn primary" type="button" value="submit">Invia nella sessione</button>
<button id="rag-submit" class="btn primary" type="button" value="submit">Enviar a la sesión</button>
</footer>
</div>
</dialog>
@@ -682,13 +682,13 @@
ui/account.js. The hero is the HF mark — a friendly smiling face. -->
<dialog id="limit-modal" class="modal limit-modal">
<div class="modal-content limit-card">
<button id="limit-close" class="icon-btn limit-close" aria-label="Close">
<button id="limit-close" class="icon-btn limit-close" aria-label="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="limit-badge" aria-hidden="true">
<svg class="hf-logo" viewBox="0 0 95 88" fill="none" aria-hidden="true"><path fill="#FFD21E" d="M47.21 76.5a34.75 34.75 0 1 0 0-69.5 34.75 34.75 0 0 0 0 69.5Z"/><path fill="#FF9D0B" d="M81.96 41.75a34.75 34.75 0 1 0-69.5 0 34.75 34.75 0 0 0 69.5 0Zm-73.5 0a38.75 38.75 0 1 1 77.5 0 38.75 38.75 0 0 1-77.5 0Z"/><path fill="#3A3B45" d="M58.5 32.3c1.28.44 1.78 3.06 3.07 2.38a5 5 0 1 0-6.76-2.07c.61 1.15 2.55-.72 3.7-.32ZM34.95 32.3c-1.28.44-1.79 3.06-3.07 2.38a5 5 0 1 1 6.76-2.07c-.61 1.15-2.56-.72-3.7-.32Z"/><path fill="#FF323D" d="M46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z"/><path fill="#3A3B45" fill-rule="evenodd" d="M39.43 54a8.7 8.7 0 0 1 5.3-4.49c.4-.12.81.57 1.24 1.28.4.68.82 1.37 1.24 1.37.45 0 .9-.68 1.33-1.35.45-.7.89-1.38 1.32-1.25a8.61 8.61 0 0 1 5 4.17c3.73-2.94 5.1-7.74 5.1-10.7 0-2.34-1.57-1.6-4.09-.36l-.14.07c-2.31 1.15-5.39 2.67-8.77 2.67s-6.45-1.52-8.77-2.67c-2.6-1.29-4.23-2.1-4.23.29 0 3.05 1.46 8.06 5.47 10.97Z" clip-rule="evenodd"/><path fill="#FF9D0B" d="M70.71 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM24.21 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM17.52 48c-1.62 0-3.06.66-4.07 1.87a5.97 5.97 0 0 0-1.33 3.76 7.1 7.1 0 0 0-1.94-.3c-1.55 0-2.95.59-3.94 1.66a5.8 5.8 0 0 0-.8 7 5.3 5.3 0 0 0-1.79 2.82c-.24.9-.48 2.8.8 4.74a5.22 5.22 0 0 0-.37 5.02c1.02 2.32 3.57 4.14 8.52 6.1 3.07 1.22 5.89 2 5.91 2.01a44.33 44.33 0 0 0 10.93 1.6c5.86 0 10.05-1.8 12.46-5.34 3.88-5.69 3.33-10.9-1.7-15.92-2.77-2.78-4.62-6.87-5-7.77-.78-2.66-2.84-5.62-6.25-5.62a5.7 5.7 0 0 0-4.6 2.46c-1-1.26-1.98-2.25-2.86-2.82A7.4 7.4 0 0 0 17.52 48Zm0 4c.51 0 1.14.22 1.82.65 2.14 1.36 6.25 8.43 7.76 11.18.5.92 1.37 1.31 2.14 1.31 1.55 0 2.75-1.53.15-3.48-3.92-2.93-2.55-7.72-.68-8.01.08-.02.17-.02.24-.02 1.7 0 2.45 2.93 2.45 2.93s2.2 5.52 5.98 9.3c3.77 3.77 3.97 6.8 1.22 10.83-1.88 2.75-5.47 3.58-9.16 3.58-3.81 0-7.73-.9-9.92-1.46-.11-.03-13.45-3.8-11.76-7 .28-.54.75-.76 1.34-.76 2.38 0 6.7 3.54 8.57 3.54.41 0 .7-.17.83-.6.79-2.85-12.06-4.05-10.98-8.17.2-.73.71-1.02 1.44-1.02 3.14 0 10.2 5.53 11.68 5.53.11 0 .2-.03.24-.1.74-1.2.33-2.04-4.9-5.2-5.21-3.16-8.88-5.06-6.8-7.33.24-.26.58-.38 1-.38 3.17 0 10.66 6.82 10.66 6.82s2.02 2.1 3.25 2.1c.28 0 .52-.1.68-.38.86-1.46-8.06-8.22-8.56-11.01-.34-1.9.24-2.85 1.31-2.85Z"/><path fill="#FFD21E" d="M38.6 76.69c2.75-4.04 2.55-7.07-1.22-10.84-3.78-3.77-5.98-9.3-5.98-9.3s-.82-3.2-2.69-2.9c-1.87.3-3.24 5.08.68 8.01 3.91 2.93-.78 4.92-2.29 2.17-1.5-2.75-5.62-9.82-7.76-11.18-2.13-1.35-3.63-.6-3.13 2.2.5 2.79 9.43 9.55 8.56 11-.87 1.47-3.93-1.71-3.93-1.71s-9.57-8.71-11.66-6.44c-2.08 2.27 1.59 4.17 6.8 7.33 5.23 3.16 5.64 4 4.9 5.2-.75 1.2-12.28-8.53-13.36-4.4-1.08 4.11 11.77 5.3 10.98 8.15-.8 2.85-9.06-5.38-10.74-2.18-1.7 3.21 11.65 6.98 11.76 7.01 4.3 1.12 15.25 3.49 19.08-2.12Z"/><path fill="#FF9D0B" d="M77.4 48c1.62 0 3.07.66 4.07 1.87a5.97 5.97 0 0 1 1.33 3.76 7.1 7.1 0 0 1 1.95-.3c1.55 0 2.95.59 3.94 1.66a5.8 5.8 0 0 1 .8 7 5.3 5.3 0 0 1 1.78 2.82c.24.9.48 2.8-.8 4.74a5.22 5.22 0 0 1 .37 5.02c-1.02 2.32-3.57 4.14-8.51 6.1-3.08 1.22-5.9 2-5.92 2.01a44.33 44.33 0 0 1-10.93 1.6c-5.86 0-10.05-1.8-12.46-5.34-3.88-5.69-3.33-10.9 1.7-15.92 2.78-2.78 4.63-6.87 5.01-7.77.78-2.66 2.83-5.62 6.24-5.62a5.7 5.7 0 0 1 4.6 2.46c1-1.26 1.98-2.25 2.87-2.82A7.4 7.4 0 0 1 77.4 48Zm0 4c-.51 0-1.13.22-1.82.65-2.13 1.36-6.25 8.43-7.76 11.18a2.43 2.43 0 0 1-2.14 1.31c-1.54 0-2.75-1.53-.14-3.48 3.91-2.93 2.54-7.72.67-8.01a1.54 1.54 0 0 0-.24-.02c-1.7 0-2.45 2.93-2.45 2.93s-2.2 5.52-5.97 9.3c-3.78 3.77-3.98 6.8-1.22 10.83 1.87 2.75 5.47 3.58 9.15 3.58 3.82 0 7.73-.9 9.93-1.46.1-.03 13.45-3.8 11.76-7-.29-.54-.75-.76-1.34-.76-2.38 0-6.71 3.54-8.57 3.54-.42 0-.71-.17-.83-.6-.8-2.85 12.05-4.05 10.97-8.17-.19-.73-.7-1.02-1.44-1.02-3.14 0-10.2 5.53-11.68 5.53-.1 0-.19-.03-.23-.1-.74-1.2-.34-2.04 4.88-5.2 5.23-3.16 8.9-5.06 6.8-7.33-.23-.26-.57-.38-.98-.38-3.18 0-10.67 6.82-10.67 6.82s-2.02 2.1-3.24 2.1a.74.74 0 0 1-.68-.38c-.87-1.46 8.05-8.22 8.55-11.01.34-1.9-.24-2.85-1.31-2.85Z"/><path fill="#FFD21E" d="M56.33 76.69c-2.75-4.04-2.56-7.07 1.22-10.84 3.77-3.77 5.97-9.3 5.97-9.3s.82-3.2 2.7-2.9c1.86.3 3.23 5.08-.68 8.01-3.92 2.93.78 4.92 2.28 2.17 1.51-2.75 5.63-9.82 7.76-11.18 2.13-1.35 3.64-.6 3.13 2.2-.5 2.79-9.42 9.55-8.55 11 .86 1.47 3.92-1.71 3.92-1.71s9.58-8.71 11.66-6.44c2.08 2.27-1.58 4.17-6.8 7.33-5.23 3.16-5.63 4-4.9 5.2.75 1.2 12.28-8.53 13.36-4.4 1.08 4.11-11.76 5.3-10.97 8.15.8 2.85 9.05-5.38 10.74-2.18 1.69 3.21-11.65 6.98-11.76 7.01-4.31 1.12-15.26 3.49-19.08-2.12Z"/></svg>
</div>
<h2 id="limit-title" class="limit-title">That's a wrap for now</h2>
<h2 id="limit-title" class="limit-title">Por ahora hemos terminado</h2>
<p id="limit-msg" class="limit-msg"></p>
<a id="limit-cta" class="btn primary wide limit-cta" href="#"></a>
<p id="limit-note" class="limit-note"></p>
@@ -700,7 +700,7 @@
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">
<button id="user-profile-close" class="icon-btn user-profile-close" aria-label="Cerrar perfil de usuario">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
@@ -714,12 +714,12 @@
<!-- Metadata: a 1-col definition-list-like grid. Empty rows are hidden
automatically via JS when the field has no value, so we never show
blank labels. -->
<div id="user-profile-metadata" class="user-profile-metadata" aria-label="Informazioni utente"></div>
<div id="user-profile-metadata" class="user-profile-metadata" aria-label="Información del usuario"></div>
</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 class="app-footer" aria-label="Versión de la aplicación">
<span class="app-footer-text">Azurian Vocero en tiempo real ver. Beta 2.1</span>
</footer>
<script type="module" src="main.js?v=202609102207"></script>
+47 -47
View File
@@ -627,15 +627,15 @@ function saveTools() {
/** @type {Record<AppState, { caption: string; disabled: boolean }>} */
const STATE_VIEWS = {
idle: { caption: "Tap or say \"Hola Vocero\"", disabled: false },
connecting: { caption: "Connecting", disabled: true },
queued: { caption: "Finding you a spot…", disabled: true },
"your-turn": { caption: "You're up! 🎉", disabled: true },
idle: { caption: "Toca o di \"Hola Vocero\"", disabled: false },
connecting: { caption: "Conectando", disabled: true },
queued: { caption: "Buscando un lugar para ti…", disabled: true },
"your-turn": { caption: "¡Es tu turno! 🎉", disabled: true },
listening: { caption: "", disabled: false },
"user-speaking": { caption: "", disabled: false },
processing: { caption: "", disabled: false },
"ai-speaking": { caption: "", disabled: false },
error: { caption: "Tap to retry", disabled: false },
error: { caption: "Toca para reintentar", disabled: false },
};
/** @type {Record<AppState, string>} */
@@ -885,11 +885,11 @@ function pushCustomRagToLiveSessionIfAny() {
function syncRagHintFooter() {
if (!ragHint) return;
if (!customRagLoaded || !customRagText.trim()) {
ragHint.textContent = "Nessun RAG context caricato. Premi \"Invia nella sessione\" per applicare le modifiche.";
ragHint.textContent = "No hay contexto RAG cargado. Pulsa \"Enviar a la sesión\" para aplicar los cambios.";
return;
}
const lines = customRagText.split(/\r?\n/).length;
ragHint.textContent = `✅ RAG context caricato: ${customRagText.length} chars / ${lines} linee. Viene aggiunto a istruzioni LLM e user_context.`;
ragHint.textContent = `✅ Contexto RAG cargado: ${customRagText.length} caracteres / ${lines} líneas. Se añade a las instrucciones del modelo y a user_context.`;
}
// Dedupe cache for [INJECT-PROFILE] browser logs (avoid 20 logs per click).
let _lastInjectProfileLogKey = "";
@@ -1073,7 +1073,7 @@ function buildProfileRows(profile) {
"email", "emailAddress", "email_address", "eMail", "mail", "contactEmail",
"user_email", "customer_email", "loginEmail", "username_email",
]);
if (email) rows.push({ label: "Email", value: email, isEmail: true, href: `mailto:${email}` });
if (email) rows.push({ label: "Correo electrónico", value: email, isEmail: true, href: `mailto:${email}` });
// Phone (linkable tel)
const phone = firstProfileField(profile, [
@@ -1086,33 +1086,33 @@ function buildProfileRows(profile) {
const username = firstProfileField(profile, [
"username", "userName", "user_name", "login", "handle", "nickname", "screenName", "accountName",
]);
if (username) rows.push({ label: "Username", value: username, mono: true });
if (username) rows.push({ label: "Nombre de usuario", value: username, mono: true });
// Role / Plan
const role = firstProfileField(profile, [
"role", "roleName", "userRole", "plan", "planName", "tier", "subscription", "membership", "accessLevel",
]);
if (role) rows.push({ label: "Ruolo / Piano", value: role });
if (role) rows.push({ label: "Rol / Plan", value: role });
// Company / Organization
const company = firstProfileField(profile, [
"company", "companyName", "company_name", "organization", "org", "organizationName", "business",
]);
if (company) rows.push({ label: "Azienda", value: company });
if (company) rows.push({ label: "Empresa", value: company });
// User/Account ID (mono for monospace copy-paste)
const userId = firstProfileField(profile, [
"userId", "user_id", "uid", "accountId", "account_id", "sub", "customerId", "customer_id",
"id", "userPk", "profileId", "profile_id", "identityId",
]);
if (userId) rows.push({ label: "ID Utente", value: userId, mono: true });
if (userId) rows.push({ label: "ID de usuario", value: userId, mono: true });
// Locale / Country
const country = firstProfileField(profile, [
"country", "countryName", "country_code", "countryCode", "locale", "language", "lang",
"region", "timezone", "tz",
]);
if (country) rows.push({ label: "Nazione / Lingua", value: country });
if (country) rows.push({ label: "País / Idioma", value: country });
// Created at / signed up date — try common date fields.
const createdAtKeys = [
@@ -1130,7 +1130,7 @@ function buildProfileRows(profile) {
}
}
}
if (createdAt) rows.push({ label: "Iscritto il", value: createdAt, isDate: true });
if (createdAt) rows.push({ label: "Fecha de registro", value: createdAt, isDate: true });
// Last sign in / active date (last_login etc)
const lastActiveKeys = [
@@ -1146,7 +1146,7 @@ function buildProfileRows(profile) {
}
}
}
if (lastActive) rows.push({ label: "Ultimo accesso", value: lastActive, isDate: true });
if (lastActive) rows.push({ label: "Último acceso", value: lastActive, isDate: true });
return rows;
}
@@ -1160,16 +1160,16 @@ function populateUserProfileDialog(profile) {
const initials = initialsForName(name);
if (userProfileAvatar) {
userProfileAvatar.textContent = initials || "??";
userProfileAvatar.setAttribute("aria-label", name ? `Avatar: ${name}` : "Utente non impostato");
userProfileAvatar.setAttribute("aria-label", name ? `Avatar: ${name}` : "Usuario no identificado");
}
if (userProfileTitle) {
userProfileTitle.textContent = name || "Utente non impostato";
userProfileTitle.textContent = name || "Usuario no identificado";
}
if (userProfileSubtitle) {
const email = firstProfileField(profile, ["email", "emailAddress", "email_address", "eMail", "mail"]);
const phone = firstProfileField(profile, ["phone", "phoneNumber", "phone_number", "mobile", "telephone", "tel"]);
const parts = [email, phone].filter(Boolean);
userProfileSubtitle.textContent = parts.length ? parts.join(" · ") : (name ? "Nessuna informazione di contatto disponibile." : "Connettersi a Vocero per vedere i dati utente.");
userProfileSubtitle.textContent = parts.length ? parts.join(" · ") : (name ? "No hay información de contacto disponible." : "Conéctate a Vocero para ver los datos del usuario.");
}
if (userProfileMetadata) {
userProfileMetadata.innerHTML = "";
@@ -1239,8 +1239,8 @@ function syncVoceroHeaderUser() {
// Show avatar with the 2-letter initials. Full name is reachable via hover
// tooltip (title) and is used by screen readers via aria-label.
identUserName.textContent = initials;
identUserName.title = `Utente: ${name}`;
identUserName.setAttribute("aria-label", `Utente: ${name}`);
identUserName.title = `Usuario: ${name}`;
identUserName.setAttribute("aria-label", `Usuario: ${name}`);
} else {
identUserName.textContent = "";
identUserName.removeAttribute("title");
@@ -1301,7 +1301,7 @@ function syncVoceroHeaderUser() {
badge1.textContent = `${count} programados`;
const badge2 = document.createElement("span");
badge2.className = "reports-summary-badge";
badge2.textContent = `Bloque LLM ${totalLen.toLocaleString()} chars`;
badge2.textContent = `Bloque LLM ${totalLen.toLocaleString()} caracteres`;
titleRow.appendChild(titleText);
titleRow.appendChild(badge1);
titleRow.appendChild(badge2);
@@ -1907,7 +1907,7 @@ function _kickIdleTimer(reason = "activity") {
idleCloseTimer = 0;
// Friendly caption flash right before teardown moves us back to the idle
// copy (so the user understands *why* the call ended).
setCaption("Chiuso per inattività", "muted");
setCaption("Sesión cerrada por inactividad", "muted");
void (async () => {
try {
await teardown();
@@ -1917,7 +1917,7 @@ function _kickIdleTimer(reason = "activity") {
// teardown() calls setState("idle") which restores the normal idle
// caption. Leave a visible breadcrumb on the subcaption briefly so the
// user can tell what happened instead of wondering why the orb reset.
circleSubcaption.textContent = "Sessione chiusa dopo 30 secondi di silenzio. Tocca l'orb o ripeti «Hola Vocero» per iniziare di nuovo.";
circleSubcaption.textContent = "Sesión cerrada tras 30 segundos de silencio. Toca el círculo o repite «Hola Vocero» para empezar de nuevo.";
circleSubcaption.hidden = false;
window.setTimeout(() => { circleSubcaption.hidden = true; }, 8000);
})();
@@ -1968,7 +1968,7 @@ function setState(next) {
// Warm reassurance under the terse position, only while waiting in line.
if (next === "queued") {
circleSubcaption.textContent =
"Sorry, we overhugged! 🤗 Every slot is busy, so we saved you a spot. Hang tight, you're moving up.";
"¡Nos pasamos de abrazos! 🤗 Todos los lugares están ocupados, pero guardamos uno para ti en la cola. Ya estás avanzando.";
circleSubcaption.hidden = false;
} else {
circleSubcaption.hidden = true;
@@ -2003,8 +2003,8 @@ function updateRestartAvailability() {
currentState === "connecting" || currentState === "queued" || currentState === "your-turn";
restartHint.hidden = false;
restartHint.textContent = LIVE_STATES.has(currentState)
? "Reconnects now with the settings above."
: "Starts a conversation with the settings above.";
? "Reconecta ahora con la configuración indicada."
: "Inicia una conversación con la configuración indicada.";
}
/**
@@ -2031,7 +2031,7 @@ function openSettings() {
inputInstructions.readOnly = !!pinnedPrompt;
primaryPromptMeta.hidden = !pinnedPrompt;
primaryPromptMeta.textContent = pinnedPrompt
? "Primary prompt pinned by this deployment."
? "Instrucciones principales fijadas por este despliegue."
: "";
syncGateUi();
updateRestartAvailability();
@@ -2118,7 +2118,7 @@ function setGateThreshold(db) {
settings.noiseGate = Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, Math.round(db)));
const off = settings.noiseGate <= GATE_OFF_DB;
inputNoiseGate.value = String(settings.noiseGate);
gateValue.textContent = off ? "Off" : `${settings.noiseGate} dB`;
gateValue.textContent = off ? "Desactivada" : `${settings.noiseGate} dB`;
renderGateHandle();
localStorage.setItem(STORAGE_KEYS.noiseGate, String(settings.noiseGate));
if (client && LIVE_STATES.has(currentState)) {
@@ -2130,7 +2130,7 @@ function setGateThreshold(db) {
function syncGateUi() {
inputNoiseGate.value = String(settings.noiseGate);
const off = settings.noiseGate <= GATE_OFF_DB;
gateValue.textContent = off ? "Off" : `${settings.noiseGate} dB`;
gateValue.textContent = off ? "Desactivada" : `${settings.noiseGate} dB`;
renderGateHandle();
}
@@ -2340,8 +2340,8 @@ searchKeyInput.addEventListener("input", () => {
pushToolsToSession();
}
toolWebHint.textContent = userSearchKey
? "Using your key — stored in this browser only."
: "No server key configured. Add your own Serper key to enable web search.";
? "Se usa tu clave, guardada solo en este navegador."
: "No hay una clave del servidor configurada. Añade tu clave de Serper para activar la búsqueda web.";
});
async function runTool(name, argsJson, callId) {
@@ -2936,7 +2936,7 @@ function connectionTarget() {
}
const directUrl = buildDirectWsUrl(pinnedUrl || settings.directUrl);
if (!directUrl) {
throw new Error("Enter a speech-to-speech server URL in Settings.");
throw new Error("Introduce la URL del servidor de voz en Configuración.");
}
return { directUrl };
}
@@ -3022,16 +3022,16 @@ function syncConnectionUi() {
inputLbUrl.value = pinnedUrl;
inputLbUrl.readOnly = true;
connHint.classList.remove("error");
connHint.textContent = "Speech-to-speech server URL pinned by this deployment.";
connHint.textContent = "URL del servidor de voz fijada por este despliegue.";
} else if (allowDirect) {
// Direct mode: the user sets their own s2s server URL.
connField.hidden = false;
inputLbUrl.value = settings.directUrl;
inputLbUrl.readOnly = false;
inputLbUrl.placeholder = "http://localhost:port";
inputLbUrl.placeholder = "http://localhost:8080";
connHint.classList.remove("error");
connHint.textContent =
"URL of your speech-to-speech server, e.g. http://localhost:8080 (the app adds /v1/realtime).";
"URL de tu servidor de voz, por ejemplo http://localhost:8080 (la aplicación añade /v1/realtime).";
} else {
// LB mode: the load balancer URL is deployment-owned — hide it entirely so
// its address is never exposed in Settings.
@@ -3049,7 +3049,7 @@ function missingServerUrl() {
function promptServerUrl() {
if (settingsModal.open) syncConnectionUi();
else openSettings();
connHint.textContent = "Set the speech-to-speech server URL to start.";
connHint.textContent = "Configura la URL del servidor de voz para empezar.";
connHint.classList.add("error");
inputLbUrl.focus();
}
@@ -3127,8 +3127,8 @@ async function handleStartError(err) {
setState("error");
setCaption(
err.code === "join-expired"
? "Your spot expired. Tap to rejoin."
: "That took a while. Tap to rejoin.",
? "Tu lugar ha caducado. Toca para volver a la cola."
: "La espera se ha alargado. Toca para volver a la cola.",
"error",
);
return;
@@ -3144,8 +3144,8 @@ micBtn.addEventListener("click", () => {
}
client.setMuted(micMuted);
micBtn.classList.toggle("muted", micMuted);
micBtn.setAttribute("aria-label", micMuted ? "Unmute" : "Mute");
micBtn.title = micMuted ? "Unmute" : "Mute";
micBtn.setAttribute("aria-label", micMuted ? "Activar micrófono" : "Silenciar micrófono");
micBtn.title = micMuted ? "Activar micrófono" : "Silenciar micrófono";
});
stopBtn.addEventListener("click", async () => {
@@ -3178,7 +3178,7 @@ async function primeMicPermission() {
for (const track of s.getTracks()) track.stop();
} catch (err) {
throw new Error(
`Microphone access denied${err instanceof Error ? `: ${err.message}` : ""}`,
`Acceso al micrófono denegado${err instanceof Error ? `: ${err.message}` : ""}`,
);
}
}
@@ -3193,7 +3193,7 @@ async function acquireMicStream() {
/** @param {number} position Update the queued caption ("You're #N in line"). */
function onQueuePosition(position) {
const n = Number(position) || 0;
setCaption(n > 0 ? `You're #${n} in line` : "Finding you a spot…", "muted");
setCaption(n > 0 ? `Estás en el puesto ${n} de la cola` : "Buscando un lugar para ti…", "muted");
}
// ── "Your turn" join countdown ──────────────────────────────────────────────
@@ -3206,14 +3206,14 @@ function startJoinCountdown(sec) {
stopJoinCountdown();
let left = Math.max(0, Math.floor(sec));
const paint = () => {
joinQueueBtn.textContent = left > 0 ? `Join now (${left}s)` : "Join now";
joinQueueBtn.textContent = left > 0 ? `Entrar ahora (${left}s)` : "Entrar ahora";
};
paint();
joinCountdownTimer = window.setInterval(() => {
left -= 1;
if (left <= 0) {
stopJoinCountdown();
joinQueueBtn.textContent = "Join now";
joinQueueBtn.textContent = "Entrar ahora";
return;
}
paint();
@@ -3241,7 +3241,7 @@ async function doStart(audioContext = null) {
chat.clear();
chat.reset();
setState("connecting");
setCaption("Asking for mic…", "muted");
setCaption("Solicitando acceso al micrófono…", "muted");
// Create + resume the AudioContext SYNCHRONOUSLY, still inside the gesture.
// iOS Safari only starts an AudioContext from a user gesture; if we waited
@@ -3644,7 +3644,7 @@ async function tryTalkFirstDirect() {
"color:#2563eb;font-weight:700;background:#dbeafe;padding:3px 6px;border-radius:4px;"
);
try {
setCaption("Preparing your session…", "muted");
setCaption("Preparando tu sesión…", "muted");
// Let _ensureAudioContext create the context. If it stays suspended, the
// client resumes it on the first audio or the next user click.
await doStart();
@@ -3691,7 +3691,7 @@ async function startTalkFromGesture() {
// This user gesture permits AudioContext and getUserMedia to start.
console.info("[start-session] 🎤 start doStart() from unlocker gesture (TALK mode)");
const audioContext = createResumedAudioContext();
setCaption("Preparing your session…", "muted");
setCaption("Preparando tu sesión…", "muted");
await doStart(audioContext);
} catch (err) {
console.error("[start-session] ⛔ doStart() from unlocker failed", err);
+52 -52
View File
@@ -3495,10 +3495,10 @@ async def apipilot_execute(request: Request, body: ApipilotExecuteRequest):
@app.post("/api/reasoning")
async def reasoning(req: ReasoningRequest):
if S2S_REASONING_TYPE == "PROXY":
raise HTTPException(status_code=409, detail="Reasoning endpoint is disabled in PROXY mode.")
raise HTTPException(status_code=409, detail="El servicio de razonamiento está deshabilitado en el modo PROXY.")
transcript = (req.transcript or "").strip()
if not transcript:
raise HTTPException(status_code=400, detail="transcript is required.")
raise HTTPException(status_code=400, detail="Se requiere el campo transcript.")
# ── S2S_INJECT_PROFILE: resolve podcast episode + caller profile + reports list + latest summary ──
# (When S2S_INJECT_PROFILE=false this entire block is a no-op; user_context
@@ -3516,7 +3516,7 @@ async def reasoning(req: ReasoningRequest):
)
else:
needs_phone_authed = bool(VOCERO_USER_PHONE_AUTH_URL.strip())
# 2026-09-11 user fix: remove mass injection of scheduled reports.
# 2026-09-11 user fix: remove mass injection of scheduled reports.
# treats S2S_REPORT_LIST as "need to fetch reports" ONLY when BOTH the URL is
# configured AND the new opt-in S2S_INJECT_REPORTS flag is true. When
# S2S_INJECT_REPORTS=false (default) needs_reports stays False here → we skip
@@ -3822,7 +3822,7 @@ async def reasoning(req: ReasoningRequest):
if not NODE_RED_REASONING_URL:
raise HTTPException(
status_code=503,
detail="S2S_REASONING_NODE_RED_URL is not configured. Set the env var to the Node-RED endpoint (e.g. http://localhost:1881).",
detail="S2S_REASONING_NODE_RED_URL no está configurado. Configure esta variable de entorno con la dirección de Node-RED (por ejemplo, http://localhost:1881).",
)
logger.info(
"[INJECT-PROFILE] REASONING->NODE_RED body fields. instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d",
@@ -3857,7 +3857,7 @@ async def reasoning(req: ReasoningRequest):
)
except httpx.RequestError as exc:
logger.warning("[EXT][NODE_RED] unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Node-RED reasoning engine is unreachable.")
raise HTTPException(status_code=502, detail="No se puede conectar con el motor de razonamiento de Node-RED.")
logger.debug(
"[EXT][NODE_RED] reasoning RESP status=%s len=%dB",
resp.status_code, len(resp.content or b""),
@@ -3866,23 +3866,23 @@ async def reasoning(req: ReasoningRequest):
logger.warning("[EXT][NODE_RED] failed %s: %s", resp.status_code, resp.text[:500])
raise HTTPException(
status_code=502,
detail=f"Node-RED reasoning failed (HTTP {resp.status_code}).",
detail=f"El razonamiento de Node-RED falló (HTTP {resp.status_code}).",
)
try:
payload = resp.json()
except Exception:
logger.warning("[EXT][NODE_RED] invalid JSON: %s", resp.text[:500])
raise HTTPException(status_code=502, detail="Node-RED returned invalid JSON.")
raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido.")
answer = payload.get("answer") if isinstance(payload, dict) else None
log_only = bool(isinstance(payload, dict) and payload.get("log_only") is True)
if not isinstance(answer, str):
logger.warning("[EXT][NODE_RED] invalid answer type: %s", json.dumps(payload, ensure_ascii=True)[:800])
raise HTTPException(status_code=502, detail="Node-RED returned invalid answer field.")
raise HTTPException(status_code=502, detail="Node-RED devolvió un campo answer no válido.")
answer_stripped = answer.strip()
# An empty answer is intentional in log-only mode; forward the flag.
if not answer_stripped and not log_only:
logger.warning("[EXT][NODE_RED] empty answer and !log_only: %s", json.dumps(payload, ensure_ascii=True)[:800])
raise HTTPException(status_code=502, detail="Node-RED returned no answer without log_only flag.")
raise HTTPException(status_code=502, detail="Node-RED no devolvió una respuesta ni la marca log_only.")
usage = payload.get("usage") if isinstance(payload, dict) else None
if isinstance(usage, dict):
logger.debug(
@@ -3920,7 +3920,7 @@ async def reasoning(req: ReasoningRequest):
tool_name_p1,
json.dumps(tool_args_p1 or {}, ensure_ascii=True)[:300],
)
logger.info("[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_EXEC_HELPER name=%r → forward a _apipilot_execute_tool", tool_name_p1)
logger.info("[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool", tool_name_p1)
tool_env_p1 = await _apipilot_execute_tool(
auth_header=apipilot_auth_header,
demo_session=None,
@@ -3951,7 +3951,7 @@ async def reasoning(req: ReasoningRequest):
},
)
logger.info(
"[APIPILOT] REASONING PASS-2 execute name=%s success=%s httpResultLen=%d trigger seconda chiamata Node-RED",
"[APIPILOT] REASONING PASS-2 execute name=%s success=%s httpResultLen=%d trigger second Node-RED call",
tool_name_p1,
str(not is_tool_err).lower(),
len(tool_text_p1),
@@ -4020,15 +4020,15 @@ async def reasoning(req: ReasoningRequest):
)
except httpx.RequestError as exc:
logger.warning("[EXT][NODE_RED PASS-2] unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Node-RED reasoning engine is unreachable (pass 2).")
raise HTTPException(status_code=502, detail="No se puede conectar con el motor de razonamiento de Node-RED (segunda pasada).")
if resp2.status_code != 200:
logger.warning("[EXT][NODE_RED PASS-2] failed %s: %s", resp2.status_code, resp2.text[:500])
raise HTTPException(status_code=502, detail=f"Node-RED reasoning pass 2 failed (HTTP {resp2.status_code}).")
raise HTTPException(status_code=502, detail=f"La segunda pasada del razonamiento de Node-RED falló (HTTP {resp2.status_code}).")
try:
payload2 = resp2.json()
except Exception:
logger.warning("[EXT][NODE_RED PASS-2] invalid JSON: %s", resp2.text[:500])
raise HTTPException(status_code=502, detail="Node-RED returned invalid JSON (pass 2).")
raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido (segunda pasada).")
final_ans = payload2.get("answer") if isinstance(payload2, dict) else None
if not isinstance(final_ans, str):
# Fallback: the first message and raw result.
@@ -4058,7 +4058,7 @@ async def reasoning(req: ReasoningRequest):
# Mode B: OPENAI calls OpenAI Chat Completions directly.
# ────────────────────────────────────────────────────────────────────
if not OPENAI_API_KEY:
raise HTTPException(status_code=503, detail="OPENAI_API_KEY is not configured.")
raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.")
system_parts = []
instructions = effective_instructions or ((req.instructions or "").strip())
if instructions:
@@ -4114,19 +4114,19 @@ async def reasoning(req: ReasoningRequest):
)
except httpx.RequestError as exc:
logger.warning("OpenAI reasoning unreachable: %r", exc)
raise HTTPException(status_code=502, detail="OpenAI reasoning unreachable.")
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de razonamiento de OpenAI.")
logger.debug(
"[EXT][OPENAI] reasoning RESP status=%s len=%dB",
resp.status_code, len(resp.content or b""),
)
if resp.status_code != 200:
logger.warning("OpenAI reasoning failed %s: %s", resp.status_code, resp.text[:400])
raise HTTPException(status_code=502, detail=f"OpenAI reasoning failed ({resp.status_code}).")
raise HTTPException(status_code=502, detail=f"El razonamiento de OpenAI falló ({resp.status_code}).")
try:
payload = resp.json()
except Exception:
logger.warning("OpenAI reasoning returned invalid JSON: %s", resp.text[:400])
raise HTTPException(status_code=502, detail="OpenAI reasoning returned invalid JSON.")
raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido.")
answer = ""
choices = payload.get("choices")
if isinstance(choices, list) and choices:
@@ -4143,7 +4143,7 @@ async def reasoning(req: ReasoningRequest):
)
if not answer:
logger.warning("OpenAI reasoning returned empty answer: %s", json.dumps(payload, ensure_ascii=True)[:800])
raise HTTPException(status_code=502, detail="OpenAI reasoning returned no answer.")
raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI no devolvió una respuesta.")
logger.info("[Vocero REASONING] engine=OPENAI transcript=%s answer=%s", transcript[:300], answer[:500])
# Capture the ApiPilot marker, execute its tool and run OpenAI pass 2.
tool_name_p1_oai, tool_args_p1_oai = _apipilot_parse_call_marker(answer)
@@ -4154,7 +4154,7 @@ async def reasoning(req: ReasoningRequest):
tool_name_p1_oai,
json.dumps(tool_args_p1_oai or {}, ensure_ascii=True)[:300],
)
logger.info("[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_EXEC_HELPER name=%r → forward a _apipilot_execute_tool", tool_name_p1_oai)
logger.info("[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool", tool_name_p1_oai)
tool_env_oai_p1 = await _apipilot_execute_tool(
auth_header=apipilot_auth_header,
demo_session=None,
@@ -4185,7 +4185,7 @@ async def reasoning(req: ReasoningRequest):
},
)
logger.info(
"[APIPILOT] REASONING PASS-2 OPENAI execute name=%s success=%s resultLen=%d → seconda chiamata chat/completions",
"[APIPILOT] REASONING PASS-2 OPENAI execute name=%s success=%s resultLen=%d → second chat/completions call",
tool_name_p1_oai,
str(not is_tool_err_oai).lower(),
len(tool_text_oai),
@@ -4263,15 +4263,15 @@ async def reasoning(req: ReasoningRequest):
)
except httpx.RequestError as exc:
logger.warning("[EXT][OPENAI PASS-2] unreachable: %r", exc)
raise HTTPException(status_code=502, detail="OpenAI reasoning unreachable (pass 2).")
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de razonamiento de OpenAI (segunda pasada).")
if resp_oai_2.status_code != 200:
logger.warning("[EXT][OPENAI PASS-2] failed %s: %s", resp_oai_2.status_code, resp_oai_2.text[:400])
raise HTTPException(status_code=502, detail=f"OpenAI reasoning pass 2 failed ({resp_oai_2.status_code}).")
raise HTTPException(status_code=502, detail=f"La segunda pasada del razonamiento de OpenAI falló ({resp_oai_2.status_code}).")
try:
payload_oai_2 = resp_oai_2.json()
except Exception:
logger.warning("[EXT][OPENAI PASS-2] invalid JSON: %s", resp_oai_2.text[:400])
raise HTTPException(status_code=502, detail="OpenAI reasoning returned invalid JSON (pass 2).")
raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido (segunda pasada).")
final_oai_ans = ""
choices_oai_2 = payload_oai_2.get("choices")
if isinstance(choices_oai_2, list) and choices_oai_2:
@@ -4346,7 +4346,7 @@ async def realtime_proxy(ws: WebSocket):
if not S2S_PROXY_TARGET:
logger.debug("[EXT][S2S-PROXY] client=%s rejected: S2S_PROXY_TARGET not configured", client_ip)
await ws.accept()
await ws.close(code=1013, reason="Realtime proxy not configured.")
await ws.close(code=1013, reason="El proxy de tiempo real no está configurado.")
return
await ws.accept()
@@ -4753,7 +4753,7 @@ async def realtime_proxy(ws: WebSocket):
logger.warning("Realtime proxy failed: %r", exc)
logger.debug("[EXT][S2S-PROXY] client=%s ERROR %s", client_ip, type(exc).__name__)
try:
await ws.close(code=1011, reason="Upstream realtime unavailable.")
await ws.close(code=1011, reason="El servicio de tiempo real no está disponible.")
except Exception:
pass
finally:
@@ -4783,11 +4783,11 @@ async def user_by_phone(phoneNumber: str):
if not VOCERO_USER_PHONE_AUTH_URL:
raise HTTPException(
status_code=503,
detail="VOCERO_USER_PHONE_AUTH_URL is not configured on this deploy.",
detail="VOCERO_USER_PHONE_AUTH_URL no está configurado en este despliegue.",
)
phone = (phoneNumber or "").strip()
if not phone:
raise HTTPException(status_code=400, detail="phoneNumber is required.")
raise HTTPException(status_code=400, detail="Se requiere el campo phoneNumber.")
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set).
login_token = None
@@ -4809,7 +4809,7 @@ async def user_by_phone(phoneNumber: str):
resp = await http.get(target_url, headers=headers or None)
except httpx.RequestError as exc:
logger.warning("[EXT][USER-BY-PHONE] unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Upstream user-info endpoint is unreachable.")
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de información del usuario.")
# Forward the upstream status + JSON verbatim; never rewrite the payload.
if resp.status_code >= 500:
logger.warning(
@@ -4821,7 +4821,7 @@ async def user_by_phone(phoneNumber: str):
except Exception:
raise HTTPException(
status_code=502,
detail="Upstream user-info endpoint returned invalid JSON.",
detail="El servicio de información del usuario devolvió un JSON no válido.",
)
return JSONResponse(status_code=resp.status_code if resp.status_code != 204 else 200, content=payload)
@@ -4870,14 +4870,14 @@ async def user_reports(phoneNumber: str | None = None):
"hints": [],
"entries": [],
"skipped": True,
"reason": "S2S_INJECT_REPORTS is disabled on this deploy; no scheduled reports exposed to UI or LLM.",
"reason": "S2S_INJECT_REPORTS está deshabilitado en este despliegue; no se muestran reportes programados en la interfaz ni se envían al modelo.",
},
)
# ───────────────────────────────────────────────────────────────────────
if not S2S_REPORT_LIST:
raise HTTPException(
status_code=503,
detail="S2S_REPORT_LIST is not configured on this deploy.",
detail="S2S_REPORT_LIST no está configurado en este despliegue.",
)
phone = (phoneNumber or "").strip() or None
# 0) Login-by-phone if enabled. Bearer token used only if login succeeded
@@ -4897,7 +4897,7 @@ async def user_reports(phoneNumber: str | None = None):
"hints": [],
"entries": [],
"skipped": True,
"reason": "empty list / upstream error / nothing to inject",
"reason": "Lista vacía, error del servicio de origen o ningún contenido disponible.",
})
# Rebuild hints + normalized entries with the exact filtered output
items_list: list = reports_raw
@@ -4951,8 +4951,8 @@ async def user_reports(phoneNumber: str | None = None):
if total_raw_ui > cap_ui:
filtered = filtered[:cap_ui]
truncated_ui_note = (
f"Showing newest {cap_ui} of {total_raw_ui} upstream reports. "
f"Adjust S2S_REPORTS_QUICK_HINT_MAX env to raise/lower the window."
f"Se muestran los {cap_ui} reportes más recientes de un total de {total_raw_ui}. "
"Ajuste la variable de entorno S2S_REPORTS_QUICK_HINT_MAX para cambiar la cantidad."
)
import datetime as _dt
# Deduplicate by label for the UI JSON response only.
@@ -5001,12 +5001,12 @@ async def user_reports(phoneNumber: str | None = None):
date_piece = str(raw_d)[:32]
if label_piece and date_piece:
hints_list.append(
f"• Report #{idx}: '{label_piece}' — scheduled on {date_piece}"
f"• Reporte #{idx}: '{label_piece}' — programado para {date_piece}"
)
elif label_piece:
hints_list.append(f"• Report #{idx}: '{label_piece}'")
hints_list.append(f"• Reporte #{idx}: '{label_piece}'")
elif date_piece:
hints_list.append(f"• Report #{idx}: (no label) — scheduled on {date_piece}")
hints_list.append(f"• Reporte #{idx}: (sin título) — programado para {date_piece}")
resp_body = {
"count": len(entries_out),
"rawCount": total_raw_ui,
@@ -5069,13 +5069,13 @@ async def search(req: SearchRequest):
the user brought their own (then theirs is used for this request only)."""
query = (req.query or "").strip()
if not query:
raise HTTPException(status_code=400, detail="Empty query.")
raise HTTPException(status_code=400, detail="La consulta está vacía.")
key = (req.key or "").strip() or SERPER_KEY
has_user_key = bool((req.key or "").strip())
if not key:
# No server key and the user didn't supply one — search is unavailable.
raise HTTPException(status_code=503, detail="Search is not configured.")
raise HTTPException(status_code=503, detail="La búsqueda no está configurada.")
headers = {"X-API-KEY": key, "Content-Type": "application/json"}
payload = {"q": query, "num": MAX_RESULTS}
@@ -5088,7 +5088,7 @@ async def search(req: SearchRequest):
resp = await http.post(SERPER_URL, headers=headers, json=payload)
except httpx.RequestError as exc:
logger.warning("Serper unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Search provider unreachable.")
raise HTTPException(status_code=502, detail="No se puede conectar con el proveedor de búsqueda.")
logger.debug(
"[EXT][SERPER] search RESP status=%s len=%dB",
resp.status_code, len(resp.content or b""),
@@ -5104,7 +5104,7 @@ async def search(req: SearchRequest):
msg = resp.json().get("message")
except Exception:
pass
detail = f"Search provider error ({resp.status_code})"
detail = f"Error del proveedor de búsqueda ({resp.status_code})"
if msg:
detail += f": {msg}"
raise HTTPException(status_code=502, detail=detail)
@@ -5149,7 +5149,7 @@ async def session(request: Request):
if not LOAD_BALANCER_URL:
# No LB configured — this deploy is direct-mode only; the browser should
# never call this. 404 so it's indistinguishable from a missing route.
raise HTTPException(status_code=404, detail="Not found.")
raise HTTPException(status_code=404, detail="No encontrado.")
tier, keys, set_cookie = auth.resolve_identity(request)
# Metering runs only on the deployed Space; off-Space the LB still proxies but
@@ -5181,7 +5181,7 @@ async def session(request: Request):
lb = await http.post(url, headers={"Content-Type": "application/json"}, content="{}")
except httpx.RequestError as exc:
logger.warning("Load balancer unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Speech service unreachable.")
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.")
logger.debug(
"[EXT][LB] session RESP status=%s len=%dB",
lb.status_code, len(lb.content or b""),
@@ -5202,7 +5202,7 @@ async def session(request: Request):
# The LB's error body may name the reason (e.g. capacity); it carries no
# secret, so relay a trimmed copy.
logger.warning("Session handshake failed %s: %s", lb.status_code, lb.text[:300])
raise HTTPException(status_code=502, detail=f"Session handshake failed ({lb.status_code}).")
raise HTTPException(status_code=502, detail=f"No se pudo establecer la sesión ({lb.status_code}).")
data = lb.json()
state = data.get("state", "?")
@@ -5236,7 +5236,7 @@ async def queue_status(queue_id: str, request: Request):
daily budget at claim, since a multi-minute wait could have spent it elsewhere."""
client_ip = request.client.host if request.client else "?"
if not LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="Not found.")
raise HTTPException(status_code=404, detail="No encontrado.")
tier, keys, set_cookie = auth.resolve_identity(request)
tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None
@@ -5252,7 +5252,7 @@ async def queue_status(queue_id: str, request: Request):
lb = await http.get(url)
except httpx.RequestError as exc:
logger.warning("Load balancer unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Speech service unreachable.")
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.")
logger.debug(
"[EXT][LB] queue RESP status=%s len=%dB",
lb.status_code, len(lb.content or b""),
@@ -5269,7 +5269,7 @@ async def queue_status(queue_id: str, request: Request):
if lb.status_code != 200:
logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300])
raise HTTPException(status_code=502, detail=f"Queue poll failed ({lb.status_code}).")
raise HTTPException(status_code=502, detail=f"No se pudo consultar la cola de espera ({lb.status_code}).")
data = lb.json()
state = data.get("state", "?")
@@ -5311,7 +5311,7 @@ async def queue_status(queue_id: str, request: Request):
async def queue_leave(queue_id: str):
"""Leave the queue from the explicit 'Leave queue' button (a real fetch)."""
if not LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="Not found.")
raise HTTPException(status_code=404, detail="No encontrado.")
logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16])
await _lb_leave(queue_id)
return {"ok": True}
@@ -5322,7 +5322,7 @@ async def queue_end(request: Request):
"""Leave the queue on teardown/tab-close (navigator.sendBeacon, which can only
POST). Body: { queueId }. Best-effort; the LB reaps the ticket on TTL anyway."""
if not LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="Not found.")
raise HTTPException(status_code=404, detail="No encontrado.")
qid = await _queue_id(request)
if qid:
logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16])
@@ -6495,7 +6495,7 @@ async def session_heartbeat(request: Request):
budget is spent — the client then tears down."""
client_ip = request.client.host if request.client else "?"
if not LIMITER_ENABLED:
raise HTTPException(status_code=404, detail="Not found.")
raise HTTPException(status_code=404, detail="No encontrado.")
sid = await _session_id(request)
alive = bool(sid) and await asyncio.to_thread(limiter.heartbeat, sid)
logger.debug(
@@ -6511,7 +6511,7 @@ async def session_end(request: Request):
chunk. Sent via navigator.sendBeacon, so it must succeed without a response."""
client_ip = request.client.host if request.client else "?"
if not LIMITER_ENABLED:
raise HTTPException(status_code=404, detail="Not found.")
raise HTTPException(status_code=404, detail="No encontrado.")
sid = await _session_id(request)
logger.debug(
"[INTERNAL] /api/session/end client=%s sessionId=%s",
+19 -19
View File
@@ -80,7 +80,7 @@ export class Account {
if (!me.loggedIn) {
// Signed-out: a sign-in pill (only when OAuth is actually available).
if (me.auth && me.loginUrl) {
this._root.innerHTML = `<a class="signin-pill" href="${escHtml(me.loginUrl)}" title="Sign in for more time"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg><span>Sign in</span></a>`;
this._root.innerHTML = `<a class="signin-pill" href="${escHtml(me.loginUrl)}" title="Inicia sesión para obtener más tiempo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg><span>Iniciar sesión</span></a>`;
} else {
this._root.innerHTML = "";
this._root.hidden = true;
@@ -97,28 +97,28 @@ export class Account {
: `<span class="account-avatar account-avatar-fallback">${escHtml((me.username || "?")[0].toUpperCase())}</span>`;
const remaining =
isUnlimited || me.remainingSec == null
? "Unlimited"
: `${fmt(me.remainingSec)} left today`;
const tierLabel = isPro ? "PRO" : isUnlimited ? "Team" : "Free";
? "Sin límite"
: `${fmt(me.remainingSec)} restantes hoy`;
const tierLabel = isPro ? "PRO" : isUnlimited ? "Equipo" : "Gratis";
this._root.innerHTML = `
<button id="account-chip" class="account-chip" aria-haspopup="true" aria-expanded="false">
${avatar}
<span class="account-handle">${escHtml(me.username || "you")}</span>
<span class="account-handle">${escHtml(me.username || "tú")}</span>
${isPro
? '<span class="account-pro">PRO</span>'
: me.tier === "org"
? '<span class="account-pro account-team">TEAM</span>'
? '<span class="account-pro account-team">EQUIPO</span>'
: ""}
</button>
<div id="account-pop" class="account-pop" hidden>
<div class="account-pop-row account-pop-name">${escHtml(me.username || "you")}</div>
<div class="account-pop-row account-pop-name">${escHtml(me.username || "tú")}</div>
<div class="account-pop-row account-pop-meta">
<span class="account-tier">${tierLabel}</span>
<span class="account-remaining">${escHtml(remaining)}</span>
</div>
${isUnlimited ? "" : `<a class="account-pop-link" href="${PRO_URL}" target="_blank" rel="noopener">Upgrade to PRO</a>`}
<a class="account-pop-link account-signout" href="${escHtml(me.logoutUrl || "#")}">Sign out</a>
${isUnlimited ? "" : `<a class="account-pop-link" href="${PRO_URL}" target="_blank" rel="noopener">Pasar a PRO</a>`}
<a class="account-pop-link account-signout" href="${escHtml(me.logoutUrl || "#")}">Cerrar sesión</a>
</div>`;
const chip = $("#account-chip");
@@ -152,13 +152,13 @@ export class Account {
*/
showLimit(tier = this.tier) {
const canSignIn = this._me.auth && this._me.loginUrl;
this._modalTitle.textContent = "Thanks for chatting!";
this._modalTitle.textContent = "¡Gracias por conversar!";
if (tier === "anon") {
this._modalMsg.textContent =
"Guest conversations run for 5 minutes. Sign in with Hugging Face to get 10 minutes a day for free, and PRO members chat with no limit at all.";
this._modalNote.textContent = "Your free minutes refresh tomorrow.";
"Las conversaciones de invitados duran 5 minutos. Inicia sesión con Hugging Face para obtener 10 minutos gratis al día. Los miembros PRO pueden conversar sin límite.";
this._modalNote.textContent = "Tus minutos gratis se renuevan mañana.";
if (canSignIn) {
this._modalCta.innerHTML = `${HF_MARK}<span>Sign in with Hugging Face</span>`;
this._modalCta.innerHTML = `${HF_MARK}<span>Iniciar sesión con Hugging Face</span>`;
this._modalCta.href = /** @type {string} */ (this._me.loginUrl);
this._modalCta.hidden = false;
} else {
@@ -167,9 +167,9 @@ export class Account {
} else {
// Signed-in, non-PRO.
this._modalMsg.textContent =
"You've enjoyed your 10 minutes for today. Go PRO for unlimited conversations and to support open source AI.";
this._modalNote.textContent = "Or come back tomorrow. Your minutes reset daily.";
this._modalCta.innerHTML = "<span>Upgrade to PRO</span>";
"Ya has usado tus 10 minutos de hoy. Pasa a PRO para conversar sin límite y apoyar la IA de código abierto.";
this._modalNote.textContent = "También puedes volver mañana. Tus minutos se renuevan cada día.";
this._modalCta.innerHTML = "<span>Pasar a PRO</span>";
this._modalCta.href = PRO_URL;
this._modalCta.hidden = false;
}
@@ -179,10 +179,10 @@ export class Account {
/** Show a warm "we're at capacity" message when even the waiting line is full.
* Reuses the limit modal shell; no call-to-action, just reassurance. */
showBusy() {
this._modalTitle.textContent = "Hugged to the limit 🤗";
this._modalTitle.textContent = "¡Estamos al límite de abrazos! 🤗";
this._modalMsg.textContent =
"Every slot and the whole line are full right now. Too much love! Grab a coffee and pop back in a minute.";
this._modalNote.textContent = "A spot usually opens up within a few minutes.";
"Todos los lugares y la cola están ocupados. ¡Demasiado cariño! Tómate un café y vuelve en un minuto.";
this._modalNote.textContent = "Normalmente se libera un lugar en pocos minutos.";
this._modalCta.hidden = true;
if (!this._modal.open) this._modal.showModal();
}
+11 -11
View File
@@ -34,7 +34,7 @@ function reportChatDebug(hypothesisId, location, msg, data = {}) {
const WRENCH_PATH = `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>`;
const CHAT_BUBBLE_SVG = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_SVG}<span class="chat-empty-title">No messages yet</span><span class="chat-empty-hint">Tap the orb and start talking</span></div>`;
const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_SVG}<span class="chat-empty-title">Aún no hay mensajes</span><span class="chat-empty-hint">Toca el círculo y empieza a hablar</span></div>`;
/**
* @param {string} prefix
* @param {string} [sep]
@@ -298,7 +298,7 @@ export class ChatView {
_buildMessageEl({ container, prefix, role, text, partial = false }) {
const el = document.createElement("div");
el.className = `${container} ${role}`;
const label = role === "user" ? "You" : "Assistant";
const label = role === "user" ? "Tú" : "Asistente";
const stamp = this._nowStamp();
if (role === "user") {
const uName = this._resolveUserName();
@@ -451,17 +451,17 @@ export class ChatView {
const el = document.createElement("div");
el.className = "hist-msg tool";
el.innerHTML = `
<div class="hist-role">Tool call</div>
<div class="hist-role">Llamada a herramienta</div>
<button class="hist-tool-header" aria-expanded="false">
<svg class="hist-tool-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${WRENCH_PATH}</svg>
<span class="hist-tool-name">${escHtml(name)}</span>
<svg class="hist-tool-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="hist-tool-body">
<div class="hist-tool-label">Input</div>
<div class="hist-tool-label">Entrada</div>
<div class="hist-tool-block">${escHtml(pretty)}</div>
<div class="hist-tool-label">Output</div>
<div class="hist-tool-block hist-tool-output">${escHtml(output || "(no output)")}</div>
<div class="hist-tool-label">Salida</div>
<div class="hist-tool-block hist-tool-output">${escHtml(output || "(sin salida)")}</div>
</div>
`;
const header = /** @type {HTMLButtonElement} */ (el.querySelector(".hist-tool-header"));
@@ -482,7 +482,7 @@ export class ChatView {
hist.classList.add("interrupted");
const note = document.createElement("div");
note.className = "hist-note";
note.textContent = "Interrupted";
note.textContent = "Interrumpido";
hist.appendChild(note);
}
@@ -493,7 +493,7 @@ export class ChatView {
if (empty) empty.remove();
const el = document.createElement("div");
el.className = "hist-msg tool";
el.innerHTML = `<div class="hist-role">Snapshot</div><img class="hist-image" alt="Webcam snapshot sent to the model" />`;
el.innerHTML = `<div class="hist-role">Captura</div><img class="hist-image" alt="Captura de la cámara enviada al modelo" />`;
const img = /** @type {HTMLImageElement} */ (el.querySelector("img"));
img.src = dataUrl;
this._chatHistory.appendChild(el);
@@ -607,11 +607,11 @@ export class ChatView {
lines.push(head);
const args = String(m.args || "").trim();
const output = String(m.output || "").trim();
if (args) { lines.push(" Input:"); lines.push(" " + args.split("\n").join("\n ")); }
if (output) { lines.push(" Output:"); lines.push(" " + output.split("\n").join("\n ")); }
if (args) { lines.push(" Entrada:"); lines.push(" " + args.split("\n").join("\n ")); }
if (output) { lines.push(" Salida:"); lines.push(" " + output.split("\n").join("\n ")); }
lines.push("");
} else if (m.type === "image") {
const head = [m.timestamp, "SNAPSHOT (imagen web-cámara enviada al modelo)"].filter(Boolean).join(" ");
const head = [m.timestamp, "CAPTURA (imagen de la cámara enviada al modelo)"].filter(Boolean).join(" ");
lines.push(head);
lines.push(" " + String(m.dataUrl || "").slice(0, 80) + (String(m.dataUrl || "").length > 80 ? "…" : ""));
lines.push("");