feat(mcp): connect authenticated AquaPortal tools to Vocero (#1)
Vocero needs to query AquaPortal on behalf of the caller selected by the existing phone link. This adds server-owned conversations and MCP tool execution for voice and HTTP reasoning while leaving the speech pipeline unchanged. Release notes: - Added browser-bound conversations that keep backend credentials server-side and isolate callers. - Connected the eight-tool AquaPortal catalog through authenticated Streamable HTTP with full input schemas and structured results. - Added bounded multi-step tool execution with cancellation, expiry, and stale-result protection. - Updated browser bootstrap, tool progress, reasoning headers, and conversation cleanup. - Disabled legacy unscoped content preloads in MCP mode and documented configuration and deployment constraints. - Preserved disabled-mode contracts and added local SDK, WebSocket, and browser regression coverage. Reviewed-on: AquaPortal/s2s#1
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { configSchema, recordSchema, reportsSchema, toolsEnvelopeSchema } from "./contracts";
|
||||
import {
|
||||
configSchema,
|
||||
conversationSchema,
|
||||
mcpToolsSchema,
|
||||
recordSchema,
|
||||
reportsSchema,
|
||||
toolsEnvelopeSchema,
|
||||
} from "./contracts";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -51,10 +58,16 @@ export async function request<T>(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
headers?: Record<string, string>,
|
||||
) {
|
||||
return request(path, schema, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
@@ -107,6 +120,41 @@ export async function getTools(signal: AbortSignal, refresh = false) {
|
||||
return result.data.tools;
|
||||
}
|
||||
|
||||
export function conversationHeaders(id: string, turn?: number): Record<string, string> {
|
||||
return { "X-Vocero-Conversation": id, ...(turn === undefined ? {} : { "X-Vocero-Turn": String(turn) }) };
|
||||
}
|
||||
export function createConversation(phoneNumber: string, signal: AbortSignal) {
|
||||
return request(
|
||||
"/api/conversations",
|
||||
conversationSchema,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phoneNumber }),
|
||||
signal,
|
||||
},
|
||||
15000,
|
||||
);
|
||||
}
|
||||
export async function getMcpTools(id: string, signal: AbortSignal) {
|
||||
const result = await request("/api/mcp/tools", mcpToolsSchema, { headers: conversationHeaders(id), signal }, 30000);
|
||||
return result.tools;
|
||||
}
|
||||
export function endConversation(id: string) {
|
||||
void fetch("/api/conversations/current", {
|
||||
method: "DELETE",
|
||||
headers: conversationHeaders(id),
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
export function interruptConversation(id: string, turn: number) {
|
||||
void fetch("/api/conversations/current/interrupt", {
|
||||
method: "POST",
|
||||
headers: conversationHeaders(id, turn),
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export const reasoningSchema = z.object({ answer: z.string().default("") });
|
||||
export const searchSchema = z.object({
|
||||
answer: z.string().nullish(),
|
||||
|
||||
@@ -68,7 +68,7 @@ export function buildInstructions(
|
||||
base,
|
||||
config.injectProfile ? profileContext(profile) : "",
|
||||
ragContext(rag),
|
||||
base.includes(TOOL_MARKER) ? "" : toolContext(tools),
|
||||
config.mcp || base.includes(TOOL_MARKER) ? "" : toolContext(tools),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
@@ -15,6 +15,7 @@ export const configSchema = z.object({
|
||||
startSessionMode: z.string().default(""),
|
||||
theme: z.string().default(""),
|
||||
apipilot: z.boolean().default(false),
|
||||
mcp: z.boolean().default(false),
|
||||
});
|
||||
export type ServerConfig = z.infer<typeof configSchema>;
|
||||
export const defaultConfig = configSchema.parse({});
|
||||
@@ -54,6 +55,12 @@ export const toolsEnvelopeSchema = z.object({
|
||||
data: z.object({ tools: z.array(toolSchema), count: z.number().optional() }).nullish(),
|
||||
error: z.unknown().optional(),
|
||||
});
|
||||
export const conversationSchema = z.object({
|
||||
conversationId: z.string().min(1),
|
||||
expiresIn: z.number(),
|
||||
profile: z.object({ id: z.string(), name: z.string() }),
|
||||
});
|
||||
export const mcpToolsSchema = z.object({ tools: z.array(toolSchema) });
|
||||
|
||||
export const grantSchema = z.object({
|
||||
session_id: z.string(),
|
||||
|
||||
@@ -91,6 +91,21 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("realtime session", () => {
|
||||
it("shows processing during a server MCP call and returns to listening on failure", async () => {
|
||||
const client = new RealtimeClient(options());
|
||||
const status = vi.fn();
|
||||
client.on("status", status);
|
||||
const socket = await connect(client);
|
||||
socket.receive({ type: "vocero.tool.started", name: "list_reports" });
|
||||
await vi.waitFor(() => {
|
||||
expect(status).toHaveBeenLastCalledWith("processing");
|
||||
});
|
||||
socket.receive({ type: "vocero.tool.failed" });
|
||||
await vi.waitFor(() => {
|
||||
expect(status).toHaveBeenLastCalledWith("listening");
|
||||
});
|
||||
await client.close();
|
||||
});
|
||||
it("waits for buffered audio to drain after response.done", async () => {
|
||||
const client = new RealtimeClient(options());
|
||||
const socket = await connect(client);
|
||||
|
||||
@@ -69,6 +69,7 @@ export class RealtimeClient {
|
||||
private activeResponses = new Set<string>();
|
||||
private activeResponse = "";
|
||||
private createInFlight = false;
|
||||
private serverToolPending = false;
|
||||
private pendingCreates = 0;
|
||||
private deferredTexts: string[] = [];
|
||||
private suppressed = new Set<string>();
|
||||
@@ -314,6 +315,14 @@ export class RealtimeClient {
|
||||
private handle(event: RealtimeEvent) {
|
||||
const id = event.response_id ?? event.response?.id ?? this.activeResponse;
|
||||
switch (event.type) {
|
||||
case "vocero.tool.started":
|
||||
this.serverToolPending = true;
|
||||
this.setStatus("processing");
|
||||
break;
|
||||
case "vocero.tool.failed":
|
||||
this.serverToolPending = false;
|
||||
this.setStatus("listening");
|
||||
break;
|
||||
case "session.created":
|
||||
this.send({
|
||||
type: "session.update",
|
||||
@@ -341,6 +350,7 @@ export class RealtimeClient {
|
||||
}
|
||||
break;
|
||||
case "input_audio_buffer.speech_started":
|
||||
this.serverToolPending = false;
|
||||
this.playbackPending = false;
|
||||
this.audio.clear();
|
||||
this.setStatus("user-speaking");
|
||||
@@ -349,6 +359,7 @@ export class RealtimeClient {
|
||||
this.setStatus("processing");
|
||||
break;
|
||||
case "response.created":
|
||||
this.serverToolPending = false;
|
||||
this.activeResponse = id;
|
||||
this.activeResponses.add(id);
|
||||
this.createInFlight = false;
|
||||
@@ -463,7 +474,7 @@ export class RealtimeClient {
|
||||
}
|
||||
}
|
||||
private busy() {
|
||||
return this.activeResponses.size > 0 || this.createInFlight;
|
||||
return this.activeResponses.size > 0 || this.createInFlight || this.serverToolPending;
|
||||
}
|
||||
private send(event: Record<string, unknown>) {
|
||||
if (!this.closed && this.socket?.readyState === WebSocket.OPEN) {
|
||||
|
||||
@@ -367,3 +367,196 @@ describe("voice application lifecycle", () => {
|
||||
expect(controller.getSnapshot().status).toBe("listening");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticated MCP conversations", () => {
|
||||
const relayUrl = () =>
|
||||
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/v1/realtime`;
|
||||
function enableMcp(reasoningType = "OPENAI") {
|
||||
controller = new VoiceController({ phoneNumber: "+56000000001", episodeId: "episode1" });
|
||||
const original = fetchMock.getMockImplementation();
|
||||
let sequence = 0;
|
||||
if (!original) {
|
||||
throw new Error("Missing fetch mock");
|
||||
}
|
||||
fetchMock.mockImplementation((path, init) => {
|
||||
if (path === "/api/config") {
|
||||
return Promise.resolve(new Response(JSON.stringify({ mcp: true, reasoningType, s2sUrl: relayUrl() })));
|
||||
}
|
||||
if (path === "/api/conversations") {
|
||||
sequence++;
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
conversationId: `conversation-${String(sequence)}`,
|
||||
expiresIn: 3600,
|
||||
profile: { id: "alice", name: "Alice" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (path === "/api/mcp/tools") {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
tools: [
|
||||
{
|
||||
name: "list_reports",
|
||||
parameters: { properties: { request: { type: "object" } } },
|
||||
requires_auth: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (path.startsWith("/api/conversations/current")) {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
return original(path, init);
|
||||
});
|
||||
}
|
||||
|
||||
it("bootstraps once, keeps credentials out of storage, and skips legacy context loading", async () => {
|
||||
enableMcp("PROXY");
|
||||
const client = await start();
|
||||
expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/config", "/api/conversations", "/api/mcp/tools"]);
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
profile: { name: "Alice" },
|
||||
reports: null,
|
||||
tools: [{ name: "list_reports" }],
|
||||
});
|
||||
expect(client.options.directUrl).toBe(`${relayUrl()}?conversation_id=conversation-1`);
|
||||
expect(client.options.instructions).not.toContain("APIPILOT");
|
||||
expect(localStorage.length).toBe(0);
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
const discovery = fetchMock.mock.calls.find(([path]) => path === "/api/mcp/tools");
|
||||
expect(new Headers(discovery?.[1]?.headers).get("X-Vocero-Conversation")).toBe("conversation-1");
|
||||
await controller.stop();
|
||||
const teardown = fetchMock.mock.calls.find(([path]) => path === "/api/conversations/current");
|
||||
expect(teardown?.[1]).toMatchObject({ method: "DELETE", keepalive: true });
|
||||
await controller.start();
|
||||
expect(MockClient.instances[1]?.options.directUrl).toContain("conversation_id=conversation-2");
|
||||
});
|
||||
|
||||
it("blocks voice startup when phone login fails and retries on a later manual start", async () => {
|
||||
enableMcp();
|
||||
const original = fetchMock.getMockImplementation();
|
||||
let denied = true;
|
||||
if (!original) {
|
||||
throw new Error("Missing fetch mock");
|
||||
}
|
||||
fetchMock.mockImplementation((path, init) =>
|
||||
path === "/api/conversations" && denied
|
||||
? Promise.resolve(new Response(JSON.stringify({ detail: "Usuario no disponible." }), { status: 401 }))
|
||||
: original(path, init),
|
||||
);
|
||||
await controller.initialize();
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
configReady: true,
|
||||
contextLoading: false,
|
||||
error: "Usuario no disponible.",
|
||||
});
|
||||
controller.offerAutoStart();
|
||||
await controller.start();
|
||||
expect(MockClient.instances).toHaveLength(0);
|
||||
denied = false;
|
||||
await controller.start();
|
||||
expect(MockClient.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not initialize voice when the phone selector is missing", async () => {
|
||||
enableMcp();
|
||||
controller = new VoiceController({ phoneNumber: "", episodeId: "" });
|
||||
await controller.initialize();
|
||||
await controller.start();
|
||||
expect(MockClient.instances).toHaveLength(0);
|
||||
expect(controller.getSnapshot().error).toContain("número de teléfono");
|
||||
expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/config"]);
|
||||
});
|
||||
|
||||
it("binds HTTP reasoning and cancellation to the conversation and exact turn", async () => {
|
||||
enableMcp();
|
||||
const client = await start();
|
||||
client.emit("transcript", { id: "u1", role: "user", text: "Reportes", partial: false });
|
||||
const old = pendingReasoning;
|
||||
const first = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning");
|
||||
const firstHeaders = new Headers(first?.[1]?.headers);
|
||||
expect(firstHeaders.get("X-Vocero-Conversation")).toBe("conversation-1");
|
||||
client.emit("status", "user-speaking");
|
||||
const interrupt = fetchMock.mock.calls.find(([path]) => path.endsWith("/interrupt"));
|
||||
expect(new Headers(interrupt?.[1]?.headers).get("X-Vocero-Turn")).toBe(firstHeaders.get("X-Vocero-Turn"));
|
||||
client.emit("transcript", { id: "u2", role: "user", text: "El último", partial: false });
|
||||
pendingReasoning?.(new Response(JSON.stringify({ answer: "Actual" })));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
old?.(new Response(JSON.stringify({ answer: "Anterior" })));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(client.renderExternalAnswer).toHaveBeenCalledExactlyOnceWith("Actual");
|
||||
});
|
||||
|
||||
it("closes a revoked conversation without falling back to public providers", async () => {
|
||||
enableMcp();
|
||||
const client = await start();
|
||||
client.emit("transcript", { id: "u1", role: "user", text: "Reportes", partial: false });
|
||||
pendingReasoning?.(new Response(JSON.stringify({ detail: "La sesión expiró." }), { status: 401 }));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(client.close).toHaveBeenCalledOnce();
|
||||
expect(controller.getSnapshot()).toMatchObject({ status: "error", error: "La sesión expiró." });
|
||||
expect(fetchMock.mock.calls.some(([path]) => path.startsWith("/api/user/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up a conversation whose catalog arrives after teardown", async () => {
|
||||
enableMcp();
|
||||
const original = fetchMock.getMockImplementation();
|
||||
let finishCatalog: ((value: Response) => void) | undefined;
|
||||
if (!original) {
|
||||
throw new Error("Missing fetch mock");
|
||||
}
|
||||
fetchMock.mockImplementation((path, init) =>
|
||||
path === "/api/mcp/tools"
|
||||
? new Promise<Response>((resolve) => {
|
||||
finishCatalog = resolve;
|
||||
})
|
||||
: original(path, init),
|
||||
);
|
||||
const initializing = controller.initialize();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await controller.stop();
|
||||
finishCatalog?.(new Response(JSON.stringify({ tools: [] })));
|
||||
await initializing;
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
profile: null,
|
||||
tools: [],
|
||||
status: "closed",
|
||||
contextLoading: false,
|
||||
});
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([path, init]) => path === "/api/conversations/current" && init?.method === "DELETE"),
|
||||
).toBe(true);
|
||||
expect(MockClient.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps separate conversation IDs in separate controller instances", async () => {
|
||||
enableMcp();
|
||||
await controller.initialize();
|
||||
const other = new VoiceController({ phoneNumber: "5678", episodeId: "" });
|
||||
await other.initialize();
|
||||
await controller.start();
|
||||
await other.start();
|
||||
expect(MockClient.instances.map((client) => client.options.directUrl)).toEqual([
|
||||
`${relayUrl()}?conversation_id=conversation-1`,
|
||||
`${relayUrl()}?conversation_id=conversation-2`,
|
||||
]);
|
||||
await other.stop();
|
||||
});
|
||||
|
||||
it("does not forward the conversation ID to a direct external speech service", async () => {
|
||||
enableMcp();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ mcp: true, reasoningType: "OPENAI", s2sUrl: "wss://speech.example.test/v1/realtime" }),
|
||||
),
|
||||
);
|
||||
const client = await start();
|
||||
expect(client.options.directUrl).toBe("wss://speech.example.test/v1/realtime");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,17 @@ import { z } from "zod";
|
||||
|
||||
import { createAudioContext } from "../audio/engine";
|
||||
import {
|
||||
ApiError,
|
||||
conversationHeaders,
|
||||
createConversation,
|
||||
endConversation,
|
||||
errorMessage,
|
||||
getConfig,
|
||||
getMcpTools,
|
||||
getProfile,
|
||||
getReports,
|
||||
getTools,
|
||||
interruptConversation,
|
||||
post,
|
||||
reasoningSchema,
|
||||
searchSchema,
|
||||
@@ -79,6 +85,9 @@ export class VoiceController {
|
||||
private sessionNumber = 0;
|
||||
private reasoned = new Set<string>();
|
||||
private autoStarted = false;
|
||||
private conversationId: string | null = null;
|
||||
private conversationVersion = 0;
|
||||
private starting = false;
|
||||
|
||||
constructor(caller = callerFromLocation()) {
|
||||
this.caller = caller;
|
||||
@@ -149,47 +158,91 @@ export class VoiceController {
|
||||
}
|
||||
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;
|
||||
try {
|
||||
const config = await getConfig(signal);
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
config,
|
||||
configReady: true,
|
||||
loading: false,
|
||||
contextLoading: config.mcp || !!this.caller.phoneNumber,
|
||||
});
|
||||
if (config.mcp) {
|
||||
try {
|
||||
await this.openConversation(signal);
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
await this.refreshTools(signal, !!this.state.profile);
|
||||
this.update({ error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
this.update({ loading: false, configError: `No se pudo cargar la configuración. ${errorMessage(error)}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
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 tools = profile.then(async () => {
|
||||
if (config.apipilot && !signal.aborted) {
|
||||
await this.refreshTools(signal, !!this.state.profile);
|
||||
}
|
||||
});
|
||||
await Promise.all([profile, reports, tools]);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
private async openConversation(signal: AbortSignal) {
|
||||
const version = ++this.conversationVersion;
|
||||
let createdId: string | undefined;
|
||||
this.releaseConversation();
|
||||
this.update({ contextLoading: true, profile: null, reports: null, tools: [], toolsLoading: true, toolsError: "" });
|
||||
try {
|
||||
if (!this.caller.phoneNumber) {
|
||||
throw new Error("Abre Vocero desde un enlace que incluya tu número de teléfono.");
|
||||
}
|
||||
const conversation = await createConversation(this.caller.phoneNumber, signal);
|
||||
createdId = conversation.conversationId;
|
||||
const tools = await getMcpTools(createdId, signal);
|
||||
signal.throwIfAborted();
|
||||
if (version !== this.conversationVersion) {
|
||||
throw new DOMException("Conversation replaced", "AbortError");
|
||||
}
|
||||
this.conversationId = createdId;
|
||||
this.update({ profile: conversation.profile, tools, error: "" });
|
||||
} catch (error) {
|
||||
if (createdId) {
|
||||
endConversation(createdId);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (version === this.conversationVersion) {
|
||||
this.update({ contextLoading: false, toolsLoading: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
private releaseConversation() {
|
||||
if (this.conversationId) {
|
||||
endConversation(this.conversationId);
|
||||
this.conversationId = null;
|
||||
}
|
||||
}
|
||||
dispose() {
|
||||
this.initialization?.abort();
|
||||
void this.stop();
|
||||
@@ -197,7 +250,9 @@ export class VoiceController {
|
||||
async refreshTools(signal: AbortSignal, refresh = false) {
|
||||
this.update({ toolsLoading: true, toolsError: "" });
|
||||
try {
|
||||
const tools = await getTools(signal, refresh);
|
||||
const tools = this.state.config.mcp
|
||||
? await getMcpTools(this.conversationId ?? "", signal)
|
||||
: await getTools(signal, refresh);
|
||||
if (!signal.aborted) {
|
||||
this.update({ tools, toolsLoading: false });
|
||||
this.pushSettings();
|
||||
@@ -265,7 +320,13 @@ export class VoiceController {
|
||||
this.update({ wakeWordEnabled });
|
||||
}
|
||||
offerAutoStart() {
|
||||
if (this.autoStarted || this.client || !this.state.configReady || this.state.contextLoading) {
|
||||
if (
|
||||
this.autoStarted ||
|
||||
this.client ||
|
||||
!this.state.configReady ||
|
||||
this.state.contextLoading ||
|
||||
(this.state.config.mcp && !this.conversationId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.autoStarted = true;
|
||||
@@ -275,12 +336,14 @@ export class VoiceController {
|
||||
}
|
||||
}
|
||||
async start(automatic = false, preparedContext?: AudioContext, greeting?: string) {
|
||||
if (this.client || !this.state.configReady || this.state.contextLoading) {
|
||||
if (this.client || this.starting || !this.state.configReady || this.state.contextLoading) {
|
||||
if (preparedContext) {
|
||||
void preparedContext.close().catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.starting = true;
|
||||
this.requests = new AbortController();
|
||||
this.update({
|
||||
error: "",
|
||||
notice: "",
|
||||
@@ -293,14 +356,36 @@ export class VoiceController {
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") {
|
||||
throw new Error("El micrófono necesita HTTPS o localhost y un navegador compatible.");
|
||||
}
|
||||
const target = connectionTarget(this.state.config, this.state.settings, this.caller);
|
||||
const target = connectionTarget(
|
||||
this.state.config,
|
||||
this.state.settings,
|
||||
this.state.config.mcp ? { phoneNumber: "", episodeId: "" } : this.caller,
|
||||
);
|
||||
context = preparedContext ?? createAudioContext();
|
||||
if (automatic && context.state !== "running") {
|
||||
void context.close().catch(() => undefined);
|
||||
this.update({ status: "idle", notice: "Toca el micrófono para empezar a conversar." });
|
||||
return;
|
||||
}
|
||||
this.requests = new AbortController();
|
||||
if (this.state.config.mcp) {
|
||||
if (!this.conversationId) {
|
||||
await this.openConversation(this.requests.signal);
|
||||
}
|
||||
this.requests.signal.throwIfAborted();
|
||||
if (target.directUrl) {
|
||||
const url = new URL(target.directUrl);
|
||||
const sameOrigin =
|
||||
url.host === window.location.host &&
|
||||
url.protocol === (window.location.protocol === "https:" ? "wss:" : "ws:");
|
||||
if (this.state.config.reasoningType === "PROXY" && (!sameOrigin || url.pathname !== "/v1/realtime")) {
|
||||
throw new Error("La conexión de voz debe pasar por Vocero para consultar AquaPortal.");
|
||||
}
|
||||
if (sameOrigin && url.pathname === "/v1/realtime") {
|
||||
url.searchParams.set("conversation_id", this.conversationId ?? "");
|
||||
target.directUrl = url.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
this.reasoned.clear();
|
||||
this.sessionNumber++;
|
||||
const client = new RealtimeClient({
|
||||
@@ -415,7 +500,11 @@ export class VoiceController {
|
||||
if (context) {
|
||||
void context.close().catch(() => undefined);
|
||||
}
|
||||
this.reportError(error);
|
||||
if (!this.requests.signal.aborted) {
|
||||
this.reportError(error);
|
||||
}
|
||||
} finally {
|
||||
this.starting = false;
|
||||
}
|
||||
}
|
||||
private reportError(error: unknown) {
|
||||
@@ -446,6 +535,9 @@ export class VoiceController {
|
||||
});
|
||||
}
|
||||
private cancelReasoning() {
|
||||
if (this.reasoningRequest && this.conversationId) {
|
||||
interruptConversation(this.conversationId, this.reasoningTurn);
|
||||
}
|
||||
this.reasoningTurn++;
|
||||
this.reasoningRequest?.abort();
|
||||
this.reasoningRequest = null;
|
||||
@@ -476,6 +568,7 @@ export class VoiceController {
|
||||
episode_id: this.caller.episodeId || null,
|
||||
},
|
||||
request.signal,
|
||||
config.mcp ? conversationHeaders(this.conversationId ?? "", turn) : undefined,
|
||||
);
|
||||
if (!current()) {
|
||||
return;
|
||||
@@ -513,6 +606,9 @@ export class VoiceController {
|
||||
if (current()) {
|
||||
this.externalSpeaking = false;
|
||||
this.update({ error: errorMessage(error) });
|
||||
if (config.mcp && error instanceof ApiError && error.status === 401) {
|
||||
await this.stop("error");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (turn === this.reasoningTurn) {
|
||||
@@ -592,6 +688,8 @@ export class VoiceController {
|
||||
await this.start(false, context);
|
||||
}
|
||||
async stop(status: ConnectionStatus = "closed") {
|
||||
this.initialization?.abort();
|
||||
this.conversationVersion++;
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
this.subscriptions.forEach((unsubscribe) => {
|
||||
@@ -601,6 +699,7 @@ export class VoiceController {
|
||||
clearInterval(this.idleTimer);
|
||||
this.requests.abort();
|
||||
this.cancelReasoning();
|
||||
this.releaseConversation();
|
||||
window.speechSynthesis?.cancel();
|
||||
this.updateLevel(0);
|
||||
this.update({
|
||||
@@ -608,6 +707,8 @@ export class VoiceController {
|
||||
wakeWordEnabled: false,
|
||||
muted: false,
|
||||
joinDeadline: 0,
|
||||
contextLoading: false,
|
||||
toolsLoading: false,
|
||||
messages: this.state.messages.map((message) =>
|
||||
message.partial ? { ...message, partial: false, interrupted: message.role === "assistant" } : message,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user