refactor(frontend): rename project to s2s-frontend

This commit is contained in:
2026-10-06 17:00:57 -03:00
parent 99413fc06d
commit 6c4ee3a5e3
54 changed files with 26 additions and 26 deletions
+89
View File
@@ -0,0 +1,89 @@
import { LogIn } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AccountInfo } from "../lib/contracts";
export function Account({ account }: { account: AccountInfo | null }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
return;
}
const close = (event: PointerEvent) => {
if (event.target instanceof Node && !ref.current?.contains(event.target)) {
setOpen(false);
}
};
const escape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("pointerdown", close);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", close);
document.removeEventListener("keydown", escape);
};
}, [open]);
if (!account?.enabled) {
return null;
}
if (!account.loggedIn) {
return account.auth && account.loginUrl ? (
<a className="signin-pill" href={account.loginUrl}>
<LogIn size={16} />
<span>Iniciar sesión</span>
</a>
) : null;
}
const unlimited = account.tier === "pro" || account.tier === "org";
const remaining =
account.remainingSec == null || unlimited
? "Sin límite"
: `${String(Math.floor(account.remainingSec / 60))}:${String(Math.round(account.remainingSec) % 60).padStart(2, "0")} restantes hoy`;
return (
<div className="account" ref={ref}>
<button
className="account-chip"
type="button"
aria-expanded={open}
onClick={() => {
setOpen(!open);
}}
>
{account.avatar ? (
<img className="account-avatar" src={account.avatar} alt="" />
) : (
<span className="account-avatar account-avatar-fallback">{account.username?.[0]?.toUpperCase() ?? "?"}</span>
)}
<span className="account-handle">{account.username}</span>
{unlimited && <span className="account-pro">{account.tier === "pro" ? "PRO" : "EQUIPO"}</span>}
</button>
{open && (
<div className="account-pop">
<div className="account-pop-row account-pop-name">{account.username}</div>
<div className="account-pop-row account-pop-meta">
<span>{remaining}</span>
</div>
{!unlimited && (
<a
className="account-pop-link"
href="https://huggingface.co/subscribe/pro"
target="_blank"
rel="noopener noreferrer"
>
Pasar a PRO
</a>
)}
{account.logoutUrl && (
<a className="account-pop-link account-signout" href={account.logoutUrl}>
Cerrar sesión
</a>
)}
</div>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { memo, useEffect, useState } from "react";
import { renderMarkdown, type Message } from "../lib/messages";
function Bubble({ message, userName }: { message: Message; userName: string }) {
const [expiredText, setExpiredText] = useState<string | null>(null);
useEffect(() => {
if (message.partial) {
return;
}
const timer = setTimeout(() => {
setExpiredText(message.text);
}, 5000);
return () => {
clearTimeout(timer);
};
}, [message.text, message.partial]);
if (expiredText === message.text) {
return null;
}
return (
<div className={`bubble ${message.role} in`}>
<div className="bubble-role">
{message.role === "user" ? userName : message.role === "tool" ? "Herramienta" : "Vocero"}
</div>
{message.role === "assistant" ? (
<div
className="bubble-body markdown-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
/>
) : (
<div className="bubble-body">{message.role === "tool" ? message.toolName : message.text}</div>
)}
</div>
);
}
export const Bubbles = memo(function Bubbles({ messages, userName }: { messages: Message[]; userName: string }) {
return (
<div className="bubble-stack" aria-hidden="true">
{messages.slice(-3).map((message) => (
<Bubble key={message.id} message={message} userName={userName} />
))}
</div>
);
});
@@ -0,0 +1,140 @@
import { Download, MessageCircle, Wrench } from "lucide-react";
import { memo, useEffect, useRef, useState } from "react";
import { initials } from "../lib/context";
import type { ConnectionStatus } from "../lib/contracts";
import { displayTime, exportConversation, renderMarkdown, type Message } from "../lib/messages";
import { LIVE_STATES } from "../realtime/controller";
const MessageRow = memo(function MessageRow({ message, userName }: { message: Message; userName: string }) {
const [expanded, setExpanded] = useState(false);
const time = displayTime(message.timestamp);
if (message.role === "tool") {
return (
<div className="hist-msg tool">
<div className="hist-role">Llamada a herramienta</div>
<button
className="hist-tool-header"
type="button"
aria-expanded={expanded}
onClick={() => {
setExpanded(!expanded);
}}
>
<Wrench className="hist-tool-icon" />
<span className="hist-tool-name">{message.toolName}</span>
</button>
<div className={`hist-tool-body ${expanded ? "open" : ""}`}>
<div className="hist-tool-label">Entrada</div>
<pre className="hist-tool-block">{message.toolArguments}</pre>
<div className="hist-tool-label">Salida</div>
<pre className="hist-tool-block hist-tool-output">{message.text}</pre>
</div>
</div>
);
}
const data = {
"data-user-name": userName,
"data-user-initials": initials(userName),
"data-asst-name": "Vocero",
"data-timestamp": time,
};
return (
<div className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`} {...data}>
<div className="hist-role">{message.role === "user" ? userName : "Vocero"}</div>
{message.role === "assistant" ? (
<div
className={`hist-body markdown-content ${message.partial ? "partial" : ""}`}
{...data}
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
/>
) : (
<div className={`hist-body ${message.partial ? "partial" : ""}`} {...data}>
{message.text}
</div>
)}
{message.interrupted && <div className="hist-note">Interrumpido</div>}
</div>
);
});
export const Conversation = memo(function Conversation({
messages,
userName,
status,
white,
}: {
messages: Message[];
userName: string;
status: ConnectionStatus;
white: boolean;
}) {
const history = useRef<HTMLDivElement>(null);
const atBottom = useRef(true);
useEffect(() => {
const element = history.current;
if (element && atBottom.current) {
element.scrollTop = element.scrollHeight;
}
}, [messages]);
const live = LIVE_STATES.has(status);
const waiting = ["connecting", "queued", "your-turn"].includes(status);
return (
<section id="chat-panel" className="chat-panel chat-panel-inline" aria-label="Transcripción de la conversación">
<div className="chat-panel-inner">
<header className="chat-panel-header">
<h2>Conversación</h2>
<div className="chat-header-actions" role="group" aria-label="Acciones de la conversación">
{white && (
<span
className="chat-status-pill"
data-status={live ? "online" : status === "error" ? "error" : waiting ? "waiting" : "idle"}
role="status"
>
<span className="chat-status-dot" aria-hidden="true" />
<span>
{live ? "En línea" : status === "error" ? "Sin conexión" : waiting ? "Conectando" : "Sin sesión"}
</span>
</span>
)}
<button
className="chat-export-btn icon-btn"
type="button"
aria-label="Descargar conversación"
title="Descargar conversación (JSON + TXT)"
disabled={!messages.length}
onClick={() => {
exportConversation(messages, userName);
}}
>
<Download size={18} />
</button>
</div>
</header>
<div
ref={history}
id="chat-history"
className="chat-history"
role="log"
aria-live="polite"
aria-atomic="false"
onScroll={() => {
const element = history.current;
if (element) {
atBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 60;
}
}}
>
{messages.length ? (
messages.map((message) => <MessageRow key={message.id} message={message} userName={userName} />)
) : (
<div className="chat-empty">
<MessageCircle aria-hidden="true" />
<span className="chat-empty-title">Aún no hay mensajes</span>
<span className="chat-empty-hint">Toca el círculo y empieza a hablar</span>
</div>
)}
</div>
</div>
</section>
);
});
+46
View File
@@ -0,0 +1,46 @@
import { X } from "lucide-react";
import { useEffect, useId, useRef, type ReactNode } from "react";
interface Props {
title: string;
children: ReactNode;
onClose: () => void;
className?: string;
}
export function Dialog({ title, children, onClose, className = "" }: Props) {
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
const dialog = ref.current;
dialog?.showModal();
return () => {
dialog?.close();
};
}, []);
return (
<dialog
ref={ref}
className={`modal ${className}`}
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div className="modal-content">
<header className="modal-header">
<h2 id={titleId}>{title}</h2>
<button type="button" className="icon-btn" aria-label="Cerrar" onClick={onClose}>
<X size={18} />
</button>
</header>
{children}
</div>
</dialog>
);
}
@@ -0,0 +1,362 @@
import { useState } from "react";
import { initials, profileName } from "../lib/context";
import { profileRows } from "../lib/profile";
import { GATE_MAX, GATE_OFF, VOICES } from "../lib/settings";
import { LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller";
import { Dialog } from "./Dialog";
interface Props {
state: AppState;
controller: VoiceController;
onClose: () => void;
}
export function SettingsDialog({ state, controller, onClose }: Props) {
const { config, settings } = state;
const meter = Math.max(
0,
Math.min(100, ((20 * Math.log10(state.level || 0.00001) - GATE_OFF) * 100) / (GATE_MAX - GATE_OFF)),
);
return (
<Dialog title="Configuración" onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onClose();
}}
>
<div className="tab-panels">
<div className="tab-panel active">
{config.allowDirect && !config.lb && (
<label className="field">
<span>URL del servidor de voz</span>
<input
type="text"
autoComplete="off"
spellCheck={false}
placeholder="http://localhost:8080"
value={config.s2sUrl || settings.directUrl}
readOnly={!!config.s2sUrl}
onChange={(event) => {
controller.changeSettings({ directUrl: event.target.value });
}}
/>
<small>
{config.s2sUrl
? "El servidor fija esta dirección para todas las conversaciones."
: "Introduce la dirección de tu servidor de voz. La aplicación añade /v1/realtime."}
</small>
</label>
)}
<div className="field-row">
<label className="field">
<span>Voz</span>
<select
value={settings.voice}
onChange={(event) => {
controller.changeSettings({ voice: event.target.value });
}}
>
{VOICES.map((voice) => (
<option key={voice}>{voice}</option>
))}
</select>
</label>
</div>
<div className="field">
<label className="field-head" htmlFor="noise-gate">
Puerta de ruido
<span className="field-value">
{settings.noiseGate <= GATE_OFF ? "Desactivada" : `${String(settings.noiseGate)} dB`}
</span>
</label>
<div className="gate">
<div className="gate-track">
<div className="gate-meter-fill" style={{ width: `${String(meter)}%` }} aria-hidden="true" />
<input
id="noise-gate"
aria-label="Umbral de la puerta de ruido"
type="range"
min={GATE_OFF}
max={GATE_MAX}
step="1"
value={settings.noiseGate}
onChange={(event) => {
controller.changeSettings({ noiseGate: Number(event.target.value) });
}}
/>
</div>
<div className="gate-ends">
<span>Desactivada</span>
<span>−3 dB</span>
</div>
</div>
<small>
Silencia el micrófono por debajo del umbral para evitar enviar ruido. Desliza hasta la izquierda para
desactivarla.
</small>
</div>
<label className="field">
<span>Instrucciones</span>
<textarea
rows={5}
value={config.primaryPrompt || settings.instructions}
readOnly={!!config.primaryPrompt}
onChange={(event) => {
controller.changeSettings({ instructions: event.target.value });
}}
/>
{config.primaryPrompt && <small>Las instrucciones están definidas por el servidor.</small>}
</label>
<div className="field">
<button
type="button"
className="btn primary wide"
disabled={!LIVE_STATES.has(state.status)}
onClick={() => {
onClose();
void controller.restart();
}}
>
Reiniciar conversación con esta configuración
</button>
<small>La voz, las instrucciones y la puerta de ruido se actualizan en la sesión actual.</small>
</div>
</div>
</div>
<footer className="modal-footer">
<button className="btn primary" type="submit">
Guardar
</button>
</footer>
</form>
</Dialog>
);
}
export function RagDialog({ state, controller, onClose }: Props) {
const [text, setText] = useState(state.rag);
const [saved, setSaved] = useState(false);
return (
<Dialog title="Contexto RAG personalizado" onClose={onClose}>
<p className="tools-intro">
Añade documentos, datos o instrucciones que Vocero debe tener en cuenta durante esta conversación. El contexto
se mantiene mientras esta página esté abierta.
</p>
<form
onSubmit={(event) => {
event.preventDefault();
controller.setRag(text);
setSaved(true);
}}
>
<label className="field">
<span>Contexto RAG (texto libre)</span>
<textarea
rows={12}
spellCheck={false}
value={text}
onChange={(event) => {
setText(event.target.value);
setSaved(false);
}}
placeholder="Pega o escribe el contexto que Vocero debe usar al responder…"
/>
</label>
<p className="tool-hint" role="status">
{saved
? "Contexto actualizado para esta conversación."
: state.rag
? `${String(state.rag.length)} caracteres de contexto cargados.`
: "No hay contexto RAG cargado."}
</p>
<footer className="modal-footer">
<button
className="btn secondary"
type="button"
onClick={() => {
setText("");
controller.setRag("");
setSaved(true);
}}
>
Vaciar
</button>
<div className="flex-1" />
<button className="btn primary" type="submit">
Enviar a la sesión
</button>
</footer>
</form>
</Dialog>
);
}
export function ToolsDialog({ state, controller, onClose }: Props) {
return (
<Dialog title="Herramientas" onClose={onClose}>
<p className="tools-intro">
Permite que el asistente actúe durante la conversación. Los cambios se aplican de inmediato.
</p>
<div className="tool-list">
<div className="tool-row">
<div className="tool-info">
<span className="tool-name">Búsqueda web</span>
<span className="tool-desc">Busca información en Google mediante Serper.</span>
</div>
<label className="switch">
<input
type="checkbox"
aria-label="Búsqueda web"
checked={state.settings.webSearch}
onChange={(event) => {
controller.changeSettings({ webSearch: event.target.checked });
}}
/>
<span className="switch-track" aria-hidden="true" />
</label>
</div>
<label className="field tools-key">
<span>Clave de la API de búsqueda</span>
<input
type="password"
autoComplete="off"
value={state.settings.searchKey}
disabled={state.config.search}
onChange={(event) => {
controller.changeSettings({ searchKey: event.target.value });
}}
/>
<small>
{state.config.search
? "La búsqueda está configurada en el servidor."
: "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."}
</small>
</label>
<div className="tool-row tool-row-sep">
<div className="tool-info">
<span className="tool-name">Herramientas MCP cargadas</span>
<span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
</div>
</div>
<p className="tool-hint" role="status">
{state.toolsLoading
? "Cargando herramientas…"
: state.toolsError ||
(state.tools.length
? `${String(state.tools.length)} herramientas disponibles.`
: "No hay herramientas remotas disponibles.")}
</p>
<div className="agent-tools-list">
{state.tools.map((tool) => (
<article key={tool.name} className="agent-tool-item">
<strong className="block text-sm">{tool.name}</strong>
<p className="mt-1 text-sm opacity-75">{tool.description}</p>
</article>
))}
</div>
</div>
</Dialog>
);
}
export function ProfileDialog({ state, onClose }: Omit<Props, "controller">) {
const name = profileName(state.profile) || "Usuario";
const rows = profileRows(state.profile);
return (
<Dialog title="Datos del usuario" className="user-profile-modal" onClose={onClose}>
<div className="user-profile-hero">
<div className="user-profile-avatar" aria-hidden="true">
{initials(name)}
</div>
<h3 className="user-profile-title">{name}</h3>
</div>
<dl className="user-profile-metadata">
{rows.map(({ label, value, href }) => (
<div key={label} className="user-profile-row">
<dt className="user-profile-label">{label}</dt>
<dd className="user-profile-value">{href ? <a href={href}>{value}</a> : value}</dd>
</div>
))}
</dl>
</Dialog>
);
}
export function AboutDialog({ onClose }: { onClose: () => void }) {
return (
<Dialog title="Acerca de" className="about-modal" onClose={onClose}>
<p className="tools-intro">
Vocero convierte tu voz en respuestas habladas, en tiempo real. Puedes interrumpirlo para continuar la
conversación.
</p>
<div className="about-pipeline">
<ol className="pipeline">
<li className="pipe-endpoint">Tú hablas</li>
<li className="pipe-stage">
<span className="pipe-tag">STT</span>
<span className="pipe-job">transcribe tu voz</span>
<a
className="pipe-model"
href="https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3"
target="_blank"
rel="noopener noreferrer"
>
nvidia/parakeet-tdt-0.6b-v3
</a>
</li>
<li className="pipe-stage">
<span className="pipe-tag">LLM</span>
<span className="pipe-job">genera una respuesta</span>
<a
className="pipe-model"
href="https://huggingface.co/google/gemma-4-31B-it"
target="_blank"
rel="noopener noreferrer"
>
google/gemma-4-31B-it
</a>
</li>
<li className="pipe-stage">
<span className="pipe-tag">TTS</span>
<span className="pipe-job">la convierte en voz</span>
<a
className="pipe-model"
href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
target="_blank"
rel="noopener noreferrer"
>
Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
</a>
</li>
<li className="pipe-endpoint">Vocero responde</li>
</ol>
</div>
</Dialog>
);
}
export function LimitDialog({ state, onClose }: Omit<Props, "controller">) {
const anonymous = !state.account?.loggedIn;
return (
<Dialog title="Por ahora hemos terminado" className="limit-modal" onClose={onClose}>
<p className="limit-msg">
Has agotado el tiempo de conversación disponible hoy. Vuelve mañana para seguir conversando.
</p>
{anonymous && state.account?.loginUrl ? (
<a className="btn primary wide" href={state.account.loginUrl}>
Inicia sesión para obtener más tiempo
</a>
) : state.account?.tier === "free" ? (
<a
className="btn primary wide"
href="https://huggingface.co/subscribe/pro"
target="_blank"
rel="noopener noreferrer"
>
Pasar a PRO
</a>
) : null}
</Dialog>
);
}
+302
View File
@@ -0,0 +1,302 @@
import { CircleAlert, Mic, MicOff, Square, Volume2 } from "lucide-react";
import { useEffect, useState, type CSSProperties, type PointerEvent } from "react";
import type { ConnectionStatus } from "../lib/contracts";
import { GATE_MAX, GATE_OFF } from "../lib/settings";
import { ACTIVE_STATES, LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller";
const CAPTIONS: Record<ConnectionStatus, string> = {
idle: "Toca para empezar",
closed: "Toca para empezar",
connecting: "Conectando…",
queued: "Esperando tu turno…",
"your-turn": "¡Es tu turno!",
listening: "Te escucho…",
"user-speaking": "Te escucho…",
processing: "Pensando…",
"ai-speaking": "Vocero está hablando…",
error: "Toca para reintentar",
};
function GateArc({
threshold,
level,
onChange,
}: {
threshold: number;
level: number;
onChange: (value: number) => void;
}) {
const angle = ((135 + ((threshold - GATE_OFF) / (GATE_MAX - GATE_OFF)) * 270) * Math.PI) / 180;
const fraction = Math.max(0, Math.min(1, (20 * Math.log10(level || 0.00001) - GATE_OFF) / (GATE_MAX - GATE_OFF)));
const path = "M 20.3 79.7 A 42 42 0 1 1 79.7 79.7";
function move(event: PointerEvent<SVGSVGElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
const degrees =
(Math.atan2(event.clientY - bounds.top - bounds.height / 2, event.clientX - bounds.left - bounds.width / 2) *
180) /
Math.PI;
const offset = (degrees - 135 + 360) % 360;
const progress = offset > 270 ? (offset > 315 ? 0 : 1) : offset / 270;
onChange(Math.round(GATE_OFF + progress * (GATE_MAX - GATE_OFF)));
}
return (
<svg
className="mic-gate-arc"
viewBox="0 0 100 100"
role="slider"
tabIndex={0}
aria-label="Umbral de la puerta de ruido"
aria-valuemin={GATE_OFF}
aria-valuemax={GATE_MAX}
aria-valuenow={threshold}
aria-valuetext={threshold === GATE_OFF ? "Desactivada" : `${String(threshold)} dB`}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
move(event);
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
move(event);
}
}}
onPointerUp={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId);
}}
onKeyDown={(event) => {
const step =
event.key === "ArrowRight" || event.key === "ArrowUp"
? 1
: event.key === "ArrowLeft" || event.key === "ArrowDown"
? -1
: 0;
if (step || event.key === "Home" || event.key === "End") {
event.preventDefault();
onChange(
event.key === "Home"
? GATE_OFF
: event.key === "End"
? GATE_MAX
: Math.max(GATE_OFF, Math.min(GATE_MAX, threshold + step)),
);
}
}}
>
<path className="mga-track" d={path} fill="none" />
<path className="mga-fill" d={path} fill="none" pathLength="1" strokeDasharray={`${String(fraction)} 1`} />
<path className="mga-hit" d={path} fill="none" />
<circle className="mga-handle" r="3" cx={50 + 42 * Math.cos(angle)} cy={50 + 42 * Math.sin(angle)} />
</svg>
);
}
function Waveform({ side }: { side: "left" | "right" }) {
const heights = [0.4, 0.65, 0.9, 1, 0.7, 0.55, 0.3];
return (
<div className={`white-waveform white-waveform-${side}`} aria-hidden="true">
{(side === "left" ? heights : [...heights].reverse()).map((height, index) => (
<span key={index} className={`w-bar wb-${String(index + 1)}`} style={{ "--bar-h": height } as CSSProperties} />
))}
</div>
);
}
function JoinCountdown({ deadline }: { deadline: number }) {
const [now, setNow] = useState(Date.now);
useEffect(() => {
const timer = setInterval(() => {
setNow(Date.now());
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
return (
<p className="circle-subcaption">
Tu lugar se reserva durante {Math.max(0, Math.ceil((deadline - now) / 1000))} s.
</p>
);
}
export function VoiceStage({ state, controller }: { state: AppState; controller: VoiceController }) {
const white = state.config.theme === "white";
const live = LIVE_STATES.has(state.status);
const active = ACTIVE_STATES.has(state.status);
const status = state.status === "closed" ? "idle" : state.status;
const caption =
state.muted && (status === "listening" || status === "user-speaking")
? "Micrófono silenciado"
: CAPTIONS[state.status];
const label = active
? caption
: status === "error"
? "Reintentar conversación de voz"
: "Iniciar conversación de voz";
const muteLabel = state.muted ? "Activar micrófono" : "Silenciar micrófono";
const start = () => {
if (status === "your-turn") {
controller.join();
} else {
void controller.start();
}
};
const disabled = active && status !== "your-turn";
return (
<main className="stage">
{white ? (
<div className={`white-mic-stage ${live ? "live" : ""}`} role="group" aria-label="Control de voz">
<Waveform side="left" />
<button
id="white-mic-circle"
className={`white-mic-circle state-${status}`}
type="button"
aria-label={label}
disabled={disabled}
onClick={start}
>
<span className="wmc-halo" aria-hidden="true" />
<span className="wmc-ring" aria-hidden="true" />
<span className="wmc-core" aria-hidden="true" />
<Mic className="wmc-icon wmc-mic" aria-hidden="true" />
<span className="wmc-icon wmc-spinner" aria-hidden="true" />
</button>
<Waveform side="right" />
<p className="white-mic-caption" role="status">
{caption}
</p>
{state.notice && <p className="white-mic-subcaption">{state.notice}</p>}
{active && (
<div className="white-session-controls" role="group" aria-label="Controles de la conversación">
{live && (
<button
className={`white-mute-btn ${state.muted ? "muted" : ""}`}
type="button"
onClick={() => {
controller.toggleMuted();
}}
aria-pressed={state.muted}
aria-label={muteLabel}
>
{state.muted ? <MicOff /> : <Mic />}
<span>{muteLabel}</span>
</button>
)}
<button
className="white-stop-btn"
type="button"
onClick={() => {
void controller.stop();
}}
>
<Square fill="currentColor" />
<span>Detener</span>
</button>
</div>
)}
</div>
) : (
<>
<div className={`orb-wrap ${active ? "live" : ""}`}>
<div
className={`mic-gate ${20 * Math.log10(state.level || 0.00001) >= state.settings.noiseGate ? "gate-open" : ""}`}
>
{live && (
<GateArc
threshold={state.settings.noiseGate}
level={state.level}
onChange={(noiseGate) => {
controller.changeSettings({ noiseGate });
}}
/>
)}
<button
className={`side-btn ${state.muted ? "muted" : ""}`}
type="button"
disabled={!live}
tabIndex={live ? 0 : -1}
aria-label={muteLabel}
aria-pressed={state.muted}
onClick={() => {
controller.toggleMuted();
}}
>
{state.muted ? <MicOff /> : <Mic />}
</button>
</div>
<button
id="main-circle"
className={`circle state-${status}`}
type="button"
aria-label={label}
disabled={disabled}
onClick={start}
>
<span className="circle-glow" aria-hidden="true" />
<span className="circle-ring" aria-hidden="true" />
<span className="circle-ring-outer" aria-hidden="true" />
<span className="circle-core">
<span className="circle-indicator" aria-hidden="true">
<Mic className="ind ind-mic" />
<CircleAlert className="ind ind-error" />
<span className="ind ind-spinner" />
<span className="ind ind-thinking">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</span>
<span className="ind ind-bars">
{[0, 1, 2, 3, 4].map((number) => (
<span key={number} className="bar" />
))}
</span>
<Volume2 className="ind ind-voice" />
</span>
</span>
</button>
<button
className="side-btn"
type="button"
disabled={!active}
tabIndex={active ? 0 : -1}
aria-label="Finalizar"
onClick={() => {
void controller.stop();
}}
>
<Square fill="currentColor" />
</button>
</div>
<p className="circle-caption" role="status">
{caption}
</p>
{state.notice && <p className="circle-subcaption">{state.notice}</p>}
</>
)}
{(status === "queued" || status === "your-turn") && (
<div className="queue-actions">
{status === "queued" ? (
<p className="circle-subcaption">Posición en la cola: {state.queuePosition}</p>
) : (
<>
<JoinCountdown deadline={state.joinDeadline} />
<button
className="join-queue-btn"
type="button"
onClick={() => {
controller.join();
}}
>
Entrar ahora
</button>
</>
)}
<button
className="leave-queue-btn"
type="button"
onClick={() => {
void controller.stop();
}}
>
Salir de la cola
</button>
</div>
)}
</main>
);
}