refactor: rename API and frontend projects to Vocero
Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts. Make AGENTS.md self-contained and remove external skill and workflow references.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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,337 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user