From 36efd5e13cd90f9301dc9d974a025a74648e25b0 Mon Sep 17 00:00:00 2001 From: Alvaro Parker Date: Tue, 6 Oct 2026 16:24:50 -0300 Subject: [PATCH] feat(frontend): render Vocero screens and controls with React --- frontend-s2s/index.html | 6 + frontend-s2s/src/App.tsx | 308 +- frontend-s2s/src/audio/engine.ts | 2 + frontend-s2s/src/components/Account.tsx | 89 + frontend-s2s/src/components/Bubbles.tsx | 45 + frontend-s2s/src/components/Conversation.tsx | 140 + frontend-s2s/src/components/Dialog.tsx | 46 + .../src/components/SettingsDialogs.tsx | 362 + frontend-s2s/src/components/VoiceStage.tsx | 302 + frontend-s2s/src/hooks/use-wake-word.ts | 78 + frontend-s2s/src/lib/api.ts | 14 + frontend-s2s/src/lib/contracts.ts | 2 +- frontend-s2s/src/lib/profile.ts | 160 + frontend-s2s/src/lib/reports.ts | 51 + frontend-s2s/src/realtime/client.ts | 19 +- frontend-s2s/src/realtime/controller.ts | 34 +- frontend-s2s/src/styles.css | 126 + frontend-s2s/src/styles/legacy-theme.css | 6211 +++++++++++++++++ 18 files changed, 7977 insertions(+), 18 deletions(-) create mode 100644 frontend-s2s/src/components/Account.tsx create mode 100644 frontend-s2s/src/components/Bubbles.tsx create mode 100644 frontend-s2s/src/components/Conversation.tsx create mode 100644 frontend-s2s/src/components/Dialog.tsx create mode 100644 frontend-s2s/src/components/SettingsDialogs.tsx create mode 100644 frontend-s2s/src/components/VoiceStage.tsx create mode 100644 frontend-s2s/src/hooks/use-wake-word.ts create mode 100644 frontend-s2s/src/lib/profile.ts create mode 100644 frontend-s2s/src/lib/reports.ts create mode 100644 frontend-s2s/src/styles/legacy-theme.css diff --git a/frontend-s2s/index.html b/frontend-s2s/index.html index 8ffcf8f..f73ba56 100644 --- a/frontend-s2s/index.html +++ b/frontend-s2s/index.html @@ -5,6 +5,12 @@ Vocero · Conversación de voz + + +
diff --git a/frontend-s2s/src/App.tsx b/frontend-s2s/src/App.tsx index 7117862..cacc219 100644 --- a/frontend-s2s/src/App.tsx +++ b/frontend-s2s/src/App.tsx @@ -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() { - return
Vocero
; + const [controller] = useState(() => new VoiceController()); + const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot); + const [modal, setModal] = useState(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 ( +
+ Preparando Vocero… +
+ ); + } + return ( + <> + {state.unlock && ( + + )} +
+
+
+
+
+ + Vocero + + {white &&

Agente conversacional

} +
+
+
+ {white && name && ( + + )} + {hasReports && ( + + )} + {debug && ( +
+ + + + + +
+ )} +
+ + {white && ( +
+ +
+

Listo para conversar

+

Informes, análisis, datos y mucho más.

+
+ +
+ )} + {state.error && ( +
+ {state.error} + +
+ )} + + +
+ {!white && ACTIVE_STATES.has(state.status) && } + {hasReports && ( + + )} + {modal === "settings" && } + {modal === "tools" && } + {modal === "rag" && } + {modal === "profile" && } + {modal === "about" && } + {state.limitReached && ( + { + controller.dismissLimit(); + }} + /> + )} +
+ Azurian Vocero en tiempo real ver. Beta 2.1 +
+ + ); } diff --git a/frontend-s2s/src/audio/engine.ts b/frontend-s2s/src/audio/engine.ts index e1e7d7b..ec3cb8a 100644 --- a/frontend-s2s/src/audio/engine.ts +++ b/frontend-s2s/src/audio/engine.ts @@ -67,6 +67,7 @@ export class AudioEngine { if (event.data instanceof ArrayBuffer) { onChunk(event.data); } else { + document.documentElement.style.setProperty("--audio-level", String(Math.min(1, event.data.rms * 5))); onLevel(event.data.rms); } }; @@ -143,5 +144,6 @@ export class AudioEngine { document.documentElement.style.removeProperty(`--bar${index}`); } document.documentElement.style.removeProperty("--ai-audio-level"); + document.documentElement.style.removeProperty("--audio-level"); } } diff --git a/frontend-s2s/src/components/Account.tsx b/frontend-s2s/src/components/Account.tsx new file mode 100644 index 0000000..41f8b1d --- /dev/null +++ b/frontend-s2s/src/components/Account.tsx @@ -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(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 ? ( + + + Iniciar sesión + + ) : 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 ( +
+ + {open && ( +
+
{account.username}
+
+ {remaining} +
+ {!unlimited && ( + + Pasar a PRO + + )} + {account.logoutUrl && ( + + Cerrar sesión + + )} +
+ )} +
+ ); +} diff --git a/frontend-s2s/src/components/Bubbles.tsx b/frontend-s2s/src/components/Bubbles.tsx new file mode 100644 index 0000000..a08a7cb --- /dev/null +++ b/frontend-s2s/src/components/Bubbles.tsx @@ -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(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 ( +
+
+ {message.role === "user" ? userName : message.role === "tool" ? "Herramienta" : "Vocero"} +
+ {message.role === "assistant" ? ( +
+ ) : ( +
{message.role === "tool" ? message.toolName : message.text}
+ )} +
+ ); +} +export const Bubbles = memo(function Bubbles({ messages, userName }: { messages: Message[]; userName: string }) { + return ( + + ); +}); diff --git a/frontend-s2s/src/components/Conversation.tsx b/frontend-s2s/src/components/Conversation.tsx new file mode 100644 index 0000000..8dd222c --- /dev/null +++ b/frontend-s2s/src/components/Conversation.tsx @@ -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 ( +
+
Llamada a herramienta
+ +
+
Entrada
+
{message.toolArguments}
+
Salida
+
{message.text}
+
+
+ ); + } + const data = { + "data-user-name": userName, + "data-user-initials": initials(userName), + "data-asst-name": "Vocero", + "data-timestamp": time, + }; + return ( +
+
{message.role === "user" ? userName : "Vocero"}
+ {message.role === "assistant" ? ( +
+ ) : ( +
+ {message.text} +
+ )} + {message.interrupted &&
Interrumpido
} +
+ ); +}); +export const Conversation = memo(function Conversation({ + messages, + userName, + status, + white, +}: { + messages: Message[]; + userName: string; + status: ConnectionStatus; + white: boolean; +}) { + const history = useRef(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 ( +
+
+
+

Conversación

+
+ {white && ( + + + )} + +
+
+
{ + const element = history.current; + if (element) { + atBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 60; + } + }} + > + {messages.length ? ( + messages.map((message) => ) + ) : ( +
+
+ )} +
+
+
+ ); +}); diff --git a/frontend-s2s/src/components/Dialog.tsx b/frontend-s2s/src/components/Dialog.tsx new file mode 100644 index 0000000..987cd46 --- /dev/null +++ b/frontend-s2s/src/components/Dialog.tsx @@ -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(null); + const titleId = useId(); + useEffect(() => { + const dialog = ref.current; + dialog?.showModal(); + return () => { + dialog?.close(); + }; + }, []); + return ( + { + event.preventDefault(); + onClose(); + }} + onClick={(event) => { + if (event.target === event.currentTarget) { + onClose(); + } + }} + > +
+
+

{title}

+ +
+ {children} +
+
+ ); +} diff --git a/frontend-s2s/src/components/SettingsDialogs.tsx b/frontend-s2s/src/components/SettingsDialogs.tsx new file mode 100644 index 0000000..b5588eb --- /dev/null +++ b/frontend-s2s/src/components/SettingsDialogs.tsx @@ -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 ( + +
{ + event.preventDefault(); + onClose(); + }} + > +
+
+ {config.allowDirect && !config.lb && ( + + )} +
+ +
+
+ +
+
+ +
+ Desactivada + −3 dB +
+
+ + Silencia el micrófono por debajo del umbral para evitar enviar ruido. Desliza hasta la izquierda para + desactivarla. + +
+