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.
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
API origin or development CORS setup. The proxy preserves the incoming Host
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`.
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
timeout, settings, RAG, profiles, and sanitized Markdown/export behavior.
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
- `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/components/`: voice controls, chat, dialogs, and caller profile views.
- `src/realtime/`: session lifecycle, observable application state, queue
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
-1
View File
@@ -9,7 +9,6 @@ export function createBackendProxy(target: string): Record<string, ProxyOptions>
"/docs": http,
"/redoc": http,
"/openapi.json": http,
"/oauth": http,
"/project": http,
"/public/docs.html": http,
};
-1
View File
@@ -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 -18
View File
@@ -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>
-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>
);
}
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 { 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" }))
-19
View File
@@ -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({
+125 -14
View File
@@ -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(
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: "metered",
session_id: "local-session",
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" }));
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();
+4 -34
View File
@@ -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");
}
+11 -1
View File
@@ -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 });
+4 -28
View File
@@ -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 });
}
}
}
}
-255
View File
@@ -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;
+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["/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"]);
});
});