refactor: rename API and frontend projects to Vocero
Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts. Make AGENTS.md self-contained and remove external skill and workflow references.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { StrictMode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime", apipilot: true };
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, "", "/?debug=true&phoneNumber=1234");
|
||||
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(function (this: HTMLDialogElement) {
|
||||
this.open = true;
|
||||
});
|
||||
vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(function (this: HTMLDialogElement) {
|
||||
this.open = false;
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: string) => {
|
||||
const path = new URL(input, window.location.origin).pathname;
|
||||
const data: Record<string, unknown> = {
|
||||
"/api/config": bootstrap,
|
||||
"/api/user/phone": { result: { name: "Ana Vargas", contact: { emailAddress: "ana@example.test" } } },
|
||||
"/api/user/reports": {
|
||||
count: 1,
|
||||
entries: [{ sourceScheduleLabel: null, date: null }],
|
||||
hints: [],
|
||||
truncated: true,
|
||||
note: "Lista abreviada",
|
||||
},
|
||||
"/api/apipilot/tools": {
|
||||
success: true,
|
||||
data: { tools: [{ name: "get_reports", description: "Consulta tus reportes", parameters: {} }] },
|
||||
},
|
||||
};
|
||||
return Promise.resolve(new Response(JSON.stringify(data[path] ?? {})));
|
||||
}),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
describe("React application", () => {
|
||||
it("loads the configured theme and persists voice settings across remounts under StrictMode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const view = render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: "Configuración" }));
|
||||
expect(document.documentElement.dataset.theme).toBe("white");
|
||||
await user.selectOptions(screen.getByRole("combobox"), "Serena");
|
||||
expect(localStorage.getItem("s2s.ws.voice")).toBe("Serena");
|
||||
await user.click(screen.getByRole("button", { name: "Guardar" }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
view.unmount();
|
||||
render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: "Configuración" }));
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Serena");
|
||||
});
|
||||
it("saves and clears session-only RAG context and supports keyboard dialog dismissal", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await user.click(await screen.findByRole("button", { name: "Contexto RAG personalizado" }));
|
||||
await user.type(screen.getByRole("textbox"), "Datos de prueba para esta conversación");
|
||||
await user.click(screen.getByRole("button", { name: "Enviar a la sesión" }));
|
||||
expect(within(screen.getByRole("dialog")).getByRole("status")).toHaveTextContent("Contexto actualizado");
|
||||
fireEvent(screen.getByRole("dialog"), new Event("cancel", { bubbles: true }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Contexto RAG personalizado" }));
|
||||
expect(screen.getByRole("textbox")).toHaveValue("Datos de prueba para esta conversación");
|
||||
await user.click(screen.getByRole("button", { name: "Vaciar" }));
|
||||
expect(screen.getByRole("textbox")).toHaveValue("");
|
||||
});
|
||||
it("shows nested profile fields and discovers remote tools after bootstrap", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await user.click(await screen.findByRole("button", { name: "Ver datos de Ana Vargas" }));
|
||||
expect(within(screen.getByRole("dialog")).getByRole("link", { name: "ana@example.test" })).toHaveAttribute(
|
||||
"href",
|
||||
"mailto:ana@example.test",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Cerrar" }));
|
||||
await user.click(screen.getByRole("button", { name: "Herramientas" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("get_reports")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Consulta tus reportes")).toBeInTheDocument();
|
||||
});
|
||||
it("keeps debug controls out of the normal user screen", async () => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
render(<App />);
|
||||
expect(await screen.findByRole("button", { name: "Iniciar conversación de voz" })).toBeEnabled();
|
||||
expect(screen.queryByRole("button", { name: "Configuración" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Aún no hay mensajes")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { BookOpen, ChartNoAxesColumn, ChevronRight, Info, PanelLeft, Settings, Wrench, X } from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { Bubbles } from "./components/Bubbles";
|
||||
import { Conversation } from "./components/Conversation";
|
||||
import { AboutDialog, ProfileDialog, RagDialog, SettingsDialog, ToolsDialog } from "./components/SettingsDialogs";
|
||||
import { VoiceStage } from "./components/VoiceStage";
|
||||
import { useWakeWord } from "./hooks/use-wake-word";
|
||||
import { initials, profileName } from "./lib/context";
|
||||
import { reportSuggestions } from "./lib/reports";
|
||||
import { isDebug } from "./lib/settings";
|
||||
import { ACTIVE_STATES, VoiceController } from "./realtime/controller";
|
||||
|
||||
type Modal = "settings" | "tools" | "rag" | "profile" | "about" | null;
|
||||
export default function App() {
|
||||
const [controller] = useState(() => new VoiceController());
|
||||
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
const [reportsOpen, setReportsOpen] = useState(false);
|
||||
const debug = isDebug();
|
||||
const white = state.config.theme === "white";
|
||||
const name = profileName(state.profile);
|
||||
const hasReports = debug && !white && !!state.reports?.count;
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
void controller.initialize(abort.signal);
|
||||
const leave = () => {
|
||||
void controller.stop();
|
||||
};
|
||||
window.addEventListener("pagehide", leave);
|
||||
return () => {
|
||||
abort.abort();
|
||||
window.removeEventListener("pagehide", leave);
|
||||
void controller.stop();
|
||||
};
|
||||
}, [controller]);
|
||||
useLayoutEffect(() => {
|
||||
if (!state.loading) {
|
||||
document.documentElement.dataset.theme = white ? "white" : "dark";
|
||||
}
|
||||
}, [state.loading, white]);
|
||||
useEffect(() => {
|
||||
if (state.loading) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
controller.offerAutoStart();
|
||||
}, 2000);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [state.loading, controller]);
|
||||
useWakeWord(!state.loading && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock, controller);
|
||||
useEffect(() => {
|
||||
if (!reportsOpen) {
|
||||
return;
|
||||
}
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setReportsOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", escape);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", escape);
|
||||
};
|
||||
}, [reportsOpen]);
|
||||
const close = () => {
|
||||
setModal(null);
|
||||
};
|
||||
if (state.loading) {
|
||||
return (
|
||||
<main className="loading-screen" role="status">
|
||||
Preparando Vocero…
|
||||
</main>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{state.unlock && (
|
||||
<button
|
||||
className="talk-unlocker"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void controller.start();
|
||||
}}
|
||||
aria-label="Empezar a hablar"
|
||||
>
|
||||
<span className="talk-unlocker-hint">
|
||||
<span className="talk-unlocker-spinner" aria-hidden="true" />
|
||||
<span className="talk-unlocker-title">Preparando Vocero…</span>
|
||||
<span className="talk-unlocker-sub">Toca en cualquier lugar para empezar a hablar</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div id="app" className={hasReports ? "has-reports" : ""}>
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<div className="ident">
|
||||
<div className="ident-head">
|
||||
<h1 className="m-0 leading-none">
|
||||
<a className="ident-title" href={window.location.pathname + window.location.search}>
|
||||
Vocero
|
||||
</a>
|
||||
</h1>
|
||||
{white && <p className="ident-subtitle">Agente conversacional</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{white && name && (
|
||||
<button
|
||||
id="ident-user-name"
|
||||
className="ident-user-name"
|
||||
type="button"
|
||||
aria-label={`Ver datos de ${name}`}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => {
|
||||
setModal("profile");
|
||||
}}
|
||||
>
|
||||
{initials(name)}
|
||||
</button>
|
||||
)}
|
||||
{hasReports && (
|
||||
<button
|
||||
id="reports-sidebar-toggle"
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
aria-label="Abrir panel de reportes"
|
||||
aria-expanded={reportsOpen}
|
||||
aria-controls="reports-sidebar"
|
||||
onClick={() => {
|
||||
setReportsOpen(!reportsOpen);
|
||||
}}
|
||||
>
|
||||
<PanelLeft size={18} />
|
||||
<span id="reports-sidebar-badge" className="chat-badge" data-count={state.reports?.count}>
|
||||
{state.reports?.count}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{debug && (
|
||||
<div className="topbar-right" data-debug-enabled="true">
|
||||
<button
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
aria-label="Acerca de"
|
||||
onClick={() => {
|
||||
setModal("about");
|
||||
}}
|
||||
>
|
||||
<Info size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
aria-label="Herramientas"
|
||||
onClick={() => {
|
||||
setModal("tools");
|
||||
}}
|
||||
>
|
||||
<Wrench size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
aria-label="Contexto RAG personalizado"
|
||||
onClick={() => {
|
||||
setModal("rag");
|
||||
}}
|
||||
>
|
||||
<BookOpen size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
aria-label="Configuración"
|
||||
onClick={() => {
|
||||
setModal("settings");
|
||||
}}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<nav className="feedback-navigation" aria-label="Comentarios">
|
||||
<a className="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">
|
||||
Enviar comentarios
|
||||
</a>
|
||||
</nav>
|
||||
{white && (
|
||||
<div className="status-card" aria-label="Estado de la sesión">
|
||||
<div className="status-card-icon" aria-hidden="true">
|
||||
<ChartNoAxesColumn size={24} />
|
||||
</div>
|
||||
<div className="status-card-text">
|
||||
<h2 className="status-card-title">Listo para conversar</h2>
|
||||
<p className="status-card-sub">Informes, análisis, datos y mucho más.</p>
|
||||
</div>
|
||||
<button
|
||||
className="status-card-arrow icon-btn"
|
||||
type="button"
|
||||
aria-label="Ver más detalles"
|
||||
onClick={() => {
|
||||
setModal("about");
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={22} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{state.error && (
|
||||
<div className="app-error" role="alert">
|
||||
<span>{state.error}</span>
|
||||
<button
|
||||
className="icon-btn shrink-0"
|
||||
type="button"
|
||||
aria-label="Cerrar aviso"
|
||||
onClick={() => {
|
||||
controller.dismissError();
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<VoiceStage state={state} controller={controller} />
|
||||
<Conversation messages={state.messages} userName={name || "Usuario"} status={state.status} white={white} />
|
||||
</div>
|
||||
{!white && ACTIVE_STATES.has(state.status) && <Bubbles messages={state.messages} userName={name || "Usuario"} />}
|
||||
{hasReports && (
|
||||
<aside
|
||||
id="reports-sidebar"
|
||||
className={`reports-sidebar ${reportsOpen ? "open" : ""}`}
|
||||
aria-label="Reportes programados"
|
||||
>
|
||||
<button
|
||||
className="reports-sidebar-backdrop"
|
||||
type="button"
|
||||
aria-label="Cerrar panel de reportes"
|
||||
tabIndex={reportsOpen ? 0 : -1}
|
||||
onClick={() => {
|
||||
setReportsOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div className="reports-sidebar-inner">
|
||||
<header className="reports-sidebar-header">
|
||||
<h2>Reportes</h2>
|
||||
<button
|
||||
id="reports-sidebar-close"
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
aria-label="Cerrar reportes"
|
||||
onClick={() => {
|
||||
setReportsOpen(false);
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="reports-hint">
|
||||
<p>{state.reports?.count} reportes disponibles</p>
|
||||
<ul>
|
||||
{state.reports?.hints.map((hint, index) => (
|
||||
<li key={index}>{hint}</li>
|
||||
))}
|
||||
</ul>
|
||||
{state.reports?.note && <p>{state.reports.note}</p>}
|
||||
</div>
|
||||
{state.reports && (
|
||||
<div className="reports-suggestions">
|
||||
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
|
||||
<ul className="reports-suggestions-list">
|
||||
{reportSuggestions(state.reports).map((suggestion) => (
|
||||
<li key={suggestion}>{suggestion}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
{modal === "settings" && <SettingsDialog state={state} controller={controller} onClose={close} />}
|
||||
{modal === "tools" && <ToolsDialog state={state} controller={controller} onClose={close} />}
|
||||
{modal === "rag" && <RagDialog state={state} controller={controller} onClose={close} />}
|
||||
{modal === "profile" && <ProfileDialog state={state} onClose={close} />}
|
||||
{modal === "about" && <AboutDialog onClose={close} />}
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
type PlaybackMessage =
|
||||
{ kind: "config"; inputRate: number } | { kind: "audio"; samples: Float32Array } | { kind: "clear" };
|
||||
|
||||
class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
||||
private rate = 16000;
|
||||
private queue: Float32Array[] = [];
|
||||
private offset = 0;
|
||||
private fraction = 0;
|
||||
private playing = false;
|
||||
private fadeIn = 0;
|
||||
private fadeOut = 0;
|
||||
private lastSample = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.port.onmessage = (event: MessageEvent<PlaybackMessage>) => {
|
||||
const data = event.data;
|
||||
switch (data.kind) {
|
||||
case "config":
|
||||
if (data.inputRate > 0) {
|
||||
this.rate = data.inputRate;
|
||||
}
|
||||
break;
|
||||
case "audio":
|
||||
if (data.samples.length) {
|
||||
this.queue.push(data.samples);
|
||||
}
|
||||
break;
|
||||
case "clear":
|
||||
this.queue = [];
|
||||
this.offset = 0;
|
||||
this.fraction = 0;
|
||||
this.fadeOut = 32;
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
private bufferedMs() {
|
||||
return (this.queue.reduce((sum, buffer) => sum + buffer.length, -this.offset) / this.rate) * 1000;
|
||||
}
|
||||
private read(): number | null {
|
||||
const head = this.queue[0];
|
||||
if (!head) {
|
||||
return null;
|
||||
}
|
||||
const a = head[this.offset] ?? 0;
|
||||
const b = head[this.offset + 1] ?? this.queue[1]?.[0] ?? a;
|
||||
return a + (b - a) * this.fraction;
|
||||
}
|
||||
private advance() {
|
||||
this.fraction += this.rate / sampleRate;
|
||||
while (this.fraction >= 1) {
|
||||
this.fraction--;
|
||||
this.offset++;
|
||||
}
|
||||
while (this.queue[0] && this.offset >= this.queue[0].length) {
|
||||
this.offset -= this.queue[0].length;
|
||||
this.queue.shift();
|
||||
}
|
||||
}
|
||||
process(_inputs: Float32Array[][], outputs: Float32Array[][]) {
|
||||
const channels = outputs[0];
|
||||
const output = channels?.[0];
|
||||
if (!output) {
|
||||
return true;
|
||||
}
|
||||
for (let index = 0; index < output.length; index++) {
|
||||
let value = 0;
|
||||
if (!this.playing && this.queue.length && this.bufferedMs() >= 90) {
|
||||
this.playing = true;
|
||||
this.fadeIn = 32;
|
||||
this.fadeOut = 0;
|
||||
}
|
||||
if (this.playing) {
|
||||
const sample = this.read();
|
||||
if (sample === null) {
|
||||
value = this.lastSample * (1 - 1 / 32);
|
||||
this.lastSample = value;
|
||||
if (Math.abs(value) < 1e-4) {
|
||||
this.playing = false;
|
||||
this.lastSample = 0;
|
||||
}
|
||||
} else {
|
||||
value = sample;
|
||||
this.lastSample = sample;
|
||||
this.advance();
|
||||
}
|
||||
if (this.fadeIn > 0) {
|
||||
value *= 1 - this.fadeIn-- / 32;
|
||||
}
|
||||
if (this.fadeOut > 0) {
|
||||
value *= this.fadeOut-- / 32;
|
||||
if (!this.fadeOut) {
|
||||
this.playing = false;
|
||||
this.lastSample = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
output[index] = value;
|
||||
const stereo = channels?.[1];
|
||||
if (stereo) {
|
||||
stereo[index] = value;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor("audio-playback", AudioPlaybackProcessor);
|
||||
@@ -0,0 +1,23 @@
|
||||
export function encodePcm(buffer: ArrayBuffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
export function decodePcm(base64: string) {
|
||||
const binary = atob(base64);
|
||||
const buffer = new ArrayBuffer(binary.length);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
const view = new DataView(buffer);
|
||||
const samples = new Float32Array(Math.floor(binary.length / 2));
|
||||
for (let index = 0; index < samples.length; index++) {
|
||||
const value = view.getInt16(index * 2, true);
|
||||
samples[index] = value < 0 ? value / 0x8000 : value / 0x7fff;
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { NoiseGate } from "../lib/contracts";
|
||||
import playbackUrl from "./audio-playback.worklet.ts?worker&url";
|
||||
import { decodePcm } from "./codec";
|
||||
import captureUrl from "./mic-capture.worklet.ts?worker&url";
|
||||
|
||||
const BANDS = [2, 5, 9, 16, 28, 52];
|
||||
export const MIC_CONSTRAINTS: MediaStreamConstraints = {
|
||||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||
video: false,
|
||||
};
|
||||
|
||||
/** Construct inside the click handler, before awaiting microphone permission. */
|
||||
export function createAudioContext() {
|
||||
const context = new AudioContext({ latencyHint: "interactive" });
|
||||
void context.resume().catch(() => undefined);
|
||||
return context;
|
||||
}
|
||||
|
||||
export class AudioEngine {
|
||||
private readonly context: AudioContext;
|
||||
private stream: MediaStream | null = null;
|
||||
private capture: AudioWorkletNode | null = null;
|
||||
private playback: AudioWorkletNode | null = null;
|
||||
private nodes: AudioNode[] = [];
|
||||
private frame = 0;
|
||||
private closed = false;
|
||||
private gate: NoiseGate;
|
||||
private muted = false;
|
||||
|
||||
constructor(context: AudioContext, gate: NoiseGate) {
|
||||
this.context = context;
|
||||
this.gate = gate;
|
||||
}
|
||||
|
||||
async start(onChunk: (chunk: ArrayBuffer) => void, onLevel: (rms: number) => void, speaking: () => boolean) {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
|
||||
if (this.closed) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.stream = stream;
|
||||
await Promise.all([
|
||||
this.context.audioWorklet.addModule(captureUrl),
|
||||
this.context.audioWorklet.addModule(playbackUrl),
|
||||
]);
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
const capture = new AudioWorkletNode(this.context, "mic-capture", { numberOfInputs: 1, numberOfOutputs: 0 });
|
||||
const playback = new AudioWorkletNode(this.context, "audio-playback", {
|
||||
numberOfInputs: 0,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [1],
|
||||
});
|
||||
// The checked-in speech pipeline emits native 16 kHz PCM, despite older 24 kHz comments.
|
||||
playback.port.postMessage({ kind: "config", inputRate: 16000 });
|
||||
this.capture = capture;
|
||||
this.playback = playback;
|
||||
this.setGate(this.gate);
|
||||
this.setMuted(this.muted);
|
||||
capture.port.onmessage = (event: MessageEvent<ArrayBuffer | { kind: "level"; rms: number }>) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
const input = this.context.createMediaStreamSource(stream);
|
||||
const micAnalyser = this.context.createAnalyser();
|
||||
const outAnalyser = this.context.createAnalyser();
|
||||
micAnalyser.fftSize = outAnalyser.fftSize = 256;
|
||||
micAnalyser.smoothingTimeConstant = 0;
|
||||
outAnalyser.smoothingTimeConstant = 0.3;
|
||||
input.connect(capture);
|
||||
input.connect(micAnalyser);
|
||||
playback.connect(outAnalyser);
|
||||
outAnalyser.connect(this.context.destination);
|
||||
this.nodes = [input, capture, playback, micAnalyser, outAnalyser];
|
||||
const buffer = new Uint8Array(micAnalyser.frequencyBinCount);
|
||||
const levels = new Float32Array(5);
|
||||
const tick = () => {
|
||||
const source = speaking() ? outAnalyser : micAnalyser;
|
||||
source.getByteFrequencyData(buffer);
|
||||
for (let band = 0; band < 5; band++) {
|
||||
const lo = BANDS[band] ?? 0;
|
||||
const hi = BANDS[band + 1] ?? 52;
|
||||
let sum = 0;
|
||||
for (let index = lo; index < hi; index++) {
|
||||
sum += buffer[index] ?? 0;
|
||||
}
|
||||
const target = sum / ((hi - lo) * 255);
|
||||
const previous = levels[band] ?? 0;
|
||||
levels[band] = previous + (target - previous) * (target > previous ? 0.6 : 0.18);
|
||||
document.documentElement.style.setProperty(`--bar${band}`, String(levels[band]));
|
||||
}
|
||||
outAnalyser.getByteFrequencyData(buffer);
|
||||
document.documentElement.style.setProperty("--ai-audio-level", String(Math.max(...buffer.subarray(0, 52)) / 255));
|
||||
this.frame = requestAnimationFrame(tick);
|
||||
};
|
||||
this.frame = requestAnimationFrame(tick);
|
||||
}
|
||||
play(base64: string) {
|
||||
if (!base64 || this.closed) {
|
||||
return;
|
||||
}
|
||||
const samples = decodePcm(base64);
|
||||
this.playback?.port.postMessage({ kind: "audio", samples }, [samples.buffer]);
|
||||
}
|
||||
clear() {
|
||||
this.playback?.port.postMessage({ kind: "clear" });
|
||||
}
|
||||
setGate(gate: NoiseGate) {
|
||||
this.gate = gate;
|
||||
this.capture?.port.postMessage({ kind: "gate", ...gate });
|
||||
}
|
||||
setMuted(muted: boolean) {
|
||||
this.muted = muted;
|
||||
this.stream?.getAudioTracks().forEach((track) => {
|
||||
track.enabled = !muted;
|
||||
});
|
||||
this.capture?.port.postMessage({ kind: "enable", value: !muted });
|
||||
}
|
||||
async close() {
|
||||
this.closed = true;
|
||||
cancelAnimationFrame(this.frame);
|
||||
this.stream?.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
this.capture?.port.close();
|
||||
this.playback?.port.close();
|
||||
this.nodes.forEach((node) => {
|
||||
node.disconnect();
|
||||
});
|
||||
if (this.context.state !== "closed") {
|
||||
await this.context.close();
|
||||
}
|
||||
for (let index = 0; index < 5; index++) {
|
||||
document.documentElement.style.removeProperty(`--bar${index}`);
|
||||
}
|
||||
document.documentElement.style.removeProperty("--ai-audio-level");
|
||||
document.documentElement.style.removeProperty("--audio-level");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
const TARGET_RATE = 16000;
|
||||
type Control = { kind: "enable"; value: boolean } | { kind: "gate"; enabled: boolean; thresholdDb: number };
|
||||
|
||||
/** Preserve the existing 40 ms PCM16 frames and smoothed gate envelope. */
|
||||
class MicCaptureProcessor extends AudioWorkletProcessor {
|
||||
private readonly ratio = sampleRate / TARGET_RATE;
|
||||
private readonly chunkSize = 640;
|
||||
private scratch = new Float32Array(0);
|
||||
private readonly decimated = new Float32Array(this.chunkSize);
|
||||
private enabled = true;
|
||||
private gateEnabled = false;
|
||||
private threshold = 0;
|
||||
private gain = 1;
|
||||
private hold = 0;
|
||||
private readonly attack = Math.exp(-1 / (0.005 * TARGET_RATE));
|
||||
private readonly release = Math.exp(-1 / (0.08 * TARGET_RATE));
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.port.onmessage = (event: MessageEvent<Control>) => {
|
||||
if (event.data.kind === "enable") {
|
||||
this.enabled = event.data.value;
|
||||
} else {
|
||||
this.gateEnabled = event.data.enabled;
|
||||
this.threshold = event.data.enabled ? 10 ** (event.data.thresholdDb / 20) : 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
process(inputs: Float32Array[][]) {
|
||||
const incoming = inputs[0]?.[0];
|
||||
if (!incoming?.length) {
|
||||
return true;
|
||||
}
|
||||
const next = new Float32Array(this.scratch.length + incoming.length);
|
||||
next.set(this.scratch);
|
||||
next.set(incoming, this.scratch.length);
|
||||
this.scratch = next;
|
||||
const needed = Math.ceil(this.chunkSize * this.ratio);
|
||||
while (this.scratch.length >= needed) {
|
||||
let sum = 0;
|
||||
for (let index = 0; index < this.chunkSize; index++) {
|
||||
const position = index * this.ratio;
|
||||
const start = Math.floor(position);
|
||||
const a = this.scratch[start] ?? 0;
|
||||
const value =
|
||||
Math.abs(this.ratio - 3) < 1e-6
|
||||
? (a + (this.scratch[start + 1] ?? 0) + (this.scratch[start + 2] ?? 0)) / 3
|
||||
: a + ((this.scratch[start + 1] ?? a) - a) * (position - start);
|
||||
this.decimated[index] = value;
|
||||
sum += value * value;
|
||||
}
|
||||
const rms = Math.sqrt(sum / this.chunkSize);
|
||||
let target = 1;
|
||||
if (this.gateEnabled) {
|
||||
if (rms >= this.threshold) {
|
||||
this.hold = TARGET_RATE * 0.25;
|
||||
} else if (this.hold > 0) {
|
||||
this.hold -= this.chunkSize;
|
||||
} else {
|
||||
target = 0;
|
||||
}
|
||||
}
|
||||
const output = new Int16Array(this.chunkSize);
|
||||
for (let index = 0; index < this.chunkSize; index++) {
|
||||
const coefficient = target > this.gain ? this.attack : this.release;
|
||||
this.gain = target + (this.gain - target) * coefficient;
|
||||
const value = Math.max(-1, Math.min(1, (this.decimated[index] ?? 0) * this.gain));
|
||||
output[index] = value < 0 ? value * 0x8000 : value * 0x7fff;
|
||||
}
|
||||
this.scratch = this.scratch.slice(Math.floor(this.chunkSize * this.ratio));
|
||||
this.port.postMessage({ kind: "level", rms });
|
||||
if (this.enabled) {
|
||||
this.port.postMessage(output.buffer, [output.buffer]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor("mic-capture", MicCaptureProcessor);
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
const sampleRate: number;
|
||||
abstract class AudioWorkletProcessor {
|
||||
readonly port: MessagePort;
|
||||
abstract process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean;
|
||||
}
|
||||
function registerProcessor(
|
||||
name: string,
|
||||
processor: new (options?: AudioWorkletNodeOptions) => AudioWorkletProcessor,
|
||||
): void;
|
||||
}
|
||||
@@ -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">
|
||||
<h2>Conversación</h2>
|
||||
<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,337 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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" : ""}`} role="group" 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]);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { configSchema, recordSchema, reportsSchema, toolsEnvelopeSchema } from "./contracts";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: unknown;
|
||||
|
||||
constructor(status: number, body: unknown) {
|
||||
const data = recordSchema.safeParse(body);
|
||||
super(data.success && typeof data.data.detail === "string" ? data.data.detail : `Error del servidor (${status}).`);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, schema: z.ZodType<T>, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, init);
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, body);
|
||||
}
|
||||
return schema.parse(body);
|
||||
}
|
||||
|
||||
export function post<T>(path: string, schema: z.ZodType<T>, body: unknown, signal?: AbortSignal) {
|
||||
return request(path, schema, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export async function optionalRequest<T>(path: string, schema: z.ZodType<T>, signal: AbortSignal) {
|
||||
try {
|
||||
return await request(path, schema, { signal });
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
// Profile and reports are optional in generic deployments.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(signal: AbortSignal) {
|
||||
return request("/api/config", configSchema, { signal });
|
||||
}
|
||||
export async function getProfile(phone: string, signal: AbortSignal) {
|
||||
if (!phone) {
|
||||
return null;
|
||||
}
|
||||
const data = await optionalRequest(
|
||||
`/api/user/phone?${new URLSearchParams({ phoneNumber: phone })}`,
|
||||
recordSchema,
|
||||
signal,
|
||||
);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
const result = recordSchema.safeParse(data.result);
|
||||
return result.success ? result.data : data;
|
||||
}
|
||||
export function getReports(phone: string, signal: AbortSignal) {
|
||||
const query = phone ? `?${new URLSearchParams({ phoneNumber: phone })}` : "";
|
||||
return optionalRequest(`/api/user/reports${query}`, reportsSchema, signal);
|
||||
}
|
||||
export async function getTools(signal: AbortSignal, refresh = false) {
|
||||
const result = await request(
|
||||
`/api/apipilot/tools?include_unauthorized=true&refresh=${String(refresh)}`,
|
||||
toolsEnvelopeSchema,
|
||||
{ signal },
|
||||
);
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error("No se pudieron cargar las herramientas.");
|
||||
}
|
||||
return result.data.tools;
|
||||
}
|
||||
|
||||
export const reasoningSchema = z.object({ answer: z.string().default("") });
|
||||
export const searchSchema = z.object({
|
||||
answer: z.string().nullish(),
|
||||
results: z.array(z.object({ title: z.string(), snippet: z.string().default(""), url: z.string() })).default([]),
|
||||
});
|
||||
|
||||
/** Teardown must survive pagehide and must not hold up a new session. */
|
||||
export function cancelQueue(queueId: string) {
|
||||
const path = "/api/queue/end";
|
||||
const payload = JSON.stringify({ queueId });
|
||||
if (
|
||||
typeof navigator.sendBeacon === "function" &&
|
||||
navigator.sendBeacon(path, new Blob([payload], { type: "application/json" }))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
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.";
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { recordSchema, type DynamicTool, type FunctionTool, type Profile, type ServerConfig } from "./contracts";
|
||||
import type { Settings } from "./settings";
|
||||
|
||||
export function profileName(profile: Profile | null): string {
|
||||
if (!profile) {
|
||||
return "";
|
||||
}
|
||||
for (const key of ["name", "fullName", "full_name", "displayName", "display_name", "username"]) {
|
||||
const value = profile[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
const full = [profile.firstName, profile.lastName]
|
||||
.filter((part): part is string => typeof part === "string")
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (full) {
|
||||
return full;
|
||||
}
|
||||
for (const key of ["user", "customer", "profile", "contact"]) {
|
||||
const nested = recordSchema.safeParse(profile[key]);
|
||||
if (nested.success) {
|
||||
const name = profileName(nested.data);
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
export function initials(name: string) {
|
||||
const words = name.trim().split(/\s+/);
|
||||
return (words.length > 1 ? `${words[0]?.[0] ?? ""}${words.at(-1)?.[0] ?? ""}` : name.slice(0, 2)).toUpperCase();
|
||||
}
|
||||
export function profileContext(profile: Profile | null) {
|
||||
if (!profile) {
|
||||
return "";
|
||||
}
|
||||
const serialized = JSON.stringify(profile);
|
||||
return `Caller profile resolved from the page phoneNumber: ${serialized.length > 1600 ? `${serialized.slice(0, 1600)}...` : serialized}. Use this as trusted caller context when relevant.`;
|
||||
}
|
||||
export function ragContext(text: string) {
|
||||
return text.trim() ? `<<<CUSTOM_RAG_BEGIN>>>\n${text.trim()}\n<<<CUSTOM_RAG_END>>>` : "";
|
||||
}
|
||||
export const TOOL_MARKER = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>";
|
||||
export function toolContext(tools: DynamicTool[]) {
|
||||
if (!tools.length) {
|
||||
return "";
|
||||
}
|
||||
const entries = tools.slice(0, 100).map((tool) => {
|
||||
const required = Array.isArray(tool.parameters.required)
|
||||
? tool.parameters.required.filter((key): key is string => typeof key === "string")
|
||||
: [];
|
||||
return `- name: ${tool.name}\n requires_auth: ${String(tool.requires_auth)}${required.length ? `\n required_parameters: [${required.slice(0, 40).join(", ")}]` : ""}\n description: ${tool.description.slice(0, 500)}`;
|
||||
});
|
||||
return `${TOOL_MARKER}\n### Available dynamic tools (ApiPilot AquaPortal)\nEach tool name is a unique identifier: DO NOT change, camelCase or translate it.\nMANDATORY EXECUTION RULES:\n1. If the user request matches a tool description, output ONLY one line as the FIRST line of your response:\n<<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1"}>>>\n2. ARGUMENTS must be valid JSON. Include ONLY fields listed in required_parameters, if any.\n3. Do NOT add text after the marker. The engine executes the tool and supplies its result in a second call.\n4. If no description matches, respond normally in Spanish without a marker.\n5. If multiple descriptions match, choose the most specific.\n${entries.join("\n")}\n<<<APIPILOT_TOOLS_LIST_END>>>`;
|
||||
}
|
||||
export function buildInstructions(
|
||||
config: ServerConfig,
|
||||
settings: Settings,
|
||||
profile: Profile | null,
|
||||
rag: string,
|
||||
tools: DynamicTool[],
|
||||
) {
|
||||
const base = config.primaryPrompt || settings.instructions;
|
||||
return [
|
||||
base,
|
||||
config.injectProfile ? profileContext(profile) : "",
|
||||
ragContext(rag),
|
||||
base.includes(TOOL_MARKER) ? "" : toolContext(tools),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
export function speechInstructions(instructions: string, config: ServerConfig) {
|
||||
return config.reasoningType === "OPENAI"
|
||||
? `${instructions}\nDo not answer ordinary user speech directly. Stay silent unless the latest user message starts with [VOCERO_RENDER_REPLY]. When it does, speak only the content after the marker naturally. Do not call tools while rendering that message.`
|
||||
: instructions;
|
||||
}
|
||||
export const WEB_SEARCH: FunctionTool = {
|
||||
type: "function",
|
||||
name: "web_search",
|
||||
description: "Search the web for current or factual information. Returns results with titles, snippets and URLs.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string", description: "The search query." } },
|
||||
required: ["query"],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const recordSchema = z.record(z.string(), z.unknown());
|
||||
export type Profile = z.infer<typeof recordSchema>;
|
||||
|
||||
export const configSchema = z.object({
|
||||
search: z.boolean().default(false),
|
||||
lb: z.boolean().default(false),
|
||||
allowDirect: z.boolean().default(true),
|
||||
s2sUrl: z.string().default(""),
|
||||
primaryPrompt: z.string().default(""),
|
||||
reasoningType: z.enum(["PROXY", "OPENAI"]).catch("PROXY"),
|
||||
injectProfile: z.boolean().default(false),
|
||||
debugPrompt: z.boolean().default(false),
|
||||
startSessionMode: z.string().default(""),
|
||||
theme: z.string().default(""),
|
||||
apipilot: z.boolean().default(false),
|
||||
});
|
||||
export type ServerConfig = z.infer<typeof configSchema>;
|
||||
export const defaultConfig = configSchema.parse({});
|
||||
|
||||
export const reportsSchema = z.object({
|
||||
count: z.number().default(0),
|
||||
hints: z.array(z.string()).default([]),
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
sourceScheduleLabel: z.string().nullish(),
|
||||
date: z.union([z.string(), z.number()]).nullish(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
reason: z.string().optional(),
|
||||
truncated: z.boolean().optional(),
|
||||
note: z.string().optional(),
|
||||
});
|
||||
export type Reports = z.infer<typeof reportsSchema>;
|
||||
|
||||
export const toolSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().default(""),
|
||||
parameters: recordSchema.default({}),
|
||||
requires_auth: z.boolean().default(false),
|
||||
});
|
||||
export type DynamicTool = z.infer<typeof toolSchema>;
|
||||
export interface FunctionTool {
|
||||
type: "function";
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
export const toolsEnvelopeSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.object({ tools: z.array(toolSchema), count: z.number().optional() }).nullish(),
|
||||
error: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const grantSchema = z.object({
|
||||
session_id: z.string(),
|
||||
connect_url: z.string(),
|
||||
pending_timeout_s: z.number().default(60),
|
||||
});
|
||||
export type SessionGrant = z.infer<typeof grantSchema>;
|
||||
export const queueSchema = z.object({
|
||||
state: z.literal("queued"),
|
||||
queue_id: z.string(),
|
||||
position: z.number().default(0),
|
||||
poll_interval_s: z.number().default(2),
|
||||
});
|
||||
export const sessionSchema = z.union([queueSchema, grantSchema]);
|
||||
|
||||
export type ConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "queued"
|
||||
| "your-turn"
|
||||
| "listening"
|
||||
| "user-speaking"
|
||||
| "processing"
|
||||
| "ai-speaking"
|
||||
| "closed"
|
||||
| "error";
|
||||
export interface Transcript {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
partial: boolean;
|
||||
id: string;
|
||||
}
|
||||
export interface FinishedResponse {
|
||||
responseId: string;
|
||||
status: string;
|
||||
transcript: string;
|
||||
audible: boolean;
|
||||
}
|
||||
export interface ToolCall {
|
||||
name: string;
|
||||
arguments: string;
|
||||
callId: string;
|
||||
}
|
||||
export interface NoiseGate {
|
||||
enabled: boolean;
|
||||
thresholdDb: number;
|
||||
}
|
||||
|
||||
const contentSchema = z.object({
|
||||
type: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
transcript: z.string().optional(),
|
||||
});
|
||||
// The wire protocol is additive; unknown event fields are ignored, while consumed fields are validated.
|
||||
export const realtimeEventSchema = z.object({
|
||||
type: z.string(),
|
||||
response_id: z.string().optional(),
|
||||
item_id: z.string().optional(),
|
||||
delta: z.string().optional(),
|
||||
transcript: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
arguments: z.string().optional(),
|
||||
call_id: z.string().optional(),
|
||||
response: z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
output: z.array(z.object({ content: z.array(contentSchema).optional() })).optional(),
|
||||
})
|
||||
.optional(),
|
||||
part: contentSchema.optional(),
|
||||
error: z
|
||||
.object({ type: z.string().optional(), code: z.string().optional(), message: z.string().optional() })
|
||||
.optional(),
|
||||
});
|
||||
export type RealtimeEvent = z.infer<typeof realtimeEventSchema>;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { conversationText, renderMarkdown, serializeConversation } from "./messages";
|
||||
|
||||
describe("assistant Markdown and export", () => {
|
||||
it("renders formatting and accessible tables while keeping embedded HTML passive", () => {
|
||||
const html = renderMarkdown(
|
||||
"## Resumen\n\n**Bien**\n\n| A | B |\n|---|---|\n|1|2|\n\n<script>alert(1)</script>\n\n[malicioso](javascript:alert%281%29)\n\n[reporte](https://example.test)",
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = html;
|
||||
expect(container.querySelector("h2")?.textContent).toBe("Resumen");
|
||||
expect(container.querySelector("strong")?.textContent).toBe("Bien");
|
||||
expect(container.querySelector("[role='region']")?.getAttribute("tabindex")).toBe("0");
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect(container.querySelector("[href^='javascript:']")).toBeNull();
|
||||
expect(container.querySelector("a")?.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
expect(container.textContent).toContain("<script>alert(1)</script>");
|
||||
});
|
||||
it("exports the original Markdown and interrupted/tool records without scraping the UI", () => {
|
||||
const conversation = serializeConversation(
|
||||
[
|
||||
{ id: "1", role: "user", text: "Hola", partial: false, timestamp: "2026-10-06T15:00:00Z" },
|
||||
{
|
||||
id: "2",
|
||||
role: "assistant",
|
||||
text: "**Respuesta** parcial",
|
||||
partial: false,
|
||||
interrupted: true,
|
||||
timestamp: "2026-10-06T15:00:01Z",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
role: "tool",
|
||||
text: "Resultado",
|
||||
toolName: "web_search",
|
||||
toolArguments: '{"query":"hoy"}',
|
||||
partial: false,
|
||||
timestamp: "2026-10-06T15:00:02Z",
|
||||
},
|
||||
],
|
||||
"Ana Vargas",
|
||||
);
|
||||
expect(conversation.messageCount).toBe(3);
|
||||
expect(conversation.messages[0]).toMatchObject({ name: "Ana Vargas", initials: "AV" });
|
||||
expect(conversation.messages[1]).toMatchObject({ text: "**Respuesta** parcial", interrupted: true });
|
||||
expect(conversationText(conversation)).toContain("[interrumpida]");
|
||||
expect(conversationText(conversation)).toContain('Entrada:\n{"query":"hoy"}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { Marked } from "marked";
|
||||
|
||||
import { initials } from "./context";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "tool";
|
||||
text: string;
|
||||
timestamp: string;
|
||||
partial: boolean;
|
||||
interrupted?: boolean;
|
||||
toolName?: string;
|
||||
toolArguments?: string;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string) {
|
||||
return text.replace(
|
||||
/[&<>"']/g,
|
||||
(character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character] ?? character,
|
||||
);
|
||||
}
|
||||
const markdown = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: { html: ({ text }) => escapeHtml(text), checkbox: ({ checked }) => (checked ? "☑ " : "☐ ") },
|
||||
});
|
||||
|
||||
/** Only passive, sanitized Markdown is allowed inside an assistant message. */
|
||||
export function renderMarkdown(text: string): string {
|
||||
if (!DOMPurify.isSupported) {
|
||||
return escapeHtml(text);
|
||||
}
|
||||
try {
|
||||
const fragment = DOMPurify.sanitize(markdown.parse(text, { async: false }), {
|
||||
ALLOWED_TAGS: [
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"em",
|
||||
"del",
|
||||
"a",
|
||||
"img",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"blockquote",
|
||||
"pre",
|
||||
"code",
|
||||
"hr",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
],
|
||||
ALLOWED_ATTR: ["href", "title", "src", "alt", "start", "align"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_ARIA_ATTR: false,
|
||||
RETURN_DOM_FRAGMENT: true,
|
||||
});
|
||||
for (const link of fragment.querySelectorAll("a")) {
|
||||
if (!link.hasAttribute("href")) {
|
||||
link.replaceWith(...link.childNodes);
|
||||
} else {
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
}
|
||||
}
|
||||
for (const image of fragment.querySelectorAll("img")) {
|
||||
image.loading = "lazy";
|
||||
}
|
||||
for (const table of fragment.querySelectorAll("table")) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "markdown-table";
|
||||
wrapper.tabIndex = 0;
|
||||
wrapper.setAttribute("role", "region");
|
||||
wrapper.setAttribute("aria-label", "Tabla de la respuesta");
|
||||
table.replaceWith(wrapper);
|
||||
wrapper.append(table);
|
||||
}
|
||||
for (const code of fragment.querySelectorAll("pre")) {
|
||||
code.tabIndex = 0;
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
container.append(fragment);
|
||||
return container.innerHTML;
|
||||
} catch {
|
||||
return escapeHtml(text);
|
||||
}
|
||||
}
|
||||
|
||||
export function displayTime(timestamp: string) {
|
||||
return new Intl.DateTimeFormat("es-CL", { timeZone: "America/Santiago", hour: "2-digit", minute: "2-digit" }).format(
|
||||
new Date(timestamp),
|
||||
);
|
||||
}
|
||||
export function serializeConversation(messages: Message[], userName: string) {
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
messageCount: messages.length,
|
||||
messages: messages.map((message) =>
|
||||
message.role === "tool"
|
||||
? {
|
||||
type: "tool" as const,
|
||||
timestamp: message.timestamp,
|
||||
name: message.toolName ?? "",
|
||||
args: message.toolArguments ?? "",
|
||||
output: message.text,
|
||||
}
|
||||
: {
|
||||
type: message.role,
|
||||
timestamp: message.timestamp,
|
||||
text: message.text,
|
||||
...(message.role === "user"
|
||||
? { name: userName || "Usuario", initials: initials(userName || "Usuario") }
|
||||
: {}),
|
||||
...(message.interrupted ? { interrupted: true } : {}),
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
export function conversationText(conversation: ReturnType<typeof serializeConversation>) {
|
||||
const lines = [
|
||||
`Conversación Vocero — ${conversation.exportedAt}`,
|
||||
`Mensajes totales: ${String(conversation.messageCount)}`,
|
||||
"—".repeat(52),
|
||||
"",
|
||||
];
|
||||
for (const message of conversation.messages) {
|
||||
if (message.type === "tool") {
|
||||
lines.push(
|
||||
`${message.timestamp} HERRAMIENTA: ${message.name ?? ""}`,
|
||||
`Entrada:\n${message.args ?? ""}`,
|
||||
`Salida:\n${message.output ?? ""}`,
|
||||
"",
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`${message.timestamp} ${message.type === "user" ? (message.name ?? "Usuario") : "Vocero"}${message.interrupted ? " [interrumpida]" : ""}:`,
|
||||
message.text ?? "",
|
||||
"",
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
export function exportConversation(messages: Message[], userName: string) {
|
||||
const conversation = serializeConversation(messages, userName);
|
||||
const name = `vocero-conversacion-${conversation.exportedAt.replace(/[:.]/g, "-")}`;
|
||||
const downloads = [
|
||||
{ content: JSON.stringify(conversation, null, 2), extension: "json", mime: "application/json" },
|
||||
{ content: conversationText(conversation), extension: "txt", mime: "text/plain;charset=utf-8" },
|
||||
];
|
||||
for (const { content, extension, mime } of downloads) {
|
||||
const url = URL.createObjectURL(new Blob([content], { type: mime }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${name}.${extension}`;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { recordSchema, type ServerConfig } from "./contracts";
|
||||
|
||||
export const VOICES = ["Aiden", "Ryan", "Dylan", "Eric", "Ono_Anna", "Serena", "Sohee", "Uncle_Fu", "Vivian"];
|
||||
export const DEFAULT_INSTRUCTIONS =
|
||||
"You are Vocero, a friendly voice assistant. Keep replies short, warm, and spoken. Avoid long monologues. Respond ALWAYS and ONLY in Spanish, regardless of the language used by the user or any other instruction. No responses in Italian, English, Portuguese, or any other language are allowed. Every spoken sentence, greeting, confirmation, clarification, and error message must be in Spanish. The comma sign ',' in numbers is used to separate thousands. The point sign '.' in numbers is used to separate decimals. If you don't know the answer, say so in Spanish instead of guessing.";
|
||||
export const GATE_OFF = -66;
|
||||
export const GATE_MAX = -3;
|
||||
export interface Settings {
|
||||
directUrl: string;
|
||||
voice: string;
|
||||
instructions: string;
|
||||
noiseGate: number;
|
||||
webSearch: boolean;
|
||||
searchKey: string;
|
||||
}
|
||||
|
||||
function read(key: string) {
|
||||
try {
|
||||
return localStorage.getItem(`s2s.ws.${key}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function loadSettings(): Settings {
|
||||
const threshold = read("noiseGate");
|
||||
const number = threshold === null || threshold === "" ? -50 : Number(threshold);
|
||||
let webSearch = true;
|
||||
try {
|
||||
const data = recordSchema.parse(JSON.parse(read("tools") ?? "{}"));
|
||||
webSearch = typeof data.web_search === "boolean" ? data.web_search : true;
|
||||
} catch {
|
||||
/* Ignore unavailable or older storage. */
|
||||
}
|
||||
return {
|
||||
directUrl: read("directUrl") ?? "",
|
||||
voice: read("voice") || "Aiden",
|
||||
instructions: read("instructions") || DEFAULT_INSTRUCTIONS,
|
||||
noiseGate: Number.isFinite(number) ? Math.min(GATE_MAX, Math.max(GATE_OFF, Math.round(number))) : -50,
|
||||
webSearch,
|
||||
searchKey: read("searchKey") ?? "",
|
||||
};
|
||||
}
|
||||
export function saveSettings(settings: Settings) {
|
||||
try {
|
||||
for (const key of ["directUrl", "voice", "instructions", "noiseGate", "searchKey"] as const) {
|
||||
localStorage.setItem(`s2s.ws.${key}`, String(settings[key]));
|
||||
}
|
||||
localStorage.setItem("s2s.ws.tools", JSON.stringify({ web_search: settings.webSearch }));
|
||||
} catch {
|
||||
/* The current session still works when browser storage is disabled. */
|
||||
}
|
||||
}
|
||||
|
||||
export interface Caller {
|
||||
phoneNumber: string;
|
||||
episodeId: string;
|
||||
}
|
||||
export function callerFromLocation(search = window.location.search): Caller {
|
||||
const query = new URLSearchParams(search);
|
||||
return { phoneNumber: (query.get("phoneNumber") ?? "").trim(), episodeId: (query.get("episode_id") ?? "").trim() };
|
||||
}
|
||||
export function realtimeUrl(raw: string, caller: Caller): string {
|
||||
let value = raw.trim();
|
||||
if (!value) {
|
||||
throw new Error("Introduce la URL del servidor de voz en Configuración.");
|
||||
}
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
value = value.replace(/^http/i, "ws");
|
||||
}
|
||||
if (!/^wss?:\/\//i.test(value)) {
|
||||
value = (/^(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(value) ? "ws://" : "wss://") + value;
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (url.pathname === "/") {
|
||||
url.pathname = "/v1/realtime";
|
||||
}
|
||||
if (caller.phoneNumber) {
|
||||
url.searchParams.set("phoneNumber", caller.phoneNumber);
|
||||
}
|
||||
if (caller.episodeId) {
|
||||
url.searchParams.set("episode_id", caller.episodeId);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
export function connectionTarget(config: ServerConfig, settings: Settings, caller: Caller) {
|
||||
return config.lb || !config.allowDirect
|
||||
? { sessionUrl: "/api/session" }
|
||||
: { directUrl: realtimeUrl(config.s2sUrl || settings.directUrl, caller) };
|
||||
}
|
||||
export function isDebug(search = window.location.search) {
|
||||
const value = new URLSearchParams(search).get("debug")?.toLowerCase();
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) {
|
||||
throw new Error("The application root was not found.");
|
||||
}
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,437 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AudioEngine } from "../audio/engine";
|
||||
|
||||
import { RealtimeClient, SessionError, type ClientOptions } from "./client";
|
||||
|
||||
const audio = vi.hoisted(() => ({
|
||||
start: vi.fn<AudioEngine["start"]>(),
|
||||
close: vi.fn<() => Promise<void>>(),
|
||||
play: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
setMuted: vi.fn(),
|
||||
setGate: vi.fn(),
|
||||
}));
|
||||
vi.mock("../audio/engine", () => ({
|
||||
MIC_CONSTRAINTS: { audio: true },
|
||||
AudioEngine: class {
|
||||
start = audio.start;
|
||||
close = audio.close;
|
||||
play = audio.play;
|
||||
clear = audio.clear;
|
||||
setMuted = audio.setMuted;
|
||||
setGate = audio.setGate;
|
||||
},
|
||||
}));
|
||||
|
||||
class Socket extends EventTarget {
|
||||
static OPEN = 1;
|
||||
static instances: Socket[] = [];
|
||||
readyState = 1;
|
||||
binaryType = "";
|
||||
sent: Record<string, unknown>[] = [];
|
||||
readonly url: string;
|
||||
constructor(url: string) {
|
||||
super();
|
||||
this.url = url;
|
||||
Socket.instances.push(this);
|
||||
}
|
||||
send(text: string) {
|
||||
this.sent.push(JSON.parse(text) as Record<string, unknown>);
|
||||
}
|
||||
close = vi.fn(() => {
|
||||
this.readyState = 3;
|
||||
});
|
||||
receive(value: unknown) {
|
||||
this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(value) }));
|
||||
}
|
||||
}
|
||||
const stopTrack = vi.fn();
|
||||
const getUserMedia = vi.fn();
|
||||
const sendBeacon = vi.fn(() => true);
|
||||
const options = (): ClientOptions => ({
|
||||
directUrl: "ws://localhost:8420/v1/realtime",
|
||||
context: { resume: vi.fn().mockResolvedValue(undefined) } as unknown as AudioContext,
|
||||
voice: "Aiden",
|
||||
instructions: "Habla español",
|
||||
tools: [],
|
||||
gate: { enabled: false, thresholdDb: -66 },
|
||||
});
|
||||
|
||||
async function connect(client: RealtimeClient, pending = client.connect()) {
|
||||
await vi.waitFor(() => {
|
||||
expect(Socket.instances).toHaveLength(1);
|
||||
});
|
||||
const socket = Socket.instances[0];
|
||||
if (!socket) {
|
||||
throw new Error("Missing socket");
|
||||
}
|
||||
socket.receive({ type: "session.created" });
|
||||
await vi.waitFor(() => {
|
||||
expect(socket.sent[0]?.type).toBe("session.update");
|
||||
});
|
||||
await pending;
|
||||
return socket;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
Socket.instances = [];
|
||||
audio.start.mockResolvedValue(undefined);
|
||||
audio.close.mockResolvedValue(undefined);
|
||||
getUserMedia.mockResolvedValue({ getTracks: () => [{ stop: stopTrack }] });
|
||||
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia }, sendBeacon });
|
||||
vi.stubGlobal("WebSocket", Socket);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("realtime session", () => {
|
||||
it("starts microphone streaming without a session.updated acknowledgment", async () => {
|
||||
const client = new RealtimeClient(options());
|
||||
const pending = client.connect();
|
||||
const settled = pending.catch(() => undefined);
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(audio.start).toHaveBeenCalledOnce();
|
||||
});
|
||||
const socket = Socket.instances[0];
|
||||
const capture = audio.start.mock.calls[0]?.[0];
|
||||
if (!socket || !capture) {
|
||||
throw new Error("Missing socket or microphone callback");
|
||||
}
|
||||
const pcm = new ArrayBuffer(2);
|
||||
capture(pcm);
|
||||
expect(socket.sent).toHaveLength(0);
|
||||
|
||||
socket.receive({ type: "session.created" });
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("listening");
|
||||
});
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
capture(pcm);
|
||||
expect(socket.sent).toEqual([
|
||||
expect.objectContaining({ type: "session.update" }),
|
||||
{ type: "input_audio_buffer.append", audio: "AAA=" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(21000);
|
||||
expect(client.status).toBe("listening");
|
||||
expect(socket.close).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await client.close();
|
||||
await settled;
|
||||
}
|
||||
expect(audio.close).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("times out and releases the microphone when the server never creates a session", async () => {
|
||||
const client = new RealtimeClient(options());
|
||||
const rejected = expect(client.connect()).rejects.toThrow("El servidor de voz no respondió a tiempo.");
|
||||
await vi.waitFor(() => {
|
||||
expect(audio.start).toHaveBeenCalledOnce();
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(20000);
|
||||
await rejected;
|
||||
expect(Socket.instances[0]?.sent).toHaveLength(0);
|
||||
expect(Socket.instances[0]?.close).toHaveBeenCalledExactlyOnceWith(1000, "client closed");
|
||||
expect(audio.close).toHaveBeenCalledOnce();
|
||||
expect(client.status).toBe("closed");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("connects with a plain load-balancer grant and closes without further HTTP requests", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
session_id: "local-session",
|
||||
connect_url: "ws://localhost/v1/realtime",
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new RealtimeClient({ ...options(), directUrl: undefined, sessionUrl: "/api/session" });
|
||||
const error = vi.fn();
|
||||
client.on("error", error);
|
||||
const socket = await connect(client);
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
expect(client.status).toBe("listening");
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
expect(socket.url).toBe("ws://localhost/v1/realtime");
|
||||
await client.close();
|
||||
expect(socket.close).toHaveBeenCalledOnce();
|
||||
expect(audio.close).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/session", expect.objectContaining({ method: "POST" }));
|
||||
expect(sendBeacon).not.toHaveBeenCalled();
|
||||
expect(client.status).toBe("closed");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["ws://voice.test/v1/realtime", "ws://localhost:8420/v1/realtime"])(
|
||||
"starts and cleans up the direct or proxied socket at %s",
|
||||
async (directUrl) => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new RealtimeClient({ ...options(), directUrl });
|
||||
const socket = await connect(client);
|
||||
expect(socket.url).toBe(directUrl);
|
||||
expect(audio.start).toHaveBeenCalledOnce();
|
||||
await client.close();
|
||||
await client.close();
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(1000, "client closed");
|
||||
expect(audio.close).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(sendBeacon).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("polls a queue ticket and joins the granted slot before its deadline", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<(path: string, init?: RequestInit) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1", position: 2 })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1", position: 1 })))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ session_id: "s1", connect_url: "ws://voice.test", pending_timeout_s: 10 })),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new RealtimeClient({ ...options(), directUrl: undefined });
|
||||
const readyToJoin = vi.fn();
|
||||
client.on("readyToJoin", readyToJoin);
|
||||
const pending = client.connect();
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("queued");
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/session", "/api/queue/q1", "/api/queue/q1"]);
|
||||
expect(client.status).toBe("your-turn");
|
||||
expect(readyToJoin).toHaveBeenCalledWith(expect.objectContaining({ expiresSec: 7 }));
|
||||
expect(Socket.instances).toHaveLength(0);
|
||||
client.join();
|
||||
await connect(client, pending);
|
||||
await client.close();
|
||||
expect(sendBeacon).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("expires an unclaimed join offer after releasing the permission probe's microphone", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" })))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ session_id: "s1", connect_url: "ws://voice.test", pending_timeout_s: 10 })),
|
||||
),
|
||||
);
|
||||
const client = new RealtimeClient({ ...options(), directUrl: undefined });
|
||||
const rejected = expect(client.connect()).rejects.toMatchObject({ code: "join-expired" });
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("queued");
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(9000);
|
||||
await rejected;
|
||||
expect(stopTrack).toHaveBeenCalledOnce();
|
||||
expect(audio.start).not.toHaveBeenCalled();
|
||||
expect(Socket.instances).toHaveLength(0);
|
||||
expect(client.status).toBe("closed");
|
||||
expect(sendBeacon).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("stops polling when the load balancer expires a queue ticket", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "expired" }), { status: 404 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new RealtimeClient({ ...options(), directUrl: undefined });
|
||||
const rejected = expect(client.connect()).rejects.toMatchObject({ code: "queue-expired" });
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("queued");
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await rejected;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(Socket.instances).toHaveLength(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("uses a keepalive request to cancel a queue ticket when sendBeacon declines it", async () => {
|
||||
sendBeacon.mockReturnValueOnce(false);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new RealtimeClient({ ...options(), directUrl: undefined });
|
||||
const rejected = expect(client.connect()).rejects.toMatchObject({ code: "aborted" });
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("queued");
|
||||
});
|
||||
await client.close();
|
||||
await rejected;
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
"/api/queue/end",
|
||||
expect.objectContaining({ method: "POST", body: JSON.stringify({ queueId: "q1" }), keepalive: true }),
|
||||
);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("replaces cumulative user transcript deltas instead of duplicating words", async () => {
|
||||
const client = new RealtimeClient(options());
|
||||
const transcript = vi.fn();
|
||||
client.on("transcript", transcript);
|
||||
const socket = await connect(client);
|
||||
socket.receive({ type: "conversation.item.input_audio_transcription.delta", item_id: "user1", delta: "Hola" });
|
||||
socket.receive({
|
||||
type: "conversation.item.input_audio_transcription.delta",
|
||||
item_id: "user1",
|
||||
delta: "Hola Vocero",
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(transcript).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(transcript).toHaveBeenLastCalledWith({ role: "user", text: "Hola Vocero", partial: true, id: "user1" });
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("suppresses the next native reply when external reasoning wins before response.created", async () => {
|
||||
const client = new RealtimeClient(options());
|
||||
const transcript = vi.fn();
|
||||
client.on("transcript", transcript);
|
||||
const socket = await connect(client);
|
||||
client.renderExternalAnswer("Respuesta externa");
|
||||
socket.receive({ type: "response.created", response: { id: "native" } });
|
||||
socket.receive({ type: "response.audio.delta", response_id: "native", delta: "AAAA" });
|
||||
socket.receive({ type: "response.audio_transcript.delta", response_id: "native", delta: "Respuesta duplicada" });
|
||||
socket.receive({ type: "response.done", response: { id: "native", status: "completed" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("listening");
|
||||
});
|
||||
expect(audio.play).not.toHaveBeenCalled();
|
||||
expect(transcript).not.toHaveBeenCalled();
|
||||
expect(socket.sent.at(-1)).toMatchObject({ item: { role: "assistant", content: [{ text: "Respuesta externa" }] } });
|
||||
await client.close();
|
||||
});
|
||||
it("sends configuration before greeting without an acknowledgment and serializes response requests", async () => {
|
||||
const client = new RealtimeClient({ ...options(), greeting: "hola vocero" });
|
||||
const socket = await connect(client);
|
||||
expect(socket.sent.map((event) => event.type)).toEqual([
|
||||
"session.update",
|
||||
"conversation.item.create",
|
||||
"response.create",
|
||||
]);
|
||||
socket.receive({ type: "response.created", response: { id: "r1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("processing");
|
||||
});
|
||||
const sentCount = socket.sent.length;
|
||||
socket.receive({ type: "session.updated" });
|
||||
socket.receive({ type: "session.updated" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(client.status).toBe("processing");
|
||||
expect(socket.sent).toHaveLength(sentCount);
|
||||
client.sendUserText("Otra pregunta");
|
||||
client.requestResponse();
|
||||
expect(socket.sent.filter((event) => event.type === "response.create")).toHaveLength(1);
|
||||
socket.receive({ type: "response.done", response: { id: "r1", status: "completed" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(socket.sent.filter((event) => event.type === "response.create")).toHaveLength(2);
|
||||
});
|
||||
expect(socket.sent.at(-2)).toMatchObject({
|
||||
type: "conversation.item.create",
|
||||
item: { role: "user", content: [{ text: "Otra pregunta" }] },
|
||||
});
|
||||
await client.close();
|
||||
expect(audio.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("assembles streaming transcripts and preserves an interrupted response", async () => {
|
||||
const client = new RealtimeClient(options());
|
||||
const transcripts = vi.fn();
|
||||
const finished = vi.fn();
|
||||
client.on("transcript", transcripts);
|
||||
client.on("finished", finished);
|
||||
const socket = await connect(client);
|
||||
socket.receive({ type: "response.created", response: { id: "reply" } });
|
||||
socket.receive({ type: "response.audio_transcript.delta", response_id: "reply", delta: "Hola " });
|
||||
socket.receive({ type: "response.audio_transcript.delta", response_id: "reply", delta: "mundo" });
|
||||
socket.receive({ type: "response.audio.delta", response_id: "reply", delta: "AAAA" });
|
||||
socket.receive({ type: "input_audio_buffer.speech_started" });
|
||||
socket.receive({ type: "response.done", response: { id: "reply", status: "cancelled" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(finished).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(transcripts).toHaveBeenLastCalledWith({ role: "assistant", id: "reply", text: "Hola mundo", partial: true });
|
||||
expect(finished).toHaveBeenCalledWith({
|
||||
responseId: "reply",
|
||||
transcript: "Hola mundo",
|
||||
status: "cancelled",
|
||||
audible: true,
|
||||
});
|
||||
expect(audio.clear).toHaveBeenCalled();
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("does not open a socket after cancellation during microphone permission", async () => {
|
||||
let allow: ((value: unknown) => void) | undefined;
|
||||
getUserMedia.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
allow = resolve;
|
||||
}),
|
||||
);
|
||||
const client = new RealtimeClient(options());
|
||||
const pending = client.connect();
|
||||
const rejected = expect(pending).rejects.toMatchObject({ code: "aborted" });
|
||||
await client.close();
|
||||
allow?.({ getTracks: () => [{ stop: stopTrack }] });
|
||||
await rejected;
|
||||
expect(stopTrack).toHaveBeenCalledOnce();
|
||||
expect(Socket.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("releases a queue ticket when leaving before admission", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ state: "queued", queue_id: "q1", position: 2 })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new RealtimeClient({ ...options(), directUrl: undefined, sessionUrl: "/api/session" });
|
||||
const pending = client.connect();
|
||||
const rejected = expect(pending).rejects.toBeInstanceOf(SessionError);
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("queued");
|
||||
});
|
||||
await client.close();
|
||||
await rejected;
|
||||
expect(sendBeacon).toHaveBeenCalledWith("/api/queue/end", expect.any(Blob));
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(Socket.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends each tool call once and returns its result using the realtime protocol", async () => {
|
||||
const client = new RealtimeClient(options());
|
||||
const tool = vi.fn();
|
||||
client.on("tool", tool);
|
||||
const socket = await connect(client);
|
||||
const call = {
|
||||
type: "response.function_call_arguments.done",
|
||||
name: "web_search",
|
||||
arguments: '{"query":"hoy"}',
|
||||
call_id: "call1",
|
||||
};
|
||||
socket.receive(call);
|
||||
socket.receive(call);
|
||||
await vi.waitFor(() => {
|
||||
expect(tool).toHaveBeenCalledOnce();
|
||||
});
|
||||
client.sendToolOutput("call1", "Resultado");
|
||||
expect(socket.sent.at(-2)).toMatchObject({
|
||||
item: { type: "function_call_output", call_id: "call1", output: "Resultado" },
|
||||
});
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,569 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { encodePcm } from "../audio/codec";
|
||||
import { AudioEngine, MIC_CONSTRAINTS } from "../audio/engine";
|
||||
import { ApiError, cancelQueue, post, request } from "../lib/api";
|
||||
import {
|
||||
grantSchema,
|
||||
realtimeEventSchema,
|
||||
sessionSchema,
|
||||
type ConnectionStatus,
|
||||
type FinishedResponse,
|
||||
type FunctionTool,
|
||||
type NoiseGate,
|
||||
type RealtimeEvent,
|
||||
type SessionGrant,
|
||||
type ToolCall,
|
||||
type Transcript,
|
||||
} from "../lib/contracts";
|
||||
|
||||
interface Events {
|
||||
status: ConnectionStatus;
|
||||
transcript: Transcript;
|
||||
finished: FinishedResponse;
|
||||
tool: ToolCall;
|
||||
level: number;
|
||||
queue: { position: number; queueId: string };
|
||||
grant: SessionGrant;
|
||||
readyToJoin: { grant: SessionGrant; expiresSec: number };
|
||||
error: Error;
|
||||
serverError: Error;
|
||||
}
|
||||
export interface ClientOptions {
|
||||
directUrl?: string;
|
||||
sessionUrl?: string;
|
||||
voice: string;
|
||||
instructions: string;
|
||||
tools: FunctionTool[];
|
||||
gate: NoiseGate;
|
||||
context: AudioContext;
|
||||
greeting?: string;
|
||||
}
|
||||
|
||||
export class SessionError extends Error {
|
||||
readonly code: "queue-full" | "queue-expired" | "join-expired" | "aborted";
|
||||
constructor(code: SessionError["code"], message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.name = "SessionError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns one complete session, including pending acquisition, audio, queue tickets and teardown. */
|
||||
export class RealtimeClient {
|
||||
private readonly options: ClientOptions;
|
||||
private readonly events = new EventTarget();
|
||||
private readonly abort = new AbortController();
|
||||
private readonly audio: AudioEngine;
|
||||
private socket: WebSocket | null = null;
|
||||
private currentStatus: ConnectionStatus = "idle";
|
||||
private configured = false;
|
||||
private closed = false;
|
||||
private closing: Promise<void> | null = null;
|
||||
private queueId = "";
|
||||
private joinResolve: (() => void) | null = null;
|
||||
private joinReject: ((reason: Error) => void) | null = null;
|
||||
private joinTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private incoming: Promise<void> = Promise.resolve();
|
||||
private activeResponses = new Set<string>();
|
||||
private activeResponse = "";
|
||||
private createInFlight = false;
|
||||
private pendingCreates = 0;
|
||||
private deferredTexts: string[] = [];
|
||||
private suppressed = new Set<string>();
|
||||
private suppressNext = false;
|
||||
private audible = new Set<string>();
|
||||
private segments = new Map<string, string>();
|
||||
private completed = new Map<string, string>();
|
||||
private userSegments = new Map<string, string>();
|
||||
private toolCalls = new Set<string>();
|
||||
private resolveReady: (() => void) | null = null;
|
||||
private rejectReady: ((reason: Error) => void) | null = null;
|
||||
private readyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
constructor(options: ClientOptions) {
|
||||
this.options = options;
|
||||
this.audio = new AudioEngine(options.context, options.gate);
|
||||
}
|
||||
get status() {
|
||||
return this.currentStatus;
|
||||
}
|
||||
on<K extends keyof Events>(name: K, listener: (detail: Events[K]) => void) {
|
||||
const handler = (event: Event) => {
|
||||
listener((event as CustomEvent<Events[K]>).detail);
|
||||
};
|
||||
this.events.addEventListener(name, handler);
|
||||
return () => {
|
||||
this.events.removeEventListener(name, handler);
|
||||
};
|
||||
}
|
||||
private emit<K extends keyof Events>(name: K, detail: Events[K]) {
|
||||
if (!this.closed || name === "status") {
|
||||
this.events.dispatchEvent(new CustomEvent(name, { detail }));
|
||||
}
|
||||
}
|
||||
private setStatus(status: ConnectionStatus) {
|
||||
if (this.currentStatus !== status) {
|
||||
this.currentStatus = status;
|
||||
this.emit("status", status);
|
||||
}
|
||||
}
|
||||
private ensureOpen() {
|
||||
if (this.closed) {
|
||||
throw new SessionError("aborted", "La conexión se canceló.");
|
||||
}
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this.setStatus("connecting");
|
||||
try {
|
||||
// Ask for permission while the click-created AudioContext is still resumed.
|
||||
const permission = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
|
||||
permission.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
this.ensureOpen();
|
||||
let url = this.options.directUrl;
|
||||
if (!url) {
|
||||
const grant = await this.acquireSession();
|
||||
this.ensureOpen();
|
||||
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;
|
||||
this.readyTimer = setTimeout(() => {
|
||||
reject(new Error("El servidor de voz no respondió a tiempo."));
|
||||
}, 20000);
|
||||
});
|
||||
const audioReady = this.audio.start(
|
||||
(buffer) => {
|
||||
if (this.configured) {
|
||||
this.send({ type: "input_audio_buffer.append", audio: encodePcm(buffer) });
|
||||
}
|
||||
},
|
||||
(rms) => {
|
||||
this.emit("level", rms);
|
||||
},
|
||||
() => this.currentStatus === "ai-speaking",
|
||||
);
|
||||
this.socket.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
this.incoming = this.incoming
|
||||
.then(async () => {
|
||||
await audioReady;
|
||||
if (!this.closed) {
|
||||
await this.receive(event.data);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!this.closed) {
|
||||
this.fail(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
this.socket.addEventListener("error", () => {
|
||||
this.fail(new Error("No se pudo conectar con el servidor de voz."));
|
||||
});
|
||||
this.socket.addEventListener("close", (event) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (event.code === 1000 && this.configured) {
|
||||
void this.close();
|
||||
} else {
|
||||
this.fail(new Error(`Se cerró la conexión de voz (${event.code}). ${event.reason}`.trim()));
|
||||
}
|
||||
});
|
||||
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 failure;
|
||||
}
|
||||
}
|
||||
|
||||
private async acquireSession() {
|
||||
let result: z.infer<typeof sessionSchema>;
|
||||
try {
|
||||
result = await post(this.options.sessionUrl ?? "/api/session", sessionSchema, {}, this.abort.signal);
|
||||
} catch (error) {
|
||||
throw this.sessionError(error);
|
||||
}
|
||||
let waited = false;
|
||||
if ("state" in result) {
|
||||
waited = true;
|
||||
const ticket = result;
|
||||
this.queueId = ticket.queue_id;
|
||||
this.setStatus("queued");
|
||||
this.emit("queue", { position: ticket.position, queueId: this.queueId });
|
||||
while ("state" in result) {
|
||||
await this.delay(Math.max(1, ticket.poll_interval_s) * 1000);
|
||||
this.ensureOpen();
|
||||
try {
|
||||
result = await request(`/api/queue/${encodeURIComponent(this.queueId)}`, sessionSchema, {
|
||||
signal: this.abort.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
this.ensureOpen();
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
throw this.sessionError(error);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ("state" in result) {
|
||||
this.emit("queue", { position: result.position, queueId: this.queueId });
|
||||
}
|
||||
}
|
||||
}
|
||||
const grant = grantSchema.parse(result);
|
||||
this.queueId = "";
|
||||
this.ensureOpen();
|
||||
if (waited) {
|
||||
const seconds = Math.max(3, grant.pending_timeout_s - 3);
|
||||
this.setStatus("your-turn");
|
||||
const joined = new Promise<void>((resolve, reject) => {
|
||||
this.joinResolve = resolve;
|
||||
this.joinReject = reject;
|
||||
this.joinTimer = setTimeout(() => {
|
||||
reject(new SessionError("join-expired", "Tu turno ha caducado. Vuelve a intentarlo."));
|
||||
}, seconds * 1000);
|
||||
});
|
||||
this.emit("readyToJoin", { grant, expiresSec: seconds });
|
||||
await joined;
|
||||
}
|
||||
this.emit("grant", grant);
|
||||
this.setStatus("connecting");
|
||||
return grant;
|
||||
}
|
||||
private sessionError(error: unknown): unknown {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 404) {
|
||||
return new SessionError("queue-expired", "La espera ha caducado. Vuelve a intentarlo.");
|
||||
}
|
||||
const data = z.object({ state: z.string() }).safeParse(error.body);
|
||||
if (error.status === 503 && data.success && data.data.state === "at_capacity") {
|
||||
return new SessionError("queue-full", "Todos los lugares y la cola están ocupados. Vuelve en un minuto.");
|
||||
}
|
||||
}
|
||||
return error;
|
||||
}
|
||||
private delay(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
this.abort.signal.removeEventListener("abort", finish);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(finish, ms);
|
||||
this.abort.signal.addEventListener("abort", finish, { once: true });
|
||||
if (this.abort.signal.aborted) {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
join() {
|
||||
clearTimeout(this.joinTimer);
|
||||
void this.options.context.resume().catch(() => undefined);
|
||||
this.joinResolve?.();
|
||||
this.joinResolve = null;
|
||||
this.joinReject = null;
|
||||
}
|
||||
private fail(error: unknown) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
const failure = error instanceof Error ? error : new Error("La sesión de voz falló.");
|
||||
this.rejectReady?.(failure);
|
||||
this.emit("error", failure);
|
||||
this.setStatus("error");
|
||||
void this.close();
|
||||
}
|
||||
private async receive(raw: unknown) {
|
||||
const text =
|
||||
typeof raw === "string"
|
||||
? raw
|
||||
: raw instanceof ArrayBuffer
|
||||
? new TextDecoder().decode(raw)
|
||||
: raw instanceof Blob
|
||||
? await raw.text()
|
||||
: "";
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const result = realtimeEventSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
this.handle(result.data);
|
||||
}
|
||||
private assistantText(id: string) {
|
||||
return [this.completed.get(id), this.segments.get(id)].filter(Boolean).join(" ");
|
||||
}
|
||||
private handle(event: RealtimeEvent) {
|
||||
const id = event.response_id ?? event.response?.id ?? this.activeResponse;
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
this.send({
|
||||
type: "session.update",
|
||||
session: {
|
||||
type: "realtime",
|
||||
instructions: this.options.instructions,
|
||||
audio: { output: { voice: this.options.voice } },
|
||||
tools: this.options.tools,
|
||||
tool_choice: this.options.tools.length ? "auto" : "none",
|
||||
},
|
||||
});
|
||||
// The speech server applies frames in order without acknowledging session.update.
|
||||
// Send configuration before microphone audio or the greeting, then consider it ready.
|
||||
if (!this.configured) {
|
||||
this.configured = true;
|
||||
clearTimeout(this.readyTimer);
|
||||
this.resolveReady?.();
|
||||
this.resolveReady = null;
|
||||
this.rejectReady = null;
|
||||
this.setStatus("listening");
|
||||
if (this.options.greeting) {
|
||||
this.sendUserText(this.options.greeting);
|
||||
this.requestResponse();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "input_audio_buffer.speech_started":
|
||||
this.audio.clear();
|
||||
this.setStatus("user-speaking");
|
||||
break;
|
||||
case "input_audio_buffer.speech_stopped":
|
||||
this.setStatus("processing");
|
||||
break;
|
||||
case "response.created":
|
||||
this.activeResponse = id;
|
||||
this.activeResponses.add(id);
|
||||
this.createInFlight = false;
|
||||
if (this.suppressNext) {
|
||||
this.suppressed.add(id);
|
||||
this.suppressNext = false;
|
||||
}
|
||||
this.setStatus("processing");
|
||||
break;
|
||||
case "response.audio.delta":
|
||||
case "response.output_audio.delta":
|
||||
if (event.delta && !this.suppressed.has(id)) {
|
||||
this.audio.play(event.delta);
|
||||
this.audible.add(id);
|
||||
this.setStatus("ai-speaking");
|
||||
}
|
||||
break;
|
||||
case "conversation.item.input_audio_transcription.delta": {
|
||||
const itemId = event.item_id ?? "user-current";
|
||||
// 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;
|
||||
}
|
||||
case "conversation.item.input_audio_transcription.completed": {
|
||||
const itemId = event.item_id ?? "user-current";
|
||||
this.emit("transcript", {
|
||||
role: "user",
|
||||
text: event.transcript ?? this.userSegments.get(itemId) ?? "",
|
||||
partial: false,
|
||||
id: itemId,
|
||||
});
|
||||
this.userSegments.delete(itemId);
|
||||
break;
|
||||
}
|
||||
case "response.audio_transcript.delta":
|
||||
case "response.output_audio_transcript.delta":
|
||||
case "response.text.delta":
|
||||
case "response.output_text.delta":
|
||||
if (!this.suppressed.has(id)) {
|
||||
this.segments.set(id, (this.segments.get(id) ?? "") + (event.delta ?? ""));
|
||||
this.emit("transcript", { role: "assistant", text: this.assistantText(id), partial: true, id });
|
||||
}
|
||||
break;
|
||||
case "response.audio_transcript.done":
|
||||
case "response.output_audio_transcript.done":
|
||||
case "response.text.done":
|
||||
case "response.output_text.done":
|
||||
if (!this.suppressed.has(id)) {
|
||||
const segment = event.transcript ?? event.text ?? this.segments.get(id) ?? "";
|
||||
this.completed.set(id, [this.completed.get(id), segment].filter(Boolean).join(" "));
|
||||
this.segments.delete(id);
|
||||
this.emit("transcript", { role: "assistant", text: this.assistantText(id), partial: false, id });
|
||||
}
|
||||
break;
|
||||
case "response.done": {
|
||||
const transcript =
|
||||
event.response?.output
|
||||
?.flatMap((item) => item.content ?? [])
|
||||
.map((part) => part.transcript ?? part.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join(" ") || this.assistantText(id);
|
||||
if (!this.suppressed.has(id)) {
|
||||
this.emit("finished", {
|
||||
responseId: id,
|
||||
status: event.response?.status ?? "completed",
|
||||
transcript,
|
||||
audible: this.audible.has(id),
|
||||
});
|
||||
}
|
||||
this.activeResponses.delete(id);
|
||||
this.audible.delete(id);
|
||||
this.suppressed.delete(id);
|
||||
this.segments.delete(id);
|
||||
this.completed.delete(id);
|
||||
if (this.activeResponse === id) {
|
||||
this.activeResponse = "";
|
||||
}
|
||||
if (this.currentStatus === "ai-speaking" || this.currentStatus === "processing") {
|
||||
this.setStatus("listening");
|
||||
}
|
||||
this.flush();
|
||||
break;
|
||||
}
|
||||
case "response.function_call_arguments.done":
|
||||
if (event.name && event.call_id && !this.toolCalls.has(event.call_id)) {
|
||||
this.toolCalls.add(event.call_id);
|
||||
this.emit("tool", { name: event.name, arguments: event.arguments ?? "{}", callId: event.call_id });
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
if (
|
||||
event.error?.code === "conversation_already_has_active_response" ||
|
||||
event.error?.type === "conversation_already_has_active_response"
|
||||
) {
|
||||
if (this.createInFlight) {
|
||||
this.createInFlight = false;
|
||||
this.pendingCreates++;
|
||||
}
|
||||
} else {
|
||||
this.emit("serverError", new Error(event.error?.message ?? "Vocero no pudo completar la respuesta."));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
private busy() {
|
||||
return this.activeResponses.size > 0 || this.createInFlight;
|
||||
}
|
||||
private send(event: Record<string, unknown>) {
|
||||
if (!this.closed && this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(event));
|
||||
}
|
||||
}
|
||||
updateSession(instructions: string, voice: string, tools: FunctionTool[]) {
|
||||
this.options.instructions = instructions;
|
||||
this.options.voice = voice;
|
||||
this.options.tools = tools;
|
||||
if (this.configured) {
|
||||
this.send({
|
||||
type: "session.update",
|
||||
session: {
|
||||
type: "realtime",
|
||||
instructions,
|
||||
audio: { output: { voice } },
|
||||
tools,
|
||||
tool_choice: tools.length ? "auto" : "none",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
sendUserText(text: string) {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
if (this.busy()) {
|
||||
this.deferredTexts.push(text.trim());
|
||||
return;
|
||||
}
|
||||
this.send({
|
||||
type: "conversation.item.create",
|
||||
item: { type: "message", role: "user", content: [{ type: "input_text", text: text.trim() }] },
|
||||
});
|
||||
}
|
||||
requestResponse() {
|
||||
if (this.closed || !this.configured) {
|
||||
return;
|
||||
}
|
||||
if (this.busy()) {
|
||||
this.pendingCreates++;
|
||||
return;
|
||||
}
|
||||
this.createInFlight = true;
|
||||
this.send({ type: "response.create" });
|
||||
}
|
||||
private flush() {
|
||||
if (this.busy()) {
|
||||
return;
|
||||
}
|
||||
const text = this.deferredTexts.shift();
|
||||
if (text) {
|
||||
this.sendUserText(text);
|
||||
}
|
||||
if (this.pendingCreates) {
|
||||
this.pendingCreates--;
|
||||
this.requestResponse();
|
||||
}
|
||||
}
|
||||
sendToolOutput(callId: string, output: string) {
|
||||
this.send({ type: "conversation.item.create", item: { type: "function_call_output", call_id: callId, output } });
|
||||
this.requestResponse();
|
||||
}
|
||||
renderExternalAnswer(text: string) {
|
||||
if (this.activeResponse) {
|
||||
this.suppressed.add(this.activeResponse);
|
||||
} else {
|
||||
this.suppressNext = true;
|
||||
}
|
||||
this.audio.clear();
|
||||
this.pendingCreates = 0;
|
||||
this.deferredTexts = [];
|
||||
this.send({
|
||||
type: "conversation.item.create",
|
||||
item: { type: "message", role: "assistant", content: [{ type: "text", text }] },
|
||||
});
|
||||
}
|
||||
setMuted(muted: boolean) {
|
||||
this.audio.setMuted(muted);
|
||||
}
|
||||
setGate(gate: NoiseGate) {
|
||||
this.audio.setGate(gate);
|
||||
}
|
||||
close(): Promise<void> {
|
||||
if (this.closing) {
|
||||
return this.closing;
|
||||
}
|
||||
this.closed = true;
|
||||
this.abort.abort();
|
||||
clearTimeout(this.joinTimer);
|
||||
clearTimeout(this.readyTimer);
|
||||
const cancelled = new SessionError("aborted", "La conexión se canceló.");
|
||||
this.joinReject?.(cancelled);
|
||||
this.rejectReady?.(cancelled);
|
||||
this.joinResolve = null;
|
||||
this.joinReject = null;
|
||||
this.resolveReady = null;
|
||||
this.rejectReady = null;
|
||||
if (this.queueId) {
|
||||
cancelQueue(this.queueId);
|
||||
this.queueId = "";
|
||||
}
|
||||
if (this.socket && this.socket.readyState <= WebSocket.OPEN) {
|
||||
this.socket.close(1000, "client closed");
|
||||
}
|
||||
this.socket = null;
|
||||
this.closing = this.audio
|
||||
.close()
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
this.setStatus("closed");
|
||||
});
|
||||
return this.closing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
import type { ClientOptions } from "./client";
|
||||
import { VoiceController } from "./controller";
|
||||
|
||||
const { MockClient, closeContext } = vi.hoisted(() => {
|
||||
const closeContext = vi.fn().mockResolvedValue(undefined);
|
||||
class MockClient {
|
||||
static instances: MockClient[] = [];
|
||||
readonly options: ClientOptions;
|
||||
private listeners = new Map<string, Set<(value: unknown) => void>>();
|
||||
constructor(options: ClientOptions) {
|
||||
this.options = options;
|
||||
MockClient.instances.push(this);
|
||||
}
|
||||
on(name: string, listener: (value: unknown) => void) {
|
||||
const listeners = this.listeners.get(name) ?? new Set();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(name, listeners);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
emit(name: string, value: unknown) {
|
||||
this.listeners.get(name)?.forEach((listener) => {
|
||||
listener(value);
|
||||
});
|
||||
}
|
||||
connect = vi.fn(() => {
|
||||
this.emit("status", "listening");
|
||||
return Promise.resolve();
|
||||
});
|
||||
close = vi.fn(() => {
|
||||
this.emit("status", "closed");
|
||||
return Promise.resolve();
|
||||
});
|
||||
updateSession = vi.fn();
|
||||
setGate = vi.fn();
|
||||
setMuted = vi.fn();
|
||||
renderExternalAnswer = vi.fn();
|
||||
sendToolOutput = vi.fn();
|
||||
join = vi.fn();
|
||||
}
|
||||
return { MockClient, closeContext };
|
||||
});
|
||||
vi.mock("./client", async (importOriginal) => ({ ...(await importOriginal()), RealtimeClient: MockClient }));
|
||||
vi.mock("../audio/engine", () => ({ createAudioContext: () => ({ state: "running", close: closeContext }) }));
|
||||
let pendingReasoning: ((value: Response) => void) | undefined;
|
||||
let controller: VoiceController;
|
||||
let fetchMock: Mock<(path: string, init?: RequestInit) => Promise<Response>>;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
MockClient.instances = [];
|
||||
pendingReasoning = undefined;
|
||||
vi.stubGlobal("AudioContext", vi.fn());
|
||||
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn() } });
|
||||
fetchMock = vi.fn((path: string) => {
|
||||
if (path === "/api/reasoning") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
pendingReasoning = resolve;
|
||||
});
|
||||
}
|
||||
const pathname = new URL(path, "http://localhost").pathname;
|
||||
const responses: Record<string, unknown> = {
|
||||
"/api/config": { reasoningType: "OPENAI", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" },
|
||||
"/api/user/phone": { result: { name: "Ana" } },
|
||||
"/api/user/reports": { count: 0 },
|
||||
};
|
||||
return Promise.resolve(new Response(JSON.stringify(responses[pathname] ?? {})));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
controller = new VoiceController({ phoneNumber: "1234", episodeId: "episode1" });
|
||||
});
|
||||
afterEach(async () => {
|
||||
await controller.stop();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
async function start() {
|
||||
await controller.initialize(new AbortController().signal);
|
||||
await controller.start();
|
||||
const client = MockClient.instances[0];
|
||||
if (!client) {
|
||||
throw new Error("No client created");
|
||||
}
|
||||
return client;
|
||||
}
|
||||
describe("voice application lifecycle", () => {
|
||||
it("loads configuration and caller context without an account request on startup or stop", async () => {
|
||||
const client = await start();
|
||||
await controller.stop();
|
||||
expect(client.close).toHaveBeenCalledOnce();
|
||||
expect(fetchMock.mock.calls.map(([path]) => new URL(path, "http://localhost").pathname)).toEqual([
|
||||
"/api/config",
|
||||
"/api/user/phone",
|
||||
"/api/user/reports",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders an external answer and preserves separate profile context", async () => {
|
||||
const client = await start();
|
||||
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false });
|
||||
pendingReasoning?.(new Response(JSON.stringify({ answer: " Hola, Ana " })));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const request = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning");
|
||||
expect(request?.[1]?.body).toContain('"phoneNumber":"1234"');
|
||||
expect(request?.[1]?.body).toContain("Caller profile resolved");
|
||||
expect(client.options.instructions).not.toContain("Caller profile resolved");
|
||||
expect(client.renderExternalAnswer).toHaveBeenCalledWith("Hola, Ana");
|
||||
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola", "Hola, Ana"]);
|
||||
});
|
||||
it.each(["PROXY", "UNSUPPORTED"])("keeps native reasoning for mode %s", async (reasoningType) => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ reasoningType, s2sUrl: "ws://localhost/v1/realtime" })),
|
||||
);
|
||||
const client = await start();
|
||||
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(fetchMock.mock.calls.some(([path]) => path === "/api/reasoning")).toBe(false);
|
||||
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
|
||||
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola"]);
|
||||
});
|
||||
it("discards external reasoning that finishes after the session was stopped", async () => {
|
||||
const client = await start();
|
||||
client.emit("transcript", { id: "u1", role: "user", text: "Consulta", partial: false });
|
||||
await controller.stop();
|
||||
pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta tardía" })));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
|
||||
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Consulta"]);
|
||||
});
|
||||
it("applies and removes RAG context in the live session", async () => {
|
||||
const client = await start();
|
||||
controller.setRag("Datos importantes");
|
||||
expect(client.updateSession).toHaveBeenLastCalledWith(expect.stringContaining("Datos importantes"), "Aiden", []);
|
||||
controller.setRag("");
|
||||
expect(client.updateSession).toHaveBeenLastCalledWith(expect.not.stringContaining("CUSTOM_RAG_BEGIN"), "Aiden", []);
|
||||
});
|
||||
it("closes after silence but does not cut off the assistant while speaking", async () => {
|
||||
const client = await start();
|
||||
client.emit("status", "ai-speaking");
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
expect(client.close).not.toHaveBeenCalled();
|
||||
client.emit("status", "listening");
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(client.close).toHaveBeenCalledOnce();
|
||||
expect(controller.getSnapshot().status).toBe("closed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,507 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { createAudioContext } from "../audio/engine";
|
||||
import {
|
||||
errorMessage,
|
||||
getConfig,
|
||||
getProfile,
|
||||
getReports,
|
||||
getTools,
|
||||
post,
|
||||
reasoningSchema,
|
||||
searchSchema,
|
||||
} from "../lib/api";
|
||||
import { buildInstructions, profileContext, ragContext, speechInstructions, WEB_SEARCH } from "../lib/context";
|
||||
import {
|
||||
defaultConfig,
|
||||
type ConnectionStatus,
|
||||
type DynamicTool,
|
||||
type Profile,
|
||||
type Reports,
|
||||
type ServerConfig,
|
||||
type ToolCall,
|
||||
type Transcript,
|
||||
} from "../lib/contracts";
|
||||
import type { Message } from "../lib/messages";
|
||||
import {
|
||||
callerFromLocation,
|
||||
connectionTarget,
|
||||
GATE_OFF,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
type Caller,
|
||||
type Settings,
|
||||
} from "../lib/settings";
|
||||
import { RealtimeClient, SessionError } from "./client";
|
||||
|
||||
export const LIVE_STATES = new Set<ConnectionStatus>(["listening", "user-speaking", "processing", "ai-speaking"]);
|
||||
export const ACTIVE_STATES = new Set<ConnectionStatus>([...LIVE_STATES, "connecting", "queued", "your-turn"]);
|
||||
export interface AppState {
|
||||
loading: boolean;
|
||||
config: ServerConfig;
|
||||
settings: Settings;
|
||||
profile: Profile | null;
|
||||
reports: Reports | null;
|
||||
tools: DynamicTool[];
|
||||
toolsLoading: boolean;
|
||||
toolsError: string;
|
||||
status: ConnectionStatus;
|
||||
error: string;
|
||||
notice: string;
|
||||
messages: Message[];
|
||||
muted: boolean;
|
||||
level: number;
|
||||
rag: string;
|
||||
queuePosition: number;
|
||||
joinDeadline: number;
|
||||
unlock: boolean;
|
||||
}
|
||||
|
||||
/** Observable application state; React owns rendering, this object owns session lifetime. */
|
||||
export class VoiceController {
|
||||
readonly caller: Caller;
|
||||
private state: AppState;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private client: RealtimeClient | null = null;
|
||||
private subscriptions: (() => void)[] = [];
|
||||
private requests = new AbortController();
|
||||
private idleTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private lastActivity = Date.now();
|
||||
private lastMeter = 0;
|
||||
private sessionNumber = 0;
|
||||
private reasoned = new Set<string>();
|
||||
private autoStarted = false;
|
||||
|
||||
constructor(caller = callerFromLocation()) {
|
||||
this.caller = caller;
|
||||
this.state = {
|
||||
loading: true,
|
||||
config: defaultConfig,
|
||||
settings: loadSettings(),
|
||||
profile: null,
|
||||
reports: null,
|
||||
tools: [],
|
||||
toolsLoading: false,
|
||||
toolsError: "",
|
||||
status: "idle",
|
||||
error: "",
|
||||
notice: "",
|
||||
messages: [],
|
||||
muted: false,
|
||||
level: 0,
|
||||
rag: "",
|
||||
queuePosition: 0,
|
||||
joinDeadline: 0,
|
||||
unlock: false,
|
||||
};
|
||||
}
|
||||
getSnapshot = () => this.state;
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
private update(patch: Partial<AppState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.listeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
|
||||
async initialize(signal: AbortSignal) {
|
||||
try {
|
||||
const [config, profile, reports] = await Promise.all([
|
||||
getConfig(signal),
|
||||
getProfile(this.caller.phoneNumber, signal),
|
||||
getReports(this.caller.phoneNumber, signal),
|
||||
]);
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
this.update({ config, profile, reports, loading: false, error: "" });
|
||||
if (config.apipilot) {
|
||||
await this.refreshTools(signal, !!profile);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
this.update({ loading: false, error: `No se pudo cargar la configuración. ${errorMessage(error)}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
async refreshTools(signal: AbortSignal, refresh = false) {
|
||||
this.update({ toolsLoading: true, toolsError: "" });
|
||||
try {
|
||||
const tools = await getTools(signal, refresh);
|
||||
if (!signal.aborted) {
|
||||
this.update({ tools, toolsLoading: false });
|
||||
this.pushSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
this.update({ toolsError: errorMessage(error), toolsLoading: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
private instructions() {
|
||||
return buildInstructions(
|
||||
this.state.config,
|
||||
this.state.settings,
|
||||
this.state.profile,
|
||||
this.state.rag,
|
||||
this.state.tools,
|
||||
);
|
||||
}
|
||||
private tools() {
|
||||
return this.state.settings.webSearch && (this.state.config.search || this.state.settings.searchKey)
|
||||
? [WEB_SEARCH]
|
||||
: [];
|
||||
}
|
||||
private gate() {
|
||||
return { enabled: this.state.settings.noiseGate > GATE_OFF, thresholdDb: this.state.settings.noiseGate };
|
||||
}
|
||||
private pushSettings() {
|
||||
this.client?.updateSession(
|
||||
speechInstructions(this.instructions(), this.state.config),
|
||||
this.state.settings.voice,
|
||||
this.tools(),
|
||||
);
|
||||
this.client?.setGate(this.gate());
|
||||
}
|
||||
changeSettings(patch: Partial<Settings>) {
|
||||
const settings = { ...this.state.settings, ...patch };
|
||||
this.update({ settings });
|
||||
saveSettings(settings);
|
||||
this.pushSettings();
|
||||
}
|
||||
setRag(rag: string) {
|
||||
this.update({ rag: rag.trim() });
|
||||
this.pushSettings();
|
||||
}
|
||||
dismissError() {
|
||||
this.update({ error: "" });
|
||||
}
|
||||
offerAutoStart() {
|
||||
if (this.autoStarted || this.client || this.state.loading) {
|
||||
return;
|
||||
}
|
||||
this.autoStarted = true;
|
||||
const mode = this.state.config.startSessionMode.trim().toUpperCase();
|
||||
if (mode === "TALK") {
|
||||
this.update({ unlock: true });
|
||||
} else if (mode === "TALKFIRST") {
|
||||
void this.start(true);
|
||||
}
|
||||
}
|
||||
async start(automatic = false, preparedContext?: AudioContext, greeting?: string) {
|
||||
if (this.client || this.state.loading) {
|
||||
if (preparedContext) {
|
||||
void preparedContext.close().catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
unlock: false,
|
||||
error: "",
|
||||
notice: "",
|
||||
status: "connecting",
|
||||
muted: false,
|
||||
joinDeadline: 0,
|
||||
});
|
||||
let context: AudioContext | undefined;
|
||||
try {
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") {
|
||||
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 = preparedContext ?? createAudioContext();
|
||||
if (automatic && context.state !== "running") {
|
||||
await context.close();
|
||||
this.update({ status: "idle", unlock: true });
|
||||
return;
|
||||
}
|
||||
this.requests = new AbortController();
|
||||
this.reasoned.clear();
|
||||
this.sessionNumber++;
|
||||
const client = new RealtimeClient({
|
||||
...target,
|
||||
context,
|
||||
voice: this.state.settings.voice,
|
||||
instructions: speechInstructions(this.instructions(), this.state.config),
|
||||
tools: this.tools(),
|
||||
gate: this.gate(),
|
||||
greeting:
|
||||
greeting ??
|
||||
(["TALK", "TALKFIRST"].includes(this.state.config.startSessionMode.toUpperCase())
|
||||
? "hola vocero"
|
||||
: undefined),
|
||||
});
|
||||
this.client = client;
|
||||
this.subscriptions = [
|
||||
client.on("status", (status) => {
|
||||
if (client !== this.client) {
|
||||
return;
|
||||
}
|
||||
if (status === "closed") {
|
||||
void this.stop(this.state.error ? "error" : "closed");
|
||||
return;
|
||||
}
|
||||
if (status === "listening" || status === "user-speaking" || status === "ai-speaking") {
|
||||
this.lastActivity = Date.now();
|
||||
}
|
||||
if (status === "user-speaking") {
|
||||
window.speechSynthesis?.cancel();
|
||||
this.update({
|
||||
messages: this.state.messages.map((message) =>
|
||||
message.role === "assistant" && message.partial
|
||||
? { ...message, partial: false, interrupted: true }
|
||||
: message,
|
||||
),
|
||||
});
|
||||
}
|
||||
this.update({ status });
|
||||
}),
|
||||
client.on("transcript", (transcript) => {
|
||||
this.lastActivity = Date.now();
|
||||
this.transcript(transcript);
|
||||
if (transcript.role === "user" && !transcript.partial && !this.reasoned.has(transcript.id)) {
|
||||
this.reasoned.add(transcript.id);
|
||||
void this.reason(transcript, client);
|
||||
}
|
||||
}),
|
||||
client.on("finished", (response) => {
|
||||
if (response.transcript) {
|
||||
this.transcript(
|
||||
{ id: response.responseId, role: "assistant", text: response.transcript, partial: false },
|
||||
response.status !== "completed",
|
||||
);
|
||||
}
|
||||
}),
|
||||
client.on("level", (level) => {
|
||||
if (!this.state.muted && level > 0.0025) {
|
||||
this.lastActivity = Date.now();
|
||||
}
|
||||
if (Date.now() - this.lastMeter > 150) {
|
||||
this.lastMeter = Date.now();
|
||||
this.update({ level });
|
||||
}
|
||||
}),
|
||||
client.on("queue", ({ position }) => {
|
||||
this.update({ queuePosition: position });
|
||||
}),
|
||||
client.on("readyToJoin", ({ expiresSec }) => {
|
||||
this.update({ joinDeadline: Date.now() + expiresSec * 1000 });
|
||||
}),
|
||||
client.on("grant", () => {
|
||||
this.update({ joinDeadline: 0 });
|
||||
}),
|
||||
client.on("error", (error) => {
|
||||
this.reportError(error);
|
||||
}),
|
||||
client.on("serverError", (error) => {
|
||||
this.update({ error: error.message });
|
||||
}),
|
||||
client.on("tool", (call) => {
|
||||
void this.runTool(call, client);
|
||||
}),
|
||||
];
|
||||
this.lastActivity = Date.now();
|
||||
this.idleTimer = setInterval(() => {
|
||||
if (
|
||||
["listening", "user-speaking", "processing"].includes(this.state.status) &&
|
||||
Date.now() - this.lastActivity >= 30000
|
||||
) {
|
||||
void this.stop();
|
||||
this.update({ notice: "Sesión finalizada por inactividad. Toca para volver a conversar." });
|
||||
}
|
||||
}, 1000);
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (error) {
|
||||
// A closed event may already have cleared the client; explicit cancellation is silent.
|
||||
if (!(error instanceof SessionError && error.code === "aborted") && (!this.client || this.client === client)) {
|
||||
this.reportError(error);
|
||||
if (automatic) {
|
||||
this.update({ unlock: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (context) {
|
||||
void context.close().catch(() => undefined);
|
||||
}
|
||||
this.reportError(error);
|
||||
}
|
||||
}
|
||||
private reportError(error: unknown) {
|
||||
this.update({
|
||||
error: errorMessage(error),
|
||||
status: "error",
|
||||
});
|
||||
}
|
||||
private transcript(transcript: Transcript, interrupted = false) {
|
||||
if (!transcript.text.trim()) {
|
||||
return;
|
||||
}
|
||||
const id = `${String(this.sessionNumber)}:${transcript.role}:${transcript.id}`;
|
||||
const existing = this.state.messages.find((message) => message.id === id);
|
||||
const message: Message = {
|
||||
...existing,
|
||||
id,
|
||||
role: transcript.role,
|
||||
text: transcript.text,
|
||||
partial: transcript.partial,
|
||||
timestamp: existing?.timestamp ?? new Date().toISOString(),
|
||||
interrupted: interrupted || existing?.interrupted,
|
||||
};
|
||||
this.update({
|
||||
messages: existing
|
||||
? this.state.messages.map((item) => (item.id === id ? message : item))
|
||||
: [...this.state.messages, message],
|
||||
});
|
||||
}
|
||||
private async reason(transcript: Transcript, client: RealtimeClient) {
|
||||
const { config, profile, rag } = this.state;
|
||||
if (config.reasoningType === "PROXY" || !transcript.text.trim()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await post(
|
||||
"/api/reasoning",
|
||||
reasoningSchema,
|
||||
{
|
||||
transcript: transcript.text.trim(),
|
||||
instructions: this.instructions(),
|
||||
language: "es-ES",
|
||||
user_context: [profileContext(profile), ragContext(rag)].filter(Boolean).join("\n\n"),
|
||||
phoneNumber: this.caller.phoneNumber || null,
|
||||
episode_id: this.caller.episodeId || null,
|
||||
},
|
||||
this.requests.signal,
|
||||
);
|
||||
if (this.client !== client) {
|
||||
return;
|
||||
}
|
||||
const answer = result.answer.trim();
|
||||
if (!answer) {
|
||||
throw new Error("El servicio de razonamiento no devolvió una respuesta.");
|
||||
}
|
||||
client.renderExternalAnswer(answer);
|
||||
this.transcript({ role: "assistant", id: `reasoning:${transcript.id}`, text: answer, partial: false });
|
||||
if ("speechSynthesis" in window) {
|
||||
const utterance = new SpeechSynthesisUtterance(answer);
|
||||
utterance.lang = "es-ES";
|
||||
const voice = window.speechSynthesis.getVoices().find((voice) => voice.lang.startsWith("es"));
|
||||
if (voice) {
|
||||
utterance.voice = voice;
|
||||
}
|
||||
utterance.onstart = () => {
|
||||
if (this.client === client) {
|
||||
this.update({ status: "ai-speaking" });
|
||||
}
|
||||
};
|
||||
utterance.onend = utterance.onerror = () => {
|
||||
if (this.client === client) {
|
||||
this.lastActivity = Date.now();
|
||||
this.update({ status: "listening" });
|
||||
}
|
||||
};
|
||||
window.speechSynthesis.cancel();
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.client === client && !this.requests.signal.aborted) {
|
||||
this.update({ error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
private async runTool(call: ToolCall, client: RealtimeClient) {
|
||||
const id = `${String(this.sessionNumber)}:tool:${call.callId}`;
|
||||
let args = call.arguments;
|
||||
let output: string;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(call.arguments || "{}");
|
||||
args = JSON.stringify(parsed, null, 2);
|
||||
if (call.name !== "web_search") {
|
||||
throw new Error(`Herramienta desconocida: ${call.name}`);
|
||||
}
|
||||
const query = z.object({ query: z.string().trim().min(1) }).parse(parsed).query;
|
||||
const result = await post(
|
||||
"/api/search",
|
||||
searchSchema,
|
||||
{
|
||||
query,
|
||||
...(!this.state.config.search && this.state.settings.searchKey ? { key: this.state.settings.searchKey } : {}),
|
||||
},
|
||||
this.requests.signal,
|
||||
);
|
||||
output = [
|
||||
`Google search result from ${new Date().toISOString().slice(0, 10)}:`,
|
||||
result.answer ? `Answer: ${result.answer}` : "",
|
||||
...result.results.map((item) => `- ${item.title}: ${item.snippet} (${item.url})`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (!result.answer && !result.results.length) {
|
||||
output += "\nNo results found.";
|
||||
}
|
||||
} catch (error) {
|
||||
output = `Tool error: ${errorMessage(error)}`;
|
||||
}
|
||||
if (client !== this.client) {
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
messages: [
|
||||
...this.state.messages,
|
||||
{
|
||||
id,
|
||||
role: "tool",
|
||||
text: output,
|
||||
toolName: call.name,
|
||||
toolArguments: args,
|
||||
partial: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
client.sendToolOutput(call.callId, output);
|
||||
}
|
||||
join() {
|
||||
this.client?.join();
|
||||
this.update({ joinDeadline: 0 });
|
||||
}
|
||||
toggleMuted() {
|
||||
const muted = !this.state.muted;
|
||||
this.client?.setMuted(muted);
|
||||
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(false, context);
|
||||
}
|
||||
async stop(status: ConnectionStatus = "closed") {
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
this.subscriptions.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
this.subscriptions = [];
|
||||
clearInterval(this.idleTimer);
|
||||
this.requests.abort();
|
||||
window.speechSynthesis?.cancel();
|
||||
this.update({
|
||||
status,
|
||||
muted: false,
|
||||
level: 0,
|
||||
joinDeadline: 0,
|
||||
unlock: false,
|
||||
messages: this.state.messages.map((message) =>
|
||||
message.partial ? { ...message, partial: false, interrupted: message.role === "assistant" } : message,
|
||||
),
|
||||
});
|
||||
await client?.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
@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 {
|
||||
.chat-empty,
|
||||
.chat-empty-hint {
|
||||
opacity: 1;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
html[data-theme="white"] .chat-empty,
|
||||
html[data-theme="white"] .chat-empty-hint {
|
||||
color: #64748b;
|
||||
}
|
||||
.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
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createBackendProxy } from "../../config/backend-proxy";
|
||||
|
||||
describe("development backend proxy", () => {
|
||||
it("keeps the public host and original paths for the FastAPI runtime URL", () => {
|
||||
const proxy = createBackendProxy("http://127.0.0.1:8421");
|
||||
expect(proxy["/api"]).toEqual({ target: "http://127.0.0.1:8421", changeOrigin: false });
|
||||
expect(proxy["/v1/realtime"]).toEqual({ target: "http://127.0.0.1:8421", changeOrigin: false, ws: true });
|
||||
expect(proxy["/openapi.json"]).toEqual(proxy["/api"]);
|
||||
expect(proxy["/docs"]).toEqual(proxy["/api"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
// jsdom has no native modal top layer; real focus/inert behavior is checked in Chromium.
|
||||
Object.defineProperties(HTMLDialogElement.prototype, {
|
||||
showModal: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: function (this: HTMLDialogElement) {
|
||||
this.open = true;
|
||||
},
|
||||
},
|
||||
close: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: function (this: HTMLDialogElement) {
|
||||
this.open = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
Reference in New Issue
Block a user