@navai/voice-frontend 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -240,6 +240,8 @@ function getNavaiRoutePromptLines(routes = []) {
240
240
  }
241
241
 
242
242
  // src/agent.ts
243
+ var RESERVED_TOOL_NAMES = /* @__PURE__ */ new Set(["navigate_to", "execute_app_function"]);
244
+ var TOOL_NAME_REGEXP = /^[a-zA-Z0-9_-]{1,64}$/;
243
245
  function toErrorMessage2(error) {
244
246
  return error instanceof Error ? error.message : String(error);
245
247
  }
@@ -274,6 +276,76 @@ async function buildNavaiAgent(options) {
274
276
  ...functionsRegistry.ordered.map((item) => item.name),
275
277
  ...backendFunctionsOrdered.map((item) => item.name)
276
278
  ];
279
+ const aliasWarnings = [];
280
+ const directFunctionToolNames = [...new Set(availableFunctionNames)].map((name) => name.trim().toLowerCase()).filter((name) => {
281
+ if (!name) {
282
+ return false;
283
+ }
284
+ if (RESERVED_TOOL_NAMES.has(name)) {
285
+ aliasWarnings.push(
286
+ `[navai] Function "${name}" is available only via execute_app_function because its name conflicts with a built-in tool.`
287
+ );
288
+ return false;
289
+ }
290
+ if (!TOOL_NAME_REGEXP.test(name)) {
291
+ aliasWarnings.push(
292
+ `[navai] Function "${name}" is available only via execute_app_function because its name is not a valid tool id.`
293
+ );
294
+ return false;
295
+ }
296
+ return true;
297
+ });
298
+ const executeAppFunction = async (requestedName, payload) => {
299
+ const requested = requestedName.trim().toLowerCase();
300
+ const frontendDefinition = functionsRegistry.byName.get(requested);
301
+ if (frontendDefinition) {
302
+ try {
303
+ const result = await frontendDefinition.run(payload ?? {}, options);
304
+ return { ok: true, function_name: frontendDefinition.name, source: frontendDefinition.source, result };
305
+ } catch (error) {
306
+ return {
307
+ ok: false,
308
+ function_name: frontendDefinition.name,
309
+ error: "Function execution failed.",
310
+ details: toErrorMessage2(error)
311
+ };
312
+ }
313
+ }
314
+ const backendDefinition = backendFunctionsByName.get(requested);
315
+ if (!backendDefinition) {
316
+ return {
317
+ ok: false,
318
+ error: "Unknown or disallowed function.",
319
+ available_functions: availableFunctionNames
320
+ };
321
+ }
322
+ if (!options.executeBackendFunction) {
323
+ return {
324
+ ok: false,
325
+ function_name: backendDefinition.name,
326
+ error: "Backend function execution is not configured."
327
+ };
328
+ }
329
+ try {
330
+ const result = await options.executeBackendFunction({
331
+ functionName: backendDefinition.name,
332
+ payload: payload ?? null
333
+ });
334
+ return {
335
+ ok: true,
336
+ function_name: backendDefinition.name,
337
+ source: backendDefinition.source ?? "backend",
338
+ result
339
+ };
340
+ } catch (error) {
341
+ return {
342
+ ok: false,
343
+ function_name: backendDefinition.name,
344
+ error: "Function execution failed.",
345
+ details: toErrorMessage2(error)
346
+ };
347
+ }
348
+ };
277
349
  const navigateTool = tool({
278
350
  name: "navigate_to",
279
351
  description: "Navigate to an allowed route in the current app.",
@@ -298,58 +370,20 @@ async function buildNavaiAgent(options) {
298
370
  "Payload object. Use null when no arguments are needed. Use payload.args as array for function args, payload.constructorArgs for class constructors, payload.methodArgs for class methods."
299
371
  )
300
372
  }),
