refactor(frontend): rename project to s2s-frontend
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user