feat(frontend): render Vocero screens and controls with React

This commit is contained in:
2026-10-06 16:24:50 -03:00
parent 833788b8d8
commit 36efd5e13c
18 changed files with 7977 additions and 18 deletions
+6
View File
@@ -5,6 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#07080d" /> <meta name="theme-color" content="#07080d" />
<title>Vocero · Conversación de voz</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
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+307 -1
View File
@@ -1,3 +1,309 @@
import { BookOpen, ChartNoAxesColumn, ChevronRight, Info, PanelLeft, Settings, Wrench, X } from "lucide-react";
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { Account } from "./components/Account";
import { Bubbles } from "./components/Bubbles";
import { Conversation } from "./components/Conversation";
import {
AboutDialog,
LimitDialog,
ProfileDialog,
RagDialog,
SettingsDialog,
ToolsDialog,
} from "./components/SettingsDialogs";
import { VoiceStage } from "./components/VoiceStage";
import { useWakeWord } from "./hooks/use-wake-word";
import { initials, profileName } from "./lib/context";
import { reportSuggestions } from "./lib/reports";
import { isDebug } from "./lib/settings";
import { ACTIVE_STATES, VoiceController } from "./realtime/controller";
type Modal = "settings" | "tools" | "rag" | "profile" | "about" | null;
export default function App() { export default function App() {
return <main>Vocero</main>; const [controller] = useState(() => new VoiceController());
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
const [modal, setModal] = useState<Modal>(null);
const [reportsOpen, setReportsOpen] = useState(false);
const debug = isDebug();
const white = state.config.theme === "white";
const name = profileName(state.profile);
const hasReports = debug && !white && !!state.reports?.count;
useEffect(() => {
const abort = new AbortController();
void controller.initialize(abort.signal);
const leave = () => {
void controller.stop();
};
window.addEventListener("pagehide", leave);
return () => {
abort.abort();
window.removeEventListener("pagehide", leave);
void controller.stop();
};
}, [controller]);
useLayoutEffect(() => {
if (!state.loading) {
document.documentElement.dataset.theme = white ? "white" : "dark";
}
}, [state.loading, white]);
useEffect(() => {
if (state.loading) {
return;
}
const timer = setTimeout(() => {
controller.offerAutoStart();
}, 2000);
return () => {
clearTimeout(timer);
};
}, [state.loading, controller]);
useWakeWord(!state.loading && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock, controller);
useEffect(() => {
if (!reportsOpen) {
return;
}
const escape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setReportsOpen(false);
}
};
window.addEventListener("keydown", escape);
return () => {
window.removeEventListener("keydown", escape);
};
}, [reportsOpen]);
const close = () => {
setModal(null);
};
if (state.loading) {
return (
<main className="loading-screen" role="status">
Preparando Vocero…
</main>
);
}
return (
<>
{state.unlock && (
<button
className="talk-unlocker"
type="button"
onClick={() => {
void controller.start();
}}
aria-label="Empezar a hablar"
>
<span className="talk-unlocker-hint">
<span className="talk-unlocker-spinner" aria-hidden="true" />
<span className="talk-unlocker-title">Preparando Vocero…</span>
<span className="talk-unlocker-sub">Toca en cualquier lugar para empezar a hablar</span>
</span>
</button>
)}
<div id="app" className={hasReports ? "has-reports" : ""}>
<header className="topbar">
<div className="brand">
<div className="ident">
<div className="ident-head">
<a className="ident-title" href={window.location.pathname + window.location.search}>
Vocero
</a>
{white && <p className="ident-subtitle">Agente conversacional</p>}
</div>
</div>
</div>
{white && name && (
<button
id="ident-user-name"
className="ident-user-name"
type="button"
aria-label={`Ver datos de ${name}`}
aria-haspopup="dialog"
onClick={() => {
setModal("profile");
}}
>
{initials(name)}
</button>
)}
{hasReports && (
<button
id="reports-sidebar-toggle"
className="icon-btn"
type="button"
aria-label="Abrir panel de reportes"
aria-expanded={reportsOpen}
aria-controls="reports-sidebar"
onClick={() => {
setReportsOpen(!reportsOpen);
}}
>
<PanelLeft size={18} />
<span id="reports-sidebar-badge" className="chat-badge" data-count={state.reports?.count}>
{state.reports?.count}
</span>
</button>
)}
{debug && (
<div className="topbar-right" data-debug-enabled="true">
<Account account={state.account} />
<button
className="icon-btn"
type="button"
aria-label="Acerca de"
onClick={() => {
setModal("about");
}}
>
<Info size={18} />
</button>
<button
className="icon-btn"
type="button"
aria-label="Herramientas"
onClick={() => {
setModal("tools");
}}
>
<Wrench size={18} />
</button>
<button
className="icon-btn"
type="button"
aria-label="Contexto RAG personalizado"
onClick={() => {
setModal("rag");
}}
>
<BookOpen size={18} />
</button>
<button
className="icon-btn"
type="button"
aria-label="Configuración"
onClick={() => {
setModal("settings");
}}
>
<Settings size={18} />
</button>
</div>
)}
</header>
<nav className="feedback-navigation" aria-label="Comentarios">
<a className="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">
Enviar comentarios
</a>
</nav>
{white && (
<div className="status-card" aria-label="Estado de la sesión">
<div className="status-card-icon" aria-hidden="true">
<ChartNoAxesColumn size={24} />
</div>
<div className="status-card-text">
<h2 className="status-card-title">Listo para conversar</h2>
<p className="status-card-sub">Informes, análisis, datos y mucho más.</p>
</div>
<button
className="status-card-arrow icon-btn"
type="button"
aria-label="Ver más detalles"
onClick={() => {
setModal("about");
}}
>
<ChevronRight size={22} />
</button>
</div>
)}
{state.error && (
<div className="app-error" role="alert">
<span>{state.error}</span>
<button
className="icon-btn shrink-0"
type="button"
aria-label="Cerrar aviso"
onClick={() => {
controller.dismissError();
}}
>
<X size={16} />
</button>
</div>
)}
<VoiceStage state={state} controller={controller} />
<Conversation messages={state.messages} userName={name || "Usuario"} status={state.status} white={white} />
</div>
{!white && ACTIVE_STATES.has(state.status) && <Bubbles messages={state.messages} userName={name || "Usuario"} />}
{hasReports && (
<aside
id="reports-sidebar"
className={`reports-sidebar ${reportsOpen ? "open" : ""}`}
aria-label="Reportes programados"
>
<button
className="reports-sidebar-backdrop"
type="button"
aria-label="Cerrar panel de reportes"
tabIndex={reportsOpen ? 0 : -1}
onClick={() => {
setReportsOpen(false);
}}
/>
<div className="reports-sidebar-inner">
<header className="reports-sidebar-header">
<h3>Reportes</h3>
<button
id="reports-sidebar-close"
className="icon-btn"
type="button"
aria-label="Cerrar reportes"
onClick={() => {
setReportsOpen(false);
}}
>
<X size={18} />
</button>
</header>
<div className="reports-hint">
<p>{state.reports?.count} reportes disponibles</p>
<ul>
{state.reports?.hints.map((hint, index) => (
<li key={index}>{hint}</li>
))}
</ul>
{state.reports?.note && <p>{state.reports.note}</p>}
</div>
{state.reports && (
<div className="reports-suggestions">
<h4 className="reports-suggestions-title">Puedes preguntar</h4>
<ul className="reports-suggestions-list">
{reportSuggestions(state.reports).map((suggestion) => (
<li key={suggestion}>{suggestion}</li>
))}
</ul>
</div>
)}
</div>
</aside>
)}
{modal === "settings" && <SettingsDialog state={state} controller={controller} onClose={close} />}
{modal === "tools" && <ToolsDialog state={state} controller={controller} onClose={close} />}
{modal === "rag" && <RagDialog state={state} controller={controller} onClose={close} />}
{modal === "profile" && <ProfileDialog state={state} onClose={close} />}
{modal === "about" && <AboutDialog onClose={close} />}
{state.limitReached && (
<LimitDialog
state={state}
onClose={() => {
controller.dismissLimit();
}}
/>
)}
<footer className="app-footer" aria-label="Versión de la aplicación">
<span className="app-footer-text">Azurian Vocero en tiempo real ver. Beta 2.1</span>
</footer>
</>
);
} }
+2
View File
@@ -67,6 +67,7 @@ export class AudioEngine {
if (event.data instanceof ArrayBuffer) { if (event.data instanceof ArrayBuffer) {
onChunk(event.data); onChunk(event.data);
} else { } else {
document.documentElement.style.setProperty("--audio-level", String(Math.min(1, event.data.rms * 5)));
onLevel(event.data.rms); onLevel(event.data.rms);
} }
}; };
@@ -143,5 +144,6 @@ export class AudioEngine {
document.documentElement.style.removeProperty(`--bar${index}`); document.documentElement.style.removeProperty(`--bar${index}`);
} }
document.documentElement.style.removeProperty("--ai-audio-level"); document.documentElement.style.removeProperty("--ai-audio-level");
document.documentElement.style.removeProperty("--audio-level");
} }
} }
+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">
<h3>Conversación</h3>
<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" : ""}`} 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>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect } from "react";
import type { VoiceController } from "../realtime/controller";
interface Recognition {
lang: string;
continuous: boolean;
interimResults: boolean;
onresult: ((event: { resultIndex: number; results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null;
onerror: ((event: { error: string }) => void) | null;
onend: (() => void) | null;
start: () => void;
abort: () => void;
}
declare global {
interface Window {
SpeechRecognition?: new () => Recognition;
webkitSpeechRecognition?: new () => Recognition;
}
}
/** Optional browser wake word; never owns the microphone during a voice session. */
export function useWakeWord(enabled: boolean, controller: VoiceController) {
useEffect(() => {
const Constructor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
if (!enabled || !Constructor) {
return;
}
const recognition = new Constructor();
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
recognition.lang = "es-ES";
recognition.continuous = true;
recognition.interimResults = true;
const start = () => {
if (!stopped) {
try {
recognition.start();
} catch {
/* A browser may require a click first. */
}
}
};
recognition.onresult = (event) => {
for (let index = event.resultIndex; index < event.results.length; index++) {
const text =
event.results[index]?.[0]?.transcript
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") ?? "";
if (/\b(?:hola|ola)\s+(?:vocero|bocero|bosero|vocerro)\b/.test(text)) {
stopped = true;
recognition.abort();
void controller.start(false, undefined, "hola vocero");
return;
}
}
};
recognition.onerror = (event) => {
if (["not-allowed", "service-not-allowed", "audio-capture"].includes(event.error)) {
stopped = true;
}
};
recognition.onend = () => {
if (!stopped) {
timer = setTimeout(start, 1000);
}
};
start();
return () => {
stopped = true;
clearTimeout(timer);
recognition.onresult = null;
recognition.onerror = null;
recognition.onend = null;
recognition.abort();
};
}, [enabled, controller]);
}
+14
View File
@@ -106,5 +106,19 @@ export function endReservation(path: "/api/session/end" | "/api/queue/end", body
} }
export function errorMessage(error: unknown) { export function errorMessage(error: unknown) {
if (error instanceof z.ZodError) {
return "El servidor devolvió una respuesta inesperada.";
}
if (error instanceof DOMException) {
if (error.name === "NotAllowedError") {
return "No se pudo acceder al micrófono. Revisa el permiso del navegador y vuelve a intentarlo.";
}
if (error.name === "NotFoundError") {
return "No se encontró un micrófono. Conecta uno y vuelve a intentarlo.";
}
if (error.name === "NotReadableError") {
return "El micrófono está ocupado o no está disponible.";
}
}
return error instanceof Error ? error.message : "No se pudo completar la operación."; return error instanceof Error ? error.message : "No se pudo completar la operación.";
} }
+1 -1
View File
@@ -26,7 +26,7 @@ export const reportsSchema = z.object({
entries: z entries: z
.array( .array(
z.object({ z.object({
sourceScheduleLabel: z.string().optional(), sourceScheduleLabel: z.string().nullish(),
date: z.union([z.string(), z.number()]).nullish(), date: z.union([z.string(), z.number()]).nullish(),
}), }),
) )
+160
View File
@@ -0,0 +1,160 @@
import { recordSchema, type Profile } from "./contracts";
function field(profile: Profile | null, keys: readonly string[], depth = 0): string {
if (!profile || depth > 3) {
return "";
}
for (const key of keys) {
const value = profile[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
}
for (const key of ["user", "customer", "profile", "contact"]) {
const nested = recordSchema.safeParse(profile[key]);
if (nested.success) {
const value = field(nested.data, keys, depth + 1);
if (value) {
return value;
}
}
}
return "";
}
const FIELDS = [
{
label: "Correo electrónico",
keys: [
"email",
"emailAddress",
"email_address",
"eMail",
"mail",
"contactEmail",
"user_email",
"customer_email",
"loginEmail",
"username_email",
],
kind: "email",
},
{
label: "Teléfono",
keys: [
"phone",
"phoneNumber",
"phone_number",
"mobile",
"mobileNumber",
"telephone",
"tel",
"cellphone",
"contactPhone",
"user_phone",
"whatsapp",
],
kind: "phone",
},
{
label: "Nombre de usuario",
keys: ["username", "userName", "user_name", "login", "handle", "nickname", "screenName", "accountName"],
},
{
label: "Rol / Plan",
keys: ["role", "roleName", "userRole", "plan", "planName", "tier", "subscription", "membership", "accessLevel"],
},
{
label: "Empresa",
keys: ["company", "companyName", "company_name", "organization", "org", "organizationName", "business"],
},
{
label: "ID de usuario",
keys: [
"userId",
"user_id",
"uid",
"accountId",
"account_id",
"sub",
"customerId",
"customer_id",
"id",
"userPk",
"profileId",
"profile_id",
"identityId",
],
},
{
label: "País / Idioma",
keys: [
"country",
"countryName",
"country_code",
"countryCode",
"locale",
"language",
"lang",
"region",
"timezone",
"tz",
],
},
{
label: "Fecha de registro",
keys: [
"createdAt",
"created_at",
"created",
"createdDate",
"created_date",
"signUpDate",
"signup_date",
"joined",
"joinDate",
"join_date",
"registrationDate",
],
kind: "date",
},
{
label: "Último acceso",
keys: [
"lastLogin",
"last_login",
"lastLoginAt",
"lastActive",
"last_active",
"lastActiveAt",
"updatedAt",
"updated_at",
],
kind: "date",
},
] satisfies { label: string; keys: string[]; kind?: string }[];
export function profileRows(profile: Profile | null) {
return FIELDS.flatMap(({ label, keys, kind }) => {
let value = field(profile, keys);
if (!value) {
return [];
}
if (kind === "date") {
const timestamp = /^\d{10,13}$/.test(value) ? Number(value) * (value.length <= 10 ? 1000 : 1) : value;
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime())) {
value = new Intl.DateTimeFormat("es-CL", {
timeZone: "America/Santiago",
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
}
const href =
kind === "email" ? `mailto:${value}` : kind === "phone" ? `tel:${value.replace(/[^\d+]/g, "")}` : undefined;
return [{ label, value, href }];
});
}
+51
View File
@@ -0,0 +1,51 @@
import type { Reports } from "./contracts";
/** Keep the original report-based conversation suggestions in the debug sidebar. */
export function reportSuggestions(reports: Reports) {
const labels = reports.entries
.map((entry) => entry.sourceScheduleLabel?.trim())
.filter((label): label is string => !!label);
const first = labels[0];
if (!first) {
return [];
}
const second = labels[1] ?? first;
const third = labels[2] ?? first;
const templates: [RegExp, (label: string) => string][] = [
[
/riesgo|mortalidad|porcentaje|kpi/i,
(label) => `Dame un resumen ejecutivo de los riesgos operativos y KPIs del reporte de ${label}.`,
],
[/diari[oa]|informe/i, (label) => `Cuéntame los puntos clave del informe diario más reciente (${label}).`],
[/métrica|metrica|medici[oó]n/i, (label) => `Compara las métricas de la última semana del reporte ${label}.`],
[/producto|servicio/i, (label) => `¿Qué hay de nuevo en productos y servicios según el reporte ${label}?`],
[
/supply|cadena|log[ií]stica|env[ií]o|chile|latin/i,
(label) => `¿Hay novedades en la cadena de suministro o logística en el reporte ${label}?`,
],
[
/synapse|ejecuci[oó]n|calendario/i,
(label) => `¿Qué ejecuciones del calendario Synapse TI están reportadas? (${label}).`,
],
[/podcast|briefing|mañana|matutino|morning/i, (label) => `Lee el podcast briefing matutino de hoy (${label}).`],
[
/financier|finanza|costo|presupuesto|venta/i,
(label) => `Análisis financiero: costos, presupuesto y ventas del reporte ${label}.`,
],
[
/ejecutivo|ceo|reuni[oó]n|semanal/i,
(label) => `Puntos de agenda para la reunión ejecutiva semanal según el reporte ${label}.`,
],
];
const contextual = templates.flatMap(([pattern, text]) => {
const label = labels.find((label) => pattern.test(label));
return label ? [text(label)] : [];
});
return [
...contextual,
`¿Qué highlights hay en el reporte más reciente «${first}»?`,
`Resume los 3 hallazgos principales de los reportes de hoy (${String(reports.count)} disponibles).`,
...(reports.count >= 2 ? [`Compara ${first} vs ${second} y dime cuál tiene mayor prioridad.`] : []),
...(reports.count >= 3 ? [`Plan de acción sugerido con base en ${first}, ${second} y ${third}.`] : []),
].slice(0, 4);
}
+13 -6
View File
@@ -132,6 +132,8 @@ export class RealtimeClient {
url = grant.connect_url; url = grant.connect_url;
} }
this.ensureOpen(); this.ensureOpen();
this.socket = new WebSocket(url);
this.socket.binaryType = "arraybuffer";
const ready = new Promise<void>((resolve, reject) => { const ready = new Promise<void>((resolve, reject) => {
this.resolveReady = resolve; this.resolveReady = resolve;
this.rejectReady = reject; this.rejectReady = reject;
@@ -150,8 +152,6 @@ export class RealtimeClient {
}, },
() => this.currentStatus === "ai-speaking", () => this.currentStatus === "ai-speaking",
); );
this.socket = new WebSocket(url);
this.socket.binaryType = "arraybuffer";
this.socket.addEventListener("message", (event: MessageEvent<unknown>) => { this.socket.addEventListener("message", (event: MessageEvent<unknown>) => {
this.incoming = this.incoming this.incoming = this.incoming
.then(async () => { .then(async () => {
@@ -182,8 +182,9 @@ export class RealtimeClient {
await Promise.all([audioReady, ready]); await Promise.all([audioReady, ready]);
this.ensureOpen(); this.ensureOpen();
} catch (error) { } catch (error) {
const failure = this.closed ? new SessionError("aborted", "La conexión se canceló.") : error;
await this.close(); await this.close();
throw error; throw failure;
} }
} }
@@ -386,7 +387,8 @@ export class RealtimeClient {
break; break;
case "conversation.item.input_audio_transcription.delta": { case "conversation.item.input_audio_transcription.delta": {
const itemId = event.item_id ?? "user-current"; const itemId = event.item_id ?? "user-current";
const text = (this.userSegments.get(itemId) ?? "") + (event.delta ?? ""); // This server sends the complete speculative transcription in each delta.
const text = event.delta ?? "";
this.userSegments.set(itemId, text); this.userSegments.set(itemId, text);
this.emit("transcript", { role: "user", text, partial: true, id: itemId }); this.emit("transcript", { role: "user", text, partial: true, id: itemId });
break; break;
@@ -445,7 +447,9 @@ export class RealtimeClient {
if (this.activeResponse === id) { if (this.activeResponse === id) {
this.activeResponse = ""; this.activeResponse = "";
} }
this.setStatus("listening"); if (this.currentStatus === "ai-speaking" || this.currentStatus === "processing") {
this.setStatus("listening");
}
this.flush(); this.flush();
break; break;
} }
@@ -509,6 +513,9 @@ export class RealtimeClient {
}); });
} }
requestResponse() { requestResponse() {
if (this.closed || !this.configured) {
return;
}
if (this.busy()) { if (this.busy()) {
this.pendingCreates++; this.pendingCreates++;
return; return;
@@ -536,7 +543,7 @@ export class RealtimeClient {
renderExternalAnswer(text: string) { renderExternalAnswer(text: string) {
if (this.activeResponse) { if (this.activeResponse) {
this.suppressed.add(this.activeResponse); this.suppressed.add(this.activeResponse);
} else if (this.createInFlight) { } else {
this.suppressNext = true; this.suppressNext = true;
} }
this.audio.clear(); this.audio.clear();
+24 -10
View File
@@ -76,6 +76,7 @@ export class VoiceController {
private sessionNumber = 0; private sessionNumber = 0;
private reasoned = new Set<string>(); private reasoned = new Set<string>();
private autoStarted = false; private autoStarted = false;
private pageSignal: AbortSignal | undefined;
constructor(caller = callerFromLocation()) { constructor(caller = callerFromLocation()) {
this.caller = caller; this.caller = caller;
@@ -118,6 +119,7 @@ export class VoiceController {
} }
async initialize(signal: AbortSignal) { async initialize(signal: AbortSignal) {
this.pageSignal = signal;
try { try {
const [config, profile, reports, account] = await Promise.all([ const [config, profile, reports, account] = await Promise.all([
getConfig(signal), getConfig(signal),
@@ -130,7 +132,7 @@ export class VoiceController {
} }
this.update({ config, profile, reports, account, loading: false, error: "" }); this.update({ config, profile, reports, account, loading: false, error: "" });
if (config.apipilot) { if (config.apipilot) {
await this.refreshTools(signal); await this.refreshTools(signal, !!profile);
} }
} catch (error) { } catch (error) {
if (!signal.aborted) { if (!signal.aborted) {
@@ -205,8 +207,11 @@ export class VoiceController {
void this.start(true); void this.start(true);
} }
} }
async start(automatic = false) { async start(automatic = false, preparedContext?: AudioContext, greeting?: string) {
if (this.client || this.state.loading) { if (this.client || this.state.loading) {
if (preparedContext) {
void preparedContext.close().catch(() => undefined);
}
return; return;
} }
this.update({ this.update({
@@ -224,7 +229,7 @@ export class VoiceController {
throw new Error("El micrófono necesita HTTPS o localhost y un navegador compatible."); throw new Error("El micrófono necesita HTTPS o localhost y un navegador compatible.");
} }
const target = connectionTarget(this.state.config, this.state.settings, this.caller); const target = connectionTarget(this.state.config, this.state.settings, this.caller);
context = createAudioContext(); context = preparedContext ?? createAudioContext();
if (automatic && context.state !== "running") { if (automatic && context.state !== "running") {
await context.close(); await context.close();
this.update({ status: "idle", unlock: true }); this.update({ status: "idle", unlock: true });
@@ -240,9 +245,11 @@ export class VoiceController {
instructions: speechInstructions(this.instructions(), this.state.config), instructions: speechInstructions(this.instructions(), this.state.config),
tools: this.tools(), tools: this.tools(),
gate: this.gate(), gate: this.gate(),
greeting: ["TALK", "TALKFIRST"].includes(this.state.config.startSessionMode.toUpperCase()) greeting:
? "hola vocero" greeting ??
: undefined, (["TALK", "TALKFIRST"].includes(this.state.config.startSessionMode.toUpperCase())
? "hola vocero"
: undefined),
}); });
this.client = client; this.client = client;
this.subscriptions = [ this.subscriptions = [
@@ -382,9 +389,7 @@ export class VoiceController {
transcript: transcript.text.trim(), transcript: transcript.text.trim(),
instructions: this.instructions(), instructions: this.instructions(),
language: "es-ES", language: "es-ES",
user_context: [config.injectProfile ? profileContext(profile) : "", ragContext(rag)] user_context: [profileContext(profile), ragContext(rag)].filter(Boolean).join("\n\n"),
.filter(Boolean)
.join("\n\n"),
phoneNumber: this.caller.phoneNumber || null, phoneNumber: this.caller.phoneNumber || null,
episode_id: this.caller.episodeId || null, episode_id: this.caller.episodeId || null,
}, },
@@ -488,8 +493,10 @@ export class VoiceController {
this.update({ muted }); this.update({ muted });
} }
async restart() { async restart() {
// Resume before awaiting teardown so browsers retain the click's audio permission.
const context = createAudioContext();
await this.stop(); await this.stop();
await this.start(); await this.start(false, context);
} }
async stop(status: ConnectionStatus = "closed") { async stop(status: ConnectionStatus = "closed") {
const client = this.client; const client = this.client;
@@ -513,5 +520,12 @@ export class VoiceController {
), ),
}); });
await client?.close(); await client?.close();
if (client && this.state.account?.enabled && this.pageSignal && !this.pageSignal.aborted) {
const signal = this.pageSignal;
const account = await getAccount(signal).catch(() => null);
if (!signal.aborted && account) {
this.update({ account });
}
}
} }
} }
+126
View File
@@ -1,3 +1,129 @@
@layer theme, base, components, utilities; @layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme); @import "tailwindcss/theme.css" layer(theme);
@import "./styles/legacy-theme.css" layer(components);
@import "tailwindcss/utilities.css" layer(utilities); @import "tailwindcss/utilities.css" layer(utilities);
@layer components {
.feedback-navigation {
align-self: flex-end;
display: flex;
}
#root {
display: contents;
}
.state-idle .ind-mic {
opacity: 1;
transform: scale(1);
}
html[data-theme="white"] .topbar-right[data-debug-enabled="true"] {
display: flex;
grid-column: 1 / -1;
grid-row: 2;
flex-wrap: wrap;
justify-content: center;
margin-top: 12px;
}
html[data-theme="white"] .white-mic-circle svg.wmc-mic {
fill: none;
}
#app:not(.has-reports) {
padding-left: 0;
}
.loading-screen {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: white;
color: #334155;
font: 500 16px system-ui;
z-index: 1000;
}
.app-error {
display: flex;
align-items: center;
gap: 12px;
margin: 12px auto;
padding: 10px 16px;
max-width: 680px;
border: 1px solid var(--error);
border-radius: 12px;
font-size: 13px;
background: var(--bg-elev);
}
.talk-unlocker {
border: 0;
width: 100%;
color: inherit;
font: inherit;
cursor: pointer;
}
.talk-unlocker-hint > span {
display: block;
}
.agent-tool-item {
padding: 12px 0;
border-bottom: 1px solid var(--border);
overflow-wrap: anywhere;
}
.queue-actions {
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.queue-actions .circle-subcaption {
flex-basis: 100%;
}
.reports-sidebar-backdrop {
border: 0;
cursor: pointer;
visibility: hidden;
}
.reports-sidebar-inner {
visibility: hidden;
}
.reports-sidebar {
pointer-events: none;
}
.reports-sidebar.open .reports-sidebar-inner,
.reports-sidebar.open .reports-sidebar-backdrop {
visibility: visible;
}
@media (min-width: 1024px) {
#reports-sidebar-close {
display: none;
}
.reports-sidebar-inner {
visibility: visible;
}
}
.bubble-body {
max-height: 240px;
overflow: auto;
}
.white-session-controls svg {
width: 18px;
height: 18px;
}
.mic-gate-arc:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 4px;
border-radius: 50%;
}
.user-profile-row {
display: grid;
grid-template-columns: 90px 1fr;
gap: 16px;
padding: 12px 0;
}
.user-profile-value {
margin: 0;
overflow-wrap: anywhere;
}
.user-profile-label {
color: var(--text-dim);
}
.hist-tool-block {
margin: 0;
}
}
File diff suppressed because it is too large Load Diff