301
- execute: async ({ function_name, payload }) => {
302
- const requested = function_name.trim().toLowerCase();
303
- const frontendDefinition = functionsRegistry.byName.get(requested);
304
- if (frontendDefinition) {
305
- try {
306
- const result = await frontendDefinition.run(payload ?? {}, options);
307
- return { ok: true, function_name: frontendDefinition.name, source: frontendDefinition.source, result };
308
- } catch (error) {
309
- return {
310
- ok: false,
311
- function_name: frontendDefinition.name,
312
- error: "Function execution failed.",
313
- details: toErrorMessage2(error)
314
- };
315
- }
316
- }
317
- const backendDefinition = backendFunctionsByName.get(requested);
318
- if (!backendDefinition) {
319
- return {
320
- ok: false,
321
- error: "Unknown or disallowed function.",
322
- available_functions: availableFunctionNames
323
- };
324
- }
325
- if (!options.executeBackendFunction) {
326
- return {
327
- ok: false,
328
- function_name: backendDefinition.name,
329
- error: "Backend function execution is not configured."
330
- };
331
- }
332
- try {
333
- const result = await options.executeBackendFunction({
334
- functionName: backendDefinition.name,
335
- payload: payload ?? null
336
- });
337
- return {
338
- ok: true,
339
- function_name: backendDefinition.name,
340
- source: backendDefinition.source ?? "backend",
341
- result
342
- };
343
- } catch (error) {
344
- return {
345
- ok: false,
346
- function_name: backendDefinition.name,
347
- error: "Function execution failed.",
348
- details: toErrorMessage2(error)
349
- };
350
- }
351
- }
373
+ execute: async ({ function_name, payload }) => await executeAppFunction(function_name, payload)
352
374
  });
375
+ const directFunctionTools = directFunctionToolNames.map(
376
+ (functionName) => tool({
377
+ name: functionName,
378
+ description: `Direct alias for execute_app_function("${functionName}").`,
379
+ parameters: z.object({
380
+ payload: z.record(z.string(), z.unknown()).nullable().optional().describe(
381
+ "Payload object. Optional. Use payload.args as array for function args, payload.constructorArgs for class constructors, payload.methodArgs for class methods."
382
+ )
383
+ }),
384
+ execute: async ({ payload }) => await executeAppFunction(functionName, payload ?? null)
385
+ })
386
+ );
353
387
  const routeLines = getNavaiRoutePromptLines(options.routes);
