refactor(frontend): isolate audio meters and apply settings on save
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user