295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
import { BookOpen, ChartNoAxesColumn, ChevronRight, Info, PanelLeft, Settings, Wrench, X } from "lucide-react";
|
|
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
|
|
|
import { Bubbles } from "./components/Bubbles";
|
|
import { Conversation } from "./components/Conversation";
|
|
import { AboutDialog, 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() {
|
|
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">
|
|
<h1 className="m-0 leading-none">
|
|
<a className="ident-title" href={window.location.pathname + window.location.search}>
|
|
Vocero
|
|
</a>
|
|
</h1>
|
|
{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">
|
|
<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">
|
|
<h2>Reportes</h2>
|
|
<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">
|
|
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
|
|
<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} />}
|
|
<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>
|
|
</>
|
|
);
|
|
}
|