refactor(frontend): rename project to s2s-frontend
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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