fix(frontend): recover startup and discard stale reasoning answers
This commit is contained in:
@@ -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 interface AppState {
|
||||
loading: boolean;
|
||||
configReady: boolean;
|
||||
configError: string;
|
||||
contextLoading: boolean;
|
||||
config: ServerConfig;
|
||||
settings: Settings;
|
||||
profile: Profile | null;
|
||||
@@ -65,6 +68,9 @@ export class VoiceController {
|
||||
private client: RealtimeClient | null = null;
|
||||
private subscriptions: (() => void)[] = [];
|
||||
private requests = new AbortController();
|
||||
private initialization: AbortController | null = null;
|
||||
private reasoningRequest: AbortController | null = null;
|
||||
private reasoningTurn = 0;
|
||||
private idleTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private lastActivity = Date.now();
|
||||
private lastMeter = 0;
|
||||
@@ -76,6 +82,9 @@ export class VoiceController {
|
||||
this.caller = caller;
|
||||
this.state = {
|
||||
loading: true,
|
||||
configReady: false,
|
||||
configError: "",
|
||||
contextLoading: false,
|
||||
config: defaultConfig,
|
||||
settings: loadSettings(),
|
||||
profile: null,
|
||||
@@ -109,25 +118,64 @@ export class VoiceController {
|
||||
});
|
||||
}
|
||||
|
||||
async initialize(signal: AbortSignal) {
|
||||
try {
|
||||
const [config, profile, reports] = await Promise.all([
|
||||
getConfig(signal),
|
||||
getProfile(this.caller.phoneNumber, signal),
|
||||
getReports(this.caller.phoneNumber, signal),
|
||||
]);
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
this.update({ config, profile, reports, loading: false, error: "" });
|
||||
if (config.apipilot) {
|
||||
await this.refreshTools(signal, !!profile);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
this.update({ loading: false, error: `No se pudo cargar la configuración. ${errorMessage(error)}` });
|
||||
}
|
||||
async initialize(parent?: AbortSignal) {
|
||||
this.initialization?.abort();
|
||||
const initialization = new AbortController();
|
||||
this.initialization = initialization;
|
||||
const { signal } = initialization;
|
||||
const cancel = () => {
|
||||
initialization.abort();
|
||||
};
|
||||
if (parent?.aborted) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
this.update({ toolsLoading: true, toolsError: "" });
|
||||
@@ -182,7 +230,7 @@ export class VoiceController {
|
||||
this.update({ error: "" });
|
||||
}
|
||||
offerAutoStart() {
|
||||
if (this.autoStarted || this.client || this.state.loading) {
|
||||
if (this.autoStarted || this.client || !this.state.configReady || this.state.contextLoading) {
|
||||
return;
|
||||
}
|
||||
this.autoStarted = true;
|
||||
@@ -194,7 +242,7 @@ export class VoiceController {
|
||||
}
|
||||
}
|
||||
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) {
|
||||
void preparedContext.close().catch(() => undefined);
|
||||
}
|
||||
@@ -250,6 +298,7 @@ export class VoiceController {
|
||||
this.lastActivity = Date.now();
|
||||
}
|
||||
if (status === "user-speaking") {
|
||||
this.cancelReasoning();
|
||||
window.speechSynthesis?.cancel();
|
||||
this.update({
|
||||
messages: this.state.messages.map((message) =>
|
||||
@@ -360,11 +409,22 @@ export class VoiceController {
|
||||
: [...this.state.messages, message],
|
||||
});
|
||||
}
|
||||
private cancelReasoning() {
|
||||
this.reasoningTurn++;
|
||||
this.reasoningRequest?.abort();
|
||||
this.reasoningRequest = null;
|
||||
}
|
||||
private async reason(transcript: Transcript, client: RealtimeClient) {
|
||||
const { config, profile, rag } = this.state;
|
||||
if (config.reasoningType === "PROXY" || !transcript.text.trim()) {
|
||||
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 {
|
||||
const result = await post(
|
||||
"/api/reasoning",
|
||||
@@ -377,9 +437,9 @@ export class VoiceController {
|
||||
phoneNumber: this.caller.phoneNumber || null,
|
||||
episode_id: this.caller.episodeId || null,
|
||||
},
|
||||
this.requests.signal,
|
||||
request.signal,
|
||||
);
|
||||
if (this.client !== client) {
|
||||
if (!current()) {
|
||||
return;
|
||||
}
|
||||
const answer = result.answer.trim();
|
||||
@@ -396,12 +456,12 @@ export class VoiceController {
|
||||
utterance.voice = voice;
|
||||
}
|
||||
utterance.onstart = () => {
|
||||
if (this.client === client) {
|
||||
if (current()) {
|
||||
this.update({ status: "ai-speaking" });
|
||||
}
|
||||
};
|
||||
utterance.onend = utterance.onerror = () => {
|
||||
if (this.client === client) {
|
||||
if (current()) {
|
||||
this.lastActivity = Date.now();
|
||||
this.update({ status: "listening" });
|
||||
}
|
||||
@@ -410,9 +470,13 @@ export class VoiceController {
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.client === client && !this.requests.signal.aborted) {
|
||||
if (current()) {
|
||||
this.update({ error: errorMessage(error) });
|
||||
}
|
||||
} finally {
|
||||
if (turn === this.reasoningTurn) {
|
||||
this.reasoningRequest = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
private async runTool(call: ToolCall, client: RealtimeClient) {
|
||||
@@ -491,6 +555,7 @@ export class VoiceController {
|
||||
this.subscriptions = [];
|
||||
clearInterval(this.idleTimer);
|
||||
this.requests.abort();
|
||||
this.cancelReasoning();
|
||||
window.speechSynthesis?.cancel();
|
||||
this.update({
|
||||
status,
|
||||
|
||||
Reference in New Issue
Block a user