@graineai/inapp-react-native 0.12.0 → 0.15.0
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/INTEGRATION.md +75 -0
- package/dist/audio.js +2 -1
- package/dist/client.d.ts +7 -1
- package/dist/client.js +9 -2
- package/dist/index.d.ts +10 -9
- package/dist/index.js +9 -8
- package/dist/react-native/index.d.ts +4 -4
- package/dist/react-native/index.js +3 -3
- package/dist/react-native/ui.js +1 -1
- package/dist/react-native/voice-launcher.js +25 -5
- package/dist/react-native/voice-rtc.d.ts +41 -0
- package/dist/react-native/voice-rtc.js +143 -0
- package/dist/voice.d.ts +1 -1
- package/dist/voice.js +1 -1
- package/package.json +9 -2
package/INTEGRATION.md
CHANGED
|
@@ -135,6 +135,34 @@ agent just does not know their name.
|
|
|
135
135
|
|
|
136
136
|
---
|
|
137
137
|
|
|
138
|
+
### Metadata before the first word
|
|
139
|
+
|
|
140
|
+
Two places, and the difference is when you know the value.
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
// Known at build time — carried in the init frame, before the agent speaks.
|
|
144
|
+
<GraineProvider variables={{ tier: 'gold', region: 'IN' }} />
|
|
145
|
+
|
|
146
|
+
// Known after sign-in — merged into that same init frame if it lands before the
|
|
147
|
+
// socket opens, sent on the next context frame if it lands after.
|
|
148
|
+
identify({ name: user.name, plan: user.plan });
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`identify()` wins over `variables` on the same key: a signed-in customer is more
|
|
152
|
+
specific than a default. Both are masked on the device before they leave it.
|
|
153
|
+
|
|
154
|
+
### It reaches the call record, not only the prompt
|
|
155
|
+
|
|
156
|
+
Traits are stored on the conversation as `app_user`, and the name labels the row
|
|
157
|
+
in Call History. A web call has no phone number to identify it by, so without
|
|
158
|
+
this every in-app conversation shows a session id.
|
|
159
|
+
|
|
160
|
+
Read from the LIVE identity rather than the init frame, so a customer who signs
|
|
161
|
+
in halfway through is identified from that moment rather than from a frame that
|
|
162
|
+
predates them.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
138
166
|
## Step 3 — Report what is ON the screen
|
|
139
167
|
|
|
140
168
|
The route gives the agent a screen *name*. This gives it the contents.
|
|
@@ -218,6 +246,23 @@ updating the screen.
|
|
|
218
246
|
|
|
219
247
|
---
|
|
220
248
|
|
|
249
|
+
### Declaring them: paste, do not retype
|
|
250
|
+
|
|
251
|
+
Keep the list in your repo — `docs/agent-actions.json` is the convention — and
|
|
252
|
+
load it with **Embed & Widgets → App actions → Import from JSON**. A bare array
|
|
253
|
+
works, so does `{ "appActions": [...] }`.
|
|
254
|
+
|
|
255
|
+
Names must match your `useGraineAction` handlers exactly, and that is the whole
|
|
256
|
+
argument for importing: a name one underscore out declares a tool your app will
|
|
257
|
+
refuse for the life of the release, and the agent keeps trying it. `enum`
|
|
258
|
+
constraints are preserved, so a value the model cannot get wrong stays that way.
|
|
259
|
+
|
|
260
|
+
Import replaces the panel's contents rather than merging — the file is the
|
|
261
|
+
source of truth, and a merge leaves actions declared here and implemented
|
|
262
|
+
nowhere. Save is what pushes the catalogue to the agent.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
221
266
|
## Step 5 — Product events, without interrupting
|
|
222
267
|
|
|
223
268
|
```tsx
|
|
@@ -235,6 +280,30 @@ and the runtime decides whether it is worth interrupting for.
|
|
|
235
280
|
|
|
236
281
|
---
|
|
237
282
|
|
|
283
|
+
### When something should be spoken about
|
|
284
|
+
|
|
285
|
+
```tsx
|
|
286
|
+
const { client } = useGraineAgent();
|
|
287
|
+
client.reportEvent({
|
|
288
|
+
name: 'mandate_failed',
|
|
289
|
+
detail: 'Their mandate was declined twice. Offer to switch to a card.',
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`track()` is "know this when they next ask". `reportEvent()` is "this may be
|
|
294
|
+
worth interrupting for". `detail` tells the agent what to OFFER — without it the
|
|
295
|
+
agent reads an event name aloud, which is worse than saying nothing.
|
|
296
|
+
|
|
297
|
+
Reserve it for a dead end the customer would want addressed without asking: a
|
|
298
|
+
save that never reached the server, an upload that failed twice, a payment
|
|
299
|
+
declined. Not a tap, not a navigation, not a save that worked.
|
|
300
|
+
|
|
301
|
+
The runtime still decides whether to speak: silent while it is talking, while a
|
|
302
|
+
reply is generating, for 20s after the last intervention, and after three in a
|
|
303
|
+
session.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
238
307
|
## Step 6 — React to the conversation
|
|
239
308
|
|
|
240
309
|
```tsx
|
|
@@ -406,6 +475,12 @@ than opening a second socket. This matters under React 18 strict mode, which
|
|
|
406
475
|
mounts effects twice: previously the first socket was orphaned with no reference
|
|
407
476
|
to close it, and the runtime kept it open and billing until its own timeout.
|
|
408
477
|
|
|
478
|
+
**Nothing is left holding the call.** An outstanding action's waiter is released
|
|
479
|
+
in a `finally`, so a customer closing the app mid-action releases it too, and
|
|
480
|
+
every wait has a deadline — an app that never answers cannot hold the turn open
|
|
481
|
+
against someone who has already gone. A timeout is reported rather than
|
|
482
|
+
swallowed, because silence would have the agent claim the change landed.
|
|
483
|
+
|
|
409
484
|
**`close()` sends a stop frame** before dropping the socket, so the conversation
|
|
410
485
|
is filed as ended rather than as a customer who vanished mid-turn. It is called
|
|
411
486
|
for you on unmount and on backgrounding.
|
package/dist/audio.js
CHANGED
|
@@ -60,7 +60,8 @@ export function decodeAgentAudio(bytes, declared, latched, fallbackPcmRate = 160
|
|
|
60
60
|
const pcm16 = muLawWins ? asMuLaw : asPcm;
|
|
61
61
|
const kind = muLawWins ? "mulaw" : "pcm";
|
|
62
62
|
const sampleRate = muLawWins ? 8000 : declared?.sampleRate || fallbackPcmRate;
|
|
63
|
-
const confident = rms(
|
|
63
|
+
const confident = rms(asPcm) >= CONFIDENT_RMS &&
|
|
64
|
+
zeroCrossingRate(asMuLaw) !== zeroCrossingRate(asPcm);
|
|
64
65
|
return { pcm16, sampleRate, kind, latch: confident ? { kind, sampleRate } : null };
|
|
65
66
|
}
|
|
66
67
|
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext } from "./protocol";
|
|
1
|
+
import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext } from "./protocol.js";
|
|
2
2
|
export declare const SUBPROTOCOL = "graine.embed.v1";
|
|
3
3
|
export interface GraineInAppOptions {
|
|
4
4
|
baseUrl: string;
|
|
@@ -54,6 +54,12 @@ export declare class GraineInAppClient {
|
|
|
54
54
|
private runAction;
|
|
55
55
|
private invokeAction;
|
|
56
56
|
getScreen(): ScreenContext | null;
|
|
57
|
+
getIdentity(): Record<string, string>;
|
|
58
|
+
getRecentEvents(): Array<{
|
|
59
|
+
name: string;
|
|
60
|
+
at: number;
|
|
61
|
+
data?: Record<string, unknown>;
|
|
62
|
+
}>;
|
|
57
63
|
getAvailableActions(): string[];
|
|
58
64
|
registerAction(name: string, handler: ActionHandler): () => void;
|
|
59
65
|
get availableActions(): string[];
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol";
|
|
2
|
-
import { maskDeep } from "./mask";
|
|
1
|
+
import { ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
|
|
2
|
+
import { maskDeep } from "./mask.js";
|
|
3
3
|
export const SUBPROTOCOL = "graine.embed.v1";
|
|
4
4
|
const PING_MS = 25000;
|
|
5
5
|
export class GraineInAppClient {
|
|
@@ -262,6 +262,12 @@ export class GraineInAppClient {
|
|
|
262
262
|
getScreen() {
|
|
263
263
|
return this.screen;
|
|
264
264
|
}
|
|
265
|
+
getIdentity() {
|
|
266
|
+
return { ...this.identity };
|
|
267
|
+
}
|
|
268
|
+
getRecentEvents() {
|
|
269
|
+
return [...this.recentEvents];
|
|
270
|
+
}
|
|
265
271
|
getAvailableActions() {
|
|
266
272
|
return [...this.actions.keys()];
|
|
267
273
|
}
|
|
@@ -299,6 +305,7 @@ export class GraineInAppClient {
|
|
|
299
305
|
this.scheduleScreen();
|
|
300
306
|
}
|
|
301
307
|
scheduleScreen() {
|
|
308
|
+
this.emit("screen", { screen: this.screen, actions: this.getAvailableActions() });
|
|
302
309
|
if (this.contextTimer) {
|
|
303
310
|
this.pendingScreen = true;
|
|
304
311
|
return;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol";
|
|
2
|
-
export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "./client";
|
|
3
|
-
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice";
|
|
4
|
-
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio";
|
|
5
|
-
export { addMaskRule, maskDeep, maskString } from "./mask";
|
|
6
|
-
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
1
|
+
export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol.js";
|
|
2
|
+
export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "./client.js";
|
|
3
|
+
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
|
|
4
|
+
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
|
|
5
|
+
export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
6
|
+
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
|
|
7
|
+
export { RtcVoiceSessionController, RtcVoiceError, type RtcVoiceState, type RtcVoiceErrorCode, type RtcVoiceSession, type RtcVoiceOptions, } from "./react-native/voice-rtc.js";
|
|
8
|
+
export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui.js";
|
|
9
|
+
export { GraineVoiceLauncher, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher.js";
|
|
10
|
+
export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation.js";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
export { GraineInAppClient } from "./client";
|
|
2
|
-
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice";
|
|
3
|
-
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio";
|
|
4
|
-
export { addMaskRule, maskDeep, maskString } from "./mask";
|
|
5
|
-
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, } from "./react-native/index";
|
|
6
|
-
export {
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
1
|
+
export { GraineInAppClient } from "./client.js";
|
|
2
|
+
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
|
|
3
|
+
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
|
|
4
|
+
export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
5
|
+
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, } from "./react-native/index.js";
|
|
6
|
+
export { RtcVoiceSessionController, RtcVoiceError, } from "./react-native/voice-rtc.js";
|
|
7
|
+
export { GraineAgentBar, GraineLauncher, } from "./react-native/ui.js";
|
|
8
|
+
export { GraineVoiceLauncher, } from "./react-native/voice-launcher.js";
|
|
9
|
+
export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation.js";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { GraineInAppClient, type GraineInAppOptions } from "../client";
|
|
3
|
-
import { type AudioAdapter, type VoiceSessionOptions } from "../voice";
|
|
4
|
-
import type { ActionHandler, ScreenContext } from "../protocol";
|
|
5
|
-
import { type LauncherInset, type LauncherVisibility } from "./navigation";
|
|
2
|
+
import { GraineInAppClient, type GraineInAppOptions } from "../client.js";
|
|
3
|
+
import { type AudioAdapter, type VoiceSessionOptions } from "../voice.js";
|
|
4
|
+
import type { ActionHandler, ScreenContext } from "../protocol.js";
|
|
5
|
+
import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
|
|
6
6
|
interface GraineContextValue {
|
|
7
7
|
client: GraineInAppClient;
|
|
8
8
|
connected: boolean;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
3
|
import { AppState } from "react-native";
|
|
4
|
-
import { GraineInAppClient } from "../client";
|
|
5
|
-
import { VoiceSession } from "../voice";
|
|
6
|
-
import { LauncherVisibilityTracker, activeRouteName, } from "./navigation";
|
|
4
|
+
import { GraineInAppClient } from "../client.js";
|
|
5
|
+
import { VoiceSession } from "../voice.js";
|
|
6
|
+
import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
|
|
7
7
|
const Ctx = createContext(null);
|
|
8
8
|
export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
|
|
9
9
|
const clientRef = useRef(null);
|
package/dist/react-native/ui.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
|
|
4
|
-
import { useGraineAgent } from "./index";
|
|
4
|
+
import { useGraineAgent } from "./index.js";
|
|
5
5
|
const DARK = {
|
|
6
6
|
surface: "#17161A",
|
|
7
7
|
border: "rgba(255,255,255,0.09)",
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { AppState, View } from "react-native";
|
|
4
|
-
import { useGraineAgent } from "./index";
|
|
4
|
+
import { useGraineAgent } from "./index.js";
|
|
5
5
|
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCaption, onCallState, onMicDenied, onEnded, children, }) {
|
|
6
6
|
const { client, appearance } = useGraineAgent();
|
|
7
7
|
const ref = useRef(null);
|
|
8
|
+
const startedRef = useRef(false);
|
|
8
9
|
const [connected, setConnected] = useState(false);
|
|
9
10
|
const [connecting, setConnecting] = useState(false);
|
|
10
11
|
const [muted, setMutedState] = useState(false);
|
|
@@ -40,12 +41,16 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
|
|
|
40
41
|
case "graine:ready":
|
|
41
42
|
setReady(true);
|
|
42
43
|
pushContext();
|
|
43
|
-
if (autoStart)
|
|
44
|
+
if (autoStart && !startedRef.current) {
|
|
45
|
+
startedRef.current = true;
|
|
44
46
|
post({ type: "graine:start-call" });
|
|
47
|
+
}
|
|
45
48
|
return;
|
|
46
49
|
case "graine:call":
|
|
47
50
|
setConnected(!!msg.connected);
|
|
48
51
|
setConnecting(!!msg.connecting);
|
|
52
|
+
if (!msg.connected && !msg.connecting)
|
|
53
|
+
startedRef.current = false;
|
|
49
54
|
onCallState?.({ connected: !!msg.connected, connecting: !!msg.connecting });
|
|
50
55
|
return;
|
|
51
56
|
case "graine:muted":
|
|
@@ -93,13 +98,28 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
|
|
|
93
98
|
useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
|
|
94
99
|
useEffect(() => {
|
|
95
100
|
let last = AppState.currentState;
|
|
101
|
+
let pending = null;
|
|
102
|
+
const CONFIRM_MS = 2000;
|
|
96
103
|
const sub = AppState.addEventListener("change", (next) => {
|
|
97
104
|
const wasActive = last === "active";
|
|
98
105
|
last = next;
|
|
99
|
-
if (next === "
|
|
100
|
-
|
|
106
|
+
if (next === "active") {
|
|
107
|
+
if (pending) {
|
|
108
|
+
clearTimeout(pending);
|
|
109
|
+
pending = null;
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (next === "background" && wasActive && !pending) {
|
|
114
|
+
pending = setTimeout(() => {
|
|
115
|
+
pending = null;
|
|
116
|
+
if (AppState.currentState !== "active")
|
|
117
|
+
post({ type: "graine:end-call" });
|
|
118
|
+
}, CONFIRM_MS);
|
|
119
|
+
}
|
|
101
120
|
});
|
|
102
|
-
return () =>
|
|
121
|
+
return () => { if (pending)
|
|
122
|
+
clearTimeout(pending); sub.remove(); };
|
|
103
123
|
}, [post]);
|
|
104
124
|
const agentId = client.config?.agentId;
|
|
105
125
|
if (!agentId)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type RtcVoiceState = "idle" | "connecting" | "ringing" | "active" | "ended";
|
|
2
|
+
export type RtcVoiceErrorCode = "mint_failed" | "microphone_denied" | "connect_failed" | "already_active";
|
|
3
|
+
export declare class RtcVoiceError extends Error {
|
|
4
|
+
readonly code: RtcVoiceErrorCode;
|
|
5
|
+
readonly cause?: unknown;
|
|
6
|
+
constructor(code: RtcVoiceErrorCode, message: string, cause?: unknown);
|
|
7
|
+
}
|
|
8
|
+
export interface RtcVoiceSession {
|
|
9
|
+
server: string;
|
|
10
|
+
username: string;
|
|
11
|
+
password: string;
|
|
12
|
+
realm: string;
|
|
13
|
+
application_sid: string;
|
|
14
|
+
agent_id?: string;
|
|
15
|
+
expires_at?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface RtcVoiceOptions {
|
|
18
|
+
publishableKey?: string;
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
getSession?: () => Promise<RtcVoiceSession>;
|
|
21
|
+
onState?: (s: RtcVoiceState) => void;
|
|
22
|
+
onEnded?: (reason: string) => void;
|
|
23
|
+
onLog?: (line: string) => void;
|
|
24
|
+
sessionId?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare class RtcVoiceSessionController {
|
|
27
|
+
private client;
|
|
28
|
+
private call;
|
|
29
|
+
private state;
|
|
30
|
+
private startToken;
|
|
31
|
+
readonly sessionId: string;
|
|
32
|
+
private opts;
|
|
33
|
+
constructor(opts: RtcVoiceOptions);
|
|
34
|
+
getState(): RtcVoiceState;
|
|
35
|
+
private set;
|
|
36
|
+
private log;
|
|
37
|
+
private mint;
|
|
38
|
+
start(): Promise<void>;
|
|
39
|
+
setMuted(muted: boolean): void;
|
|
40
|
+
stop(): Promise<void>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
export class RtcVoiceError extends Error {
|
|
2
|
+
constructor(code, message, cause) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "RtcVoiceError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.cause = cause;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function newSessionId() {
|
|
10
|
+
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
|
|
11
|
+
}
|
|
12
|
+
export class RtcVoiceSessionController {
|
|
13
|
+
constructor(opts) {
|
|
14
|
+
this.client = null;
|
|
15
|
+
this.call = null;
|
|
16
|
+
this.state = "idle";
|
|
17
|
+
this.startToken = null;
|
|
18
|
+
this.opts = opts;
|
|
19
|
+
this.sessionId = opts.sessionId || newSessionId();
|
|
20
|
+
}
|
|
21
|
+
getState() {
|
|
22
|
+
return this.state;
|
|
23
|
+
}
|
|
24
|
+
set(s) {
|
|
25
|
+
this.state = s;
|
|
26
|
+
this.opts.onState?.(s);
|
|
27
|
+
}
|
|
28
|
+
log(l) {
|
|
29
|
+
this.opts.onLog?.(l);
|
|
30
|
+
}
|
|
31
|
+
async mint() {
|
|
32
|
+
if (this.opts.getSession)
|
|
33
|
+
return this.opts.getSession();
|
|
34
|
+
if (!this.opts.publishableKey) {
|
|
35
|
+
throw new RtcVoiceError("mint_failed", "Pass publishableKey (or getSession for a custom backend).");
|
|
36
|
+
}
|
|
37
|
+
const base = (this.opts.baseUrl || "https://www.graine.ai").replace(/\/$/, "");
|
|
38
|
+
let res;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetch(`${base}/api/embed/rtc-session`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
body: JSON.stringify({ publishableKey: this.opts.publishableKey }),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
throw new RtcVoiceError("mint_failed", "Could not reach the call service.", e);
|
|
48
|
+
}
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
let detail = "";
|
|
51
|
+
try {
|
|
52
|
+
detail = (await res.json())?.error || "";
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
}
|
|
56
|
+
throw new RtcVoiceError("mint_failed", detail || `Could not start a call (${res.status}).`);
|
|
57
|
+
}
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
async start() {
|
|
61
|
+
if (this.state !== "idle" && this.state !== "ended") {
|
|
62
|
+
throw new RtcVoiceError("already_active", "A call is already in progress.");
|
|
63
|
+
}
|
|
64
|
+
const token = (this.startToken = Symbol("start"));
|
|
65
|
+
this.set("connecting");
|
|
66
|
+
try {
|
|
67
|
+
const session = await this.mint();
|
|
68
|
+
if (token !== this.startToken)
|
|
69
|
+
return;
|
|
70
|
+
this.log(`session for ${session.realm}`);
|
|
71
|
+
const { createJambonzClient } = await import("@jambonz/client-sdk-react-native");
|
|
72
|
+
this.client = createJambonzClient({
|
|
73
|
+
server: session.server,
|
|
74
|
+
username: session.username,
|
|
75
|
+
password: session.password,
|
|
76
|
+
realm: session.realm,
|
|
77
|
+
});
|
|
78
|
+
this.client.on("error", (e) => this.log(`client error: ${e?.message ?? e}`));
|
|
79
|
+
await this.client.connect();
|
|
80
|
+
if (token !== this.startToken) {
|
|
81
|
+
await this.stop();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.log("registered");
|
|
85
|
+
this.call = this.client.callApplication(session.application_sid, {
|
|
86
|
+
headers: {
|
|
87
|
+
"X-Graine-Session-Id": this.sessionId,
|
|
88
|
+
...(session.agent_id ? { "X-Graine-Agent-Id": session.agent_id } : {}),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
this.call.on("stateChanged", (s) => this.log(`call: ${s}`));
|
|
92
|
+
this.call.on("accepted", () => {
|
|
93
|
+
this.set("active");
|
|
94
|
+
this.log("audio flowing");
|
|
95
|
+
});
|
|
96
|
+
this.call.on("ended", (c) => {
|
|
97
|
+
this.set("ended");
|
|
98
|
+
this.opts.onEnded?.(c?.reason || "ended");
|
|
99
|
+
});
|
|
100
|
+
this.call.on("failed", (c) => {
|
|
101
|
+
this.set("ended");
|
|
102
|
+
this.opts.onEnded?.(c?.reason || "failed");
|
|
103
|
+
});
|
|
104
|
+
this.set("ringing");
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (token !== this.startToken)
|
|
108
|
+
return;
|
|
109
|
+
this.set("ended");
|
|
110
|
+
throw err instanceof RtcVoiceError
|
|
111
|
+
? err
|
|
112
|
+
: new RtcVoiceError("connect_failed", err?.message || "Could not connect.", err);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
setMuted(muted) {
|
|
116
|
+
try {
|
|
117
|
+
if (muted)
|
|
118
|
+
this.call?.mute?.();
|
|
119
|
+
else
|
|
120
|
+
this.call?.unmute?.();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async stop() {
|
|
126
|
+
if (this.state === "idle" || this.state === "ended")
|
|
127
|
+
return;
|
|
128
|
+
this.startToken = null;
|
|
129
|
+
try {
|
|
130
|
+
this.call?.hangup?.();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
await this.client?.disconnect?.();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
}
|
|
139
|
+
this.call = null;
|
|
140
|
+
this.client = null;
|
|
141
|
+
this.set("ended");
|
|
142
|
+
}
|
|
143
|
+
}
|
package/dist/voice.d.ts
CHANGED
package/dist/voice.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EchoGuard, base64ToBytes, decodeAgentAudio, decodePcm16 } from "./audio";
|
|
1
|
+
import { EchoGuard, base64ToBytes, decodeAgentAudio, decodePcm16 } from "./audio.js";
|
|
2
2
|
export const CAPTURE_SAMPLE_RATE = 16000;
|
|
3
3
|
export const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
|
|
4
4
|
export function base64ToPcm16(b64) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graineai/inapp-react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"private": false,
|
|
@@ -35,7 +36,8 @@
|
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
37
38
|
"react": ">=17",
|
|
38
|
-
"react-native": ">=0.68"
|
|
39
|
+
"react-native": ">=0.68",
|
|
40
|
+
"@jambonz/client-sdk-react-native": "^0.1.4"
|
|
39
41
|
},
|
|
40
42
|
"devDependencies": {
|
|
41
43
|
"@types/react": "^18.2.0",
|
|
@@ -60,5 +62,10 @@
|
|
|
60
62
|
"homepage": "https://www.graine.ai/docs/in-app",
|
|
61
63
|
"bugs": {
|
|
62
64
|
"url": "https://www.graine.ai/docs/in-app"
|
|
65
|
+
},
|
|
66
|
+
"peerDependenciesMeta": {
|
|
67
|
+
"@jambonz/client-sdk-react-native": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
63
70
|
}
|
|
64
71
|
}
|