fix(frontend): recover startup and discard stale reasoning answers
This commit is contained in:
@@ -7,6 +7,13 @@ the implementation at the recorded date; current operating instructions live in
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Make frontend configuration failures retryable, bound startup requests, and
|
||||||
|
load optional reports independently while waiting for caller context before
|
||||||
|
starting a session. Cancel superseded external reasoning and reject stale
|
||||||
|
answers after a new turn, interruption, or session teardown.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Renamed the API and browser projects to `vocero-api/` and `vocero-frontend/`.
|
- Renamed the API and browser projects to `vocero-api/` and `vocero-frontend/`.
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("React application", () => {
|
describe("React application", () => {
|
||||||
|
it("offers configuration retry without exposing a broken start control", async () => {
|
||||||
|
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await screen.findByRole("alert");
|
||||||
|
expect(screen.queryByRole("button", { name: "Iniciar conversación de voz" })).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Reintentar" }));
|
||||||
|
expect(await screen.findByRole("button", { name: "Iniciar conversación de voz" })).toBeEnabled();
|
||||||
|
});
|
||||||
it("loads the configured theme and persists voice settings across remounts under StrictMode", async () => {
|
it("loads the configured theme and persists voice settings across remounts under StrictMode", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const view = render(
|
const view = render(
|
||||||
|
|||||||
@@ -21,17 +21,18 @@ export default function App() {
|
|||||||
const white = state.config.theme === "white";
|
const white = state.config.theme === "white";
|
||||||
const name = profileName(state.profile);
|
const name = profileName(state.profile);
|
||||||
const hasReports = debug && !white && !!state.reports?.count;
|
const hasReports = debug && !white && !!state.reports?.count;
|
||||||
|
const ready = state.configReady && !state.contextLoading;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
void controller.initialize(abort.signal);
|
void controller.initialize(abort.signal);
|
||||||
const leave = () => {
|
const leave = () => {
|
||||||
void controller.stop();
|
controller.dispose();
|
||||||
};
|
};
|
||||||
window.addEventListener("pagehide", leave);
|
window.addEventListener("pagehide", leave);
|
||||||
return () => {
|
return () => {
|
||||||
abort.abort();
|
abort.abort();
|
||||||
window.removeEventListener("pagehide", leave);
|
window.removeEventListener("pagehide", leave);
|
||||||
void controller.stop();
|
controller.dispose();
|
||||||
};
|
};
|
||||||
}, [controller]);
|
}, [controller]);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -40,7 +41,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [state.loading, white]);
|
}, [state.loading, white]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.loading) {
|
if (!ready) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -49,8 +50,8 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [state.loading, controller]);
|
}, [ready, controller]);
|
||||||
useWakeWord(!state.loading && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock, controller);
|
useWakeWord(ready && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock, controller);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!reportsOpen) {
|
if (!reportsOpen) {
|
||||||
return;
|
return;
|
||||||
@@ -75,6 +76,25 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!state.configReady) {
|
||||||
|
return (
|
||||||
|
<main className="loading-screen">
|
||||||
|
<div className="startup-error">
|
||||||
|
<h1>No pudimos preparar Vocero</h1>
|
||||||
|
<p role="alert">{state.configError}</p>
|
||||||
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void controller.initialize();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{state.unlock && (
|
{state.unlock && (
|
||||||
|
|||||||
@@ -119,8 +119,10 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
|
|||||||
const live = LIVE_STATES.has(state.status);
|
const live = LIVE_STATES.has(state.status);
|
||||||
const active = ACTIVE_STATES.has(state.status);
|
const active = ACTIVE_STATES.has(state.status);
|
||||||
const status = state.status === "closed" ? "idle" : state.status;
|
const status = state.status === "closed" ? "idle" : state.status;
|
||||||
const caption =
|
const ready = state.configReady && !state.contextLoading;
|
||||||
state.muted && (status === "listening" || status === "user-speaking")
|
const caption = !ready
|
||||||
|
? "Preparando contexto…"
|
||||||
|
: state.muted && (status === "listening" || status === "user-speaking")
|
||||||
? "Micrófono silenciado"
|
? "Micrófono silenciado"
|
||||||
: CAPTIONS[state.status];
|
: CAPTIONS[state.status];
|
||||||
const label = active
|
const label = active
|
||||||
@@ -136,7 +138,7 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
|
|||||||
void controller.start();
|
void controller.start();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const disabled = active && status !== "your-turn";
|
const disabled = !ready || (active && status !== "your-turn");
|
||||||
return (
|
return (
|
||||||
<main className="stage">
|
<main className="stage">
|
||||||
{white ? (
|
{white ? (
|
||||||
|
|||||||
@@ -15,13 +15,40 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function request<T>(path: string, schema: z.ZodType<T>, init?: RequestInit): Promise<T> {
|
export async function request<T>(
|
||||||
const response = await fetch(path, init);
|
path: string,
|
||||||
const body: unknown = await response.json().catch(() => null);
|
schema: z.ZodType<T>,
|
||||||
if (!response.ok) {
|
init?: RequestInit,
|
||||||
throw new ApiError(response.status, body);
|
timeoutMs?: number,
|
||||||
|
): Promise<T> {
|
||||||
|
const abort = new AbortController();
|
||||||
|
const parent = init?.signal;
|
||||||
|
const cancel = () => {
|
||||||
|
abort.abort(parent?.reason);
|
||||||
|
};
|
||||||
|
if (parent?.aborted) {
|
||||||
|
cancel();
|
||||||
|
} else {
|
||||||
|
parent?.addEventListener("abort", cancel, { once: true });
|
||||||
|
}
|
||||||
|
const timer =
|
||||||
|
timeoutMs === undefined
|
||||||
|
? undefined
|
||||||
|
: setTimeout(() => {
|
||||||
|
abort.abort(new DOMException("Request timed out", "TimeoutError"));
|
||||||
|
}, timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(path, { ...init, signal: abort.signal });
|
||||||
|
const body: unknown = await response.json().catch(() => null);
|
||||||
|
abort.signal.throwIfAborted();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(response.status, body);
|
||||||
|
}
|
||||||
|
return schema.parse(body);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
parent?.removeEventListener("abort", cancel);
|
||||||
}
|
}
|
||||||
return schema.parse(body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function post<T>(path: string, schema: z.ZodType<T>, body: unknown, signal?: AbortSignal) {
|
export function post<T>(path: string, schema: z.ZodType<T>, body: unknown, signal?: AbortSignal) {
|
||||||
@@ -35,7 +62,7 @@ export function post<T>(path: string, schema: z.ZodType<T>, body: unknown, signa
|
|||||||
|
|
||||||
export async function optionalRequest<T>(path: string, schema: z.ZodType<T>, signal: AbortSignal) {
|
export async function optionalRequest<T>(path: string, schema: z.ZodType<T>, signal: AbortSignal) {
|
||||||
try {
|
try {
|
||||||
return await request(path, schema, { signal });
|
return await request(path, schema, { signal }, 8000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -46,7 +73,7 @@ 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 }, 10000);
|
||||||
}
|
}
|
||||||
export async function getProfile(phone: string, signal: AbortSignal) {
|
export async function getProfile(phone: string, signal: AbortSignal) {
|
||||||
if (!phone) {
|
if (!phone) {
|
||||||
@@ -72,6 +99,7 @@ export async function getTools(signal: AbortSignal, refresh = false) {
|
|||||||
`/api/apipilot/tools?include_unauthorized=true&refresh=${String(refresh)}`,
|
`/api/apipilot/tools?include_unauthorized=true&refresh=${String(refresh)}`,
|
||||||
toolsEnvelopeSchema,
|
toolsEnvelopeSchema,
|
||||||
{ signal },
|
{ signal },
|
||||||
|
10000,
|
||||||
);
|
);
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error("No se pudieron cargar las herramientas.");
|
throw new Error("No se pudieron cargar las herramientas.");
|
||||||
@@ -108,6 +136,9 @@ export function errorMessage(error: unknown) {
|
|||||||
return "El servidor devolvió una respuesta inesperada.";
|
return "El servidor devolvió una respuesta inesperada.";
|
||||||
}
|
}
|
||||||
if (error instanceof DOMException) {
|
if (error instanceof DOMException) {
|
||||||
|
if (error.name === "TimeoutError") {
|
||||||
|
return "El servidor tardó demasiado en responder. Vuelve a intentarlo.";
|
||||||
|
}
|
||||||
if (error.name === "NotAllowedError") {
|
if (error.name === "NotAllowedError") {
|
||||||
return "No se pudo acceder al micrófono. Revisa el permiso del navegador y vuelve a intentarlo.";
|
return "No se pudo acceder al micrófono. Revisa el permiso del navegador y vuelve a intentarlo.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,78 @@ async function start() {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
describe("voice application lifecycle", () => {
|
describe("voice application lifecycle", () => {
|
||||||
|
it("renders configuration and starts without waiting for optional reports", async () => {
|
||||||
|
const original = fetchMock.getMockImplementation();
|
||||||
|
if (!original) {
|
||||||
|
throw new Error("Missing fetch mock");
|
||||||
|
}
|
||||||
|
let finishReports: ((value: Response) => void) | undefined;
|
||||||
|
fetchMock.mockImplementation((path, init) =>
|
||||||
|
path.startsWith("/api/user/reports")
|
||||||
|
? new Promise<Response>((resolve) => {
|
||||||
|
finishReports = resolve;
|
||||||
|
})
|
||||||
|
: original(path, init),
|
||||||
|
);
|
||||||
|
const initializing = controller.initialize();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(controller.getSnapshot()).toMatchObject({ loading: false, configReady: true, profile: { name: "Ana" } });
|
||||||
|
await controller.start();
|
||||||
|
expect(MockClient.instances).toHaveLength(1);
|
||||||
|
finishReports?.(new Response(JSON.stringify({ count: 2 })));
|
||||||
|
await initializing;
|
||||||
|
expect(controller.getSnapshot().reports?.count).toBe(2);
|
||||||
|
});
|
||||||
|
it("waits for caller context but bounds an unavailable profile request", async () => {
|
||||||
|
const original = fetchMock.getMockImplementation();
|
||||||
|
if (!original) {
|
||||||
|
throw new Error("Missing fetch mock");
|
||||||
|
}
|
||||||
|
fetchMock.mockImplementation((path, init) =>
|
||||||
|
path.startsWith("/api/user/phone")
|
||||||
|
? new Promise<Response>((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener("abort", () => {
|
||||||
|
reject(new DOMException("Request cancelled", "AbortError"));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
: original(path, init),
|
||||||
|
);
|
||||||
|
const initializing = controller.initialize();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(controller.getSnapshot()).toMatchObject({ loading: false, contextLoading: true });
|
||||||
|
await controller.start();
|
||||||
|
expect(MockClient.instances).toHaveLength(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(8000);
|
||||||
|
await initializing;
|
||||||
|
expect(controller.getSnapshot()).toMatchObject({ contextLoading: false, profile: null });
|
||||||
|
await controller.start();
|
||||||
|
expect(MockClient.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it("blocks session startup after a configuration failure and allows retry", async () => {
|
||||||
|
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||||
|
await controller.initialize();
|
||||||
|
expect(controller.getSnapshot()).toMatchObject({ loading: false, configReady: false });
|
||||||
|
await controller.start();
|
||||||
|
expect(MockClient.instances).toHaveLength(0);
|
||||||
|
await controller.initialize();
|
||||||
|
expect(controller.getSnapshot()).toMatchObject({ configReady: true, configError: "" });
|
||||||
|
await controller.start();
|
||||||
|
expect(MockClient.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it("discards a superseded initialization even if its fetch ignores cancellation", async () => {
|
||||||
|
let finishConfig: ((value: Response) => void) | undefined;
|
||||||
|
fetchMock.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
finishConfig = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const earlier = controller.initialize();
|
||||||
|
await controller.initialize();
|
||||||
|
finishConfig?.(new Response(JSON.stringify({ theme: "stale" })));
|
||||||
|
await earlier;
|
||||||
|
expect(controller.getSnapshot()).toMatchObject({ configReady: true, config: { theme: "" } });
|
||||||
|
});
|
||||||
it("loads configuration and caller context without an account request on startup or stop", async () => {
|
it("loads configuration and caller context without an account request on startup or stop", async () => {
|
||||||
const client = await start();
|
const client = await start();
|
||||||
await controller.stop();
|
await controller.stop();
|
||||||
@@ -130,6 +202,34 @@ describe("voice application lifecycle", () => {
|
|||||||
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
|
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
|
||||||
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Consulta"]);
|
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Consulta"]);
|
||||||
});
|
});
|
||||||
|
it("aborts an older answer and discards it if it finishes after the current turn", async () => {
|
||||||
|
const client = await start();
|
||||||
|
client.emit("transcript", { id: "older", role: "user", text: "Anterior", partial: false });
|
||||||
|
const earlier = pendingReasoning;
|
||||||
|
const earlierSignal = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning")?.[1]?.signal;
|
||||||
|
client.emit("transcript", { id: "newer", role: "user", text: "Actual", partial: false });
|
||||||
|
expect(earlierSignal?.aborted).toBe(true);
|
||||||
|
pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta actual" })));
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
earlier?.(new Response(JSON.stringify({ answer: "Respuesta anterior" })));
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(client.renderExternalAnswer).toHaveBeenCalledExactlyOnceWith("Respuesta actual");
|
||||||
|
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual([
|
||||||
|
"Anterior",
|
||||||
|
"Actual",
|
||||||
|
"Respuesta actual",
|
||||||
|
]);
|
||||||
|
expect(controller.getSnapshot().error).toBe("");
|
||||||
|
});
|
||||||
|
it("invalidates pending reasoning as soon as the user interrupts", async () => {
|
||||||
|
const client = await start();
|
||||||
|
client.emit("transcript", { id: "u1", role: "user", text: "Pregunta", partial: false });
|
||||||
|
client.emit("status", "user-speaking");
|
||||||
|
pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta interrumpida" })));
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
|
||||||
|
expect(controller.getSnapshot().status).toBe("user-speaking");
|
||||||
|
});
|
||||||
it("applies and removes RAG context in the live session", async () => {
|
it("applies and removes RAG context in the live session", async () => {
|
||||||
const client = await start();
|
const client = await start();
|
||||||
controller.setRag("Datos importantes");
|
controller.setRag("Datos importantes");
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export const LIVE_STATES = new Set<ConnectionStatus>(["listening", "user-speakin
|
|||||||
export const ACTIVE_STATES = new Set<ConnectionStatus>([...LIVE_STATES, "connecting", "queued", "your-turn"]);
|
export const ACTIVE_STATES = new Set<ConnectionStatus>([...LIVE_STATES, "connecting", "queued", "your-turn"]);
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
configReady: boolean;
|
||||||
|
configError: string;
|
||||||
|
contextLoading: boolean;
|
||||||
config: ServerConfig;
|
config: ServerConfig;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
@@ -65,6 +68,9 @@ export class VoiceController {
|
|||||||
private client: RealtimeClient | null = null;
|
private client: RealtimeClient | null = null;
|
||||||
private subscriptions: (() => void)[] = [];
|
private subscriptions: (() => void)[] = [];
|
||||||
private requests = new AbortController();
|
private requests = new AbortController();
|
||||||
|
private initialization: AbortController | null = null;
|
||||||
|
private reasoningRequest: AbortController | null = null;
|
||||||
|
private reasoningTurn = 0;
|
||||||
private idleTimer: ReturnType<typeof setInterval> | undefined;
|
private idleTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
private lastActivity = Date.now();
|
private lastActivity = Date.now();
|
||||||
private lastMeter = 0;
|
private lastMeter = 0;
|
||||||
@@ -76,6 +82,9 @@ export class VoiceController {
|
|||||||
this.caller = caller;
|
this.caller = caller;
|
||||||
this.state = {
|
this.state = {
|
||||||
loading: true,
|
loading: true,
|
||||||
|
configReady: false,
|
||||||
|
configError: "",
|
||||||
|
contextLoading: false,
|
||||||
config: defaultConfig,
|
config: defaultConfig,
|
||||||
settings: loadSettings(),
|
settings: loadSettings(),
|
||||||
profile: null,
|
profile: null,
|
||||||
@@ -109,25 +118,64 @@ export class VoiceController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(signal: AbortSignal) {
|
async initialize(parent?: AbortSignal) {
|
||||||
try {
|
this.initialization?.abort();
|
||||||
const [config, profile, reports] = await Promise.all([
|
const initialization = new AbortController();
|
||||||
getConfig(signal),
|
this.initialization = initialization;
|
||||||
getProfile(this.caller.phoneNumber, signal),
|
const { signal } = initialization;
|
||||||
getReports(this.caller.phoneNumber, signal),
|
const cancel = () => {
|
||||||
]);
|
initialization.abort();
|
||||||
if (signal.aborted) {
|
};
|
||||||
return;
|
if (parent?.aborted) {
|
||||||
}
|
cancel();
|
||||||
this.update({ config, profile, reports, loading: false, error: "" });
|
return;
|
||||||
if (config.apipilot) {
|
|
||||||
await this.refreshTools(signal, !!profile);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!signal.aborted) {
|
|
||||||
this.update({ loading: false, error: `No se pudo cargar la configuración. ${errorMessage(error)}` });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
parent?.addEventListener("abort", cancel, { once: true });
|
||||||
|
this.update({ loading: true, configReady: false, configError: "", contextLoading: !!this.caller.phoneNumber });
|
||||||
|
const configuration = getConfig(signal);
|
||||||
|
const profile = getProfile(this.caller.phoneNumber, signal)
|
||||||
|
.then((profile) => {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
this.update({ profile, contextLoading: false });
|
||||||
|
this.pushSettings();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
const reports = getReports(this.caller.phoneNumber, signal)
|
||||||
|
.then((reports) => {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
this.update({ reports });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
const configure = async () => {
|
||||||
|
try {
|
||||||
|
const config = await configuration;
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.update({ config, configReady: true, loading: false });
|
||||||
|
if (config.apipilot) {
|
||||||
|
await profile;
|
||||||
|
if (!signal.aborted) {
|
||||||
|
await this.refreshTools(signal, !!this.state.profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
this.update({ loading: false, configError: `No se pudo cargar la configuración. ${errorMessage(error)}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await Promise.all([configure(), profile, reports]);
|
||||||
|
} finally {
|
||||||
|
parent?.removeEventListener("abort", cancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dispose() {
|
||||||
|
this.initialization?.abort();
|
||||||
|
void this.stop();
|
||||||
}
|
}
|
||||||
async refreshTools(signal: AbortSignal, refresh = false) {
|
async refreshTools(signal: AbortSignal, refresh = false) {
|
||||||
this.update({ toolsLoading: true, toolsError: "" });
|
this.update({ toolsLoading: true, toolsError: "" });
|
||||||
@@ -182,7 +230,7 @@ export class VoiceController {
|
|||||||
this.update({ error: "" });
|
this.update({ error: "" });
|
||||||
}
|
}
|
||||||
offerAutoStart() {
|
offerAutoStart() {
|
||||||
if (this.autoStarted || this.client || this.state.loading) {
|
if (this.autoStarted || this.client || !this.state.configReady || this.state.contextLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.autoStarted = true;
|
this.autoStarted = true;
|
||||||
@@ -194,7 +242,7 @@ export class VoiceController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async start(automatic = false, preparedContext?: AudioContext, greeting?: string) {
|
async start(automatic = false, preparedContext?: AudioContext, greeting?: string) {
|
||||||
if (this.client || this.state.loading) {
|
if (this.client || !this.state.configReady || this.state.contextLoading) {
|
||||||
if (preparedContext) {
|
if (preparedContext) {
|
||||||
void preparedContext.close().catch(() => undefined);
|
void preparedContext.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -250,6 +298,7 @@ export class VoiceController {
|
|||||||
this.lastActivity = Date.now();
|
this.lastActivity = Date.now();
|
||||||
}
|
}
|
||||||
if (status === "user-speaking") {
|
if (status === "user-speaking") {
|
||||||
|
this.cancelReasoning();
|
||||||
window.speechSynthesis?.cancel();
|
window.speechSynthesis?.cancel();
|
||||||
this.update({
|
this.update({
|
||||||
messages: this.state.messages.map((message) =>
|
messages: this.state.messages.map((message) =>
|
||||||
@@ -360,11 +409,22 @@ export class VoiceController {
|
|||||||
: [...this.state.messages, message],
|
: [...this.state.messages, message],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
private cancelReasoning() {
|
||||||
|
this.reasoningTurn++;
|
||||||
|
this.reasoningRequest?.abort();
|
||||||
|
this.reasoningRequest = null;
|
||||||
|
}
|
||||||
private async reason(transcript: Transcript, client: RealtimeClient) {
|
private async reason(transcript: Transcript, client: RealtimeClient) {
|
||||||
const { config, profile, rag } = this.state;
|
const { config, profile, rag } = this.state;
|
||||||
if (config.reasoningType === "PROXY" || !transcript.text.trim()) {
|
if (config.reasoningType === "PROXY" || !transcript.text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.cancelReasoning();
|
||||||
|
const turn = this.reasoningTurn;
|
||||||
|
const request = new AbortController();
|
||||||
|
this.reasoningRequest = request;
|
||||||
|
const current = () => this.client === client && turn === this.reasoningTurn && !request.signal.aborted;
|
||||||
|
window.speechSynthesis?.cancel();
|
||||||
try {
|
try {
|
||||||
const result = await post(
|
const result = await post(
|
||||||
"/api/reasoning",
|
"/api/reasoning",
|
||||||
@@ -377,9 +437,9 @@ export class VoiceController {
|
|||||||
phoneNumber: this.caller.phoneNumber || null,
|
phoneNumber: this.caller.phoneNumber || null,
|
||||||
episode_id: this.caller.episodeId || null,
|
episode_id: this.caller.episodeId || null,
|
||||||
},
|
},
|
||||||
this.requests.signal,
|
request.signal,
|
||||||
);
|
);
|
||||||
if (this.client !== client) {
|
if (!current()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const answer = result.answer.trim();
|
const answer = result.answer.trim();
|
||||||
@@ -396,12 +456,12 @@ export class VoiceController {
|
|||||||
utterance.voice = voice;
|
utterance.voice = voice;
|
||||||
}
|
}
|
||||||
utterance.onstart = () => {
|
utterance.onstart = () => {
|
||||||
if (this.client === client) {
|
if (current()) {
|
||||||
this.update({ status: "ai-speaking" });
|
this.update({ status: "ai-speaking" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
utterance.onend = utterance.onerror = () => {
|
utterance.onend = utterance.onerror = () => {
|
||||||
if (this.client === client) {
|
if (current()) {
|
||||||
this.lastActivity = Date.now();
|
this.lastActivity = Date.now();
|
||||||
this.update({ status: "listening" });
|
this.update({ status: "listening" });
|
||||||
}
|
}
|
||||||
@@ -410,9 +470,13 @@ export class VoiceController {
|
|||||||
window.speechSynthesis.speak(utterance);
|
window.speechSynthesis.speak(utterance);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.client === client && !this.requests.signal.aborted) {
|
if (current()) {
|
||||||
this.update({ error: errorMessage(error) });
|
this.update({ error: errorMessage(error) });
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (turn === this.reasoningTurn) {
|
||||||
|
this.reasoningRequest = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private async runTool(call: ToolCall, client: RealtimeClient) {
|
private async runTool(call: ToolCall, client: RealtimeClient) {
|
||||||
@@ -491,6 +555,7 @@ export class VoiceController {
|
|||||||
this.subscriptions = [];
|
this.subscriptions = [];
|
||||||
clearInterval(this.idleTimer);
|
clearInterval(this.idleTimer);
|
||||||
this.requests.abort();
|
this.requests.abort();
|
||||||
|
this.cancelReasoning();
|
||||||
window.speechSynthesis?.cancel();
|
window.speechSynthesis?.cancel();
|
||||||
this.update({
|
this.update({
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
font: 500 16px system-ui;
|
font: 500 16px system-ui;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
.startup-error {
|
||||||
|
max-width: 32rem;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.app-error {
|
.app-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user