@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/README.en.md +320 -0
- package/README.es.md +320 -0
- package/README.md +320 -95
- package/bin/auto-configure-consumer.mjs +119 -0
- package/bin/generate-web-module-loaders.mjs +483 -0
- package/dist/index.cjs +263 -56
- package/dist/index.d.cts +32 -5
- package/dist/index.d.ts +32 -5
- package/dist/index.js +261 -55
- package/package.json +13 -2
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,
|
|
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.
|
|
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
|
-
"
|
|
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"
|