refactor(frontend): remove quota UI and preserve queue lifecycle

This commit is contained in:
2026-10-06 17:18:45 -03:00
parent e4b3833770
commit f91001169c
14 changed files with 161 additions and 503 deletions
+5 -5
View File
@@ -31,7 +31,7 @@ The Vite child explicitly uses `NODE_ENV=development`, including when a process
manager sets `NODE_ENV=production`, so React Fast Refresh remains enabled. manager sets `NODE_ENV=production`, so React Fast Refresh remains enabled.
Vite serves React and Tailwind with hot updates. It proxies `/api`, Vite serves React and Tailwind with hot updates. It proxies `/api`,
`/v1/realtime` (including WebSockets), OAuth, and backend documentation routes `/v1/realtime` (including WebSockets), and backend documentation routes
to FastAPI. The browser uses one origin on port `8420`, so there is no separate 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 API origin or development CORS setup. The proxy preserves the incoming Host
header because FastAPI uses it to construct the speech WebSocket URL. header because FastAPI uses it to construct the speech WebSocket URL.
@@ -114,7 +114,7 @@ by Git. The launcher serves source through Vite; it does not use `dist/` or
`vite preview`. `vite preview`.
Tests cover session initialization, response sequencing, cumulative transcripts, Tests cover session initialization, response sequencing, cumulative transcripts,
interruptions, queue cancellation, microphone-permission cancellation, tool interruptions, queue polling, admission deadlines, cancellation, microphone-permission cancellation, tool
results, external reasoning and `LOG_ONLY`, late-response cleanup, the idle results, external reasoning and `LOG_ONLY`, late-response cleanup, the idle
timeout, settings, RAG, profiles, and sanitized Markdown/export behavior. timeout, settings, RAG, profiles, and sanitized Markdown/export behavior.
Launcher tests start temporary local children to check readiness, environment Launcher tests start temporary local children to check readiness, environment
@@ -123,9 +123,9 @@ Vitest handles Node 26's experimental web storage behavior internally.
## Source layout ## Source layout
- `src/components/`: voice controls, chat, dialogs, profile, and account views. - `src/components/`: voice controls, chat, dialogs, and caller profile views.
- `src/realtime/`: session lifecycle, observable application state, queue and - `src/realtime/`: session lifecycle, observable application state, queue
budget handling, WebSocket protocol, tools, and external reasoning. handling, WebSocket protocol, tools, and external reasoning.
- `src/audio/`: TypeScript capture/playback worklets, audio graph, PCM codec, - `src/audio/`: TypeScript capture/playback worklets, audio graph, PCM codec,
and visualizer. Vite emits the worklets as separate JavaScript assets. and visualizer. Vite emits the worklets as separate JavaScript assets.
- `src/lib/`: validated backend contracts, settings, prompt context, profile - `src/lib/`: validated backend contracts, settings, prompt context, profile
-1
View File
@@ -9,7 +9,6 @@ export function createBackendProxy(target: string): Record<string, ProxyOptions>
"/docs": http, "/docs": http,
"/redoc": http, "/redoc": http,
"/openapi.json": http, "/openapi.json": http,
"/oauth": http,
"/project": http, "/project": http,
"/public/docs.html": http, "/public/docs.html": http,
}; };
-1
View File
@@ -20,7 +20,6 @@ beforeEach(() => {
const path = new URL(input, window.location.origin).pathname; const path = new URL(input, window.location.origin).pathname;
const data: Record<string, unknown> = { const data: Record<string, unknown> = {
"/api/config": bootstrap, "/api/config": bootstrap,
"/api/me": { enabled: false },
"/api/user/phone": { result: { name: "Ana Vargas", contact: { emailAddress: "ana@example.test" } } }, "/api/user/phone": { result: { name: "Ana Vargas", contact: { emailAddress: "ana@example.test" } } },
"/api/user/reports": { "/api/user/reports": {
count: 1, count: 1,
+1 -18
View File
@@ -1,17 +1,9 @@
import { BookOpen, ChartNoAxesColumn, ChevronRight, Info, PanelLeft, Settings, Wrench, X } from "lucide-react"; import { BookOpen, ChartNoAxesColumn, ChevronRight, Info, PanelLeft, Settings, Wrench, X } from "lucide-react";
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react"; import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { Account } from "./components/Account";
import { Bubbles } from "./components/Bubbles"; import { Bubbles } from "./components/Bubbles";
import { Conversation } from "./components/Conversation"; import { Conversation } from "./components/Conversation";
import { import { AboutDialog, ProfileDialog, RagDialog, SettingsDialog, ToolsDialog } from "./components/SettingsDialogs";
AboutDialog,
LimitDialog,
ProfileDialog,
RagDialog,
SettingsDialog,
ToolsDialog,
} from "./components/SettingsDialogs";
import { VoiceStage } from "./components/VoiceStage"; import { VoiceStage } from "./components/VoiceStage";
import { useWakeWord } from "./hooks/use-wake-word"; import { useWakeWord } from "./hooks/use-wake-word";
import { initials, profileName } from "./lib/context"; import { initials, profileName } from "./lib/context";
@@ -149,7 +141,6 @@ export default function App() {
)} )}
{debug && ( {debug && (
<div className="topbar-right" data-debug-enabled="true"> <div className="topbar-right" data-debug-enabled="true">
<Account account={state.account} />
<button <button
className="icon-btn" className="icon-btn"
type="button" type="button"
@@ -295,14 +286,6 @@ export default function App() {
{modal === "rag" && <RagDialog state={state} controller={controller} onClose={close} />} {modal === "rag" && <RagDialog state={state} controller={controller} onClose={close} />}
{modal === "profile" && <ProfileDialog state={state} onClose={close} />} {modal === "profile" && <ProfileDialog state={state} onClose={close} />}
{modal === "about" && <AboutDialog 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"> <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> <span className="app-footer-text">Azurian Vocero en tiempo real ver. Beta 2.1</span>
</footer> </footer>
-89
View File
@@ -1,89 +0,0 @@
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>
);
}
@@ -335,28 +335,3 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
</Dialog> </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>
);
}
+5 -7
View File
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { accountSchema, configSchema, recordSchema, reportsSchema, toolsEnvelopeSchema } from "./contracts"; import { configSchema, recordSchema, reportsSchema, toolsEnvelopeSchema } from "./contracts";
export class ApiError extends Error { export class ApiError extends Error {
readonly status: number; readonly status: number;
@@ -40,7 +40,7 @@ export async function optionalRequest<T>(path: string, schema: z.ZodType<T>, sig
if (signal.aborted) { if (signal.aborted) {
throw error; throw error;
} }
// Profile, reports, and account features are optional in generic deployments. // Profile and reports are optional in generic deployments.
return null; return null;
} }
} }
@@ -48,9 +48,6 @@ export async function optionalRequest<T>(path: string, schema: z.ZodType<T>, sig
export function getConfig(signal: AbortSignal) { export function getConfig(signal: AbortSignal) {
return request("/api/config", configSchema, { signal }); 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) { export async function getProfile(phone: string, signal: AbortSignal) {
if (!phone) { if (!phone) {
return null; return null;
@@ -89,8 +86,9 @@ export const searchSchema = z.object({
}); });
/** Teardown must survive pagehide and must not hold up a new session. */ /** 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>) { export function cancelQueue(queueId: string) {
const payload = JSON.stringify(body); const path = "/api/queue/end";
const payload = JSON.stringify({ queueId });
if ( if (
typeof navigator.sendBeacon === "function" && typeof navigator.sendBeacon === "function" &&
navigator.sendBeacon(path, new Blob([payload], { type: "application/json" })) navigator.sendBeacon(path, new Blob([payload], { type: "application/json" }))
-19
View File
@@ -10,7 +10,6 @@ export const configSchema = z.object({
s2sUrl: z.string().default(""), s2sUrl: z.string().default(""),
primaryPrompt: z.string().default(""), primaryPrompt: z.string().default(""),
reasoningType: z.enum(["PROXY", "OPENAI", "NODE_RED"]).catch("PROXY"), reasoningType: z.enum(["PROXY", "OPENAI", "NODE_RED"]).catch("PROXY"),
auth: z.boolean().default(false),
injectProfile: z.boolean().default(false), injectProfile: z.boolean().default(false),
debugPrompt: z.boolean().default(false), debugPrompt: z.boolean().default(false),
startSessionMode: z.string().default(""), startSessionMode: z.string().default(""),
@@ -37,20 +36,6 @@ export const reportsSchema = z.object({
}); });
export type Reports = z.infer<typeof reportsSchema>; 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({ export const toolSchema = z.object({
name: z.string(), name: z.string(),
description: z.string().default(""), description: z.string().default(""),
@@ -74,10 +59,6 @@ export const grantSchema = z.object({
session_id: z.string(), session_id: z.string(),
connect_url: z.string(), connect_url: z.string(),
pending_timeout_s: z.number().default(60), 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 type SessionGrant = z.infer<typeof grantSchema>;
export const queueSchema = z.object({ export const queueSchema = z.object({
+130 -19
View File
@@ -56,8 +56,7 @@ const options = (): ClientOptions => ({
gate: { enabled: false, thresholdDb: -66 }, gate: { enabled: false, thresholdDb: -66 },
}); });
async function connect(client: RealtimeClient) { async function connect(client: RealtimeClient, pending = client.connect()) {
const pending = client.connect();
await vi.waitFor(() => { await vi.waitFor(() => {
expect(Socket.instances).toHaveLength(1); expect(Socket.instances).toHaveLength(1);
}); });
@@ -89,33 +88,145 @@ afterEach(() => {
}); });
describe("realtime session", () => { describe("realtime session", () => {
it("ends a metered session and releases its reservation when the budget expires", async () => { it("connects with a plain load-balancer grant and closes without further HTTP requests", async () => {
const fetchMock = vi const fetchMock = vi.fn().mockResolvedValueOnce(
.fn() new Response(
.mockResolvedValueOnce( JSON.stringify({
new Response( session_id: "local-session",
JSON.stringify({ connect_url: "ws://localhost/v1/realtime",
session_id: "metered", }),
connect_url: "ws://localhost/v1/realtime", ),
limited: true, );
heartbeatSec: 5,
}),
),
)
.mockResolvedValueOnce(new Response(JSON.stringify({ expired: true })));
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const client = new RealtimeClient({ ...options(), directUrl: undefined, sessionUrl: "/api/session" }); const client = new RealtimeClient({ ...options(), directUrl: undefined, sessionUrl: "/api/session" });
const error = vi.fn(); const error = vi.fn();
client.on("error", error); client.on("error", error);
const socket = await connect(client); const socket = await connect(client);
await vi.advanceTimersByTimeAsync(5000); await vi.advanceTimersByTimeAsync(60000);
expect(error).toHaveBeenCalledWith(expect.objectContaining({ code: "limit" })); expect(client.status).toBe("listening");
expect(error).not.toHaveBeenCalled();
expect(socket.url).toBe("ws://localhost/v1/realtime");
await client.close();
expect(socket.close).toHaveBeenCalledOnce(); expect(socket.close).toHaveBeenCalledOnce();
expect(sendBeacon).toHaveBeenCalledWith("/api/session/end", expect.any(Blob)); expect(audio.close).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledWith("/api/session", expect.objectContaining({ method: "POST" }));
expect(sendBeacon).not.toHaveBeenCalled();
expect(client.status).toBe("closed"); expect(client.status).toBe("closed");
expect(vi.getTimerCount()).toBe(0); expect(vi.getTimerCount()).toBe(0);
}); });
it.each(["ws://voice.test/v1/realtime", "ws://localhost:8420/v1/realtime"])(
"starts and cleans up the direct or proxied socket at %s",
async (directUrl) => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const client = new RealtimeClient({ ...options(), directUrl });
const socket = await connect(client);
expect(socket.url).toBe(directUrl);
expect(audio.start).toHaveBeenCalledOnce();
await client.close();
await client.close();
expect(socket.close).toHaveBeenCalledExactlyOnceWith(1000, "client closed");
expect(audio.close).toHaveBeenCalledOnce();
expect(fetchMock).not.toHaveBeenCalled();
expect(sendBeacon).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
},
);
it("polls a queue ticket and joins the granted slot before its deadline", async () => {
const fetchMock = vi
.fn<(path: string, init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1", position: 2 })))
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1", position: 1 })))
.mockResolvedValueOnce(
new Response(JSON.stringify({ session_id: "s1", connect_url: "ws://voice.test", pending_timeout_s: 10 })),
);
vi.stubGlobal("fetch", fetchMock);
const client = new RealtimeClient({ ...options(), directUrl: undefined });
const readyToJoin = vi.fn();
client.on("readyToJoin", readyToJoin);
const pending = client.connect();
await vi.waitFor(() => {
expect(client.status).toBe("queued");
});
await vi.advanceTimersByTimeAsync(4000);
expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/session", "/api/queue/q1", "/api/queue/q1"]);
expect(client.status).toBe("your-turn");
expect(readyToJoin).toHaveBeenCalledWith(expect.objectContaining({ expiresSec: 7 }));
expect(Socket.instances).toHaveLength(0);
client.join();
await connect(client, pending);
await client.close();
expect(sendBeacon).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it("expires an unclaimed join offer after releasing the permission probe's microphone", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" })))
.mockResolvedValueOnce(
new Response(JSON.stringify({ session_id: "s1", connect_url: "ws://voice.test", pending_timeout_s: 10 })),
),
);
const client = new RealtimeClient({ ...options(), directUrl: undefined });
const rejected = expect(client.connect()).rejects.toMatchObject({ code: "join-expired" });
await vi.waitFor(() => {
expect(client.status).toBe("queued");
});
await vi.advanceTimersByTimeAsync(9000);
await rejected;
expect(stopTrack).toHaveBeenCalledOnce();
expect(audio.start).not.toHaveBeenCalled();
expect(Socket.instances).toHaveLength(0);
expect(client.status).toBe("closed");
expect(sendBeacon).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it("stops polling when the load balancer expires a queue ticket", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" })))
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "expired" }), { status: 404 }));
vi.stubGlobal("fetch", fetchMock);
const client = new RealtimeClient({ ...options(), directUrl: undefined });
const rejected = expect(client.connect()).rejects.toMatchObject({ code: "queue-expired" });
await vi.waitFor(() => {
expect(client.status).toBe("queued");
});
await vi.advanceTimersByTimeAsync(2000);
await rejected;
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(Socket.instances).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
});
it("uses a keepalive request to cancel a queue ticket when sendBeacon declines it", async () => {
sendBeacon.mockReturnValueOnce(false);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" })))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true })));
vi.stubGlobal("fetch", fetchMock);
const client = new RealtimeClient({ ...options(), directUrl: undefined });
const rejected = expect(client.connect()).rejects.toMatchObject({ code: "aborted" });
await vi.waitFor(() => {
expect(client.status).toBe("queued");
});
await client.close();
await rejected;
expect(fetchMock).toHaveBeenLastCalledWith(
"/api/queue/end",
expect.objectContaining({ method: "POST", body: JSON.stringify({ queueId: "q1" }), keepalive: true }),
);
expect(vi.getTimerCount()).toBe(0);
});
it("replaces cumulative user transcript deltas instead of duplicating words", async () => { it("replaces cumulative user transcript deltas instead of duplicating words", async () => {
const client = new RealtimeClient(options()); const client = new RealtimeClient(options());
const transcript = vi.fn(); const transcript = vi.fn();
+4 -34
View File
@@ -2,7 +2,7 @@ import { z } from "zod";
import { encodePcm } from "../audio/codec"; import { encodePcm } from "../audio/codec";
import { AudioEngine, MIC_CONSTRAINTS } from "../audio/engine"; import { AudioEngine, MIC_CONSTRAINTS } from "../audio/engine";
import { ApiError, endReservation, post, request } from "../lib/api"; import { ApiError, cancelQueue, post, request } from "../lib/api";
import { import {
grantSchema, grantSchema,
realtimeEventSchema, realtimeEventSchema,
@@ -41,7 +41,7 @@ export interface ClientOptions {
} }
export class SessionError extends Error { export class SessionError extends Error {
readonly code: "limit" | "queue-full" | "queue-expired" | "join-expired" | "aborted"; readonly code: "queue-full" | "queue-expired" | "join-expired" | "aborted";
constructor(code: SessionError["code"], message: string) { constructor(code: SessionError["code"], message: string) {
super(message); super(message);
this.code = code; this.code = code;
@@ -60,12 +60,10 @@ export class RealtimeClient {
private configured = false; private configured = false;
private closed = false; private closed = false;
private closing: Promise<void> | null = null; private closing: Promise<void> | null = null;
private grant: SessionGrant | null = null;
private queueId = ""; private queueId = "";
private joinResolve: (() => void) | null = null; private joinResolve: (() => void) | null = null;
private joinReject: ((reason: Error) => void) | null = null; private joinReject: ((reason: Error) => void) | null = null;
private joinTimer: ReturnType<typeof setTimeout> | undefined; private joinTimer: ReturnType<typeof setTimeout> | undefined;
private heartbeat: ReturnType<typeof setInterval> | undefined;
private incoming: Promise<void> = Promise.resolve(); private incoming: Promise<void> = Promise.resolve();
private activeResponses = new Set<string>(); private activeResponses = new Set<string>();
private activeResponse = ""; private activeResponse = "";
@@ -211,7 +209,7 @@ export class RealtimeClient {
}); });
} catch (error) { } catch (error) {
this.ensureOpen(); this.ensureOpen();
if (error instanceof ApiError && (error.status === 402 || error.status === 404)) { if (error instanceof ApiError && error.status === 404) {
throw this.sessionError(error); throw this.sessionError(error);
} }
continue; continue;
@@ -222,7 +220,6 @@ export class RealtimeClient {
} }
} }
const grant = grantSchema.parse(result); const grant = grantSchema.parse(result);
this.grant = grant;
this.queueId = ""; this.queueId = "";
this.ensureOpen(); this.ensureOpen();
if (waited) { if (waited) {
@@ -239,33 +236,11 @@ export class RealtimeClient {
await joined; await joined;
} }
this.emit("grant", grant); 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"); this.setStatus("connecting");
return grant; return grant;
} }
private sessionError(error: unknown): unknown { private sessionError(error: unknown): unknown {
if (error instanceof ApiError) { if (error instanceof ApiError) {
if (error.status === 402) {
return new SessionError("limit", "Se ha agotado tu tiempo de conversación.");
}
if (error.status === 404) { if (error.status === 404) {
return new SessionError("queue-expired", "La espera ha caducado. Vuelve a intentarlo."); return new SessionError("queue-expired", "La espera ha caducado. Vuelve a intentarlo.");
} }
@@ -566,7 +541,6 @@ export class RealtimeClient {
} }
this.closed = true; this.closed = true;
this.abort.abort(); this.abort.abort();
clearInterval(this.heartbeat);
clearTimeout(this.joinTimer); clearTimeout(this.joinTimer);
clearTimeout(this.readyTimer); clearTimeout(this.readyTimer);
const cancelled = new SessionError("aborted", "La conexión se canceló."); const cancelled = new SessionError("aborted", "La conexión se canceló.");
@@ -577,13 +551,9 @@ export class RealtimeClient {
this.resolveReady = null; this.resolveReady = null;
this.rejectReady = null; this.rejectReady = null;
if (this.queueId) { if (this.queueId) {
endReservation("/api/queue/end", { queueId: this.queueId }); cancelQueue(this.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) { if (this.socket && this.socket.readyState <= WebSocket.OPEN) {
this.socket.close(1000, "client closed"); this.socket.close(1000, "client closed");
} }
+11 -1
View File
@@ -64,7 +64,6 @@ beforeEach(() => {
const pathname = new URL(path, "http://localhost").pathname; const pathname = new URL(path, "http://localhost").pathname;
const responses: Record<string, unknown> = { const responses: Record<string, unknown> = {
"/api/config": { reasoningType: "NODE_RED", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" }, "/api/config": { reasoningType: "NODE_RED", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" },
"/api/me": { enabled: false },
"/api/user/phone": { result: { name: "Ana" } }, "/api/user/phone": { result: { name: "Ana" } },
"/api/user/reports": { count: 0 }, "/api/user/reports": { count: 0 },
}; };
@@ -88,6 +87,17 @@ async function start() {
return client; return client;
} }
describe("voice application lifecycle", () => { describe("voice application lifecycle", () => {
it("loads configuration and caller context without an account request on startup or stop", async () => {
const client = await start();
await controller.stop();
expect(client.close).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls.map(([path]) => new URL(path, "http://localhost").pathname)).toEqual([
"/api/config",
"/api/user/phone",
"/api/user/reports",
]);
});
it("keeps LOG_ONLY reasoning on the native voice path and preserves separate profile context", async () => { it("keeps LOG_ONLY reasoning on the native voice path and preserves separate profile context", async () => {
const client = await start(); const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false }); client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false });
+4 -28
View File
@@ -3,7 +3,6 @@ import { z } from "zod";
import { createAudioContext } from "../audio/engine"; import { createAudioContext } from "../audio/engine";
import { import {
errorMessage, errorMessage,
getAccount,
getConfig, getConfig,
getProfile, getProfile,
getReports, getReports,
@@ -15,7 +14,6 @@ import {
import { buildInstructions, profileContext, ragContext, speechInstructions, WEB_SEARCH } from "../lib/context"; import { buildInstructions, profileContext, ragContext, speechInstructions, WEB_SEARCH } from "../lib/context";
import { import {
defaultConfig, defaultConfig,
type AccountInfo,
type ConnectionStatus, type ConnectionStatus,
type DynamicTool, type DynamicTool,
type Profile, type Profile,
@@ -44,7 +42,6 @@ export interface AppState {
settings: Settings; settings: Settings;
profile: Profile | null; profile: Profile | null;
reports: Reports | null; reports: Reports | null;
account: AccountInfo | null;
tools: DynamicTool[]; tools: DynamicTool[];
toolsLoading: boolean; toolsLoading: boolean;
toolsError: string; toolsError: string;
@@ -57,8 +54,6 @@ export interface AppState {
rag: string; rag: string;
queuePosition: number; queuePosition: number;
joinDeadline: number; joinDeadline: number;
remainingSec: number | null;
limitReached: boolean;
unlock: boolean; unlock: boolean;
} }
@@ -76,7 +71,6 @@ export class VoiceController {
private sessionNumber = 0; private sessionNumber = 0;
private reasoned = new Set<string>(); private reasoned = new Set<string>();
private autoStarted = false; private autoStarted = false;
private pageSignal: AbortSignal | undefined;
constructor(caller = callerFromLocation()) { constructor(caller = callerFromLocation()) {
this.caller = caller; this.caller = caller;
@@ -86,7 +80,6 @@ export class VoiceController {
settings: loadSettings(), settings: loadSettings(),
profile: null, profile: null,
reports: null, reports: null,
account: null,
tools: [], tools: [],
toolsLoading: false, toolsLoading: false,
toolsError: "", toolsError: "",
@@ -99,8 +92,6 @@ export class VoiceController {
rag: "", rag: "",
queuePosition: 0, queuePosition: 0,
joinDeadline: 0, joinDeadline: 0,
remainingSec: null,
limitReached: false,
unlock: false, unlock: false,
}; };
} }
@@ -119,18 +110,16 @@ export class VoiceController {
} }
async initialize(signal: AbortSignal) { async initialize(signal: AbortSignal) {
this.pageSignal = signal;
try { try {
const [config, profile, reports, account] = await Promise.all([ const [config, profile, reports] = await Promise.all([
getConfig(signal), getConfig(signal),
getProfile(this.caller.phoneNumber, signal), getProfile(this.caller.phoneNumber, signal),
getReports(this.caller.phoneNumber, signal), getReports(this.caller.phoneNumber, signal),
getAccount(signal),
]); ]);
if (signal.aborted) { if (signal.aborted) {
return; return;
} }
this.update({ config, profile, reports, account, loading: false, error: "" }); this.update({ config, profile, reports, loading: false, error: "" });
if (config.apipilot) { if (config.apipilot) {
await this.refreshTools(signal, !!profile); await this.refreshTools(signal, !!profile);
} }
@@ -189,9 +178,6 @@ export class VoiceController {
this.update({ rag: rag.trim() }); this.update({ rag: rag.trim() });
this.pushSettings(); this.pushSettings();
} }
dismissLimit() {
this.update({ limitReached: false });
}
dismissError() { dismissError() {
this.update({ error: "" }); this.update({ error: "" });
} }
@@ -220,7 +206,6 @@ export class VoiceController {
notice: "", notice: "",
status: "connecting", status: "connecting",
muted: false, muted: false,
limitReached: false,
joinDeadline: 0, joinDeadline: 0,
}); });
let context: AudioContext | undefined; let context: AudioContext | undefined;
@@ -307,8 +292,8 @@ export class VoiceController {
client.on("readyToJoin", ({ expiresSec }) => { client.on("readyToJoin", ({ expiresSec }) => {
this.update({ joinDeadline: Date.now() + expiresSec * 1000 }); this.update({ joinDeadline: Date.now() + expiresSec * 1000 });
}), }),
client.on("grant", ({ remainingSec }) => { client.on("grant", () => {
this.update({ remainingSec: remainingSec ?? null, joinDeadline: 0 }); this.update({ joinDeadline: 0 });
}), }),
client.on("error", (error) => { client.on("error", (error) => {
this.reportError(error); this.reportError(error);
@@ -352,7 +337,6 @@ export class VoiceController {
this.update({ this.update({
error: errorMessage(error), error: errorMessage(error),
status: "error", status: "error",
limitReached: error instanceof SessionError && error.code === "limit",
}); });
} }
private transcript(transcript: Transcript, interrupted = false) { private transcript(transcript: Transcript, interrupted = false) {
@@ -513,19 +497,11 @@ export class VoiceController {
muted: false, muted: false,
level: 0, level: 0,
joinDeadline: 0, joinDeadline: 0,
remainingSec: null,
unlock: false, unlock: false,
messages: this.state.messages.map((message) => messages: this.state.messages.map((message) =>
message.partial ? { ...message, partial: false, interrupted: message.role === "assistant" } : message, message.partial ? { ...message, partial: false, interrupted: message.role === "assistant" } : message,
), ),
}); });
await client?.close(); 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 });
}
}
} }
} }
-255
View File
@@ -645,18 +645,6 @@ a:hover {
width: 18px; width: 18px;
height: 18px; height: 18px;
} }
/* Keep the account control compact on phones: avatar-only chip, shorter pill. */
.account-chip {
height: 36px;
padding: 0 6px;
}
.account-handle {
display: none;
}
.signin-pill {
height: 36px;
padding: 0 12px;
}
/* ── Footer (narrow viewport): reduce tracking to avoid awkward wrapping; /* ── Footer (narrow viewport): reduce tracking to avoid awkward wrapping;
* use compact padding and a taller line height. */ * use compact padding and a taller line height. */
.app-footer { .app-footer {
@@ -857,236 +845,6 @@ a:hover {
height: 20px; height: 20px;
} }
/* ─── Account (HF login chip + sign-in pill + popover) ─────────────────────── */
.account {
position: relative;
display: inline-flex;
align-items: center;
}
/* Signed-out: a compact pill that reads as the primary affordance. */
.signin-pill {
display: inline-flex;
align-items: center;
gap: 7px;
height: 40px;
padding: 0 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--bg-elev);
color: var(--text);
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition:
background 0.15s,
border-color 0.15s;
}
.signin-pill svg {
width: 16px;
height: 16px;
flex: none;
}
.signin-pill:hover {
background: var(--bg-elev-2);
border-color: var(--text);
}
/* Narrow viewports: collapse the pill to an icon-only square. */
@media (max-width: 800px) {
.signin-pill {
gap: 0;
width: 40px;
padding: 0;
justify-content: center;
}
.signin-pill span {
display: none;
}
}
/* Signed-in: avatar + handle chip. */
.account-chip {
display: inline-flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 10px 0 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text-dim);
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.account-chip:hover {
background: var(--bg-elev-2);
color: var(--text);
border-color: var(--border-strong);
}
.account-avatar {
width: 26px;
height: 26px;
border-radius: 50%;
object-fit: cover;
flex: none;
}
.account-avatar-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg-elev-2);
color: var(--text);
font-size: 12px;
font-weight: 700;
}
.account-handle {
font-size: 13px;
font-weight: 600;
max-width: 12ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-pro {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2px 5px;
border-radius: 5px;
background: var(--text);
color: var(--bg);
}
/* Org "Team" badge: same pill as PRO, accent-coloured so it reads as
* unlimited without claiming a paid PRO subscription. */
.account-team {
background: var(--accent);
color: var(--bg);
}
.account-pop {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 200px;
padding: 6px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: var(--bg-elev);
box-shadow: var(--shadow-soft);
z-index: 50;
}
.account-pop[hidden] {
display: none;
}
.account-pop-row {
padding: 8px 10px;
}
.account-pop-name {
font-weight: 600;
font-size: 13px;
}
.account-pop-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 0;
font-size: 12px;
color: var(--text-dim);
}
.account-tier {
font-weight: 600;
color: var(--text);
}
.account-pop-link {
display: block;
padding: 8px 10px;
border-radius: var(--radius-sm);
color: var(--text-dim);
font-size: 13px;
text-decoration: none;
transition:
background 0.15s,
color 0.15s;
}
.account-pop-link:hover {
background: var(--bg-elev-2);
color: var(--text);
}
.account-signout {
border-top: 1px solid var(--border);
margin-top: 4px;
padding-top: 10px;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
/* ─── Daily-limit modal ────────────────────────────────────────────────────── */
.limit-modal {
width: min(400px, 92vw);
}
.limit-card {
position: relative;
align-items: center;
text-align: center;
gap: 14px;
padding: 34px 28px 26px;
}
.limit-close {
position: absolute;
top: 12px;
right: 12px;
}
/* HF smiling face (brand yellow) on a neutral badge — friendly, not a stop sign.
* The logo is the single pop of colour, so the badge itself stays quiet. */
.limit-badge {
width: 66px;
height: 66px;
border-radius: 50%;
display: grid;
place-items: center;
margin: 2px auto 2px;
background: var(--bg-elev-2);
border: 1px solid var(--border-strong);
box-shadow: 0 0 0 6px rgba(255, 210, 30, 0.06);
}
.limit-badge .hf-logo {
width: 46px;
height: auto;
}
.limit-title {
margin: 0;
font-size: 20px;
font-weight: 700;
letter-spacing: 0;
}
.limit-msg {
color: var(--text-dim);
font-size: 14px;
line-height: 1.55;
margin: 0;
max-width: 30ch;
}
.limit-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 4px;
}
.limit-cta .hf-logo {
width: 20px;
height: auto;
flex: none;
}
.limit-note {
margin: 0;
font-size: 12px;
color: var(--text-faint);
}
#limit-cta[hidden] {
display: none;
}
/* ─── Stage ───────────────────────────────────────────────────────────── */ /* ─── Stage ───────────────────────────────────────────────────────────── */
.stage { .stage {
@@ -6112,19 +5870,6 @@ html[data-theme="white"] .reports-sidebar-header h2 {
color: #0f172a; color: #0f172a;
} }
/* ── Limit modal (budget) ───────────────────────────────────────────── */
html[data-theme="white"] .limit-card {
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.08);
}
html[data-theme="white"] .limit-title,
html[data-theme="white"] .limit-msg {
color: #0f172a;
}
html[data-theme="white"] .limit-note {
color: rgba(15, 23, 42, 0.62);
}
/* ── User profile modal ─────────────────────────────────────────────── */ /* ── User profile modal ─────────────────────────────────────────────── */
html[data-theme="white"] .user-profile-card { html[data-theme="white"] .user-profile-card {
background: #ffffff; background: #ffffff;
+1 -1
View File
@@ -8,6 +8,6 @@ describe("development backend proxy", () => {
expect(proxy["/api"]).toEqual({ target: "http://127.0.0.1:8421", changeOrigin: false }); 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["/v1/realtime"]).toEqual({ target: "http://127.0.0.1:8421", changeOrigin: false, ws: true });
expect(proxy["/openapi.json"]).toEqual(proxy["/api"]); expect(proxy["/openapi.json"]).toEqual(proxy["/api"]);
expect(proxy["/oauth"]).toEqual(proxy["/api"]); expect(proxy["/docs"]).toEqual(proxy["/api"]);
}); });
}); });