refactor(frontend): rename project to s2s-frontend

This commit is contained in:
2026-10-06 17:00:57 -03:00
parent 99413fc06d
commit 6c4ee3a5e3
54 changed files with 26 additions and 26 deletions
+12
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# FastAPI target when running npm run dev independently. start_ui.sh sets this
# to its own backend using S2S_BACKEND_PORT (default 8421).
S2S_BACKEND_URL=http://127.0.0.1:8421
# Comma-separated hostnames for remote access, without scheme or port.
# With start_ui.sh this can also be set in the repository root .env.
S2S_DEV_ALLOWED_HOSTS=
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+4
View File
@@ -0,0 +1,4 @@
dist/
coverage/
node_modules/
package-lock.json
+155
View File
@@ -0,0 +1,155 @@
# Vocero React frontend
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.
## Run during development
From the repository root, run `./start_ui.sh`. Open `http://localhost:8420`.
Add `?debug=true` to access settings, tools, and custom RAG context. Existing
`phoneNumber` and `episode_id` query parameters are supported.
The launcher requires Python 3 with venv support, Node.js 22.18 or later on the
22.x line or Node.js 24+, npm, Bash 4.4+, `setsid` (util-linux), and `curl`.
It creates `.venv` and installs missing Python dependencies. It runs
`npm ci --include=dev` on first start and when either frontend package manifest
changes. Subsequent restarts reuse the installed dependencies.
| Server | Default address | Purpose |
| ------- | ---------------- | ---------------------------------------------- |
| Vite | `0.0.0.0:8420` | Public React UI, backend proxy, and hot reload |
| FastAPI | `127.0.0.1:8421` | Internal HTTP APIs and speech WebSocket relay |
`UI_PORT` preserves the existing public port override. `UI_HOST` defaults to
`0.0.0.0`; `S2S_BACKEND_PORT` changes the loopback backend port. The two ports
must differ. Occupied ports fail startup rather than silently selecting a new
endpoint. The launcher waits for FastAPI before starting Vite, stops both
process groups on Ctrl+C/SIGTERM, and exits with a failure if either server exits.
Uvicorn watches only `s2s-client`, avoiding frontend and dependency directories.
The Vite child explicitly uses `NODE_ENV=development`, including when a process
manager sets `NODE_ENV=production`, so React Fast Refresh remains enabled.
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 `8420`, 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.
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 launcher sources the root `.env`, retaining its precedence over shell
variables. The Vite configuration reads this project's environment files and
the inherited `S2S_*` settings. Do not copy the repository's backend secrets
into this directory or into `VITE_*`
variables, which are exposed to browser code.
## Staging with hot reload
PM2 can continue running the root `start_ui.sh` as its existing `s2s_ui` process.
Ensure the Node/npm versions above are on PM2's PATH and deploy `s2s-frontend`
alongside `s2s-client` and `docs`. The reverse proxy still targets port `8420`.
Set the exact public hostname in the root `.env`, without scheme or port:
```dotenv
S2S_DEV_ALLOWED_HOSTS=voice-staging.example.com
```
Replace the example with the existing staging hostname; use commas for multiple
hosts. IP addresses and localhost are accepted without this setting.
The HTTPS reverse proxy must preserve `Host`, `X-Forwarded-Host` and
`X-Forwarded-Proto`, and forward WebSocket upgrades for `/v1/realtime` and
`/__vite_hmr`. HMR uses the browser's public origin, including HTTPS/WSS, so no
second public port is needed. The browser needs HTTPS for microphone access
outside localhost. Restart `s2s_ui` after changing launcher or environment
settings; source edits use the reloaders. The speech service remains separately
managed.
## Run the servers separately
Start FastAPI on `127.0.0.1:8421` as described in the
[client usage guide](../docs/client/usage.md), then run:
```sh
cd s2s-frontend
npm ci
npm run dev
```
Standalone Vite binds `127.0.0.1:8420`; add `-- --host 0.0.0.0` for remote
access. For a different backend, copy `.env.example` to `.env.local` and set
`S2S_BACKEND_URL`. The combined launcher overrides that setting to point at the
backend it starts. Do not start standalone Vite while the combined launcher
already owns the public port.
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 8420, 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 test:launcher` | Test launcher supervision and signal handling on Linux |
| `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. The launcher serves source through Vite; it does not use `dist/` or
`vite preview`.
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.
Launcher tests start temporary local children to check readiness, environment
forwarding, failures, and shutdown of complete process groups.
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)
+16
View File
@@ -0,0 +1,16 @@
import type { ProxyOptions } from "vite";
/** Preserve the public origin used by FastAPI to construct the speech WebSocket URL. */
export function createBackendProxy(target: string): Record<string, ProxyOptions> {
const http: ProxyOptions = { target, changeOrigin: false };
return {
"/api": http,
"/v1/realtime": { ...http, ws: true },
"/docs": http,
"/redoc": http,
"/openapi.json": http,
"/oauth": http,
"/project": http,
"/public/docs.html": http,
};
}
+187
View File
@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { setTimeout as delay } from "node:timers/promises";
// Exercise the real Bash supervisor with lightweight children, without installing
// packages, starting upstream services, or reading the checkout's private .env.
const childSource = `
import { createServer } from "node:http";
import { writeFileSync } from "node:fs";
import { spawn } from "node:child_process";
const role = process.argv[2] === "backend" ? "backend" : "frontend";
const root = process.env.S2S_TEST_ROOT;
const args = process.argv.slice(2);
writeFileSync(root + "/" + role + ".pid", String(process.pid));
writeFileSync(root + "/" + role + ".json", JSON.stringify({
args, target: process.env.S2S_BACKEND_URL, hosts: process.env.S2S_DEV_ALLOWED_HOSTS,
nodeEnv: process.env.NODE_ENV,
}));
if (process.env.S2S_TEST_FAIL === role) process.exit(37);
const worker = spawn(process.execPath, ["-e",
'const fs = require("node:fs"); process.on("SIGTERM", () => { fs.writeFileSync(process.argv[1], "yes"); process.exit(0); }); fs.writeFileSync(process.argv[2], "yes"); setInterval(() => {}, 1000);',
root + "/" + role + "-worker-stopped", root + "/" + role + "-worker-ready",
], { stdio: "ignore" });
process.on("SIGTERM", () => {
worker.once("exit", () => process.exit(0));
if (worker.exitCode !== null || worker.signalCode !== null) process.exit(0);
});
createServer((request, response) => {
response.setHeader("Content-Type", "application/json");
response.end("{}");
}).listen(Number(args[args.indexOf("--port") + 1]), "127.0.0.1");
`;
async function freePort() {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address === "object");
await new Promise<void>((resolve) =>
server.close(() => {
resolve();
}),
);
return address.port;
}
async function waitForFile(filename: string) {
for (let attempt = 0; attempt < 100; attempt++) {
try {
return await readFile(filename, "utf8");
} catch {
await delay(30);
}
}
assert.fail(`Timed out waiting for ${filename}`);
}
async function launcher(context: TestContext, env: Record<string, string> = {}) {
const root = await mkdtemp(join(tmpdir(), "s2s-launcher-"));
const frontend = join(root, "s2s-frontend");
const modules = join(frontend, "node_modules");
await mkdir(join(root, ".venv/bin"), { recursive: true });
await mkdir(join(root, "s2s-client"));
await mkdir(join(modules, "vite/bin"), { recursive: true });
await copyFile(new URL("../../start_ui.sh", import.meta.url), join(root, "start_ui.sh"));
await writeFile(join(root, "s2s-client/requirements.txt"), "");
await writeFile(join(root, ".venv/bin/activate"), 'export PATH="$S2S_TEST_ROOT/.venv/bin:$PATH"\n');
await writeFile(
join(root, ".venv/bin/python"),
'#!/bin/bash\nif [ "$1" = "-c" ]; then exit 0; fi\nexec node "$S2S_TEST_ROOT/child.mjs" backend "$@"\n',
{ mode: 0o755 },
);
await writeFile(join(root, "child.mjs"), childSource);
await writeFile(join(modules, "vite/bin/vite.js"), 'import(process.env.S2S_TEST_ROOT + "/child.mjs");\n');
for (const manifest of ["package.json", "package-lock.json"]) {
await writeFile(join(frontend, manifest), "{}\n");
await copyFile(join(frontend, manifest), join(modules, `.s2s-${manifest}`));
}
const publicPort = await freePort();
let backendPort = await freePort();
while (backendPort === publicPort) {
backendPort = await freePort();
}
// The root .env retains the launcher's existing precedence over shell values.
await writeFile(join(root, ".env"), `S2S_DEV_ALLOWED_HOSTS=s2s.test\nS2S_BACKEND_PORT=${backendPort}\n`);
const child = spawn("bash", [join(root, "start_ui.sh")], {
cwd: tmpdir(),
env: {
...process.env,
UI_PORT: String(publicPort),
UI_HOST: "0.0.0.0",
NODE_ENV: "production",
S2S_BACKEND_PORT: "1",
S2S_BACKEND_URL: "http://wrong-target.invalid",
S2S_TEST_ROOT: root,
S2S_TEST_FAIL: "",
...env,
},
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
const exited = new Promise<number | null>((resolve) => child.once("close", resolve));
context.after(async () => {
child.kill("SIGTERM");
await exited;
await rm(root, { recursive: true, force: true });
});
return { root, child, exited, publicPort, backendPort, output: () => output };
}
for (const signal of ["SIGINT", "SIGTERM"] as const) {
await test(
`keeps the public endpoint and stops both process groups on ${signal}`,
{ timeout: 10_000 },
async (context) => {
const run = await launcher(context);
for (const role of ["backend", "frontend"]) {
await waitForFile(join(run.root, `${role}-worker-ready`));
}
const metadata: unknown = JSON.parse(await readFile(join(run.root, "frontend.json"), "utf8"));
assert.deepEqual(metadata, {
args: ["--host", "0.0.0.0", "--port", String(run.publicPort), "--strictPort"],
target: `http://127.0.0.1:${run.backendPort}`,
hosts: "s2s.test",
nodeEnv: "development",
});
assert.equal(run.output().includes("Installing"), false);
run.child.kill(signal);
assert.equal(await run.exited, signal === "SIGINT" ? 130 : 143);
for (const role of ["backend", "frontend"]) {
assert.equal(await waitForFile(join(run.root, `${role}-worker-stopped`)), "yes");
}
for (const port of [run.publicPort, run.backendPort]) {
await assert.rejects(fetch(`http://127.0.0.1:${port}`));
}
},
);
}
await test(
"stops the backend if Vite exits and returns the failing exit code",
{ timeout: 10_000 },
async (context) => {
const run = await launcher(context, { S2S_TEST_FAIL: "frontend" });
assert.equal(await run.exited, 37, run.output());
assert.equal(await waitForFile(join(run.root, "backend-worker-stopped")), "yes");
await assert.rejects(fetch(`http://127.0.0.1:${run.backendPort}`));
},
);
await test("does not start Vite when the backend fails readiness", { timeout: 10_000 }, async (context) => {
const run = await launcher(context, { S2S_TEST_FAIL: "backend" });
assert.equal(await run.exited, 1, run.output());
await assert.rejects(readFile(join(run.root, "frontend.json")));
assert.match(run.output(), /FastAPI exited before it was ready/);
});
await test("stops Vite if the backend exits after startup", { timeout: 10_000 }, async (context) => {
const run = await launcher(context);
await waitForFile(join(run.root, "frontend-worker-ready"));
const backendPid = Number(await readFile(join(run.root, "backend.pid"), "utf8"));
assert.ok(Number.isInteger(backendPid) && backendPid > 0);
process.kill(-backendPid, "SIGTERM");
assert.equal(await run.exited, 1, run.output());
assert.equal(await waitForFile(join(run.root, "frontend-worker-stopped")), "yes");
await assert.rejects(fetch(`http://127.0.0.1:${run.publicPort}`));
});
await test("rejects an invalid public port before starting either server", { timeout: 10_000 }, async (context) => {
const run = await launcher(context, { UI_PORT: "65536" });
assert.equal(await run.exited, 1);
assert.match(run.output(), /UI_PORT must be a port/);
await assert.rejects(readFile(join(run.root, "backend.json")));
});
+36
View File
@@ -0,0 +1,36 @@
import js from "@eslint/js";
import { defineConfig, globalIgnores } from "eslint/config";
import prettier from "eslint-config-prettier/flat";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default defineConfig([
globalIgnores(["dist/**", "coverage/**", "node_modules/**"]),
{
files: ["**/*.{ts,tsx}"],
extends: [js.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked],
languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } },
rules: {
"@typescript-eslint/consistent-type-imports": ["error", { fixStyle: "inline-type-imports" }],
"@typescript-eslint/no-import-type-side-effects": "error",
"@typescript-eslint/no-unnecessary-condition": "off",
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
"@typescript-eslint/prefer-nullish-coalescing": ["error", { ignorePrimitives: { string: true } }],
"no-console": ["error", { allow: ["warn", "error"] }],
"no-debugger": "error",
curly: ["error", "all"],
eqeqeq: ["error", "always", { null: "ignore" }],
},
},
{
files: ["src/**/*.{ts,tsx}"],
plugins: { "react-hooks": { rules: reactHooks.rules }, "react-refresh": reactRefresh },
rules: {
...reactHooks.configs.recommended.rules,
"react-hooks/exhaustive-deps": "error",
"react-refresh/only-export-components": ["error", { allowConstantExport: true }],
},
},
prettier,
]);
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#07080d" />
<title>Vocero · Conversación de voz</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4609
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
{
"name": "s2s-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": "^22.18.0 || >=24.0.0"
},
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"preview": "vite preview",
"lint": "eslint . --cache --cache-strategy content --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc -b",
"test": "vitest run",
"test:watch": "vitest",
"test:launcher": "node --test config/launcher.integration.ts",
"check": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run test:launcher"
},
"dependencies": {
"dompurify": "^3.4.16",
"lucide-react": "^1.52.0",
"marked": "^18.1.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"zod": "^4.6.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.12.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"jiti": "^2.7.0",
"jsdom": "^30.1.2",
"prettier": "^3.9.9",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"typescript-eslint": "^8.71.1",
"vite": "^8.3.0",
"vitest": "^5.0.3"
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { Config } from "prettier";
export default {
arrowParens: "always",
endOfLine: "lf",
plugins: ["prettier-plugin-tailwindcss"],
printWidth: 120,
semi: true,
singleQuote: false,
tabWidth: 2,
tailwindStylesheet: "./src/styles.css",
trailingComma: "all",
} satisfies Config & { tailwindStylesheet: string };
+105
View File
@@ -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();
});
});
+311
View File
@@ -0,0 +1,311 @@
import { BookOpen, ChartNoAxesColumn, ChevronRight, Info, PanelLeft, Settings, Wrench, X } from "lucide-react";
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { Account } from "./components/Account";
import { Bubbles } from "./components/Bubbles";
import { Conversation } from "./components/Conversation";
import {
AboutDialog,
LimitDialog,
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">
<Account account={state.account} />
<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} />}
{state.limitReached && (
<LimitDialog
state={state}
onClose={() => {
controller.dismissLimit();
}}
/>
)}
<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);
+23
View File
@@ -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;
}
+149
View File
@@ -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
View File
@@ -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;
}
+89
View File
@@ -0,0 +1,89 @@
import { LogIn } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AccountInfo } from "../lib/contracts";
export function Account({ account }: { account: AccountInfo | null }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
return;
}
const close = (event: PointerEvent) => {
if (event.target instanceof Node && !ref.current?.contains(event.target)) {
setOpen(false);
}
};
const escape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("pointerdown", close);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", close);
document.removeEventListener("keydown", escape);
};
}, [open]);
if (!account?.enabled) {
return null;
}
if (!account.loggedIn) {
return account.auth && account.loginUrl ? (
<a className="signin-pill" href={account.loginUrl}>
<LogIn size={16} />
<span>Iniciar sesión</span>
</a>
) : null;
}
const unlimited = account.tier === "pro" || account.tier === "org";
const remaining =
account.remainingSec == null || unlimited
? "Sin límite"
: `${String(Math.floor(account.remainingSec / 60))}:${String(Math.round(account.remainingSec) % 60).padStart(2, "0")} restantes hoy`;
return (
<div className="account" ref={ref}>
<button
className="account-chip"
type="button"
aria-expanded={open}
onClick={() => {
setOpen(!open);
}}
>
{account.avatar ? (
<img className="account-avatar" src={account.avatar} alt="" />
) : (
<span className="account-avatar account-avatar-fallback">{account.username?.[0]?.toUpperCase() ?? "?"}</span>
)}
<span className="account-handle">{account.username}</span>
{unlimited && <span className="account-pro">{account.tier === "pro" ? "PRO" : "EQUIPO"}</span>}
</button>
{open && (
<div className="account-pop">
<div className="account-pop-row account-pop-name">{account.username}</div>
<div className="account-pop-row account-pop-meta">
<span>{remaining}</span>
</div>
{!unlimited && (
<a
className="account-pop-link"
href="https://huggingface.co/subscribe/pro"
target="_blank"
rel="noopener noreferrer"
>
Pasar a PRO
</a>
)}
{account.logoutUrl && (
<a className="account-pop-link account-signout" href={account.logoutUrl}>
Cerrar sesión
</a>
)}
</div>
)}
</div>
);
}
+45
View File
@@ -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>
);
});
+46
View File
@@ -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,362 @@
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>
);
}
export function LimitDialog({ state, onClose }: Omit<Props, "controller">) {
const anonymous = !state.account?.loggedIn;
return (
<Dialog title="Por ahora hemos terminado" className="limit-modal" onClose={onClose}>
<p className="limit-msg">
Has agotado el tiempo de conversación disponible hoy. Vuelve mañana para seguir conversando.
</p>
{anonymous && state.account?.loginUrl ? (
<a className="btn primary wide" href={state.account.loginUrl}>
Inicia sesión para obtener más tiempo
</a>
) : state.account?.tier === "free" ? (
<a
className="btn primary wide"
href="https://huggingface.co/subscribe/pro"
target="_blank"
rel="noopener noreferrer"
>
Pasar a PRO
</a>
) : null}
</Dialog>
);
}
+302
View File
@@ -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>
);
}
+78
View File
@@ -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]);
}
+124
View File
@@ -0,0 +1,124 @@
import { z } from "zod";
import { accountSchema, 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, reports, and account features are optional in generic deployments.
return null;
}
}
export function getConfig(signal: AbortSignal) {
return request("/api/config", configSchema, { signal });
}
export function getAccount(signal: AbortSignal) {
return optionalRequest("/api/me", accountSchema, 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(""), log_only: z.boolean().default(false) });
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 endReservation(path: "/api/session/end" | "/api/queue/end", body: Record<string, string>) {
const payload = JSON.stringify(body);
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.";
}
+90
View File
@@ -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"],
},
};
+152
View File
@@ -0,0 +1,152 @@
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", "NODE_RED"]).catch("PROXY"),
auth: z.boolean().default(false),
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 accountSchema = z.object({
enabled: z.boolean(),
loggedIn: z.boolean().default(false),
auth: z.boolean().default(false),
tier: z.string().default("anon"),
username: z.string().nullish(),
avatar: z.string().nullish(),
loginUrl: z.string().nullish(),
logoutUrl: z.string().nullish(),
remainingSec: z.number().nullish(),
limitSec: z.number().nullish(),
});
export type AccountInfo = z.infer<typeof accountSchema>;
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),
tier: z.string().default("anon"),
limited: z.boolean().default(false),
heartbeatSec: z.number().default(5),
remainingSec: z.number().nullish(),
});
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>;
+50
View File
@@ -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"}');
});
});
+174
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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);
}
}
+160
View File
@@ -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 }];
});
}
+51
View File
@@ -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);
}
+93
View File
@@ -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";
}
+14
View File
@@ -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>,
);
+266
View File
@@ -0,0 +1,266 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RealtimeClient, SessionError, type ClientOptions } from "./client";
const audio = vi.hoisted(() => ({
start: vi.fn<(...callbacks: unknown[]) => Promise<void>>(),
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) {
const 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");
});
socket.receive({ type: "session.updated" });
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("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);
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");
});
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();
});
});
+599
View File
@@ -0,0 +1,599 @@
import { z } from "zod";
import { encodePcm } from "../audio/codec";
import { AudioEngine, MIC_CONSTRAINTS } from "../audio/engine";
import { ApiError, endReservation, 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: "limit" | "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 grant: SessionGrant | null = null;
private queueId = "";
private joinResolve: (() => void) | null = null;
private joinReject: ((reason: Error) => void) | null = null;
private joinTimer: ReturnType<typeof setTimeout> | undefined;
private heartbeat: ReturnType<typeof setInterval> | 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 === 402 || 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.grant = grant;
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);
if (grant.limited) {
this.heartbeat = setInterval(
() => {
void post(
"/api/session/heartbeat",
z.object({ expired: z.boolean() }),
{ sessionId: grant.session_id },
this.abort.signal,
)
.then((result) => {
if (result.expired) {
this.fail(new SessionError("limit", "Se ha agotado tu tiempo de conversación."));
}
})
.catch(() => undefined);
},
Math.max(1, grant.heartbeatSec) * 1000,
);
}
this.setStatus("connecting");
return grant;
}
private sessionError(error: unknown): unknown {
if (error instanceof ApiError) {
if (error.status === 402) {
return new SessionError("limit", "Se ha agotado tu tiempo de conversación.");
}
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",
},
});
break;
case "session.updated":
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();
clearInterval(this.heartbeat);
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) {
endReservation("/api/queue/end", { queueId: this.queueId });
this.queueId = "";
}
if (this.grant) {
endReservation("/api/session/end", { sessionId: this.grant.session_id });
this.grant = null;
}
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,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");
});
});
+531
View File
@@ -0,0 +1,531 @@
import { z } from "zod";
import { createAudioContext } from "../audio/engine";
import {
errorMessage,
getAccount,
getConfig,
getProfile,
getReports,
getTools,
post,
reasoningSchema,
searchSchema,
} from "../lib/api";
import { buildInstructions, profileContext, ragContext, speechInstructions, WEB_SEARCH } from "../lib/context";
import {
defaultConfig,
type AccountInfo,
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;
account: AccountInfo | 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;
remainingSec: number | null;
limitReached: boolean;
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;
private pageSignal: AbortSignal | undefined;
constructor(caller = callerFromLocation()) {
this.caller = caller;
this.state = {
loading: true,
config: defaultConfig,
settings: loadSettings(),
profile: null,
reports: null,
account: null,
tools: [],
toolsLoading: false,
toolsError: "",
status: "idle",
error: "",
notice: "",
messages: [],
muted: false,
level: 0,
rag: "",
queuePosition: 0,
joinDeadline: 0,
remainingSec: null,
limitReached: false,
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) {
this.pageSignal = signal;
try {
const [config, profile, reports, account] = await Promise.all([
getConfig(signal),
getProfile(this.caller.phoneNumber, signal),
getReports(this.caller.phoneNumber, signal),
getAccount(signal),
]);
if (signal.aborted) {
return;
}
this.update({ config, profile, reports, account, 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();
}
dismissLimit() {
this.update({ limitReached: false });
}
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,
limitReached: 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", ({ remainingSec }) => {
this.update({ remainingSec: remainingSec ?? null, 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",
limitReached: error instanceof SessionError && error.code === "limit",
});
}
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 || result.log_only) {
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,
remainingSec: null,
unlock: false,
messages: this.state.messages.map((message) =>
message.partial ? { ...message, partial: false, interrupted: message.role === "assistant" } : message,
),
});
await client?.close();
if (client && this.state.account?.enabled && this.pageSignal && !this.pageSignal.aborted) {
const signal = this.pageSignal;
const account = await getAccount(signal).catch(() => null);
if (!signal.aborted && account) {
this.update({ account });
}
}
}
}
+138
View File
@@ -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["/oauth"]).toEqual(proxy["/api"]);
});
});
+28
View File
@@ -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();
});
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["*.config.ts", "config/**/*.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig, loadEnv } from "vite";
import { createBackendProxy } from "./config/backend-proxy.ts";
export default defineConfig(({ mode }) => {
// Read this project's configuration; the repository .env contains server secrets.
const env = loadEnv(mode, process.cwd(), "S2S_");
return {
plugins: [react(), tailwindcss()],
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
server: {
host: "127.0.0.1",
port: 8420,
strictPort: true,
ws: { path: "/__vite_hmr" },
allowedHosts: (env.S2S_DEV_ALLOWED_HOSTS ?? "")
.split(",")
.map((host) => host.trim())
.filter(Boolean),
proxy: createBackendProxy(env.S2S_BACKEND_URL ?? "http://127.0.0.1:8421"),
},
build: { target: "es2022" },
};
});
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
execArgv: process.allowedNodeEnvironmentFlags.has("--no-experimental-webstorage")
? ["--no-experimental-webstorage"]
: [],
setupFiles: ["./src/test/setup.ts"],
restoreMocks: true,
},
});