@graineai/inapp-react-native 0.27.4 → 0.28.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
CHANGED
|
@@ -389,6 +389,46 @@ nowhere. Save is what pushes the catalogue to the agent.
|
|
|
389
389
|
|
|
390
390
|
---
|
|
391
391
|
|
|
392
|
+
### The contract, exactly
|
|
393
|
+
|
|
394
|
+
Four rules decide what the agent can do and what the customer is told. Each
|
|
395
|
+
one has bitten a real integration.
|
|
396
|
+
|
|
397
|
+
**Registration is per mounted screen.** `useGraineAction` registers the
|
|
398
|
+
handler when the component mounts and unregisters it when it unmounts, so the
|
|
399
|
+
agent is offered only what the screen in front of the customer can actually
|
|
400
|
+
do. Register in the screen that performs the action, not at the root — an
|
|
401
|
+
action registered at the root is offered on every screen, including the ones
|
|
402
|
+
where it makes no sense, and the agent will call it there.
|
|
403
|
+
|
|
404
|
+
**`availableActions` narrows; omitting it is the default you want.**
|
|
405
|
+
On `useGraineScreen`, leave it out and every action registered on the mounted
|
|
406
|
+
screen is offered. Pass a list to offer a subset. Pass `[]` to offer
|
|
407
|
+
*nothing* — say that deliberately, for a read-only screen. It can only ever
|
|
408
|
+
narrow: an action the dashboard never declared is not callable whatever
|
|
409
|
+
appears here, and the server enforces that, not this field. (Before 0.27.3
|
|
410
|
+
the SDK could send `[]` by accident while handlers were still registering,
|
|
411
|
+
which disarmed the agent for the whole screen; it now sends `[]` only when
|
|
412
|
+
you wrote `[]`.)
|
|
413
|
+
|
|
414
|
+
**A handler has six seconds.** `ACTION_DEADLINE_MS`. Return promptly — do
|
|
415
|
+
the slow part after you have answered. A handler that has not resolved by
|
|
416
|
+
then is answered as a timeout on the runtime side, and the agent tells the
|
|
417
|
+
customer it was *not* done rather than guessing. A handler that throws is
|
|
418
|
+
answered as `error` for the same reason.
|
|
419
|
+
|
|
420
|
+
**The status is what the customer is told.** `{ status: "ok" }` makes the
|
|
421
|
+
agent say it is done, so return it only when it is. `{ status: "refused",
|
|
422
|
+
message }` is often the most useful answer you can give — "this field is
|
|
423
|
+
locked after submission" — and the agent passes the reason on. `message` is
|
|
424
|
+
for the agent, which decides how to phrase it, not text read to the customer.
|
|
425
|
+
|
|
426
|
+
**Where it all ends up.** Every call the agent makes lands in Call History
|
|
427
|
+
with the app's answer beside it — "Ran set_ring (state: off) → ok" or
|
|
428
|
+
"→ refused: …" — on the timeline against the turn it happened in, and in the
|
|
429
|
+
execution log. A refusal there is the moment a customer was told something
|
|
430
|
+
that did not happen, which is exactly the row a product team wants to find.
|
|
431
|
+
|
|
392
432
|
## Step 4b — Show them where, and draw them things
|
|
393
433
|
|
|
394
434
|
### Pointing at something on screen
|
|
@@ -518,6 +558,53 @@ your funnel.
|
|
|
518
558
|
|
|
519
559
|
---
|
|
520
560
|
|
|
561
|
+
## Captions and hanging up
|
|
562
|
+
|
|
563
|
+
`GraineVoiceLauncher`'s render prop gives you the whole transcript and the CC
|
|
564
|
+
switch, so a caption box is a `map` and nothing else. **0.28.0.**
|
|
565
|
+
|
|
566
|
+
```tsx
|
|
567
|
+
<GraineVoiceLauncher webView={WebView} active={open} autoStart>
|
|
568
|
+
{(api) => (
|
|
569
|
+
<>
|
|
570
|
+
{api.captionsOn && api.captions.map((c, i) => (
|
|
571
|
+
<Text key={i} style={{ opacity: c.live ? 0.7 : 1 }}>
|
|
572
|
+
{c.role === "agent" ? "Ring" : "You"}: {c.text}
|
|
573
|
+
</Text>
|
|
574
|
+
))}
|
|
575
|
+
<Button title="CC" onPress={() => api.setCaptionsOn(!api.captionsOn)} />
|
|
576
|
+
<Button title="End" onPress={api.end} />
|
|
577
|
+
</>
|
|
578
|
+
)}
|
|
579
|
+
</GraineVoiceLauncher>
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
| On `api` | |
|
|
583
|
+
|---|---|
|
|
584
|
+
| `captions` | Every turn so far, oldest first, capped at `CAPTION_HISTORY` (12) |
|
|
585
|
+
| `caption` | The turn being said now, or the last one. `null` before any |
|
|
586
|
+
| `captionsOn` / `setCaptionsOn` | The CC switch. Defaults **on** |
|
|
587
|
+
| `clearCaptions()` | Forget the transcript without ending the call |
|
|
588
|
+
| `end()` | Hang up |
|
|
589
|
+
|
|
590
|
+
A turn arrives many times as its words come in — each with fuller text and
|
|
591
|
+
`live: true` — then once more settled with `live: false`. The launcher folds
|
|
592
|
+
those into one entry, so `captions` holds turns, not keystrokes. Render `live`
|
|
593
|
+
ones as provisional if you want; they are the same line about to be finished.
|
|
594
|
+
|
|
595
|
+
The transcript is **cleared when a call starts and when you set `active={false}`**.
|
|
596
|
+
A call's words outliving its call is a privacy question, not a feature — if you
|
|
597
|
+
need them afterwards, copy them in `onEnded`.
|
|
598
|
+
|
|
599
|
+
`onCaption` still fires for every line and is unchanged; it is now a convenience
|
|
600
|
+
on top of `captions` rather than the only way to see them.
|
|
601
|
+
|
|
602
|
+
**Hanging up** happens on `api.end()` and again on `active={false}` — both post
|
|
603
|
+
the same idempotent stop frame, so a host that does one, the other, or both
|
|
604
|
+
ends exactly one call. Before 0.27.6 the deactivate path was conditional on the
|
|
605
|
+
launcher's own idea of whether a call was running, which could close the UI and
|
|
606
|
+
leave the call up.
|
|
607
|
+
|
|
521
608
|
## Tap capture when your screens sit above the provider
|
|
522
609
|
|
|
523
610
|
`GraineProvider` wraps its children in `GraineTouchCapture`, so an app whose
|
|
@@ -727,7 +814,7 @@ is not happening, and the table says which.
|
|
|
727
814
|
|
|
728
815
|
| Wait | What it is | When it is paid | Typical |
|
|
729
816
|
|---|---|---|---|
|
|
730
|
-
| Page load | The voice engine is a page in a WebView | **Before the tap** — `GraineFab`
|
|
817
|
+
| Page load | The voice engine is a page in a WebView | **Before the tap** — `GraineFab` mounts the engine on open; a custom bar mounts `GraineVoiceLauncher` at launch with `active={false}` and flips it on open (0.27.5) | ~4s, hidden |
|
|
731
818
|
| Mint + REGISTER | A 120-second SIP credential, then registration on the SBC | **Before the tap**, re-warmed as it nears expiry | ~1.6s, hidden |
|
|
732
819
|
| Microphone | The OS permission prompt, first time only | At the tap | 0 once granted |
|
|
733
820
|
| INVITE → answer | The call itself | At the tap | ~0.3s |
|
|
@@ -1037,6 +1124,7 @@ told to ignore).
|
|
|
1037
1124
|
| `onMicDenied` | `(reason) => void` | `NotAllowedError` is a refusal; `NotFoundError` is a device with no usable microphone. Different sentences for a customer. |
|
|
1038
1125
|
| `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. |
|
|
1039
1126
|
| `children` | `(api) => ReactNode` | Receives `{ connected, connecting, muted, start, end, setMuted }`. |
|
|
1127
|
+
| `active` | `boolean` | Default `true`. Mount early with `false` so the engine page loads before the customer needs it (it registers nothing while idle); flip to `true` when your UI opens — with `autoStart` that places the call, without it it warms the registration so the tap is instant. Back to `false` ends a live call. (0.27.5; 0.27.6 makes that hangup unconditional) |
|
|
1040
1128
|
|
|
1041
1129
|
**The launcher owns the connection.** It asks for the microphone, resolves the
|
|
1042
1130
|
session, holds the socket and relays your screen, actions and events onto it. So
|
package/dist/index.d.ts
CHANGED
|
@@ -11,5 +11,5 @@ export { UNNAMED_TAP } from "./protocol.js";
|
|
|
11
11
|
export { resolveNativeAppearance, type NativeAppearance, type NativeAppearanceFallback, } from "./react-native/appearance.js";
|
|
12
12
|
export { RtcVoiceSessionController, RtcVoiceError, type RtcVoiceState, type RtcVoiceErrorCode, type RtcVoiceSession, type RtcVoiceOptions, } from "./react-native/voice-rtc.js";
|
|
13
13
|
export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui.js";
|
|
14
|
-
export { GraineVoiceLauncher, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher.js";
|
|
14
|
+
export { GraineVoiceLauncher, CAPTION_HISTORY, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher.js";
|
|
15
15
|
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
|
@@ -10,5 +10,5 @@ export { UNNAMED_TAP } from "./protocol.js";
|
|
|
10
10
|
export { resolveNativeAppearance, } from "./react-native/appearance.js";
|
|
11
11
|
export { RtcVoiceSessionController, RtcVoiceError, } from "./react-native/voice-rtc.js";
|
|
12
12
|
export { GraineAgentBar, GraineLauncher, } from "./react-native/ui.js";
|
|
13
|
-
export { GraineVoiceLauncher, } from "./react-native/voice-launcher.js";
|
|
13
|
+
export { GraineVoiceLauncher, CAPTION_HISTORY, } from "./react-native/voice-launcher.js";
|
|
14
14
|
export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { type Caption } from "./index.js";
|
|
2
3
|
export interface GraineVoiceLauncherProps {
|
|
3
4
|
transport?: "ws" | "rtc";
|
|
4
5
|
onTransport?: (info: {
|
|
@@ -9,6 +10,7 @@ export interface GraineVoiceLauncherProps {
|
|
|
9
10
|
}) => void;
|
|
10
11
|
webView: React.ComponentType<any>;
|
|
11
12
|
autoStart?: boolean;
|
|
13
|
+
active?: boolean;
|
|
12
14
|
onCaption?: (c: {
|
|
13
15
|
role: string;
|
|
14
16
|
text: string;
|
|
@@ -31,5 +33,11 @@ export interface GraineVoiceApi {
|
|
|
31
33
|
start: () => void;
|
|
32
34
|
end: () => void;
|
|
33
35
|
setMuted: (m: boolean) => void;
|
|
36
|
+
captions: Caption[];
|
|
37
|
+
caption: Caption | null;
|
|
38
|
+
captionsOn: boolean;
|
|
39
|
+
setCaptionsOn: (on: boolean) => void;
|
|
40
|
+
clearCaptions: () => void;
|
|
34
41
|
}
|
|
35
|
-
export declare
|
|
42
|
+
export declare const CAPTION_HISTORY = 12;
|
|
43
|
+
export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
|
|
@@ -3,14 +3,28 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { AppState, View } from "react-native";
|
|
4
4
|
import { useGraineAgent } from "./index.js";
|
|
5
5
|
import { requestMicrophonePermission } from "./permissions.js";
|
|
6
|
-
export
|
|
6
|
+
export const CAPTION_HISTORY = 12;
|
|
7
|
+
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }) {
|
|
7
8
|
const { client, appearance } = useGraineAgent();
|
|
8
9
|
const ref = useRef(null);
|
|
9
10
|
const startedRef = useRef(false);
|
|
11
|
+
const activeRef = useRef(active);
|
|
12
|
+
activeRef.current = active;
|
|
13
|
+
const mountedIdleRef = useRef(!active);
|
|
10
14
|
const [connected, setConnected] = useState(false);
|
|
11
15
|
const [connecting, setConnecting] = useState(false);
|
|
12
16
|
const [muted, setMutedState] = useState(false);
|
|
13
17
|
const [ready, setReady] = useState(false);
|
|
18
|
+
const [captions, setCaptions] = useState([]);
|
|
19
|
+
const [captionsOn, setCaptionsOn] = useState(true);
|
|
20
|
+
const foldCaption = useCallback((next) => {
|
|
21
|
+
setCaptions((prev) => {
|
|
22
|
+
const tail = prev[prev.length - 1];
|
|
23
|
+
const continues = tail && tail.live && tail.role === next.role;
|
|
24
|
+
const merged = continues ? [...prev.slice(0, -1), next] : [...prev, next];
|
|
25
|
+
return merged.length > CAPTION_HISTORY ? merged.slice(-CAPTION_HISTORY) : merged;
|
|
26
|
+
});
|
|
27
|
+
}, []);
|
|
14
28
|
const post = useCallback((msg) => {
|
|
15
29
|
ref.current?.injectJavaScript(`window.postMessage(${JSON.stringify(msg)}, "*"); true;`);
|
|
16
30
|
}, []);
|
|
@@ -113,7 +127,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
|
|
|
113
127
|
case "graine:ready":
|
|
114
128
|
setReady(true);
|
|
115
129
|
pushContext();
|
|
116
|
-
if ((autoStart || pendingStartRef.current) && !startedRef.current) {
|
|
130
|
+
if ((autoStart || pendingStartRef.current) && !startedRef.current && activeRef.current) {
|
|
117
131
|
pendingStartRef.current = false;
|
|
118
132
|
startedRef.current = true;
|
|
119
133
|
void beginCall();
|
|
@@ -129,9 +143,16 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
|
|
|
129
143
|
case "graine:muted":
|
|
130
144
|
setMutedState(!!msg.muted);
|
|
131
145
|
return;
|
|
132
|
-
case "graine:caption":
|
|
133
|
-
|
|
146
|
+
case "graine:caption": {
|
|
147
|
+
const line = {
|
|
148
|
+
role: msg.role === "agent" ? "agent" : "customer",
|
|
149
|
+
text: String(msg.text ?? ""),
|
|
150
|
+
live: !!msg.live,
|
|
151
|
+
};
|
|
152
|
+
foldCaption(line);
|
|
153
|
+
onCaption?.(line);
|
|
134
154
|
return;
|
|
155
|
+
}
|
|
135
156
|
case "graine:ended":
|
|
136
157
|
setConnected(false);
|
|
137
158
|
setConnecting(false);
|
|
@@ -158,15 +179,21 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
|
|
|
158
179
|
return;
|
|
159
180
|
}
|
|
160
181
|
}
|
|
161
|
-
}, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext]);
|
|
182
|
+
}, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
|
|
162
183
|
const pendingStartRef = useRef(false);
|
|
163
184
|
const api = {
|
|
164
185
|
connected,
|
|
165
186
|
connecting,
|
|
166
187
|
muted,
|
|
167
188
|
ready: !!voiceAgentId && ready,
|
|
189
|
+
captions,
|
|
190
|
+
caption: captions.length ? captions[captions.length - 1] : null,
|
|
191
|
+
captionsOn,
|
|
192
|
+
setCaptionsOn,
|
|
193
|
+
clearCaptions: useCallback(() => setCaptions([]), []),
|
|
168
194
|
start: useCallback(() => {
|
|
169
195
|
setConnecting(true);
|
|
196
|
+
setCaptions([]);
|
|
170
197
|
if (!ready) {
|
|
171
198
|
pendingStartRef.current = true;
|
|
172
199
|
return;
|
|
@@ -180,6 +207,38 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
|
|
|
180
207
|
}, [post]),
|
|
181
208
|
};
|
|
182
209
|
useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
|
|
210
|
+
const activeSeenRef = useRef(null);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const first = activeSeenRef.current === null;
|
|
213
|
+
activeSeenRef.current = active;
|
|
214
|
+
if (active) {
|
|
215
|
+
if (autoStart) {
|
|
216
|
+
if (startedRef.current)
|
|
217
|
+
return;
|
|
218
|
+
setConnecting(true);
|
|
219
|
+
setCaptions([]);
|
|
220
|
+
if (ready) {
|
|
221
|
+
startedRef.current = true;
|
|
222
|
+
void beginCall();
|
|
223
|
+
}
|
|
224
|
+
else
|
|
225
|
+
pendingStartRef.current = true;
|
|
226
|
+
}
|
|
227
|
+
else if (!first) {
|
|
228
|
+
post({ type: "graine:warm" });
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (first)
|
|
233
|
+
return;
|
|
234
|
+
post({ type: "graine:end-call" });
|
|
235
|
+
startedRef.current = false;
|
|
236
|
+
pendingStartRef.current = false;
|
|
237
|
+
setConnected(false);
|
|
238
|
+
setConnecting(false);
|
|
239
|
+
setCaptions([]);
|
|
240
|
+
onCallState?.({ connected: false, connecting: false });
|
|
241
|
+
}, [active]);
|
|
183
242
|
useEffect(() => {
|
|
184
243
|
let last = AppState.currentState;
|
|
185
244
|
let pending = null;
|
|
@@ -211,6 +270,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
|
|
|
211
270
|
const effectiveTransport = transport ?? (appearance?.audioTransport === "rtc" ? "rtc" : "ws");
|
|
212
271
|
const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${voiceAgentId}` +
|
|
213
272
|
`?mode=voice&branding=0&embed=1&transport=${effectiveTransport}` +
|
|
273
|
+
(mountedIdleRef.current ? "&idle=1" : "") +
|
|
214
274
|
`&pk=${encodeURIComponent(client.opts.publishableKey)}` +
|
|
215
275
|
(appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
|
|
216
276
|
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)] }));
|
package/package.json
CHANGED