@graineai/inapp-react-native 0.11.0 → 0.11.1
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 +89 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +10 -0
- package/dist/react-native/index.d.ts +2 -1
- package/dist/react-native/index.js +25 -1
- package/dist/react-native/voice-launcher.js +11 -1
- package/package.json +1 -1
package/INTEGRATION.md
CHANGED
|
@@ -18,6 +18,24 @@ Package: `@graineai/inapp-react-native`
|
|
|
18
18
|
| Peer install | `react-native-webview` — autolinks, no setup code |
|
|
19
19
|
| From Graine | your **publishable key**, from Agent → Embed & Widgets |
|
|
20
20
|
|
|
21
|
+
### Two dashboard steps first
|
|
22
|
+
|
|
23
|
+
Each takes a minute and each, skipped, produces a failure that looks like
|
|
24
|
+
something else.
|
|
25
|
+
|
|
26
|
+
**1. Give the app its own agent.** Clone the website agent rather than pointing
|
|
27
|
+
the app at it. Actions are declared per agent and every surface that agent serves
|
|
28
|
+
is offered all of them, so a shared agent hands your website tools that only
|
|
29
|
+
exist inside the app — and picks worse for having them. The clone carries the
|
|
30
|
+
prompt, knowledge base and voice across; change only what differs.
|
|
31
|
+
|
|
32
|
+
**2. Add `app://` to that agent's Allowed domains** (Agent → Embed & Widgets →
|
|
33
|
+
Install & domains). A native app has no `Origin` header, and there is no header a
|
|
34
|
+
client could send that we would trust instead — so `app://` is how you say "this
|
|
35
|
+
agent may also be reached from an app". Without it the connection is refused with
|
|
36
|
+
`no_origin` and **the launcher never renders**, which reads exactly like a bad
|
|
37
|
+
key. An empty allowlist refuses everything for the same reason.
|
|
38
|
+
|
|
21
39
|
**There is no native setup.** No `MainApplication.kt` edit, no `AppDelegate.swift`
|
|
22
40
|
edit, no ProGuard rules, no babel plugin ordering. The microphone and speaker run
|
|
23
41
|
inside a WebView pointed at your hosted agent page, so every fix to the audio
|
|
@@ -140,9 +158,33 @@ screen; when it fires it names the field that is `invalid` or `empty`, so the
|
|
|
140
158
|
agent opens with "the PAN isn't being accepted" rather than "need a hand?".
|
|
141
159
|
Without `status` it can only manage the second one.
|
|
142
160
|
|
|
161
|
+
| `status` | Means | What the agent does |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `empty` | Nothing entered | Offers to fill it |
|
|
164
|
+
| `filled` | Has a value | Leaves it alone |
|
|
165
|
+
| `invalid` | Rejected — pair with `error` | Names the problem, offers the fix |
|
|
166
|
+
| `pending` | In flight | Waits instead of claiming either outcome |
|
|
167
|
+
| `locked` | Not changeable from here | Routes elsewhere rather than offering to type |
|
|
168
|
+
|
|
169
|
+
`locked` is the one worth remembering. An OS permission the customer must grant
|
|
170
|
+
in system settings is not `empty` — nobody can fill it on this screen. Marked
|
|
171
|
+
`empty`, the agent offers to do it and fails; marked `locked`, it says where to go.
|
|
172
|
+
|
|
143
173
|
Report the real value. Masking happens on the way out and again on arrival — a
|
|
144
174
|
screen that pre-redacts just blinds the agent.
|
|
145
175
|
|
|
176
|
+
**Name every screen.** The route the SDK reports is an identifier, not a name.
|
|
177
|
+
Ship `kyc_documents` and the agent reads your routing table to your customers.
|
|
178
|
+
Keep a `Record<ScreenName, string>` of the words actually printed on each screen,
|
|
179
|
+
typed over your route union so the screen somebody adds next year fails the build
|
|
180
|
+
instead of reaching a customer as a slug.
|
|
181
|
+
|
|
182
|
+
**Where the hook goes.** Below every piece of state it reads — the object is
|
|
183
|
+
built during render, so a `const` declared under it is in its temporal dead zone
|
|
184
|
+
and throws — and above every early return, or the hook order changes between
|
|
185
|
+
renders. Action handlers have neither constraint: their bodies only run when the
|
|
186
|
+
agent calls them.
|
|
187
|
+
|
|
146
188
|
---
|
|
147
189
|
|
|
148
190
|
## Step 4 — Let it act
|
|
@@ -276,8 +318,55 @@ part. Everything else is unchanged.
|
|
|
276
318
|
|
|
277
319
|
---
|
|
278
320
|
|
|
321
|
+
## Session lifecycle
|
|
322
|
+
|
|
323
|
+
One session per client, and it follows the app.
|
|
324
|
+
|
|
325
|
+
**Backgrounding ends the call.** Switching apps does not unmount a React tree,
|
|
326
|
+
so without this the WebView stays mounted with the microphone open and the
|
|
327
|
+
conversation still billing while nobody is listening. iOS makes it worse rather
|
|
328
|
+
than better: it suspends the WebView's audio without telling the socket, so the
|
|
329
|
+
call survives as a leg that can no longer hear anything and bills for silence.
|
|
330
|
+
|
|
331
|
+
The session ends on `background`, never on `inactive` — iOS reports `inactive`
|
|
332
|
+
for the app switcher, a pulled-down notification, and the moment a permission
|
|
333
|
+
dialog appears, so ending on it would mean the microphone prompt hangs up the
|
|
334
|
+
call that asked for it.
|
|
335
|
+
|
|
336
|
+
```tsx
|
|
337
|
+
<GraineProvider … endOnBackground={false}> {/* default: true */}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Turn it off only if a conversation should genuinely survive a switch to another
|
|
341
|
+
app, and you are prepared to explain the open microphone.
|
|
342
|
+
|
|
343
|
+
Text sessions reconnect when the app returns. **Voice does not auto-resume** — a
|
|
344
|
+
call is something a person chose to start, and starting one again because they
|
|
345
|
+
came back to the app is a microphone opening unasked. `connected` goes false;
|
|
346
|
+
draw from that.
|
|
347
|
+
|
|
348
|
+
**`connect()` is idempotent.** Calling it twice returns the same session rather
|
|
349
|
+
than opening a second socket. This matters under React 18 strict mode, which
|
|
350
|
+
mounts effects twice: previously the first socket was orphaned with no reference
|
|
351
|
+
to close it, and the runtime kept it open and billing until its own timeout.
|
|
352
|
+
|
|
353
|
+
**`close()` sends a stop frame** before dropping the socket, so the conversation
|
|
354
|
+
is filed as ended rather than as a customer who vanished mid-turn. It is called
|
|
355
|
+
for you on unmount and on backgrounding.
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
279
359
|
## Troubleshooting
|
|
280
360
|
|
|
361
|
+
**Nothing renders at all, and the console says `no_origin`.**
|
|
362
|
+
The agent's Allowed domains has no `app://` entry. `no_domains_configured` means
|
|
363
|
+
the allowlist is empty, which denies everything. Both are dashboard fixes, not
|
|
364
|
+
code.
|
|
365
|
+
|
|
366
|
+
**The agent offers to do things this app cannot.**
|
|
367
|
+
The website agent is being reused. Actions are declared per agent and offered on
|
|
368
|
+
every surface it serves — clone it and give the app its own.
|
|
369
|
+
|
|
281
370
|
**The agent talks but does not know what screen I am on.**
|
|
282
371
|
`navigationRef` must be the same ref you pass to `NavigationContainer`, and
|
|
283
372
|
`GraineProvider` must wrap it.
|
package/dist/client.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export declare class GraineInAppClient {
|
|
|
23
23
|
config: SessionConfig | null;
|
|
24
24
|
transport: "gateway" | "direct" | null;
|
|
25
25
|
private ws;
|
|
26
|
+
private connecting;
|
|
26
27
|
private listeners;
|
|
27
28
|
private actions;
|
|
28
29
|
private screen;
|
|
@@ -46,6 +47,7 @@ export declare class GraineInAppClient {
|
|
|
46
47
|
session(): Promise<SessionConfig>;
|
|
47
48
|
private ticket;
|
|
48
49
|
connect(): Promise<void>;
|
|
50
|
+
private openSocket;
|
|
49
51
|
private send;
|
|
50
52
|
private onFrame;
|
|
51
53
|
executeAction(name: string, args?: Record<string, unknown>): Promise<AppActionResult>;
|
package/dist/client.js
CHANGED
|
@@ -7,6 +7,7 @@ export class GraineInAppClient {
|
|
|
7
7
|
this.config = null;
|
|
8
8
|
this.transport = null;
|
|
9
9
|
this.ws = null;
|
|
10
|
+
this.connecting = null;
|
|
10
11
|
this.listeners = new Map();
|
|
11
12
|
this.actions = new Map();
|
|
12
13
|
this.screen = null;
|
|
@@ -87,6 +88,14 @@ export class GraineInAppClient {
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
async connect() {
|
|
91
|
+
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1))
|
|
92
|
+
return;
|
|
93
|
+
if (this.connecting)
|
|
94
|
+
return this.connecting;
|
|
95
|
+
this.connecting = this.openSocket().finally(() => { this.connecting = null; });
|
|
96
|
+
return this.connecting;
|
|
97
|
+
}
|
|
98
|
+
async openSocket() {
|
|
90
99
|
if (!this.config)
|
|
91
100
|
await this.session();
|
|
92
101
|
const agentId = this.config.webcallAgentId || this.config.agentId;
|
|
@@ -410,6 +419,7 @@ export class GraineInAppClient {
|
|
|
410
419
|
}
|
|
411
420
|
close() {
|
|
412
421
|
this.stopTimers();
|
|
422
|
+
this.connecting = null;
|
|
413
423
|
const sock = this.ws;
|
|
414
424
|
this.ws = null;
|
|
415
425
|
if (!sock)
|
|
@@ -64,6 +64,7 @@ export interface GraineProviderProps extends GraineInAppOptions {
|
|
|
64
64
|
children: React.ReactNode;
|
|
65
65
|
autoConnect?: boolean;
|
|
66
66
|
onProactive?: (text: string) => void;
|
|
67
|
+
endOnBackground?: boolean;
|
|
67
68
|
navigationRef?: {
|
|
68
69
|
current: any;
|
|
69
70
|
} | null;
|
|
@@ -71,7 +72,7 @@ export interface GraineProviderProps extends GraineInAppOptions {
|
|
|
71
72
|
launcherDelayMs?: number;
|
|
72
73
|
visibility?: LauncherVisibility;
|
|
73
74
|
}
|
|
74
|
-
export declare function GraineProvider({ children, autoConnect, onProactive, navigationRef, includeScreens, launcherDelayMs, visibility, ...options }: GraineProviderProps): React.JSX.Element;
|
|
75
|
+
export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, includeScreens, launcherDelayMs, visibility, ...options }: GraineProviderProps): React.JSX.Element;
|
|
75
76
|
export declare function useGraineAgent(): GraineContextValue;
|
|
76
77
|
export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
|
|
77
78
|
export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
|
+
import { AppState } from "react-native";
|
|
3
4
|
import { GraineInAppClient } from "../client";
|
|
4
5
|
import { VoiceSession } from "../voice";
|
|
5
6
|
import { LauncherVisibilityTracker, activeRouteName, } from "./navigation";
|
|
6
7
|
const Ctx = createContext(null);
|
|
7
|
-
export function GraineProvider({ children, autoConnect = true, onProactive, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
|
|
8
|
+
export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
|
|
8
9
|
const clientRef = useRef(null);
|
|
9
10
|
if (!clientRef.current)
|
|
10
11
|
clientRef.current = new GraineInAppClient(options);
|
|
@@ -158,6 +159,29 @@ export function GraineProvider({ children, autoConnect = true, onProactive, navi
|
|
|
158
159
|
client.close();
|
|
159
160
|
};
|
|
160
161
|
}, [client, autoConnect]);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!endOnBackground)
|
|
164
|
+
return;
|
|
165
|
+
let last = AppState.currentState;
|
|
166
|
+
const sub = AppState.addEventListener("change", (next) => {
|
|
167
|
+
const wasActive = last === "active";
|
|
168
|
+
last = next;
|
|
169
|
+
if (next === "background" && wasActive) {
|
|
170
|
+
client.close();
|
|
171
|
+
setConnected(false);
|
|
172
|
+
setConnecting(false);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (next === "active" && !wasActive && autoConnect) {
|
|
176
|
+
setConnecting(true);
|
|
177
|
+
client.connect().catch((err) => {
|
|
178
|
+
setConnecting(false);
|
|
179
|
+
console.error("[Graine]", err?.message ?? err);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
return () => sub.remove();
|
|
184
|
+
}, [client, endOnBackground, autoConnect]);
|
|
161
185
|
const value = useMemo(() => ({
|
|
162
186
|
client,
|
|
163
187
|
connected,
|
|
@@ -1,6 +1,6 @@
|
|
|
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 { View } from "react-native";
|
|
3
|
+
import { AppState, View } from "react-native";
|
|
4
4
|
import { useGraineAgent } from "./index";
|
|
5
5
|
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCaption, onCallState, onMicDenied, onEnded, children, }) {
|
|
6
6
|
const { client, appearance } = useGraineAgent();
|
|
@@ -91,6 +91,16 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
|
|
|
91
91
|
}, [post]),
|
|
92
92
|
};
|
|
93
93
|
useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
let last = AppState.currentState;
|
|
96
|
+
const sub = AppState.addEventListener("change", (next) => {
|
|
97
|
+
const wasActive = last === "active";
|
|
98
|
+
last = next;
|
|
99
|
+
if (next === "background" && wasActive)
|
|
100
|
+
post({ type: "graine:end-call" });
|
|
101
|
+
});
|
|
102
|
+
return () => sub.remove();
|
|
103
|
+
}, [post]);
|
|
94
104
|
const agentId = client.config?.agentId;
|
|
95
105
|
if (!agentId)
|
|
96
106
|
return _jsx(_Fragment, { children: children?.(api) });
|
package/package.json
CHANGED