Files
vocero-client/s2s-frontend/src/App.tsx
T

312 lines
10 KiB
TypeScript

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() {
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">
<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">
<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} />}
{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>
</>
);
}