diff --git a/frontend-s2s/.editorconfig b/frontend-s2s/.editorconfig new file mode 100644 index 0000000..1014ba7 --- /dev/null +++ b/frontend-s2s/.editorconfig @@ -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 diff --git a/frontend-s2s/README.md b/frontend-s2s/README.md index 0f08d6a..4a81f81 100644 --- a/frontend-s2s/README.md +++ b/frontend-s2s/README.md @@ -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) diff --git a/frontend-s2s/src/App.test.tsx b/frontend-s2s/src/App.test.tsx new file mode 100644 index 0000000..7f74b5b --- /dev/null +++ b/frontend-s2s/src/App.test.tsx @@ -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 = { + "/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( + + + , + ); + 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( + + + , + ); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/frontend-s2s/src/App.tsx b/frontend-s2s/src/App.tsx index cacc219..ba5a661 100644 --- a/frontend-s2s/src/App.tsx +++ b/frontend-s2s/src/App.tsx @@ -106,9 +106,11 @@ export default function App() {
- - Vocero - +

+ + Vocero + +

{white &&

Agente conversacional

}
@@ -253,7 +255,7 @@ export default function App() { />
-

Reportes

+

Reportes