refactor(frontend): isolate audio meters and apply settings on save

This commit is contained in:
2026-10-07 09:29:20 -03:00
parent 09bf6ece00
commit 0c57f7c76d
7 changed files with 240 additions and 105 deletions
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useSyncExternalStore } from "react";
import { initials, profileName } from "../lib/context";
import { profileRows } from "../lib/profile";
@@ -13,15 +13,17 @@ interface Props {
}
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)),
);
const [draft, setDraft] = useState(() => ({
directUrl: settings.directUrl,
voice: settings.voice,
instructions: settings.instructions,
}));
return (
<Dialog title="Configuración" onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
controller.changeSettings(draft);
onClose();
}}
>
@@ -35,10 +37,10 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
autoComplete="off"
spellCheck={false}
placeholder="http://localhost:8080"
value={config.s2sUrl || settings.directUrl}
value={config.s2sUrl || draft.directUrl}
readOnly={!!config.s2sUrl}
onChange={(event) => {
controller.changeSettings({ directUrl: event.target.value });
setDraft({ ...draft, directUrl: event.target.value });
}}
/>
<small>
@@ -52,9 +54,9 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
<label className="field">
<span>Voz</span>
<select
value={settings.voice}
value={draft.voice}
onChange={(event) => {
controller.changeSettings({ voice: event.target.value });
setDraft({ ...draft, voice: event.target.value });
}}
>
{VOICES.map((voice) => (
@@ -72,7 +74,7 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
</label>
<div className="gate">
<div className="gate-track">
<div className="gate-meter-fill" style={{ width: `${String(meter)}%` }} aria-hidden="true" />
<GateMeter controller={controller} />
<input
id="noise-gate"
aria-label="Umbral de la puerta de ruido"
@@ -92,18 +94,18 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
</div>
</div>
<small>
Silencia el micrófono por debajo del umbral para evitar enviar ruido. Desliza hasta la izquierda para
desactivarla.
Se aplica y guarda al instante. Silencia el micrófono por debajo del umbral. Desliza hasta la izquierda
para desactivarla.
</small>
</div>
<label className="field">
<span>Instrucciones</span>
<textarea
rows={5}
value={config.primaryPrompt || settings.instructions}
value={config.primaryPrompt || draft.instructions}
readOnly={!!config.primaryPrompt}
onChange={(event) => {
controller.changeSettings({ instructions: event.target.value });
setDraft({ ...draft, instructions: event.target.value });
}}
/>
{config.primaryPrompt && <small>Las instrucciones están definidas por el servidor.</small>}
@@ -114,17 +116,24 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
className="btn primary wide"
disabled={!LIVE_STATES.has(state.status)}
onClick={() => {
controller.changeSettings(draft);
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>
<small>
Al guardar, la voz y las instrucciones se aplican a la sesión actual. Los cambios de servidor requieren
una nueva conversación.
</small>
</div>
</div>
</div>
<footer className="modal-footer">
<button className="btn secondary" type="button" onClick={onClose}>
Cancelar
</button>
<button className="btn primary" type="submit">
Guardar
</button>
@@ -134,6 +143,15 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
);
}
function GateMeter({ controller }: { controller: VoiceController }) {
const level = useSyncExternalStore(controller.subscribeLevel, controller.getLevel);
const meter = Math.max(
0,
Math.min(100, ((20 * Math.log10(level || 0.00001) - GATE_OFF) * 100) / (GATE_MAX - GATE_OFF)),
);
return <div className="gate-meter-fill" style={{ width: `${String(meter)}%` }} aria-hidden="true" />;
}
export function RagDialog({ state, controller, onClose }: Props) {
const [text, setText] = useState(state.rag);
const [saved, setSaved] = useState(false);
@@ -193,69 +211,89 @@ export function RagDialog({ state, controller, onClose }: Props) {
}
export function ToolsDialog({ state, controller, onClose }: Props) {
const [draft, setDraft] = useState(() => ({
webSearch: state.settings.webSearch,
searchKey: state.settings.searchKey,
}));
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.
Permite que el asistente actúe durante la conversación. Los cambios se aplican al guardar.
</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>
<form
onSubmit={(event) => {
event.preventDefault();
controller.changeSettings(draft);
onClose();
}}
>
<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={draft.webSearch}
onChange={(event) => {
setDraft({ ...draft, webSearch: event.target.checked });
}}
/>
<span className="switch-track" aria-hidden="true" />
</label>
</div>
<label className="switch">
<label className="field tools-key">
<span>Clave de la API de búsqueda</span>
<input
type="checkbox"
aria-label="Búsqueda web"
checked={state.settings.webSearch}
type="password"
autoComplete="off"
value={draft.searchKey}
disabled={state.config.search}
onChange={(event) => {
controller.changeSettings({ webSearch: event.target.checked });
setDraft({ ...draft, searchKey: event.target.value });
}}
/>
<span className="switch-track" aria-hidden="true" />
<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>
<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 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>
<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>
<footer className="modal-footer">
<button className="btn secondary" type="button" onClick={onClose}>
Cancelar
</button>
<button className="btn primary" type="submit">
Guardar
</button>
</footer>
</form>
</Dialog>
);
}
+46 -27
View File
@@ -1,5 +1,5 @@
import { CircleAlert, Mic, MicOff, Square, Volume2 } from "lucide-react";
import { useEffect, useState, type CSSProperties, type PointerEvent } from "react";
import { useEffect, useState, useSyncExternalStore, type CSSProperties, type PointerEvent } from "react";
import type { ConnectionStatus } from "../lib/contracts";
import { GATE_MAX, GATE_OFF } from "../lib/settings";
@@ -98,6 +98,45 @@ function Waveform({ side }: { side: "left" | "right" }) {
</div>
);
}
function MicrophoneGate({
controller,
threshold,
muted,
live,
}: {
controller: VoiceController;
threshold: number;
muted: boolean;
live: boolean;
}) {
const level = useSyncExternalStore(controller.subscribeLevel, controller.getLevel);
return (
<div className={`mic-gate ${20 * Math.log10(level || 0.00001) >= threshold ? "gate-open" : ""}`}>
{live && (
<GateArc
threshold={threshold}
level={level}
onChange={(noiseGate) => {
controller.changeSettings({ noiseGate });
}}
/>
)}
<button
className={`side-btn ${muted ? "muted" : ""}`}
type="button"
disabled={!live}
tabIndex={live ? 0 : -1}
aria-label={muted ? "Activar micrófono" : "Silenciar micrófono"}
aria-pressed={muted}
onClick={() => {
controller.toggleMuted();
}}
>
{muted ? <MicOff /> : <Mic />}
</button>
</div>
);
}
function JoinCountdown({ deadline }: { deadline: number }) {
const [now, setNow] = useState(Date.now);
useEffect(() => {
@@ -195,32 +234,12 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
) : (
<>
<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>
<MicrophoneGate
controller={controller}
threshold={state.settings.noiseGate}
muted={state.muted}
live={live}
/>
<button
id="main-circle"
className={`circle state-${status}`}