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="theme-color" content="#07080d" />
|
||||
<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>
|
||||
<body>
|
||||
<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() {
|
||||
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) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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.";
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const reportsSchema = z.object({
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
sourceScheduleLabel: z.string().optional(),
|
||||
sourceScheduleLabel: z.string().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;
|
||||
}
|
||||
this.ensureOpen();
|
||||
this.socket = new WebSocket(url);
|
||||
this.socket.binaryType = "arraybuffer";
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
this.resolveReady = resolve;
|
||||
this.rejectReady = reject;
|
||||
@@ -150,8 +152,6 @@ export class RealtimeClient {
|
||||
},
|
||||
() => this.currentStatus === "ai-speaking",
|
||||
);
|
||||
this.socket = new WebSocket(url);
|
||||
this.socket.binaryType = "arraybuffer";
|
||||
this.socket.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
this.incoming = this.incoming
|
||||
.then(async () => {
|
||||
@@ -182,8 +182,9 @@ export class RealtimeClient {
|
||||
await Promise.all([audioReady, ready]);
|
||||
this.ensureOpen();
|
||||
} catch (error) {
|
||||
const failure = this.closed ? new SessionError("aborted", "La conexión se canceló.") : error;
|
||||
await this.close();
|
||||
throw error;
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +387,8 @@ export class RealtimeClient {
|
||||
break;
|
||||
case "conversation.item.input_audio_transcription.delta": {
|
||||
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.emit("transcript", { role: "user", text, partial: true, id: itemId });
|
||||
break;
|
||||
@@ -445,7 +447,9 @@ export class RealtimeClient {
|
||||
if (this.activeResponse === id) {
|
||||
this.activeResponse = "";
|
||||
}
|
||||
this.setStatus("listening");
|
||||
if (this.currentStatus === "ai-speaking" || this.currentStatus === "processing") {
|
||||
this.setStatus("listening");
|
||||
}
|
||||
this.flush();
|
||||
break;
|
||||
}
|
||||
@@ -509,6 +513,9 @@ export class RealtimeClient {
|
||||
});
|
||||
}
|
||||
requestResponse() {
|
||||
if (this.closed || !this.configured) {
|
||||
return;
|
||||
}
|
||||
if (this.busy()) {
|
||||
this.pendingCreates++;
|
||||
return;
|
||||
@@ -536,7 +543,7 @@ export class RealtimeClient {
|
||||
renderExternalAnswer(text: string) {
|
||||
if (this.activeResponse) {
|
||||
this.suppressed.add(this.activeResponse);
|
||||
} else if (this.createInFlight) {
|
||||
} else {
|
||||
this.suppressNext = true;
|
||||
}
|
||||
this.audio.clear();
|
||||
|
||||
@@ -76,6 +76,7 @@ export class VoiceController {
|
||||
private sessionNumber = 0;
|
||||
private reasoned = new Set<string>();
|
||||
private autoStarted = false;
|
||||
private pageSignal: AbortSignal | undefined;
|
||||
|
||||
constructor(caller = callerFromLocation()) {
|
||||
this.caller = caller;
|
||||
@@ -118,6 +119,7 @@ export class VoiceController {
|
||||
}
|
||||
|
||||
async initialize(signal: AbortSignal) {
|
||||
this.pageSignal = signal;
|
||||
try {
|
||||
const [config, profile, reports, account] = await Promise.all([
|
||||
getConfig(signal),
|
||||
@@ -130,7 +132,7 @@ export class VoiceController {
|
||||
}
|
||||
this.update({ config, profile, reports, account, loading: false, error: "" });
|
||||
if (config.apipilot) {
|
||||
await this.refreshTools(signal);
|
||||
await this.refreshTools(signal, !!profile);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
@@ -205,8 +207,11 @@ export class VoiceController {
|
||||
void this.start(true);
|
||||
}
|
||||
}
|
||||
async start(automatic = false) {
|
||||
async start(automatic = false, preparedContext?: AudioContext, greeting?: string) {
|
||||
if (this.client || this.state.loading) {
|
||||
if (preparedContext) {
|
||||
void preparedContext.close().catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
@@ -224,7 +229,7 @@ export class VoiceController {
|
||||
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);
|
||||
context = createAudioContext();
|
||||
context = preparedContext ?? createAudioContext();
|
||||
if (automatic && context.state !== "running") {
|
||||
await context.close();
|
||||
this.update({ status: "idle", unlock: true });
|
||||
@@ -240,9 +245,11 @@ export class VoiceController {
|
||||
instructions: speechInstructions(this.instructions(), this.state.config),
|
||||
tools: this.tools(),
|
||||
gate: this.gate(),
|
||||
greeting: ["TALK", "TALKFIRST"].includes(this.state.config.startSessionMode.toUpperCase())
|
||||
? "hola vocero"
|
||||
: undefined,
|
||||
greeting:
|
||||
greeting ??
|
||||
(["TALK", "TALKFIRST"].includes(this.state.config.startSessionMode.toUpperCase())
|
||||
? "hola vocero"
|
||||
: undefined),
|
||||
});
|
||||
this.client = client;
|
||||
this.subscriptions = [
|
||||
@@ -382,9 +389,7 @@ export class VoiceController {
|
||||
transcript: transcript.text.trim(),
|
||||
instructions: this.instructions(),
|
||||
language: "es-ES",
|
||||
user_context: [config.injectProfile ? profileContext(profile) : "", ragContext(rag)]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
user_context: [profileContext(profile), ragContext(rag)].filter(Boolean).join("\n\n"),
|
||||
phoneNumber: this.caller.phoneNumber || null,
|
||||
episode_id: this.caller.episodeId || null,
|
||||
},
|
||||
@@ -488,8 +493,10 @@ export class VoiceController {
|
||||
this.update({ muted });
|
||||
}
|
||||
async restart() {
|
||||
// Resume before awaiting teardown so browsers retain the click's audio permission.
|
||||
const context = createAudioContext();
|
||||
await this.stop();
|
||||
await this.start();
|
||||
await this.start(false, context);
|
||||
}
|
||||
async stop(status: ConnectionStatus = "closed") {
|
||||
const client = this.client;
|
||||
@@ -513,5 +520,12 @@ export class VoiceController {
|
||||
),
|
||||
});
|
||||
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;
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "./styles/legacy-theme.css" layer(components);
|
||||
@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