chore(frontend): validate migration and document development
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
+111
-9
@@ -1,14 +1,116 @@
|
||||
# Vocero React frontend
|
||||
|
||||
React, TypeScript, Tailwind CSS, and Vite client for the existing `s2s-client` FastAPI backend.
|
||||
React 19, TypeScript, Tailwind CSS 4, and Vite 8 client for the existing
|
||||
`s2s-client` FastAPI backend. This is a client-side application with React-owned
|
||||
rendering and state, including the realtime WebSocket and audio integration.
|
||||
|
||||
Requires Node.js 22.18+ or 24+ and npm. Run `npm ci`, then `npm run dev`.
|
||||
The frontend listens on `http://127.0.0.1:5173` and proxies backend requests to
|
||||
`http://127.0.0.1:8420`. Copy `.env.example` to `.env.local` to override that target.
|
||||
## Run during development
|
||||
|
||||
`npm run check` runs formatting, strict ESLint, TypeScript, and Vitest checks.
|
||||
`npm run build` typechecks and produces `dist/`. `npm run format` and `npm run lint:fix`
|
||||
apply mechanical formatting and lint fixes.
|
||||
Use Node.js 22.18 or later on the 22.x line, or Node.js 24+. Install dependencies
|
||||
with npm; the lockfile is committed.
|
||||
|
||||
The repository's `start_ui.sh` still serves the original UI. This project is started
|
||||
separately during the frontend migration.
|
||||
1. Start the existing backend using `./start_ui.sh` from your configured S2S
|
||||
checkout, or reuse an already-running instance. It normally listens on port
|
||||
`8420` and continues serving the original static UI there. Its Python
|
||||
environment, `.env`, speech service, and upstream services must be configured
|
||||
as before.
|
||||
2. In a second terminal, start this frontend:
|
||||
|
||||
```sh
|
||||
cd frontend-s2s
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. Open `http://127.0.0.1:5173`. Add `?debug=true` to access settings, tools,
|
||||
and custom RAG context. Existing `phoneNumber` and `episode_id` query
|
||||
parameters are supported.
|
||||
|
||||
Vite serves React and Tailwind with hot updates. It proxies `/api`,
|
||||
`/v1/realtime` (including WebSockets), OAuth, and backend documentation routes
|
||||
to FastAPI. The browser uses one origin on port `5173`, so there is no separate
|
||||
API origin or development CORS setup. The proxy preserves the incoming Host
|
||||
header because FastAPI uses it to construct the speech WebSocket URL.
|
||||
|
||||
For a different backend, copy `.env.example` to `.env.local` and set
|
||||
`S2S_BACKEND_URL`. This is a **Vite server** setting. Runtime UI flags, voices,
|
||||
reasoning mode, prompts, and the speech URL still come from FastAPI's
|
||||
`/api/config`. A backend explicitly configured with `SPEECH_TO_SPEECH_URL`
|
||||
continues to return that direct speech endpoint.
|
||||
|
||||
The Vite configuration reads only this project's environment files. Do not
|
||||
copy the repository's backend secrets into this directory or into `VITE_*`
|
||||
variables, which are exposed to browser code.
|
||||
|
||||
For access through a development reverse proxy, set the exact hostname in
|
||||
`S2S_DEV_ALLOWED_HOSTS`. Use `npm run dev -- --host 0.0.0.0` only when Vite must
|
||||
listen beyond localhost. The browser needs HTTPS for microphone access outside
|
||||
localhost, and that proxy must forward WebSocket upgrades for both speech and
|
||||
Vite HMR. Wiring the staging entrypoint and launcher is a separate step:
|
||||
**this port does not change `start_ui.sh`.**
|
||||
|
||||
Component and CSS changes use Fast Refresh. Editing the session controller or
|
||||
audio modules can require restarting a conversation or a page reload. Teardown
|
||||
closes microphone tracks, worklets, sockets, queue tickets, timers, and pending
|
||||
requests on session stop or page exit.
|
||||
|
||||
## Checks and commands
|
||||
|
||||
The scripts and code style follow `frontend-admin` conventions, with Vite and
|
||||
React plugins replacing Next.js-specific tooling. Executable configuration
|
||||
files use TypeScript; TypeScript's own project settings use JSON.
|
||||
|
||||
| Command | Purpose |
|
||||
| -------------------- | ----------------------------------------------------------- |
|
||||
| `npm run dev` | Vite development server on port 5173, with a fixed port |
|
||||
| `npm run check` | Formatting, strict typed ESLint, type checking, and tests |
|
||||
| `npm run lint:fix` | Apply ESLint fixes |
|
||||
| `npm run format` | Format source and sort Tailwind classes |
|
||||
| `npm run typecheck` | Check application, tests, audio worklets, and configuration |
|
||||
| `npm test` | Run Vitest and React Testing Library regression tests |
|
||||
| `npm run test:watch` | Watch tests while editing |
|
||||
| `npm run build` | Typecheck and compile the app and worklets into `dist/` |
|
||||
| `npm run preview` | Inspect the compiled output locally |
|
||||
|
||||
No build step is needed before `npm run dev`. `dist/` is generated and ignored
|
||||
by Git. Build output is available for later serving integration; this change
|
||||
does not mount it in FastAPI or change the staging launch process.
|
||||
|
||||
Tests cover session initialization, response sequencing, cumulative transcripts,
|
||||
interruptions, queue cancellation, microphone-permission cancellation, tool
|
||||
results, external reasoning and `LOG_ONLY`, late-response cleanup, the idle
|
||||
timeout, settings, RAG, profiles, and sanitized Markdown/export behavior.
|
||||
Vitest handles Node 26's experimental web storage behavior internally.
|
||||
|
||||
## Source layout
|
||||
|
||||
- `src/components/`: voice controls, chat, dialogs, profile, and account views.
|
||||
- `src/realtime/`: session lifecycle, observable application state, queue and
|
||||
budget handling, WebSocket protocol, tools, and external reasoning.
|
||||
- `src/audio/`: TypeScript capture/playback worklets, audio graph, PCM codec,
|
||||
and visualizer. Vite emits the worklets as separate JavaScript assets.
|
||||
- `src/lib/`: validated backend contracts, settings, prompt context, profile
|
||||
and report presentation, Markdown sanitization, and conversation exports.
|
||||
- `config/backend-proxy.ts`: the shared HTTP/WebSocket development proxy.
|
||||
- `src/styles.css`: Tailwind integration and React-specific layout adjustments.
|
||||
- `src/styles/legacy-theme.css`: the existing visual styles, retained for
|
||||
fidelity to both the white microphone and dark orb themes. The UI is ported
|
||||
to React; the full design has not been rewritten as Tailwind utilities.
|
||||
|
||||
The current Spanish interface, local preference keys, session-only RAG,
|
||||
Markdown replies, JSON/TXT exports, optional browser wake word, TALK/TALKFIRST
|
||||
startup modes, and optional backend features are retained. Camera controls and
|
||||
quick actions remain absent, matching their disabled state in the old UI.
|
||||
|
||||
Native speech stays at the current project's 16 kHz PCM rate. The backend
|
||||
continues to own VAD, transcription, model inference, synthesis, upstream
|
||||
credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only
|
||||
when the configured external reasoning service returns an answer instead of
|
||||
`log_only`.
|
||||
|
||||
## References
|
||||
|
||||
- [Vite getting started](https://vite.dev/guide/)
|
||||
- [Vite server and proxy options](https://vite.dev/config/server-options)
|
||||
- [Vite features and asset handling](https://vite.dev/guide/features)
|
||||
- [Tailwind's Vite integration](https://tailwindcss.com/docs/installation/using-vite)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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/me": { enabled: false },
|
||||
"/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();
|
||||
});
|
||||
});
|
||||
@@ -106,9 +106,11 @@ export default function App() {
|
||||
<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>
|
||||
@@ -253,7 +255,7 @@ export default function App() {
|
||||
/>
|
||||
<div className="reports-sidebar-inner">
|
||||
<header className="reports-sidebar-header">
|
||||
<h3>Reportes</h3>
|
||||
<h2>Reportes</h2>
|
||||
<button
|
||||
id="reports-sidebar-close"
|
||||
className="icon-btn"
|
||||
@@ -277,7 +279,7 @@ export default function App() {
|
||||
</div>
|
||||
{state.reports && (
|
||||
<div className="reports-suggestions">
|
||||
<h4 className="reports-suggestions-title">Puedes preguntar</h4>
|
||||
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
|
||||
<ul className="reports-suggestions-list">
|
||||
{reportSuggestions(state.reports).map((suggestion) => (
|
||||
<li key={suggestion}>{suggestion}</li>
|
||||
|
||||
@@ -82,7 +82,7 @@ export const Conversation = memo(function Conversation({
|
||||
<section id="chat-panel" className="chat-panel chat-panel-inline" aria-label="Transcripción de la conversación">
|
||||
<div className="chat-panel-inner">
|
||||
<header className="chat-panel-header">
|
||||
<h3>Conversación</h3>
|
||||
<h2>Conversación</h2>
|
||||
<div className="chat-header-actions" role="group" aria-label="Acciones de la conversación">
|
||||
{white && (
|
||||
<span
|
||||
|
||||
@@ -140,7 +140,7 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
|
||||
return (
|
||||
<main className="stage">
|
||||
{white ? (
|
||||
<div className={`white-mic-stage ${live ? "live" : ""}`} aria-label="Control de voz">
|
||||
<div className={`white-mic-stage ${live ? "live" : ""}`} role="group" aria-label="Control de voz">
|
||||
<Waveform side="left" />
|
||||
<button
|
||||
id="white-mic-circle"
|
||||
|
||||
@@ -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"}');
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,69 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("realtime session", () => {
|
||||
it("ends a metered session and releases its reservation when the budget expires", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
session_id: "metered",
|
||||
connect_url: "ws://localhost/v1/realtime",
|
||||
limited: true,
|
||||
heartbeatSec: 5,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ expired: true })));
|
||||
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(5000);
|
||||
expect(error).toHaveBeenCalledWith(expect.objectContaining({ code: "limit" }));
|
||||
expect(socket.close).toHaveBeenCalledOnce();
|
||||
expect(sendBeacon).toHaveBeenCalledWith("/api/session/end", expect.any(Blob));
|
||||
expect(client.status).toBe("closed");
|
||||
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("waits for configuration before greeting and serializes response requests", async () => {
|
||||
const client = new RealtimeClient({ ...options(), greeting: "hola vocero" });
|
||||
const socket = await connect(client);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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: "NODE_RED", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" },
|
||||
"/api/me": { enabled: false },
|
||||
"/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("keeps LOG_ONLY reasoning on the native voice path 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({ log_only: true, answer: "" })));
|
||||
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).not.toHaveBeenCalled();
|
||||
expect(controller.getSnapshot().messages).toHaveLength(1);
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,15 @@
|
||||
@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;
|
||||
|
||||
@@ -2739,7 +2739,7 @@ body.cam-on .footer {
|
||||
border-bottom: 0 solid transparent;
|
||||
flex: none;
|
||||
}
|
||||
.chat-panel-header h3 {
|
||||
.chat-panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -2873,7 +2873,7 @@ body.cam-on .footer {
|
||||
);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.reports-sidebar-header h3 {
|
||||
.reports-sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
@@ -4709,7 +4709,7 @@ html[data-theme="white"] .markdown-content a {
|
||||
.about-intro p,
|
||||
.about-repo,
|
||||
.brand,
|
||||
.chat-panel-header h3,
|
||||
.chat-panel-header h2,
|
||||
.field textarea,
|
||||
.ident-meta,
|
||||
.pipe-job,
|
||||
@@ -5581,7 +5581,7 @@ html[data-theme="white"] .chat-panel-header {
|
||||
/* Header never shrinks out of existence; keep it pinned. */
|
||||
flex: none;
|
||||
}
|
||||
html[data-theme="white"] .chat-panel-header h3 {
|
||||
html[data-theme="white"] .chat-panel-header h2 {
|
||||
margin: 0;
|
||||
color: #0f172a !important; /* slate-900 flat, no brand gradient */
|
||||
background: none !important;
|
||||
@@ -6108,7 +6108,7 @@ html[data-theme="white"] .reports-sidebar-header {
|
||||
border-bottom-color: rgba(15, 23, 42, 0.08);
|
||||
color: #0f172a;
|
||||
}
|
||||
html[data-theme="white"] .reports-sidebar-header h3 {
|
||||
html[data-theme="white"] .reports-sidebar-header h2 {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,24 @@ 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();
|
||||
|
||||
Reference in New Issue
Block a user