chore(vocero): remove the legacy ApiPilot integration (#3)
Vocero now retrieves AquaPortal content through authenticated MCP. This removes the obsolete ApiPilot integration from the API and browser while preserving the separately deployed speech pipeline. Release notes: - Removed ApiPilot endpoints, configuration, credential overrides, and HTTP/realtime tool execution. - Removed browser ApiPilot catalog loading and prompt generation while retaining authenticated MCP discovery. - Updated regression coverage and operating documentation for the retired routes and settings. Reviewed-on: AquaPortal/s2s#3
This commit is contained in:
@@ -144,10 +144,11 @@ Markdown replies, JSON/TXT exports, optional browser wake word, TALK/TALKFIRST
|
||||
startup modes, and optional backend features are retained. Camera controls and
|
||||
quick actions remain absent, matching their disabled state in the old UI.
|
||||
|
||||
Native speech stays at the current project's 16 kHz PCM rate. The backend
|
||||
continues to own VAD, transcription, model inference, synthesis, upstream
|
||||
credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only
|
||||
when the configured external reasoning service returns an answer.
|
||||
Native speech stays at the current project's 16 kHz PCM rate. The speech service
|
||||
owns VAD, transcription, model inference, and synthesis. The Vocero API owns
|
||||
AquaPortal credentials and authenticated MCP discovery/execution. Browser
|
||||
`SpeechSynthesis` is used only when the configured external reasoning service
|
||||
returns an answer.
|
||||
|
||||
Session startup waits for audio initialization and `session.created`, then sends
|
||||
`session.update` before microphone audio or the initial greeting. The speech
|
||||
|
||||
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import App from "./App";
|
||||
import { VoiceController } from "./realtime/controller";
|
||||
|
||||
const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime", apipilot: true };
|
||||
const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime" };
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, "", "/?debug=true&phoneNumber=1234");
|
||||
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(function (this: HTMLDialogElement) {
|
||||
@@ -29,9 +29,13 @@ beforeEach(() => {
|
||||
truncated: true,
|
||||
note: "Lista abreviada",
|
||||
},
|
||||
"/api/apipilot/tools": {
|
||||
success: true,
|
||||
data: { tools: [{ name: "get_reports", description: "Consulta tus reportes", parameters: {} }] },
|
||||
"/api/conversations": {
|
||||
conversationId: "conversation-ana",
|
||||
expiresIn: 3600,
|
||||
profile: { id: "ana", name: "Ana Vargas" },
|
||||
},
|
||||
"/api/mcp/tools": {
|
||||
tools: [{ name: "list_reports", description: "Consulta tus reportes", parameters: {}, requires_auth: true }],
|
||||
},
|
||||
};
|
||||
return Promise.resolve(new Response(JSON.stringify(data[path] ?? {})));
|
||||
@@ -150,7 +154,7 @@ describe("React application", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Vaciar" }));
|
||||
expect(screen.getByRole("textbox")).toHaveValue("");
|
||||
});
|
||||
it("shows nested profile fields and discovers remote tools after bootstrap", async () => {
|
||||
it("shows nested profile fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await user.click(await screen.findByRole("button", { name: "Ver datos de Ana Vargas" }));
|
||||
@@ -159,11 +163,23 @@ describe("React application", () => {
|
||||
"mailto:ana@example.test",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Cerrar" }));
|
||||
await user.click(screen.getByRole("button", { name: "Herramientas" }));
|
||||
});
|
||||
it("discovers and displays MCP tools for the authenticated conversation", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ ...bootstrap, mcp: true })));
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await user.click(await screen.findByRole("button", { name: "Herramientas" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("get_reports")).toBeInTheDocument();
|
||||
expect(screen.getByText("list_reports")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Consulta tus reportes")).toBeInTheDocument();
|
||||
expect(vi.mocked(fetch).mock.calls.map(([path]) => path)).toEqual([
|
||||
"/api/config",
|
||||
"/api/conversations",
|
||||
"/api/mcp/tools",
|
||||
]);
|
||||
const discovery = vi.mocked(fetch).mock.calls.find(([path]) => path === "/api/mcp/tools");
|
||||
expect(new Headers(discovery?.[1]?.headers).get("X-Vocero-Conversation")).toBe("conversation-ana");
|
||||
});
|
||||
it("keeps debug controls out of the normal user screen", async () => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
|
||||
@@ -282,17 +282,16 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
|
||||
Herramientas MCP cargadas
|
||||
</span>
|
||||
<span className="text-[13.5px] text-muted compact:text-[12.5px]">
|
||||
Herramientas publicadas por el perfil remoto del agente.
|
||||
Herramientas disponibles para esta conversación.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="block text-[13px] leading-[1.4] text-faint compact:text-[12px]" role="status">
|
||||
{state.toolsLoading
|
||||
? "Cargando herramientas…"
|
||||
: state.toolsError ||
|
||||
(state.tools.length
|
||||
? `${String(state.tools.length)} herramientas disponibles.`
|
||||
: "No hay herramientas remotas disponibles.")}
|
||||
: state.tools.length
|
||||
? `${String(state.tools.length)} herramientas disponibles.`
|
||||
: "No hay herramientas remotas disponibles."}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{state.tools.map((tool) => (
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
configSchema,
|
||||
conversationSchema,
|
||||
mcpToolsSchema,
|
||||
recordSchema,
|
||||
reportsSchema,
|
||||
toolsEnvelopeSchema,
|
||||
} from "./contracts";
|
||||
import { configSchema, conversationSchema, mcpToolsSchema, recordSchema, reportsSchema } from "./contracts";
|
||||
import type { Caller } from "./settings";
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -108,18 +101,6 @@ export function getReports(phone: string, signal: AbortSignal) {
|
||||
const query = phone ? `?${new URLSearchParams({ phoneNumber: phone })}` : "";
|
||||
return optionalRequest(`/api/user/reports${query}`, reportsSchema, signal);
|
||||
}
|
||||
export async function getTools(signal: AbortSignal, refresh = false) {
|
||||
const result = await request(
|
||||
`/api/apipilot/tools?include_unauthorized=true&refresh=${String(refresh)}`,
|
||||
toolsEnvelopeSchema,
|
||||
{ signal },
|
||||
10000,
|
||||
);
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error("No se pudieron cargar las herramientas.");
|
||||
}
|
||||
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) }) };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { recordSchema, type DynamicTool, type FunctionTool, type Profile, type ServerConfig } from "./contracts";
|
||||
import { recordSchema, type FunctionTool, type Profile, type ServerConfig } from "./contracts";
|
||||
import type { Settings } from "./settings";
|
||||
|
||||
export function profileName(profile: Profile | null): string {
|
||||
@@ -43,35 +43,9 @@ export function profileContext(profile: Profile | null) {
|
||||
export function ragContext(text: string) {
|
||||
return text.trim() ? `<<<CUSTOM_RAG_BEGIN>>>\n${text.trim()}\n<<<CUSTOM_RAG_END>>>` : "";
|
||||
}
|
||||
export const TOOL_MARKER = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>";
|
||||
export function toolContext(tools: DynamicTool[]) {
|
||||
if (!tools.length) {
|
||||
return "";
|
||||
}
|
||||
const entries = tools.slice(0, 100).map((tool) => {
|
||||
const required = Array.isArray(tool.parameters.required)
|
||||
? tool.parameters.required.filter((key): key is string => typeof key === "string")
|
||||
: [];
|
||||
return `- name: ${tool.name}\n requires_auth: ${String(tool.requires_auth)}${required.length ? `\n required_parameters: [${required.slice(0, 40).join(", ")}]` : ""}\n description: ${tool.description.slice(0, 500)}`;
|
||||
});
|
||||
return `${TOOL_MARKER}\n### Available dynamic tools (ApiPilot AquaPortal)\nEach tool name is a unique identifier: DO NOT change, camelCase or translate it.\nMANDATORY EXECUTION RULES:\n1. If the user request matches a tool description, output ONLY one line as the FIRST line of your response:\n<<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1"}>>>\n2. ARGUMENTS must be valid JSON. Include ONLY fields listed in required_parameters, if any.\n3. Do NOT add text after the marker. The engine executes the tool and supplies its result in a second call.\n4. If no description matches, respond normally in Spanish without a marker.\n5. If multiple descriptions match, choose the most specific.\n${entries.join("\n")}\n<<<APIPILOT_TOOLS_LIST_END>>>`;
|
||||
}
|
||||
export function buildInstructions(
|
||||
config: ServerConfig,
|
||||
settings: Settings,
|
||||
profile: Profile | null,
|
||||
rag: string,
|
||||
tools: DynamicTool[],
|
||||
) {
|
||||
export function buildInstructions(config: ServerConfig, settings: Settings, profile: Profile | null, rag: string) {
|
||||
const base = config.primaryPrompt || settings.instructions;
|
||||
return [
|
||||
base,
|
||||
config.injectProfile ? profileContext(profile) : "",
|
||||
ragContext(rag),
|
||||
config.mcp || base.includes(TOOL_MARKER) ? "" : toolContext(tools),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
return [base, config.injectProfile ? profileContext(profile) : "", ragContext(rag)].filter(Boolean).join("\n\n");
|
||||
}
|
||||
export function speechInstructions(instructions: string, config: ServerConfig) {
|
||||
return config.reasoningType === "OPENAI"
|
||||
|
||||
@@ -14,7 +14,6 @@ export const configSchema = z.object({
|
||||
debugPrompt: z.boolean().default(false),
|
||||
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>;
|
||||
@@ -50,11 +49,6 @@ export interface FunctionTool {
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
export const toolsEnvelopeSchema = z.object({
|
||||
success: z.boolean(),
|
||||
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(),
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getMcpTools,
|
||||
getProfile,
|
||||
getReports,
|
||||
getTools,
|
||||
interruptConversation,
|
||||
post,
|
||||
reasoningSchema,
|
||||
@@ -53,7 +52,6 @@ export interface AppState {
|
||||
reports: Reports | null;
|
||||
tools: DynamicTool[];
|
||||
toolsLoading: boolean;
|
||||
toolsError: string;
|
||||
status: ConnectionStatus;
|
||||
error: string;
|
||||
notice: string;
|
||||
@@ -102,7 +100,6 @@ export class VoiceController {
|
||||
reports: null,
|
||||
tools: [],
|
||||
toolsLoading: false,
|
||||
toolsError: "",
|
||||
status: "idle",
|
||||
error: "",
|
||||
notice: "",
|
||||
@@ -194,12 +191,7 @@ export class VoiceController {
|
||||
}
|
||||
})
|
||||
.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]);
|
||||
await Promise.all([profile, reports]);
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
this.update({ loading: false, configError: `No se pudo cargar la configuración. ${errorMessage(error)}` });
|
||||
@@ -212,7 +204,7 @@ export class VoiceController {
|
||||
const version = ++this.conversationVersion;
|
||||
let createdId: string | undefined;
|
||||
this.releaseConversation();
|
||||
this.update({ contextLoading: true, profile: null, reports: null, tools: [], toolsLoading: true, toolsError: "" });
|
||||
this.update({ contextLoading: true, profile: null, reports: null, tools: [], toolsLoading: true });
|
||||
try {
|
||||
if (!this.caller.phoneNumber) {
|
||||
throw new Error("Abre Vocero desde un enlace que incluya tu número de teléfono.");
|
||||
@@ -247,30 +239,8 @@ export class VoiceController {
|
||||
this.initialization?.abort();
|
||||
void this.stop();
|
||||
}
|
||||
async refreshTools(signal: AbortSignal, refresh = false) {
|
||||
this.update({ toolsLoading: true, toolsError: "" });
|
||||
try {
|
||||
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();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
this.update({ toolsError: errorMessage(error), toolsLoading: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
private instructions() {
|
||||
return buildInstructions(
|
||||
this.state.config,
|
||||
this.state.settings,
|
||||
this.state.profile,
|
||||
this.state.rag,
|
||||
this.state.tools,
|
||||
);
|
||||
return buildInstructions(this.state.config, this.state.settings, this.state.profile, this.state.rag);
|
||||
}
|
||||
private tools() {
|
||||
return this.state.settings.webSearch && (this.state.config.search || this.state.settings.searchKey)
|
||||
|
||||
Reference in New Issue
Block a user