feat(frontend): render Vocero screens and controls with React
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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" : ""}`} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 = "";
|
||||||
}
|
}
|
||||||
|
if (this.currentStatus === "ai-speaking" || this.currentStatus === "processing") {
|
||||||
this.setStatus("listening");
|
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();
|
||||||
|
|||||||
@@ -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:
|
||||||
|
greeting ??
|
||||||
|
(["TALK", "TALKFIRST"].includes(this.state.config.startSessionMode.toUpperCase())
|
||||||
? "hola vocero"
|
? "hola vocero"
|
||||||
: undefined,
|
: 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user