354
388
  const functionLines = functionsRegistry.ordered.length + backendFunctionsOrdered.length > 0 ? [
355
389
  ...functionsRegistry.ordered.map((item) => `- ${item.name}: ${item.description}`),
@@ -365,7 +399,7 @@ async function buildNavaiAgent(options) {
365
399
  ...functionLines,
366
400
  "Rules:",
367
401
  "- If user asks to go/open a section, always call navigate_to.",
368
- "- If user asks to run an internal action, always call execute_app_function.",
402
+ "- If user asks to run an internal action, call execute_app_function or the matching direct function tool.",
369
403
  "- Always include payload in execute_app_function. Use null when no arguments are needed.",
370
404
  "- For execute_app_function, pass arguments using payload.args (array).",
371
405
  "- For class methods, pass payload.constructorArgs and payload.methodArgs.",
@@ -375,9 +409,9 @@ async function buildNavaiAgent(options) {
375
409
  const agent = new RealtimeAgent({
376
410
  name: options.agentName ?? "Navai Voice Agent",
377
411
  instructions,
378
- tools: [navigateTool, executeFunctionTool]
412
+ tools: [navigateTool, executeFunctionTool, ...directFunctionTools]
379
413
  });
380
- return { agent, warnings: [...functionsRegistry.warnings, ...backendWarnings] };
414
+ return { agent, warnings: [...functionsRegistry.warnings, ...backendWarnings, ...aliasWarnings] };
381
415
  }
382
416
 
383
417
  // src/backend.ts
@@ -675,11 +709,183 @@ function readOptional2(value) {
675
709
  function toErrorMessage3(error) {
676
710
  return error instanceof Error ? error.message : String(error);
677
711
  }
712
+
713
+ // src/useWebVoiceAgent.ts
714
+ import { RealtimeSession } from "@openai/agents/realtime";
715
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
716
+ function formatError(error) {
717
+ if (error instanceof Error) {
718
+ return error.message;
719
+ }
720
+ return String(error);
721
+ }
722
+ function emitWarnings(warnings) {
723
+ for (const warning of warnings) {
724
+ if (warning.trim().length > 0) {
725
+ console.warn(warning);
726
+ }
727
+ }
728
+ }
729
+ function useWebVoiceAgent(options) {
730
+ const sessionRef = useRef(null);
731
+ const attachedRealtimeSessionRef = useRef(null);
732
+ const runtimeConfigPromise = useMemo(
733
+ () => resolveNavaiFrontendRuntimeConfig({
734
+ moduleLoaders: options.moduleLoaders,
735
+ defaultRoutes: options.defaultRoutes,
736
+ env: options.env,
737
+ routesFile: options.routesFile,
738
+ functionsFolders: options.functionsFolders,
739
+ modelOverride: options.modelOverride,
740
+ defaultRoutesFile: options.defaultRoutesFile,
741
+ defaultFunctionsFolder: options.defaultFunctionsFolder
742
+ }),
743
+ [
744
+ options.defaultFunctionsFolder,
745
+ options.defaultRoutes,
746
+ options.defaultRoutesFile,
747
+ options.env,
748
+ options.functionsFolders,
749
+ options.modelOverride,
750
+ options.moduleLoaders,
751
+ options.routesFile
752
+ ]
753
+ );
754
+ const backendClient = useMemo(
755
+ () => createNavaiBackendClient({
756
+ ...options.apiBaseUrl ? { apiBaseUrl: options.apiBaseUrl } : {},
757
+ env: options.env
758
+ }),
759
+ [options.apiBaseUrl, options.env]
760
+ );
761
+ const [status, setStatus] = useState("idle");
762
+ const [agentVoiceState, setAgentVoiceState] = useState("idle");
763
+ const [error, setError] = useState(null);
764
+ const setAgentVoiceStateIfChanged = useCallback((next) => {
765
+ setAgentVoiceState((current) => current === next ? current : next);
766
+ }, []);
767
+ const handleSessionAudioStart = useCallback(() => {
768
+ setAgentVoiceStateIfChanged("speaking");
769
+ }, [setAgentVoiceStateIfChanged]);
770
+ const handleSessionAudioStopped = useCallback(() => {
771
+ setAgentVoiceStateIfChanged("idle");
772
+ }, [setAgentVoiceStateIfChanged]);
773
+ const handleSessionAudioInterrupted = useCallback(() => {
774
+ setAgentVoiceStateIfChanged("idle");
775
+ }, [setAgentVoiceStateIfChanged]);
776
+ const handleSessionError = useCallback(() => {
777
+ setAgentVoiceStateIfChanged("idle");
778
+ }, [setAgentVoiceStateIfChanged]);
779
+ const detachSessionAudioListeners = useCallback(() => {
780
+ const attachedSession = attachedRealtimeSessionRef.current;
781
+ if (!attachedSession) {
782
+ return;
783
+ }
784
+ attachedSession.off("audio_start", handleSessionAudioStart);
785
+ attachedSession.off("audio_stopped", handleSessionAudioStopped);
786
+ attachedSession.off("audio_interrupted", handleSessionAudioInterrupted);
787
+ attachedSession.off("error", handleSessionError);
788
+ attachedRealtimeSessionRef.current = null;
789
+ }, [handleSessionAudioInterrupted, handleSessionAudioStart, handleSessionAudioStopped, handleSessionError]);
790
+ const attachSessionAudioListeners = useCallback(
791
+ (session) => {
792
+ detachSessionAudioListeners();
793
+ session.on("audio_start", handleSessionAudioStart);
794
+ session.on("audio_stopped", handleSessionAudioStopped);
795
+ session.on("audio_interrupted", handleSessionAudioInterrupted);
796
+ session.on("error", handleSessionError);
797
+ attachedRealtimeSessionRef.current = session;
798
+ },
799
+ [
800
+ detachSessionAudioListeners,
801
+ handleSessionAudioInterrupted,
802
+ handleSessionAudioStart,
803
+ handleSessionAudioStopped,
804
+ handleSessionError
805
+ ]
806
+ );
807
+ const stop = useCallback(() => {
808
+ detachSessionAudioListeners();
809
+ try {
810
+ sessionRef.current?.close();
811
+ } finally {
812
+ sessionRef.current = null;
813
+ setStatus("idle");
814
+ setAgentVoiceStateIfChanged("idle");
815
+ }
816
+ }, [detachSessionAudioListeners, setAgentVoiceStateIfChanged]);
817
+ useEffect(() => {
818
+ return () => {
819
+ stop();
820
+ };
821
+ }, [stop]);
822
+ const start = useCallback(async () => {
823
+ if (status === "connecting" || status === "connected") {
824
+ return;
825
+ }
826
+ setError(null);
827
+ setStatus("connecting");
828
+ setAgentVoiceStateIfChanged("idle");
829
+ try {
830
+ const runtimeConfig = await runtimeConfigPromise;
831
+ const requestPayload = runtimeConfig.modelOverride ? { model: runtimeConfig.modelOverride } : {};
832
+ const secretPayload = await backendClient.createClientSecret(requestPayload);
833
+ const backendFunctionsResult = await backendClient.listFunctions();
834
+ const { agent, warnings } = await buildNavaiAgent({
835
+ navigate: options.navigate,
836
+ routes: runtimeConfig.routes,
837
+ functionModuleLoaders: runtimeConfig.functionModuleLoaders,
838
+ backendFunctions: backendFunctionsResult.functions,
839
+ executeBackendFunction: backendClient.executeFunction
840
+ });
841
+ emitWarnings([...runtimeConfig.warnings, ...backendFunctionsResult.warnings, ...warnings]);
842
+ const session = new RealtimeSession(agent);
843
+ attachSessionAudioListeners(session);
844
+ if (runtimeConfig.modelOverride) {
845
+ await session.connect({ apiKey: secretPayload.value, model: runtimeConfig.modelOverride });
846
+ } else {
847
+ await session.connect({ apiKey: secretPayload.value });
848
+ }
849
+ sessionRef.current = session;
850
+ setStatus("connected");
851
+ } catch (startError) {
852
+ const message = formatError(startError);
853
+ setError(message);
854
+ setStatus("error");
855
+ setAgentVoiceStateIfChanged("idle");
856
+ detachSessionAudioListeners();
857
+ try {
858
+ sessionRef.current?.close();
859
+ } catch {
860
+ }
861
+ sessionRef.current = null;
862
+ }
863
+ }, [
864
+ attachSessionAudioListeners,
865
+ backendClient,
866
+ detachSessionAudioListeners,
867
+ options.navigate,
868
+ runtimeConfigPromise,
869
+ setAgentVoiceStateIfChanged,
870
+ status
871
+ ]);
872
+ return {
873
+ status,
874
+ agentVoiceState,
875
+ error,
876
+ isConnecting: status === "connecting",
877
+ isConnected: status === "connected",
878
+ isAgentSpeaking: agentVoiceState === "speaking",
879
+ start,
880
+ stop
881
+ };
882
+ }
678
883
  export {
679
884
  buildNavaiAgent,
680
885
  createNavaiBackendClient,
681
886
  getNavaiRoutePromptLines,
682
887
  loadNavaiFunctions,
683
888
  resolveNavaiFrontendRuntimeConfig,
684
- resolveNavaiRoute
889
+ resolveNavaiRoute,
890
+ useWebVoiceAgent
685
891
  };
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@navai/voice-frontend",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Frontend helpers to build OpenAI Realtime voice agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
7
7
  "module": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "navai-generate-web-loaders": "./bin/generate-web-module-loaders.mjs",
11
+ "navai-setup-voice-frontend": "./bin/auto-configure-consumer.mjs"
12
+ },
9
13
  "exports": {
10
14
  ".": {
11
15
  "types": "./dist/index.d.ts",
@@ -15,10 +19,14 @@
15
19
  },
16
20
  "files": [
17
21
  "dist",
18
- "README.md"
22
+ "bin",
23
+ "README.md",
24
+ "README.es.md",
25
+ "README.en.md"
19
26
  ],
20
27
  "scripts": {
21
28
  "build": "tsup src/index.ts --format cjs,esm --dts --clean",
29
+ "postinstall": "node ./bin/auto-configure-consumer.mjs",
22
30
  "typecheck": "tsc --noEmit -p tsconfig.json",
23
31
  "lint": "tsc --noEmit -p tsconfig.json"
24
32
  },
@@ -26,6 +34,9 @@
26
34
  "@openai/agents": "^0.4.14",
27
35
  "zod": "^4.0.0"
28
36
  },
37
+ "peerDependencies": {
38
+ "react": ">=18"
39
+ },
29
40
  "devDependencies": {
30
41
  "tsup": "^8.3.5",
31
42
  "typescript": "^5.7.3"