@graineai/inapp-react-native 0.12.0 → 0.16.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 +286 -6
- package/README.md +39 -0
- package/dist/audio.js +2 -1
- package/dist/client.d.ts +14 -1
- package/dist/client.js +28 -11
- package/dist/index.d.ts +10 -9
- package/dist/index.js +9 -8
- package/dist/react-native/index.d.ts +7 -6
- package/dist/react-native/index.js +8 -6
- package/dist/react-native/ui.js +1 -1
- package/dist/react-native/voice-launcher.d.ts +7 -1
- package/dist/react-native/voice-launcher.js +111 -21
- 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
|
@@ -8,6 +8,87 @@ Package: `@graineai/inapp-react-native`
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
## How it works, end to end
|
|
12
|
+
|
|
13
|
+
Two things travel between your app and the agent, and keeping them straight
|
|
14
|
+
explains most of this document.
|
|
15
|
+
|
|
16
|
+
**Media** is the customer's voice and the agent's. **Control** is everything the
|
|
17
|
+
agent knows about your app: the screen, who is on it, what just happened, and
|
|
18
|
+
what it is allowed to do about it. They are different problems — media wants
|
|
19
|
+
pacing and loss tolerance, control wants ordered, reliable delivery — so they are
|
|
20
|
+
carried differently, and the SDK is what hides that from you.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
YOUR APP GRAINE
|
|
24
|
+
───────────────────────────────────── ──────────────────────────────
|
|
25
|
+
|
|
26
|
+
┌───────────────────────────────────┐
|
|
27
|
+
│ your screens │
|
|
28
|
+
│ useGraineScreen(...) ──────┐ │
|
|
29
|
+
│ useGraineAction(...) ──────┤ │
|
|
30
|
+
│ identify() / trackEvent() │ │
|
|
31
|
+
└───────────────────────────────┼───┘
|
|
32
|
+
│ one client, one source of truth
|
|
33
|
+
▼
|
|
34
|
+
┌─────────────────────────┐
|
|
35
|
+
│ GraineInAppClient │
|
|
36
|
+
│ screen · identity │
|
|
37
|
+
│ events · actions │
|
|
38
|
+
│ buildContextFrame() │
|
|
39
|
+
│ maskDeep() ← PII never │
|
|
40
|
+
│ leaves the device │
|
|
41
|
+
└───────────┬─────────────┘
|
|
42
|
+
│
|
|
43
|
+
┌───────────▼─────────────┐
|
|
44
|
+
│ GraineVoiceLauncher │
|
|
45
|
+
│ mic · lifecycle · relay│
|
|
46
|
+
└───────────┬─────────────┘
|
|
47
|
+
│ postMessage
|
|
48
|
+
┌───────────▼─────────────┐
|
|
49
|
+
│ hidden WebView (1×1) │ ┌──────────────┐
|
|
50
|
+
│ the audio engine │══ media ══▶│ the agent │
|
|
51
|
+
│ capture · playback │ │ │
|
|
52
|
+
│ jitter buffer · barge-in│──control ─▶│ runtime │
|
|
53
|
+
└─────────────────────────┘ └──────┬───────┘
|
|
54
|
+
│
|
|
55
|
+
▲ │
|
|
56
|
+
└──────── app_action ────────────┘
|
|
57
|
+
"open the offers screen"
|
|
58
|
+
(the agent BLOCKS on your answer)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Why a WebView.** React Native has no `getUserMedia`. The alternative is a
|
|
62
|
+
native audio module, which means a store release for every audio fix — and the
|
|
63
|
+
audio path is exactly where the fixes are. The page is one pixel, invisible and
|
|
64
|
+
unreachable; your customer only ever sees your own UI. See *Why there is no
|
|
65
|
+
LiveKit step* at the end.
|
|
66
|
+
|
|
67
|
+
### What travels, and when
|
|
68
|
+
|
|
69
|
+
| Frame | Direction | When |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `app_context` | app → agent | Every screen change and field edit, throttled. Masked first. |
|
|
72
|
+
| `app_action` | agent → app | The agent wants to do something. **It blocks on your reply** (~6s). |
|
|
73
|
+
| `app_action_result` | app → agent | Your handler's answer: `ok`, `refused`, or `error`. |
|
|
74
|
+
| `app_event` | app → agent | Something happened that may be worth speaking about unprompted. |
|
|
75
|
+
|
|
76
|
+
### The one rule that matters
|
|
77
|
+
|
|
78
|
+
**One client.** `GraineProvider` builds a client unless you hand it one. If your
|
|
79
|
+
app already has a module-level instance — and any app of size does, because
|
|
80
|
+
`identify()` and `trackEvent()` are not all called from React — pass it in:
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
<GraineProvider client={myClient} autoConnect={false}>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Two clients is the failure that looks like a broken product: your screens write
|
|
87
|
+
the context into one, the launcher reads the other and finds nothing, and the
|
|
88
|
+
agent connects, greets, and says it cannot see the screen.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
11
92
|
## What you need
|
|
12
93
|
|
|
13
94
|
| | |
|
|
@@ -89,12 +170,18 @@ export default function App() {
|
|
|
89
170
|
baseUrl="https://www.graine.ai"
|
|
90
171
|
publishableKey="pk_live_..."
|
|
91
172
|
navigationRef={navigationRef}
|
|
173
|
+
// The launcher owns the connection — see below.
|
|
174
|
+
autoConnect={false}
|
|
92
175
|
>
|
|
93
176
|
<NavigationContainer ref={navigationRef}>
|
|
94
177
|
<RootNavigator />
|
|
95
178
|
</NavigationContainer>
|
|
96
179
|
|
|
97
|
-
<GraineVoiceLauncher
|
|
180
|
+
<GraineVoiceLauncher
|
|
181
|
+
webView={WebView}
|
|
182
|
+
autoStart
|
|
183
|
+
onError={(m) => console.warn("[graine]", m)}
|
|
184
|
+
>
|
|
98
185
|
{() => <GraineAgentBar />}
|
|
99
186
|
</GraineVoiceLauncher>
|
|
100
187
|
</GraineProvider>
|
|
@@ -102,6 +189,16 @@ export default function App() {
|
|
|
102
189
|
}
|
|
103
190
|
```
|
|
104
191
|
|
|
192
|
+
**`autoConnect={false}` is not optional here.** The provider opens a connection
|
|
193
|
+
of its own by default, and `GraineVoiceLauncher` opens the one that carries the
|
|
194
|
+
call. Leave both on and the agent answers twice — the customer hears two
|
|
195
|
+
greetings over each other, and you are billed for two conversations. Drop the
|
|
196
|
+
launcher and use text only, and `autoConnect` goes back to its default.
|
|
197
|
+
|
|
198
|
+
**Pass `onError`.** A rejected key or an agent that has not been enabled for apps
|
|
199
|
+
leaves the launcher rendering nothing at all, forever, with nothing in the
|
|
200
|
+
console. This is the only way to find out.
|
|
201
|
+
|
|
105
202
|
`navigationRef` is the line that matters. With it the SDK reads the current route
|
|
106
203
|
itself, so the agent knows which screen the customer is on without you calling
|
|
107
204
|
anything per screen — including the screen you add next year when nobody
|
|
@@ -135,6 +232,63 @@ agent just does not know their name.
|
|
|
135
232
|
|
|
136
233
|
---
|
|
137
234
|
|
|
235
|
+
### Metadata before the first word
|
|
236
|
+
|
|
237
|
+
Two places, and the difference is when you know the value.
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
// Known at build time — carried in the init frame, before the agent speaks.
|
|
241
|
+
<GraineProvider variables={{ tier: 'gold', region: 'IN' }} />
|
|
242
|
+
|
|
243
|
+
// Known after sign-in — merged into that same init frame if it lands before the
|
|
244
|
+
// socket opens, sent on the next context frame if it lands after.
|
|
245
|
+
identify({ name: user.name, plan: user.plan });
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`identify()` wins over `variables` on the same key: a signed-in customer is more
|
|
249
|
+
specific than a default. Both are masked on the device before they leave it.
|
|
250
|
+
|
|
251
|
+
### It reaches the call record, not only the prompt
|
|
252
|
+
|
|
253
|
+
Traits are stored on the conversation as `app_user`, and they are what labels
|
|
254
|
+
the row in Call History. **A web call has no phone number**, so without this
|
|
255
|
+
every in-app conversation shows a bare session id — a page of uuids.
|
|
256
|
+
|
|
257
|
+
Read from the LIVE identity rather than the init frame, so a customer who signs
|
|
258
|
+
in halfway through is identified from that moment rather than from a frame that
|
|
259
|
+
predates them.
|
|
260
|
+
|
|
261
|
+
#### Tag the session with whatever you file people under
|
|
262
|
+
|
|
263
|
+
You do not have to have a `name`. Call History looks for a label in this order:
|
|
264
|
+
|
|
265
|
+
`name` → `full_name` / `fullName` → `display_name` / `displayName` → `label` →
|
|
266
|
+
`username` / `user_name` → `customer_name` → `company` → `email` → **any other
|
|
267
|
+
string trait**
|
|
268
|
+
|
|
269
|
+
So an app that files people by policy number, customer id or internal handle is
|
|
270
|
+
still legible:
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
identify({ label: `Policy ${policy.number}` }); // → "Policy HDFC-4471"
|
|
274
|
+
identify({ customer_name: acct.holder }); // → "R. Bhanot"
|
|
275
|
+
identify({ crm_id: lead.id }); // → falls through to the id
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Two rules worth knowing:
|
|
279
|
+
|
|
280
|
+
- **Redacted values are skipped.** `phone` and `email` are masked on the device,
|
|
281
|
+
so a row never renders `[PHONE]` as somebody's name. Send the phone anyway if
|
|
282
|
+
it is useful — the agent is told a number is on file without being told the
|
|
283
|
+
digits — just do not rely on it as the label.
|
|
284
|
+
- **Identity alone is enough.** You do not need `useGraineScreen` for this. An
|
|
285
|
+
app that only calls `identify()` still tags every conversation.
|
|
286
|
+
|
|
287
|
+
`identify(null)` on sign-out. An agent still greeting the previous account by
|
|
288
|
+
name after a switch is worse than one that never knew it.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
138
292
|
## Step 3 — Report what is ON the screen
|
|
139
293
|
|
|
140
294
|
The route gives the agent a screen *name*. This gives it the contents.
|
|
@@ -218,6 +372,23 @@ updating the screen.
|
|
|
218
372
|
|
|
219
373
|
---
|
|
220
374
|
|
|
375
|
+
### Declaring them: paste, do not retype
|
|
376
|
+
|
|
377
|
+
Keep the list in your repo — `docs/agent-actions.json` is the convention — and
|
|
378
|
+
load it with **Embed & Widgets → App actions → Import from JSON**. A bare array
|
|
379
|
+
works, so does `{ "appActions": [...] }`.
|
|
380
|
+
|
|
381
|
+
Names must match your `useGraineAction` handlers exactly, and that is the whole
|
|
382
|
+
argument for importing: a name one underscore out declares a tool your app will
|
|
383
|
+
refuse for the life of the release, and the agent keeps trying it. `enum`
|
|
384
|
+
constraints are preserved, so a value the model cannot get wrong stays that way.
|
|
385
|
+
|
|
386
|
+
Import replaces the panel's contents rather than merging — the file is the
|
|
387
|
+
source of truth, and a merge leaves actions declared here and implemented
|
|
388
|
+
nowhere. Save is what pushes the catalogue to the agent.
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
221
392
|
## Step 5 — Product events, without interrupting
|
|
222
393
|
|
|
223
394
|
```tsx
|
|
@@ -235,6 +406,30 @@ and the runtime decides whether it is worth interrupting for.
|
|
|
235
406
|
|
|
236
407
|
---
|
|
237
408
|
|
|
409
|
+
### When something should be spoken about
|
|
410
|
+
|
|
411
|
+
```tsx
|
|
412
|
+
const { client } = useGraineAgent();
|
|
413
|
+
client.reportEvent({
|
|
414
|
+
name: 'mandate_failed',
|
|
415
|
+
detail: 'Their mandate was declined twice. Offer to switch to a card.',
|
|
416
|
+
});
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
`track()` is "know this when they next ask". `reportEvent()` is "this may be
|
|
420
|
+
worth interrupting for". `detail` tells the agent what to OFFER — without it the
|
|
421
|
+
agent reads an event name aloud, which is worse than saying nothing.
|
|
422
|
+
|
|
423
|
+
Reserve it for a dead end the customer would want addressed without asking: a
|
|
424
|
+
save that never reached the server, an upload that failed twice, a payment
|
|
425
|
+
declined. Not a tap, not a navigation, not a save that worked.
|
|
426
|
+
|
|
427
|
+
The runtime still decides whether to speak: silent while it is talking, while a
|
|
428
|
+
reply is generating, for 20s after the last intervention, and after three in a
|
|
429
|
+
session.
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
238
433
|
## Step 6 — React to the conversation
|
|
239
434
|
|
|
240
435
|
```tsx
|
|
@@ -406,6 +601,12 @@ than opening a second socket. This matters under React 18 strict mode, which
|
|
|
406
601
|
mounts effects twice: previously the first socket was orphaned with no reference
|
|
407
602
|
to close it, and the runtime kept it open and billing until its own timeout.
|
|
408
603
|
|
|
604
|
+
**Nothing is left holding the call.** An outstanding action's waiter is released
|
|
605
|
+
in a `finally`, so a customer closing the app mid-action releases it too, and
|
|
606
|
+
every wait has a deadline — an app that never answers cannot hold the turn open
|
|
607
|
+
against someone who has already gone. A timeout is reported rather than
|
|
608
|
+
swallowed, because silence would have the agent claim the change landed.
|
|
609
|
+
|
|
409
610
|
**`close()` sends a stop frame** before dropping the socket, so the conversation
|
|
410
611
|
is filed as ended rather than as a customer who vanished mid-turn. It is called
|
|
411
612
|
for you on unmount and on backgrounding.
|
|
@@ -445,6 +646,29 @@ Echo. The SDK suppresses the agent's own voice from the microphone, but a very
|
|
|
445
646
|
loud speaker defeats a level-based guard. Lower the volume, or use earphones to
|
|
446
647
|
confirm that is what it is.
|
|
447
648
|
|
|
649
|
+
**Nothing at all happens, and `onError` never fires either.**
|
|
650
|
+
You did not pass `onError`. Without it a failed session — a rejected key, an
|
|
651
|
+
agent not enabled for apps — leaves the launcher rendering nothing, silently.
|
|
652
|
+
Pass it before you debug anything else.
|
|
653
|
+
|
|
654
|
+
**On `rtc`: every call reports a fallback to `ws`.**
|
|
655
|
+
`onTransport` gives a `reason`. `unreachable` means WebRTC could not be reached
|
|
656
|
+
at all — usually no SIP realm on the org, or the SBC is not reachable from the
|
|
657
|
+
customer's network. `screen_channel` means the audio connected but the control
|
|
658
|
+
socket did not join, so the call was restarted on the WebSocket rather than run
|
|
659
|
+
blind. Both are org configuration, not integration code.
|
|
660
|
+
|
|
661
|
+
**On `rtc`: the greeting plays twice, occasionally.**
|
|
662
|
+
That is the `screen_channel` fallback restarting the call. It only happens when
|
|
663
|
+
the control channel fails to join within five seconds, and it is deliberate: an
|
|
664
|
+
in-app agent that cannot see the screen is not a lesser version of the product.
|
|
665
|
+
If it happens on every call, fix the control channel rather than living with it.
|
|
666
|
+
|
|
667
|
+
**Audio is muffled or crackly on Android but fine on iOS.**
|
|
668
|
+
Update the SDK. Versions before 0.15.0 asked the platform for the stream's own
|
|
669
|
+
sample rate; iOS refuses and falls back to the device rate, Android grants it
|
|
670
|
+
and resamples in its output stage, which is where the artefacts came from.
|
|
671
|
+
|
|
448
672
|
---
|
|
449
673
|
|
|
450
674
|
## Reference
|
|
@@ -459,12 +683,68 @@ confirm that is what it is.
|
|
|
459
683
|
| `useGraineEvents()` | conversation lifecycle |
|
|
460
684
|
| `useGraineVoice()` | only if you bring your own native audio instead of the WebView |
|
|
461
685
|
|
|
462
|
-
`GraineProvider
|
|
463
|
-
`launcherDelayMs`, `visibility`, `autoConnect`, `onProactive`.
|
|
686
|
+
### `GraineProvider`
|
|
464
687
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
688
|
+
| Prop | Type | Notes |
|
|
689
|
+
|---|---|---|
|
|
690
|
+
| `publishableKey` | `string` | Required unless you pass `client`. |
|
|
691
|
+
| `baseUrl` | `string` | Defaults to Graine's host. |
|
|
692
|
+
| `client` | `GraineInAppClient` | Use **your** instance instead of building one. Pass this if your app has a module-level client. |
|
|
693
|
+
| `navigationRef` | `ref` | The same ref you give `NavigationContainer`. Makes the agent screen-aware with no per-screen code. |
|
|
694
|
+
| `includeScreens` | `string[]` | Route names where the launcher may appear. Omit for everywhere. |
|
|
695
|
+
| `launcherDelayMs` | `number` | Delay before the launcher appears on an eligible screen. |
|
|
696
|
+
| `visibility` | `LauncherVisibility` | Groups, continuity, per-group delays and insets. See *Controlling where the launcher appears*. |
|
|
697
|
+
| `autoConnect` | `boolean` | Default `true`. Set `false` when `GraineVoiceLauncher` owns the connection — see below. |
|
|
698
|
+
| `onProactive` | `(text) => void` | The agent spoke first; draw your own nudge. |
|
|
699
|
+
| `endOnBackground` | `boolean` | Default `true`. You want it on. |
|
|
700
|
+
|
|
701
|
+
### `GraineVoiceLauncher`
|
|
702
|
+
|
|
703
|
+
| Prop | Type | Notes |
|
|
704
|
+
|---|---|---|
|
|
705
|
+
| `webView` | `ComponentType` | **Required.** Pass `react-native-webview`'s `WebView`. Passed in rather than imported so the package installs without it. |
|
|
706
|
+
| `autoStart` | `boolean` | Start talking as soon as the page is ready. |
|
|
707
|
+
| `transport` | `"ws" \| "rtc"` | Which transport carries **audio**. See below. |
|
|
708
|
+
| `onTransport` | `({transport, requested}) => void` | Which transport actually carried the call, including after a fallback. |
|
|
709
|
+
| `onCaption` | `({role, text, live}) => void` | Each caption line, for your own UI. |
|
|
710
|
+
| `onCallState` | `({connected, connecting}) => void` | Connection state. |
|
|
711
|
+
| `onEnded` | `() => void` | The call is over. Fires once. Give the goodbye ~400ms to play before you unmount. |
|
|
712
|
+
| `onMicDenied` | `(reason) => void` | `NotAllowedError` is a refusal; `NotFoundError` is a device with no usable microphone. Different sentences for a customer. |
|
|
713
|
+
| `onError` | `(message) => void` | Voice could not be set up at all — key rejected, agent not enabled for apps, no network. Without this the launcher renders nothing and you cannot tell why. |
|
|
714
|
+
| `children` | `(api) => ReactNode` | Receives `{ connected, connecting, muted, start, end, setMuted }`. |
|
|
715
|
+
|
|
716
|
+
**The launcher owns the connection.** It asks for the microphone, resolves the
|
|
717
|
+
session, holds the socket and relays your screen, actions and events onto it. So
|
|
718
|
+
set `autoConnect={false}` on the provider when you use it: two live connections
|
|
719
|
+
on one agent is two conversations, and the customer hears both greetings.
|
|
720
|
+
|
|
721
|
+
### `transport` — how audio travels
|
|
722
|
+
|
|
723
|
+
**Omit it and the dashboard decides.** Agent → Embed & Widgets → *Call audio*
|
|
724
|
+
sets this for every app and site running the agent, so the transport can be
|
|
725
|
+
changed without shipping a release. Passing the prop overrides it.
|
|
726
|
+
|
|
727
|
+
`ws` (the default) sends raw PCM on the same socket that carries screen context
|
|
728
|
+
and in-app actions.
|
|
729
|
+
|
|
730
|
+
`rtc` hands media to jambonz's SBC — Opus, DTLS-SRTP, an adaptive jitter buffer,
|
|
731
|
+
and the platform's own echo canceller running below the microphone with the
|
|
732
|
+
playout signal as its reference. Better audio on a bad network.
|
|
733
|
+
|
|
734
|
+
**`rtc` carries the screen too.** The browser holds two connections — media to
|
|
735
|
+
the SBC, and a control socket for `app_context`, `app_action` and `app_event` —
|
|
736
|
+
and the server joins them by a session id the browser puts on the INVITE. So an
|
|
737
|
+
in-app agent on `rtc` sees exactly what it sees on `ws`. Frames sent before the
|
|
738
|
+
join completes are queued rather than dropped, because the first screen frame
|
|
739
|
+
almost always arrives while the phone is still ringing, and it is the one the
|
|
740
|
+
agent's opening line is composed from.
|
|
741
|
+
|
|
742
|
+
`rtc` **falls back.** WebRTC has more ways to be unavailable than a WebSocket —
|
|
743
|
+
no SIP realm on the org, an unreachable SBC, a network that eats UDP — and each
|
|
744
|
+
is a reason to carry the call differently rather than refuse it. The page
|
|
745
|
+
reconnects on the WebSocket and tells you through `onTransport`, so a permanent
|
|
746
|
+
misconfiguration is visible instead of being merely audible. A refused
|
|
747
|
+
microphone is **not** a transport problem and does not fall back.
|
|
468
748
|
|
|
469
749
|
---
|
|
470
750
|
|
package/README.md
CHANGED
|
@@ -114,6 +114,45 @@ the outcome through the next `useGraineScreen` update.
|
|
|
114
114
|
|
|
115
115
|
## Voice
|
|
116
116
|
|
|
117
|
+
The short way: no native audio module, no rebuild, and audio fixes reach you
|
|
118
|
+
over the air.
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
import { WebView } from "react-native-webview";
|
|
122
|
+
import { GraineProvider, GraineVoiceLauncher, GraineAgentBar } from "@graineai/inapp-react-native";
|
|
123
|
+
|
|
124
|
+
<GraineProvider
|
|
125
|
+
baseUrl="…"
|
|
126
|
+
publishableKey="pk_live_…"
|
|
127
|
+
navigationRef={navigationRef}
|
|
128
|
+
autoConnect={false} // the launcher owns the connection
|
|
129
|
+
>
|
|
130
|
+
<RootNavigator />
|
|
131
|
+
<GraineVoiceLauncher
|
|
132
|
+
webView={WebView}
|
|
133
|
+
autoStart
|
|
134
|
+
onError={(m) => console.warn("[graine]", m)}
|
|
135
|
+
>
|
|
136
|
+
{() => <GraineAgentBar />}
|
|
137
|
+
</GraineVoiceLauncher>
|
|
138
|
+
</GraineProvider>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`react-native-webview` is the only peer dependency and it autolinks. The
|
|
142
|
+
microphone and speaker live in a hidden one-pixel WebView — your customer never
|
|
143
|
+
sees a web page. Set `autoConnect={false}`: leave the provider connecting too
|
|
144
|
+
and the agent answers twice, so the customer hears two greetings and you are
|
|
145
|
+
billed for two conversations.
|
|
146
|
+
|
|
147
|
+
Audio travels on a WebSocket by default. Set **Embed & Widgets → Call audio** to
|
|
148
|
+
*High quality* for WebRTC — Opus, a jitter buffer and the device's own echo
|
|
149
|
+
canceller — which carries screen context just the same and falls back on its own
|
|
150
|
+
if it cannot connect. See `INTEGRATION.md`.
|
|
151
|
+
|
|
152
|
+
### Bringing your own audio
|
|
153
|
+
|
|
154
|
+
Only if you already have a native audio module and want it used instead.
|
|
155
|
+
|
|
117
156
|
```tsx
|
|
118
157
|
<GraineProvider baseUrl="…" publishableKey="pk_live_…" voice>
|
|
119
158
|
```
|
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;
|
|
@@ -53,7 +53,14 @@ export declare class GraineInAppClient {
|
|
|
53
53
|
executeAction(name: string, args?: Record<string, unknown>): Promise<AppActionResult>;
|
|
54
54
|
private runAction;
|
|
55
55
|
private invokeAction;
|
|
56
|
+
get isConnected(): boolean;
|
|
56
57
|
getScreen(): ScreenContext | null;
|
|
58
|
+
getIdentity(): Record<string, string>;
|
|
59
|
+
getRecentEvents(): Array<{
|
|
60
|
+
name: string;
|
|
61
|
+
at: number;
|
|
62
|
+
data?: Record<string, unknown>;
|
|
63
|
+
}>;
|
|
57
64
|
getAvailableActions(): string[];
|
|
58
65
|
registerAction(name: string, handler: ActionHandler): () => void;
|
|
59
66
|
get availableActions(): string[];
|
|
@@ -61,6 +68,12 @@ export declare class GraineInAppClient {
|
|
|
61
68
|
reportEvent(event: AppEvent): void;
|
|
62
69
|
track(name: string, data?: Record<string, unknown>): void;
|
|
63
70
|
private scheduleScreen;
|
|
71
|
+
buildContextFrame(): {
|
|
72
|
+
type: "app_context";
|
|
73
|
+
context: Record<string, unknown>;
|
|
74
|
+
available_actions: string[];
|
|
75
|
+
seq: number;
|
|
76
|
+
} | null;
|
|
64
77
|
private flushScreen;
|
|
65
78
|
private armStall;
|
|
66
79
|
identify(user: Record<string, unknown> | null): void;
|
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 {
|
|
@@ -259,9 +259,18 @@ export class GraineInAppClient {
|
|
|
259
259
|
};
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
|
+
get isConnected() {
|
|
263
|
+
return !!this.ws && this.ws.readyState === 1;
|
|
264
|
+
}
|
|
262
265
|
getScreen() {
|
|
263
266
|
return this.screen;
|
|
264
267
|
}
|
|
268
|
+
getIdentity() {
|
|
269
|
+
return { ...this.identity };
|
|
270
|
+
}
|
|
271
|
+
getRecentEvents() {
|
|
272
|
+
return [...this.recentEvents];
|
|
273
|
+
}
|
|
265
274
|
getAvailableActions() {
|
|
266
275
|
return [...this.actions.keys()];
|
|
267
276
|
}
|
|
@@ -287,6 +296,7 @@ export class GraineInAppClient {
|
|
|
287
296
|
this.scheduleScreen();
|
|
288
297
|
}
|
|
289
298
|
reportEvent(event) {
|
|
299
|
+
this.emit("app_event", event);
|
|
290
300
|
this.send({ type: "app_event", event });
|
|
291
301
|
}
|
|
292
302
|
track(name, data) {
|
|
@@ -299,6 +309,7 @@ export class GraineInAppClient {
|
|
|
299
309
|
this.scheduleScreen();
|
|
300
310
|
}
|
|
301
311
|
scheduleScreen() {
|
|
312
|
+
this.emit("screen", { screen: this.screen, actions: this.getAvailableActions() });
|
|
302
313
|
if (this.contextTimer) {
|
|
303
314
|
this.pendingScreen = true;
|
|
304
315
|
return;
|
|
@@ -313,21 +324,28 @@ export class GraineInAppClient {
|
|
|
313
324
|
}
|
|
314
325
|
}, CONTEXT_THROTTLE_MS);
|
|
315
326
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
327
|
+
buildContextFrame() {
|
|
328
|
+
const hasIdentity = Object.keys(this.identity).length > 0;
|
|
329
|
+
if (!this.screen && !hasIdentity && this.recentEvents.length === 0)
|
|
330
|
+
return null;
|
|
331
|
+
const { availableActions, ...rest } = (this.screen ?? {});
|
|
332
|
+
return {
|
|
321
333
|
type: "app_context",
|
|
322
334
|
context: maskDeep({
|
|
323
335
|
...rest,
|
|
324
|
-
idle_ms: Date.now() - this.screenSince,
|
|
336
|
+
...(this.screen ? { idle_ms: Date.now() - this.screenSince } : {}),
|
|
325
337
|
...(Object.keys(this.identity).length ? { user: this.identity } : {}),
|
|
326
338
|
...(this.recentEvents.length ? { recent_events: this.recentEvents } : {}),
|
|
327
339
|
}),
|
|
328
340
|
available_actions: (availableActions ?? this.availableActions).filter((a) => this.actions.has(a)),
|
|
329
341
|
seq: ++this.screenSeq,
|
|
330
|
-
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
flushScreen() {
|
|
345
|
+
const frame = this.buildContextFrame();
|
|
346
|
+
if (!frame)
|
|
347
|
+
return false;
|
|
348
|
+
return this.send(frame);
|
|
331
349
|
}
|
|
332
350
|
armStall() {
|
|
333
351
|
clearTimeout(this.stallTimer);
|
|
@@ -362,8 +380,7 @@ export class GraineInAppClient {
|
|
|
362
380
|
flat[k] = typeof v === "string" ? v : JSON.stringify(v);
|
|
363
381
|
}
|
|
364
382
|
this.identity = maskDeep(flat);
|
|
365
|
-
|
|
366
|
-
this.scheduleScreen();
|
|
383
|
+
this.scheduleScreen();
|
|
367
384
|
}
|
|
368
385
|
noteInteraction() {
|
|
369
386
|
this.screenSince = Date.now();
|
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;
|
|
@@ -60,8 +60,9 @@ export interface Turn {
|
|
|
60
60
|
text: string;
|
|
61
61
|
live?: boolean;
|
|
62
62
|
}
|
|
63
|
-
export interface GraineProviderProps extends GraineInAppOptions {
|
|
63
|
+
export interface GraineProviderProps extends Partial<GraineInAppOptions> {
|
|
64
64
|
children: React.ReactNode;
|
|
65
|
+
client?: GraineInAppClient;
|
|
65
66
|
autoConnect?: boolean;
|
|
66
67
|
onProactive?: (text: string) => void;
|
|
67
68
|
endOnBackground?: boolean;
|
|
@@ -72,7 +73,7 @@ export interface GraineProviderProps extends GraineInAppOptions {
|
|
|
72
73
|
launcherDelayMs?: number;
|
|
73
74
|
visibility?: LauncherVisibility;
|
|
74
75
|
}
|
|
75
|
-
export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, includeScreens, launcherDelayMs, visibility, ...options }: GraineProviderProps): React.JSX.Element;
|
|
76
|
+
export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
|
|
76
77
|
export declare function useGraineAgent(): GraineContextValue;
|
|
77
78
|
export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
|
|
78
79
|
export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
|
|
@@ -1,14 +1,16 @@
|
|
|
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
|
-
export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
|
|
8
|
+
export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
|
|
9
9
|
const clientRef = useRef(null);
|
|
10
|
-
if (!clientRef.current)
|
|
11
|
-
clientRef.current =
|
|
10
|
+
if (!clientRef.current) {
|
|
11
|
+
clientRef.current =
|
|
12
|
+
providedClient ?? new GraineInAppClient(options);
|
|
13
|
+
}
|
|
12
14
|
const client = clientRef.current;
|
|
13
15
|
const [connected, setConnected] = useState(false);
|
|
14
16
|
const [connecting, setConnecting] = useState(false);
|
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,5 +1,10 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
export interface GraineVoiceLauncherProps {
|
|
3
|
+
transport?: "ws" | "rtc";
|
|
4
|
+
onTransport?: (info: {
|
|
5
|
+
transport: "ws" | "rtc";
|
|
6
|
+
requested: "ws" | "rtc";
|
|
7
|
+
}) => void;
|
|
3
8
|
webView: React.ComponentType<any>;
|
|
4
9
|
autoStart?: boolean;
|
|
5
10
|
onCaption?: (c: {
|
|
@@ -13,6 +18,7 @@ export interface GraineVoiceLauncherProps {
|
|
|
13
18
|
}) => void;
|
|
14
19
|
onEnded?: () => void;
|
|
15
20
|
onMicDenied?: (reason: string) => void;
|
|
21
|
+
onError?: (message: string) => void;
|
|
16
22
|
children?: (api: GraineVoiceApi) => React.ReactNode;
|
|
17
23
|
}
|
|
18
24
|
export interface GraineVoiceApi {
|
|
@@ -23,4 +29,4 @@ export interface GraineVoiceApi {
|
|
|
23
29
|
end: () => void;
|
|
24
30
|
setMuted: (m: boolean) => void;
|
|
25
31
|
}
|
|
26
|
-
export declare function GraineVoiceLauncher({ webView: WebView, autoStart, onCaption, onCallState, onMicDenied, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
|
|
32
|
+
export declare function GraineVoiceLauncher({ webView: WebView, autoStart, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
|
|
@@ -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
|
-
import { AppState, View } from "react-native";
|
|
4
|
-
import { useGraineAgent } from "./index";
|
|
5
|
-
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCaption, onCallState, onMicDenied, onEnded, children, }) {
|
|
3
|
+
import { AppState, PermissionsAndroid, Platform, View } from "react-native";
|
|
4
|
+
import { useGraineAgent } from "./index.js";
|
|
5
|
+
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, transport, onTransport, onCaption, onCallState, onMicDenied, onError, 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);
|
|
@@ -12,22 +13,86 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
|
|
|
12
13
|
const post = useCallback((msg) => {
|
|
13
14
|
ref.current?.injectJavaScript(`window.postMessage(${JSON.stringify(msg)}, "*"); true;`);
|
|
14
15
|
}, []);
|
|
16
|
+
const [voiceAgentId, setVoiceAgentId] = useState(client.config ? client.config.webcallAgentId || client.config.agentId : null);
|
|
17
|
+
const onErrorRef = useRef(onError);
|
|
18
|
+
onErrorRef.current = onError;
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (voiceAgentId)
|
|
21
|
+
return;
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
(async () => {
|
|
24
|
+
try {
|
|
25
|
+
const cfg = client.config ?? (await client.session());
|
|
26
|
+
if (!cancelled)
|
|
27
|
+
setVoiceAgentId(cfg.webcallAgentId || cfg.agentId);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (!cancelled) {
|
|
31
|
+
onErrorRef.current?.(String(err?.message || err || "Voice is unavailable."));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
return () => { cancelled = true; };
|
|
36
|
+
}, [client, voiceAgentId]);
|
|
37
|
+
const ensureMic = useCallback(async () => {
|
|
38
|
+
if (Platform.OS !== "android")
|
|
39
|
+
return true;
|
|
40
|
+
try {
|
|
41
|
+
const perm = PermissionsAndroid?.PERMISSIONS?.RECORD_AUDIO;
|
|
42
|
+
if (!perm)
|
|
43
|
+
return true;
|
|
44
|
+
if (await PermissionsAndroid.check(perm))
|
|
45
|
+
return true;
|
|
46
|
+
const res = await PermissionsAndroid.request(perm, {
|
|
47
|
+
title: "Microphone",
|
|
48
|
+
message: "Allow the microphone so you can talk to the assistant.",
|
|
49
|
+
buttonPositive: "Allow",
|
|
50
|
+
});
|
|
51
|
+
return res === PermissionsAndroid.RESULTS.GRANTED;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}, []);
|
|
15
57
|
const pushContext = useCallback(() => {
|
|
16
|
-
const
|
|
17
|
-
if (!
|
|
58
|
+
const frame = client.buildContextFrame();
|
|
59
|
+
if (!frame)
|
|
18
60
|
return;
|
|
19
|
-
const { availableActions, ...rest } = screen;
|
|
20
61
|
post({
|
|
21
62
|
type: "graine:app-context",
|
|
22
|
-
context:
|
|
23
|
-
availableActions:
|
|
24
|
-
seq:
|
|
63
|
+
context: frame.context,
|
|
64
|
+
availableActions: frame.available_actions,
|
|
65
|
+
seq: frame.seq,
|
|
25
66
|
});
|
|
26
67
|
}, [client, post]);
|
|
27
68
|
useEffect(() => {
|
|
28
69
|
if (ready)
|
|
29
70
|
pushContext();
|
|
30
71
|
}, [ready, pushContext]);
|
|
72
|
+
const beginCall = useCallback(async () => {
|
|
73
|
+
if (client.isConnected) {
|
|
74
|
+
console.warn("[graine] The provider has its own connection open while GraineVoiceLauncher " +
|
|
75
|
+
"is starting a call. Pass autoConnect={false} to <GraineProvider> — otherwise " +
|
|
76
|
+
"the agent answers twice and the customer hears two greetings.");
|
|
77
|
+
}
|
|
78
|
+
if (!(await ensureMic())) {
|
|
79
|
+
setConnecting(false);
|
|
80
|
+
onMicDenied?.("NotAllowedError");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
post({ type: "graine:start-call" });
|
|
84
|
+
}, [client, ensureMic, onMicDenied, post]);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!connected)
|
|
87
|
+
return;
|
|
88
|
+
return client.on("app_event", (event) => {
|
|
89
|
+
pushContext();
|
|
90
|
+
const { name, ...rest } = (event ?? {});
|
|
91
|
+
if (!name)
|
|
92
|
+
return;
|
|
93
|
+
post({ type: "graine:app-event", name, data: rest });
|
|
94
|
+
});
|
|
95
|
+
}, [client, connected, post, pushContext]);
|
|
31
96
|
const onMessage = useCallback(async (e) => {
|
|
32
97
|
let msg;
|
|
33
98
|
try {
|
|
@@ -37,15 +102,25 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
|
|
|
37
102
|
return;
|
|
38
103
|
}
|
|
39
104
|
switch (msg.type) {
|
|
105
|
+
case "graine:transport":
|
|
106
|
+
onTransport?.({
|
|
107
|
+
transport: msg.transport === "rtc" ? "rtc" : "ws",
|
|
108
|
+
requested: msg.requested === "rtc" ? "rtc" : "ws",
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
40
111
|
case "graine:ready":
|
|
41
112
|
setReady(true);
|
|
42
113
|
pushContext();
|
|
43
|
-
if (autoStart)
|
|
44
|
-
|
|
114
|
+
if (autoStart && !startedRef.current) {
|
|
115
|
+
startedRef.current = true;
|
|
116
|
+
void beginCall();
|
|
117
|
+
}
|
|
45
118
|
return;
|
|
46
119
|
case "graine:call":
|
|
47
120
|
setConnected(!!msg.connected);
|
|
48
121
|
setConnecting(!!msg.connecting);
|
|
122
|
+
if (!msg.connected && !msg.connecting)
|
|
123
|
+
startedRef.current = false;
|
|
49
124
|
onCallState?.({ connected: !!msg.connected, connecting: !!msg.connecting });
|
|
50
125
|
return;
|
|
51
126
|
case "graine:muted":
|
|
@@ -75,15 +150,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
|
|
|
75
150
|
return;
|
|
76
151
|
}
|
|
77
152
|
}
|
|
78
|
-
}, [autoStart, client, onCaption, onCallState, onMicDenied, onEnded, post, pushContext]);
|
|
153
|
+
}, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onEnded, post, pushContext]);
|
|
79
154
|
const api = {
|
|
80
155
|
connected,
|
|
81
156
|
connecting,
|
|
82
157
|
muted,
|
|
83
158
|
start: useCallback(() => {
|
|
84
159
|
setConnecting(true);
|
|
85
|
-
|
|
86
|
-
}, [
|
|
160
|
+
void beginCall();
|
|
161
|
+
}, [beginCall]),
|
|
87
162
|
end: useCallback(() => post({ type: "graine:end-call" }), [post]),
|
|
88
163
|
setMuted: useCallback((m) => {
|
|
89
164
|
setMutedState(m);
|
|
@@ -93,19 +168,34 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
|
|
|
93
168
|
useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
|
|
94
169
|
useEffect(() => {
|
|
95
170
|
let last = AppState.currentState;
|
|
171
|
+
let pending = null;
|
|
172
|
+
const CONFIRM_MS = 2000;
|
|
96
173
|
const sub = AppState.addEventListener("change", (next) => {
|
|
97
174
|
const wasActive = last === "active";
|
|
98
175
|
last = next;
|
|
99
|
-
if (next === "
|
|
100
|
-
|
|
176
|
+
if (next === "active") {
|
|
177
|
+
if (pending) {
|
|
178
|
+
clearTimeout(pending);
|
|
179
|
+
pending = null;
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (next === "background" && wasActive && !pending) {
|
|
184
|
+
pending = setTimeout(() => {
|
|
185
|
+
pending = null;
|
|
186
|
+
if (AppState.currentState !== "active")
|
|
187
|
+
post({ type: "graine:end-call" });
|
|
188
|
+
}, CONFIRM_MS);
|
|
189
|
+
}
|
|
101
190
|
});
|
|
102
|
-
return () =>
|
|
191
|
+
return () => { if (pending)
|
|
192
|
+
clearTimeout(pending); sub.remove(); };
|
|
103
193
|
}, [post]);
|
|
104
|
-
|
|
105
|
-
if (!agentId)
|
|
194
|
+
if (!voiceAgentId)
|
|
106
195
|
return _jsx(_Fragment, { children: children?.(api) });
|
|
107
|
-
const
|
|
108
|
-
|
|
196
|
+
const effectiveTransport = transport ?? (appearance?.audioTransport === "rtc" ? "rtc" : "ws");
|
|
197
|
+
const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${voiceAgentId}` +
|
|
198
|
+
`?mode=voice&branding=0&embed=1&transport=${effectiveTransport}` +
|
|
109
199
|
(appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
|
|
110
200
|
return (_jsxs(_Fragment, { children: [_jsx(View, { style: { position: "absolute", width: 1, height: 1, opacity: 0, bottom: 0, left: 0 }, pointerEvents: "none", children: _jsx(WebView, { ref: ref, source: { uri: src }, originWhitelist: ["*"], javaScriptEnabled: true, domStorageEnabled: true, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: false, mediaCapturePermissionGrantType: "grant", onMessage: onMessage, style: { width: 1, height: 1, backgroundColor: "transparent" } }) }), children?.(api)] }));
|
|
111
201
|
}
|
|
@@ -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.16.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
|
}
|