refactor(frontend): remove quota UI and preserve queue lifecycle
This commit is contained in:
@@ -20,7 +20,6 @@ beforeEach(() => {
|
||||
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,
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
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 { AboutDialog, ProfileDialog, RagDialog, SettingsDialog, ToolsDialog } from "./components/SettingsDialogs";
|
||||
import { VoiceStage } from "./components/VoiceStage";
|
||||
import { useWakeWord } from "./hooks/use-wake-word";
|
||||
import { initials, profileName } from "./lib/context";
|
||||
@@ -149,7 +141,6 @@ export default function App() {
|
||||
)}
|
||||
{debug && (
|
||||
<div className="topbar-right" data-debug-enabled="true">
|
||||
<Account account={state.account} />
|
||||
<button
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
@@ -295,14 +286,6 @@ export default function App() {
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { accountSchema, configSchema, recordSchema, reportsSchema, toolsEnvelopeSchema } from "./contracts";
|
||||
import { configSchema, recordSchema, reportsSchema, toolsEnvelopeSchema } from "./contracts";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -40,7 +40,7 @@ export async function optionalRequest<T>(path: string, schema: z.ZodType<T>, sig
|
||||
if (signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
// Profile, reports, and account features are optional in generic deployments.
|
||||
// Profile and reports are optional in generic deployments.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,6 @@ export async function optionalRequest<T>(path: string, schema: z.ZodType<T>, sig
|
||||
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;
|
||||
@@ -89,8 +86,9 @@ export const searchSchema = z.object({
|
||||
});
|
||||
|
||||
/** 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);
|
||||
export function cancelQueue(queueId: string) {
|
||||
const path = "/api/queue/end";
|
||||
const payload = JSON.stringify({ queueId });
|
||||
if (
|
||||
typeof navigator.sendBeacon === "function" &&
|
||||
navigator.sendBeacon(path, new Blob([payload], { type: "application/json" }))
|
||||
|
||||
@@ -10,7 +10,6 @@ export const configSchema = z.object({
|
||||
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(""),
|
||||
@@ -37,20 +36,6 @@ export const reportsSchema = z.object({
|
||||
});
|
||||
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(""),
|
||||
@@ -74,10 +59,6 @@ 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({
|
||||
|
||||
@@ -56,8 +56,7 @@ const options = (): ClientOptions => ({
|
||||
gate: { enabled: false, thresholdDb: -66 },
|
||||
});
|
||||
|
||||
async function connect(client: RealtimeClient) {
|
||||
const pending = client.connect();
|
||||
async function connect(client: RealtimeClient, pending = client.connect()) {
|
||||
await vi.waitFor(() => {
|
||||
expect(Socket.instances).toHaveLength(1);
|
||||
});
|
||||
@@ -89,33 +88,145 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("realtime session", () => {
|
||||
it("ends a metered session and releases its reservation when the budget expires", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
session_id: "metered",
|
||||
connect_url: "ws://localhost/v1/realtime",
|
||||
limited: true,
|
||||
heartbeatSec: 5,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ expired: true })));
|
||||
it("connects with a plain load-balancer grant and closes without further HTTP requests", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
session_id: "local-session",
|
||||
connect_url: "ws://localhost/v1/realtime",
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new RealtimeClient({ ...options(), directUrl: undefined, sessionUrl: "/api/session" });
|
||||
const error = vi.fn();
|
||||
client.on("error", error);
|
||||
const socket = await connect(client);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(error).toHaveBeenCalledWith(expect.objectContaining({ code: "limit" }));
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
expect(client.status).toBe("listening");
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
expect(socket.url).toBe("ws://localhost/v1/realtime");
|
||||
await client.close();
|
||||
expect(socket.close).toHaveBeenCalledOnce();
|
||||
expect(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(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["ws://voice.test/v1/realtime", "ws://localhost:8420/v1/realtime"])(
|
||||
"starts and cleans up the direct or proxied socket at %s",
|
||||
async (directUrl) => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new RealtimeClient({ ...options(), directUrl });
|
||||
const socket = await connect(client);
|
||||
expect(socket.url).toBe(directUrl);
|
||||
expect(audio.start).toHaveBeenCalledOnce();
|
||||
await client.close();
|
||||
await client.close();
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(1000, "client closed");
|
||||
expect(audio.close).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(sendBeacon).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("polls a queue ticket and joins the granted slot before its deadline", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<(path: string, init?: RequestInit) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1", position: 2 })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1", position: 1 })))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ session_id: "s1", connect_url: "ws://voice.test", pending_timeout_s: 10 })),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new RealtimeClient({ ...options(), directUrl: undefined });
|
||||
const readyToJoin = vi.fn();
|
||||
client.on("readyToJoin", readyToJoin);
|
||||
const pending = client.connect();
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("queued");
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/session", "/api/queue/q1", "/api/queue/q1"]);
|
||||
expect(client.status).toBe("your-turn");
|
||||
expect(readyToJoin).toHaveBeenCalledWith(expect.objectContaining({ expiresSec: 7 }));
|
||||
expect(Socket.instances).toHaveLength(0);
|
||||
client.join();
|
||||
await connect(client, pending);
|
||||
await client.close();
|
||||
expect(sendBeacon).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("expires an unclaimed join offer after releasing the permission probe's microphone", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" })))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ session_id: "s1", connect_url: "ws://voice.test", pending_timeout_s: 10 })),
|
||||
),
|
||||
);
|
||||
const client = new RealtimeClient({ ...options(), directUrl: undefined });
|
||||
const rejected = expect(client.connect()).rejects.toMatchObject({ code: "join-expired" });
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("queued");
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(9000);
|
||||
await rejected;
|
||||
expect(stopTrack).toHaveBeenCalledOnce();
|
||||
expect(audio.start).not.toHaveBeenCalled();
|
||||
expect(Socket.instances).toHaveLength(0);
|
||||
expect(client.status).toBe("closed");
|
||||
expect(sendBeacon).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("stops polling when the load balancer expires a queue ticket", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "expired" }), { status: 404 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new RealtimeClient({ ...options(), directUrl: undefined });
|
||||
const rejected = expect(client.connect()).rejects.toMatchObject({ code: "queue-expired" });
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("queued");
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await rejected;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(Socket.instances).toHaveLength(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("uses a keepalive request to cancel a queue ticket when sendBeacon declines it", async () => {
|
||||
sendBeacon.mockReturnValueOnce(false);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new RealtimeClient({ ...options(), directUrl: undefined });
|
||||
const rejected = expect(client.connect()).rejects.toMatchObject({ code: "aborted" });
|
||||
await vi.waitFor(() => {
|
||||
expect(client.status).toBe("queued");
|
||||
});
|
||||
await client.close();
|
||||
await rejected;
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
"/api/queue/end",
|
||||
expect.objectContaining({ method: "POST", body: JSON.stringify({ queueId: "q1" }), keepalive: true }),
|
||||
);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("replaces cumulative user transcript deltas instead of duplicating words", async () => {
|
||||
const client = new RealtimeClient(options());
|
||||
const transcript = vi.fn();
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { ApiError, cancelQueue, post, request } from "../lib/api";
|
||||
import {
|
||||
grantSchema,
|
||||
realtimeEventSchema,
|
||||
@@ -41,7 +41,7 @@ export interface ClientOptions {
|
||||
}
|
||||
|
||||
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) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
@@ -60,12 +60,10 @@ export class RealtimeClient {
|
||||
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 = "";
|
||||
@@ -211,7 +209,7 @@ export class RealtimeClient {
|
||||
});
|
||||
} catch (error) {
|
||||
this.ensureOpen();
|
||||
if (error instanceof ApiError && (error.status === 402 || error.status === 404)) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
throw this.sessionError(error);
|
||||
}
|
||||
continue;
|
||||
@@ -222,7 +220,6 @@ export class RealtimeClient {
|
||||
}
|
||||
}
|
||||
const grant = grantSchema.parse(result);
|
||||
this.grant = grant;
|
||||
this.queueId = "";
|
||||
this.ensureOpen();
|
||||
if (waited) {
|
||||
@@ -239,33 +236,11 @@ export class RealtimeClient {
|
||||
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.");
|
||||
}
|
||||
@@ -566,7 +541,6 @@ export class RealtimeClient {
|
||||
}
|
||||
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ó.");
|
||||
@@ -577,13 +551,9 @@ export class RealtimeClient {
|
||||
this.resolveReady = null;
|
||||
this.rejectReady = null;
|
||||
if (this.queueId) {
|
||||
endReservation("/api/queue/end", { queueId: this.queueId });
|
||||
cancelQueue(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");
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ beforeEach(() => {
|
||||
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 },
|
||||
};
|
||||
@@ -88,6 +87,17 @@ async function start() {
|
||||
return client;
|
||||
}
|
||||
describe("voice application lifecycle", () => {
|
||||
it("loads configuration and caller context without an account request on startup or stop", async () => {
|
||||
const client = await start();
|
||||
await controller.stop();
|
||||
expect(client.close).toHaveBeenCalledOnce();
|
||||
expect(fetchMock.mock.calls.map(([path]) => new URL(path, "http://localhost").pathname)).toEqual([
|
||||
"/api/config",
|
||||
"/api/user/phone",
|
||||
"/api/user/reports",
|
||||
]);
|
||||
});
|
||||
|
||||
it("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 });
|
||||
|
||||
@@ -3,7 +3,6 @@ import { z } from "zod";
|
||||
import { createAudioContext } from "../audio/engine";
|
||||
import {
|
||||
errorMessage,
|
||||
getAccount,
|
||||
getConfig,
|
||||
getProfile,
|
||||
getReports,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
import { buildInstructions, profileContext, ragContext, speechInstructions, WEB_SEARCH } from "../lib/context";
|
||||
import {
|
||||
defaultConfig,
|
||||
type AccountInfo,
|
||||
type ConnectionStatus,
|
||||
type DynamicTool,
|
||||
type Profile,
|
||||
@@ -44,7 +42,6 @@ export interface AppState {
|
||||
settings: Settings;
|
||||
profile: Profile | null;
|
||||
reports: Reports | null;
|
||||
account: AccountInfo | null;
|
||||
tools: DynamicTool[];
|
||||
toolsLoading: boolean;
|
||||
toolsError: string;
|
||||
@@ -57,8 +54,6 @@ export interface AppState {
|
||||
rag: string;
|
||||
queuePosition: number;
|
||||
joinDeadline: number;
|
||||
remainingSec: number | null;
|
||||
limitReached: boolean;
|
||||
unlock: boolean;
|
||||
}
|
||||
|
||||
@@ -76,7 +71,6 @@ export class VoiceController {
|
||||
private sessionNumber = 0;
|
||||
private reasoned = new Set<string>();
|
||||
private autoStarted = false;
|
||||
private pageSignal: AbortSignal | undefined;
|
||||
|
||||
constructor(caller = callerFromLocation()) {
|
||||
this.caller = caller;
|
||||
@@ -86,7 +80,6 @@ export class VoiceController {
|
||||
settings: loadSettings(),
|
||||
profile: null,
|
||||
reports: null,
|
||||
account: null,
|
||||
tools: [],
|
||||
toolsLoading: false,
|
||||
toolsError: "",
|
||||
@@ -99,8 +92,6 @@ export class VoiceController {
|
||||
rag: "",
|
||||
queuePosition: 0,
|
||||
joinDeadline: 0,
|
||||
remainingSec: null,
|
||||
limitReached: false,
|
||||
unlock: false,
|
||||
};
|
||||
}
|
||||
@@ -119,18 +110,16 @@ export class VoiceController {
|
||||
}
|
||||
|
||||
async initialize(signal: AbortSignal) {
|
||||
this.pageSignal = signal;
|
||||
try {
|
||||
const [config, profile, reports, account] = await Promise.all([
|
||||
const [config, profile, reports] = 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: "" });
|
||||
this.update({ config, profile, reports, loading: false, error: "" });
|
||||
if (config.apipilot) {
|
||||
await this.refreshTools(signal, !!profile);
|
||||
}
|
||||
@@ -189,9 +178,6 @@ export class VoiceController {
|
||||
this.update({ rag: rag.trim() });
|
||||
this.pushSettings();
|
||||
}
|
||||
dismissLimit() {
|
||||
this.update({ limitReached: false });
|
||||
}
|
||||
dismissError() {
|
||||
this.update({ error: "" });
|
||||
}
|
||||
@@ -220,7 +206,6 @@ export class VoiceController {
|
||||
notice: "",
|
||||
status: "connecting",
|
||||
muted: false,
|
||||
limitReached: false,
|
||||
joinDeadline: 0,
|
||||
});
|
||||
let context: AudioContext | undefined;
|
||||
@@ -307,8 +292,8 @@ export class VoiceController {
|
||||
client.on("readyToJoin", ({ expiresSec }) => {
|
||||
this.update({ joinDeadline: Date.now() + expiresSec * 1000 });
|
||||
}),
|
||||
client.on("grant", ({ remainingSec }) => {
|
||||
this.update({ remainingSec: remainingSec ?? null, joinDeadline: 0 });
|
||||
client.on("grant", () => {
|
||||
this.update({ joinDeadline: 0 });
|
||||
}),
|
||||
client.on("error", (error) => {
|
||||
this.reportError(error);
|
||||
@@ -352,7 +337,6 @@ export class VoiceController {
|
||||
this.update({
|
||||
error: errorMessage(error),
|
||||
status: "error",
|
||||
limitReached: error instanceof SessionError && error.code === "limit",
|
||||
});
|
||||
}
|
||||
private transcript(transcript: Transcript, interrupted = false) {
|
||||
@@ -513,19 +497,11 @@ export class VoiceController {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,18 +645,6 @@ a:hover {
|
||||
width: 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;
|
||||
* use compact padding and a taller line height. */
|
||||
.app-footer {
|
||||
@@ -857,236 +845,6 @@ a:hover {
|
||||
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 {
|
||||
@@ -6112,19 +5870,6 @@ html[data-theme="white"] .reports-sidebar-header h2 {
|
||||
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 ─────────────────────────────────────────────── */
|
||||
html[data-theme="white"] .user-profile-card {
|
||||
background: #ffffff;
|
||||
|
||||
@@ -8,6 +8,6 @@ describe("development backend proxy", () => {
|
||||
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"]);
|
||||
expect(proxy["/docs"]).toEqual(proxy["/api"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user