@graineai/inapp-react-native 0.7.1 → 0.9.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 +339 -0
- package/dist/client.d.ts +5 -1
- package/dist/client.js +18 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/react-native/index.d.ts +1 -0
- package/dist/react-native/index.js +5 -1
- package/dist/react-native/ui.d.ts +1 -1
- package/dist/react-native/ui.js +5 -2
- package/dist/react-native/voice-launcher.d.ts +24 -0
- package/dist/react-native/voice-launcher.js +93 -0
- package/package.json +4 -3
package/INTEGRATION.md
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# Graine in-app agent — React Native
|
|
2
|
+
|
|
3
|
+
A voice agent inside your app that can see the screen the customer is on and act
|
|
4
|
+
on it. Target integration time: **under an hour**, because there is no native
|
|
5
|
+
setup step.
|
|
6
|
+
|
|
7
|
+
Package: `@graineai/inapp-react-native`
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What you need
|
|
12
|
+
|
|
13
|
+
| | |
|
|
14
|
+
|---|---|
|
|
15
|
+
| React Native | 0.68+ |
|
|
16
|
+
| React | 17+ |
|
|
17
|
+
| Navigation | `@react-navigation/native` — optional, but it is what makes the agent screen-aware without per-screen code |
|
|
18
|
+
| Peer install | `react-native-webview` — autolinks, no setup code |
|
|
19
|
+
| From Graine | your **publishable key**, from Agent → Embed & Widgets |
|
|
20
|
+
|
|
21
|
+
**There is no native setup.** No `MainApplication.kt` edit, no `AppDelegate.swift`
|
|
22
|
+
edit, no ProGuard rules, no babel plugin ordering. The microphone and speaker run
|
|
23
|
+
inside a WebView pointed at your hosted agent page, so every fix to the audio
|
|
24
|
+
path ships over the air instead of through the app stores.
|
|
25
|
+
|
|
26
|
+
You still declare microphone permission, because it is your app asking:
|
|
27
|
+
|
|
28
|
+
**Android** — `android/app/src/main/AndroidManifest.xml`
|
|
29
|
+
|
|
30
|
+
```xml
|
|
31
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
32
|
+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
33
|
+
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**iOS** — `Info.plist`
|
|
37
|
+
|
|
38
|
+
```xml
|
|
39
|
+
<key>NSMicrophoneUsageDescription</key>
|
|
40
|
+
<string>Used to talk to the in-app assistant.</string>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install @graineai/inapp-react-native react-native-webview
|
|
49
|
+
cd ios && pod install && cd .. # for react-native-webview only
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Step 1 — Wrap your app
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { useRef } from "react";
|
|
58
|
+
import { NavigationContainer } from "@react-navigation/native";
|
|
59
|
+
import { WebView } from "react-native-webview";
|
|
60
|
+
import {
|
|
61
|
+
GraineProvider,
|
|
62
|
+
GraineVoiceLauncher,
|
|
63
|
+
GraineAgentBar,
|
|
64
|
+
} from "@graineai/inapp-react-native";
|
|
65
|
+
|
|
66
|
+
export default function App() {
|
|
67
|
+
const navigationRef = useRef(null);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<GraineProvider
|
|
71
|
+
baseUrl="https://www.graine.ai"
|
|
72
|
+
publishableKey="pk_live_..."
|
|
73
|
+
navigationRef={navigationRef}
|
|
74
|
+
>
|
|
75
|
+
<NavigationContainer ref={navigationRef}>
|
|
76
|
+
<RootNavigator />
|
|
77
|
+
</NavigationContainer>
|
|
78
|
+
|
|
79
|
+
<GraineVoiceLauncher webView={WebView} autoStart>
|
|
80
|
+
{() => <GraineAgentBar />}
|
|
81
|
+
</GraineVoiceLauncher>
|
|
82
|
+
</GraineProvider>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`navigationRef` is the line that matters. With it the SDK reads the current route
|
|
88
|
+
itself, so the agent knows which screen the customer is on without you calling
|
|
89
|
+
anything per screen — including the screen you add next year when nobody
|
|
90
|
+
remembers this page. Without it the agent still talks; it just has no idea where
|
|
91
|
+
anyone is.
|
|
92
|
+
|
|
93
|
+
Appearance — colour, title, logo — comes from the dashboard, not from props, so a
|
|
94
|
+
brand change reaches every app without a release.
|
|
95
|
+
|
|
96
|
+
At this point you have a working voice agent. Everything below makes it useful.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Step 2 — Tell it who it is talking to
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
const identify = useGraineIdentify();
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (user) identify({ name: user.name, plan: user.plan });
|
|
107
|
+
else identify(null); // on sign-out
|
|
108
|
+
}, [user, identify]);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Traits become prompt variables, so a prompt can say `greet {name}` or "the
|
|
112
|
+
customer is on the {plan} plan". Everything is masked before it leaves the
|
|
113
|
+
device, so passing a PAN or a card number by accident does not leak it.
|
|
114
|
+
|
|
115
|
+
Nothing else is gated on this. Skip it and every other feature still works — the
|
|
116
|
+
agent just does not know their name.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Step 3 — Report what is ON the screen
|
|
121
|
+
|
|
122
|
+
The route gives the agent a screen *name*. This gives it the contents.
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
useGraineScreen(useMemo(() => ({
|
|
126
|
+
screen: "kyc_documents",
|
|
127
|
+
title: "Upload your documents",
|
|
128
|
+
journey: "onboarding",
|
|
129
|
+
fields: [
|
|
130
|
+
{ name: "pan", label: "PAN", value: pan,
|
|
131
|
+
status: panError ? "invalid" : pan ? "filled" : "empty", error: panError },
|
|
132
|
+
{ name: "address", label: "Address proof", value: addressFile?.name,
|
|
133
|
+
status: addressFile ? "filled" : "empty" },
|
|
134
|
+
],
|
|
135
|
+
}), [pan, panError, addressFile]));
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**`status` is what makes proactive help work.** The SDK runs a 45-second timer per
|
|
139
|
+
screen; when it fires it names the field that is `invalid` or `empty`, so the
|
|
140
|
+
agent opens with "the PAN isn't being accepted" rather than "need a hand?".
|
|
141
|
+
Without `status` it can only manage the second one.
|
|
142
|
+
|
|
143
|
+
Report the real value. Masking happens on the way out and again on arrival — a
|
|
144
|
+
screen that pre-redacts just blinds the agent.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Step 4 — Let it act
|
|
149
|
+
|
|
150
|
+
Two halves, and both are required.
|
|
151
|
+
|
|
152
|
+
**In your app**, a handler:
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
useGraineAction("set_tenure", ({ months }) => {
|
|
156
|
+
const n = Number(months);
|
|
157
|
+
if (!TENURES.includes(n))
|
|
158
|
+
return { status: "refused", message: `I can set ${TENURES.join(", ")} months.` };
|
|
159
|
+
setTenure(n);
|
|
160
|
+
return { status: "ok", message: `Tenure set to ${n} months.` };
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**In the dashboard**, the declaration: Agent → Embed & Widgets → "What this agent
|
|
165
|
+
may do in your app". The name must match exactly. Saving pushes it to the agent
|
|
166
|
+
immediately.
|
|
167
|
+
|
|
168
|
+
Neither half works alone. A handler with no declaration is dead code; a
|
|
169
|
+
declaration with no handler is worse, because the agent will promise it and the
|
|
170
|
+
runtime answers "this app build does not implement it".
|
|
171
|
+
|
|
172
|
+
Return a message saying what actually happened — the agent repeats it, so a vague
|
|
173
|
+
result becomes a vague promise. **The runtime blocks on your handler**, so return
|
|
174
|
+
promptly; anything slow should return immediately and report completion by
|
|
175
|
+
updating the screen.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Step 5 — Product events, without interrupting
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
const track = useGraineTrack();
|
|
183
|
+
track("payment_declined", { reason: "insufficient_funds", amount: 4999 });
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
This does **not** make the agent speak. It lands in the context, so when the
|
|
187
|
+
customer asks "why was my card refused" the agent already knows. Use it for what
|
|
188
|
+
explains a conversation: a declined payment, a rejected document, an OTP retried
|
|
189
|
+
three times.
|
|
190
|
+
|
|
191
|
+
The last 10 are kept. To make the agent speak first, report an event instead —
|
|
192
|
+
and the runtime decides whether it is worth interrupting for.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Step 6 — React to the conversation
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
useGraineEvents((e) => {
|
|
200
|
+
switch (e.type) {
|
|
201
|
+
case "conversation_started": analytics.track("agent_call_started"); break;
|
|
202
|
+
case "conversation_ended": analytics.track("agent_call_ended", { reason: e.reason }); break;
|
|
203
|
+
case "agent_volunteered": analytics.track("agent_spoke_first"); break;
|
|
204
|
+
case "action_requested": analytics.track("agent_action", { name: e.name }); break;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Fires once, in order, at the moment it happens. `conversation_ended` carries a
|
|
210
|
+
reason so a customer hanging up and a socket dying are not the same number in
|
|
211
|
+
your funnel.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Controlling where the launcher appears
|
|
216
|
+
|
|
217
|
+
`includeScreens` for a simple allowlist:
|
|
218
|
+
|
|
219
|
+
```tsx
|
|
220
|
+
<GraineProvider includeScreens={["Home", "Settings"]} ... >
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Groups when a flow needs its own rules:
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
<GraineProvider
|
|
227
|
+
navigationRef={navigationRef}
|
|
228
|
+
visibility={{
|
|
229
|
+
defaultDelayMs: 1200,
|
|
230
|
+
defaultInset: { right: 16, bottom: 20 },
|
|
231
|
+
groups: [
|
|
232
|
+
{ id: "kyc", screens: ["Step1", "Step2", "Step3"],
|
|
233
|
+
continuity: "continuous", delayMs: 1500,
|
|
234
|
+
delayPolicy: "oncePerGroupEntry", inset: { right: 16, bottom: 54 } },
|
|
235
|
+
{ id: "done", screens: ["Confirmation"],
|
|
236
|
+
continuity: "perScreen", delayMs: 1500, delayPolicy: "oncePerAppSession" },
|
|
237
|
+
],
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
| Setting | Effect |
|
|
243
|
+
|---|---|
|
|
244
|
+
| `continuity: "continuous"` | the launcher stays put across screens in the flow — no flicker between steps |
|
|
245
|
+
| `delayPolicy: "oncePerGroupEntry"` | wait once on entering the flow, not on every step |
|
|
246
|
+
| `delayPolicy: "oncePerAppSession"` | wait once per app run |
|
|
247
|
+
| `inset` | clears tab bars and sheets, per flow |
|
|
248
|
+
|
|
249
|
+
Screens named in any group count as included, so groups alone are enough. With
|
|
250
|
+
**no** rules configured everything is eligible — the SDK does not hide its own
|
|
251
|
+
button until you configure it.
|
|
252
|
+
|
|
253
|
+
Walked through `Login -> Home -> Step1 -> Step2 -> Step3 -> Done -> Home ->
|
|
254
|
+
Step1 -> Done` with the config above:
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
Login not eligible
|
|
258
|
+
Home 1200ms default, no group
|
|
259
|
+
Step1 1500ms group entry
|
|
260
|
+
Step2 0ms continuous
|
|
261
|
+
Step3 0ms continuous
|
|
262
|
+
Done 1500ms first visit
|
|
263
|
+
Step1 1500ms re-entry after leaving
|
|
264
|
+
Done 0ms session policy already spent
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Route names are **case-sensitive** and must match your navigator exactly.
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Without React Navigation
|
|
272
|
+
|
|
273
|
+
Omit `navigationRef` and call `useGraineScreen` yourself on each screen — it
|
|
274
|
+
carries `screen` as well as the fields, so nothing is lost except the automatic
|
|
275
|
+
part. Everything else is unchanged.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Troubleshooting
|
|
280
|
+
|
|
281
|
+
**The agent talks but does not know what screen I am on.**
|
|
282
|
+
`navigationRef` must be the same ref you pass to `NavigationContainer`, and
|
|
283
|
+
`GraineProvider` must wrap it.
|
|
284
|
+
|
|
285
|
+
**"Sorry, I can't help with that here."**
|
|
286
|
+
The action is not declared in the dashboard, or the name does not match your
|
|
287
|
+
`useGraineAction` exactly, or the screen did not list it in `availableActions`.
|
|
288
|
+
The client may only narrow the declared list, never extend it.
|
|
289
|
+
|
|
290
|
+
**No sound on iPhone.**
|
|
291
|
+
The ringer switch. Your audio session needs `playsInSilentModeIOS: true` and
|
|
292
|
+
`allowsRecordingIOS: true` while a call is live.
|
|
293
|
+
|
|
294
|
+
**The launcher never appears.**
|
|
295
|
+
The current route is not in `includeScreens` or any group, or a delay is still
|
|
296
|
+
running. Omit `includeScreens` to test.
|
|
297
|
+
|
|
298
|
+
**The agent interrupts itself after a word or two.**
|
|
299
|
+
Echo. The SDK suppresses the agent's own voice from the microphone, but a very
|
|
300
|
+
loud speaker defeats a level-based guard. Lower the volume, or use earphones to
|
|
301
|
+
confirm that is what it is.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Reference
|
|
306
|
+
|
|
307
|
+
| Hook | For |
|
|
308
|
+
|---|---|
|
|
309
|
+
| `useGraineAgent()` | connection state, transcript, mute, send a turn |
|
|
310
|
+
| `useGraineIdentify()` | who the customer is |
|
|
311
|
+
| `useGraineScreen()` | what is on the screen |
|
|
312
|
+
| `useGraineAction()` | one handler per declared action |
|
|
313
|
+
| `useGraineTrack()` | product events that inform but do not interrupt |
|
|
314
|
+
| `useGraineEvents()` | conversation lifecycle |
|
|
315
|
+
| `useGraineVoice()` | only if you bring your own native audio instead of the WebView |
|
|
316
|
+
|
|
317
|
+
`GraineProvider`: `baseUrl`, `publishableKey`, `navigationRef`, `includeScreens`,
|
|
318
|
+
`launcherDelayMs`, `visibility`, `autoConnect`, `onProactive`.
|
|
319
|
+
|
|
320
|
+
`GraineVoiceLauncher`: `webView` (required), `autoStart`, `onCaption`,
|
|
321
|
+
`onCallState`, and a child function receiving `{ connected, connecting, muted,
|
|
322
|
+
start, end, setMuted }`.
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Why there is no LiveKit step
|
|
327
|
+
|
|
328
|
+
Other in-app agent SDKs run voice over LiveKit, which means native
|
|
329
|
+
initialisation in `MainApplication.kt` and `AppDelegate.swift`, a pod install, a
|
|
330
|
+
Lottie dependency, ProGuard rules, and a clean rebuild of both platforms. Their
|
|
331
|
+
own documentation leads its troubleshooting with the error you get when one of
|
|
332
|
+
those is missed.
|
|
333
|
+
|
|
334
|
+
We put the audio in a WebView instead. The trade is real and worth stating: WebRTC
|
|
335
|
+
gets acoustic echo cancellation from the platform's audio stack, and our
|
|
336
|
+
level-based echo guard is an approximation of it that gives up when the speaker
|
|
337
|
+
is very loud. What you get in exchange is that the audio path — where nearly
|
|
338
|
+
every fix lands — ships over the air, and integration is an npm install rather
|
|
339
|
+
than an afternoon in Xcode.
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ActionHandler, type AppEvent, type ScreenContext } from "./protocol";
|
|
1
|
+
import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext } from "./protocol";
|
|
2
2
|
export declare const SUBPROTOCOL = "graine.embed.v1";
|
|
3
3
|
export interface GraineInAppOptions {
|
|
4
4
|
baseUrl: string;
|
|
@@ -48,7 +48,11 @@ export declare class GraineInAppClient {
|
|
|
48
48
|
connect(): Promise<void>;
|
|
49
49
|
private send;
|
|
50
50
|
private onFrame;
|
|
51
|
+
executeAction(name: string, args?: Record<string, unknown>): Promise<AppActionResult>;
|
|
51
52
|
private runAction;
|
|
53
|
+
private invokeAction;
|
|
54
|
+
getScreen(): ScreenContext | null;
|
|
55
|
+
getAvailableActions(): string[];
|
|
52
56
|
registerAction(name: string, handler: ActionHandler): () => void;
|
|
53
57
|
get availableActions(): string[];
|
|
54
58
|
setScreen(context: ScreenContext | null): void;
|
package/dist/client.js
CHANGED
|
@@ -215,15 +215,20 @@ export class GraineInAppClient {
|
|
|
215
215
|
this.emit("frame", msg);
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
|
+
async executeAction(name, args = {}) {
|
|
219
|
+
return this.invokeAction({ action_id: "", name, arguments: args });
|
|
220
|
+
}
|
|
218
221
|
async runAction(request) {
|
|
219
|
-
const
|
|
222
|
+
const result = await this.invokeAction(request);
|
|
223
|
+
this.send({ type: "app_action_result", action_id: request.action_id, ...result });
|
|
224
|
+
}
|
|
225
|
+
async invokeAction(request) {
|
|
220
226
|
const handler = this.actions.get(request.name);
|
|
221
227
|
if (!handler) {
|
|
222
|
-
|
|
228
|
+
return {
|
|
223
229
|
status: "refused",
|
|
224
230
|
message: `This app build does not implement "${request.name}".`,
|
|
225
|
-
}
|
|
226
|
-
return;
|
|
231
|
+
};
|
|
227
232
|
}
|
|
228
233
|
this.emit("action", request);
|
|
229
234
|
try {
|
|
@@ -231,20 +236,26 @@ export class GraineInAppClient {
|
|
|
231
236
|
Promise.resolve(handler(request.arguments ?? {})),
|
|
232
237
|
new Promise((_, rej) => setTimeout(() => rej(new Error("handler_timeout")), ACTION_DEADLINE_MS - 500)),
|
|
233
238
|
]);
|
|
234
|
-
|
|
239
|
+
return result && typeof result === "object" ? result : { status: "ok" };
|
|
235
240
|
}
|
|
236
241
|
catch (err) {
|
|
237
242
|
const timedOut = err?.message === "handler_timeout";
|
|
238
243
|
if (!timedOut)
|
|
239
244
|
console.error(`[Graine] action "${request.name}" threw`, err);
|
|
240
|
-
|
|
245
|
+
return {
|
|
241
246
|
status: "error",
|
|
242
247
|
message: timedOut
|
|
243
248
|
? `"${request.name}" did not finish in time.`
|
|
244
249
|
: `"${request.name}" failed: ${err?.message ?? "unknown error"}`,
|
|
245
|
-
}
|
|
250
|
+
};
|
|
246
251
|
}
|
|
247
252
|
}
|
|
253
|
+
getScreen() {
|
|
254
|
+
return this.screen;
|
|
255
|
+
}
|
|
256
|
+
getAvailableActions() {
|
|
257
|
+
return [...this.actions.keys()];
|
|
258
|
+
}
|
|
248
259
|
registerAction(name, handler) {
|
|
249
260
|
this.actions.set(name, handler);
|
|
250
261
|
this.scheduleScreen();
|
package/dist/index.d.ts
CHANGED
|
@@ -5,4 +5,5 @@ export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, t
|
|
|
5
5
|
export { addMaskRule, maskDeep, maskString } from "./mask";
|
|
6
6
|
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
|
|
7
7
|
export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps } from "./react-native/ui";
|
|
8
|
+
export { GraineVoiceLauncher, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher";
|
|
8
9
|
export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation";
|
package/dist/index.js
CHANGED
|
@@ -4,4 +4,5 @@ export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, }
|
|
|
4
4
|
export { addMaskRule, maskDeep, maskString } from "./mask";
|
|
5
5
|
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, } from "./react-native/index";
|
|
6
6
|
export { GraineAgentBar, GraineLauncher } from "./react-native/ui";
|
|
7
|
+
export { GraineVoiceLauncher, } from "./react-native/voice-launcher";
|
|
7
8
|
export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation";
|
|
@@ -20,6 +20,7 @@ interface GraineContextValue {
|
|
|
20
20
|
launcherVisible: boolean;
|
|
21
21
|
launcherInset: LauncherInset;
|
|
22
22
|
currentScreen: string | null;
|
|
23
|
+
appearance: Record<string, any> | null;
|
|
23
24
|
onEvent: (fn: (e: GraineEvent) => void) => () => void;
|
|
24
25
|
track: (name: string, data?: Record<string, unknown>) => void;
|
|
25
26
|
identify: (user: Record<string, unknown> | null) => void;
|
|
@@ -33,6 +33,7 @@ export function GraineProvider({ children, autoConnect = true, onProactive, navi
|
|
|
33
33
|
listenersRef.current.add(fn);
|
|
34
34
|
return () => { listenersRef.current.delete(fn); };
|
|
35
35
|
}, []);
|
|
36
|
+
const [appearance, setAppearance] = useState(null);
|
|
36
37
|
const [currentScreen, setCurrentScreen] = useState(null);
|
|
37
38
|
const [launcherVisible, setLauncherVisible] = useState(false);
|
|
38
39
|
const [launcherInset, setLauncherInset] = useState({ right: 16, bottom: 20 });
|
|
@@ -103,6 +104,8 @@ export function GraineProvider({ children, autoConnect = true, onProactive, navi
|
|
|
103
104
|
setConnected(true);
|
|
104
105
|
setConnecting(false);
|
|
105
106
|
setError(null);
|
|
107
|
+
if (client.config?.appearance)
|
|
108
|
+
setAppearance(client.config.appearance);
|
|
106
109
|
emitEvent({ type: "conversation_started" });
|
|
107
110
|
}),
|
|
108
111
|
client.on("close", (info) => {
|
|
@@ -181,11 +184,12 @@ export function GraineProvider({ children, autoConnect = true, onProactive, navi
|
|
|
181
184
|
launcherVisible,
|
|
182
185
|
launcherInset,
|
|
183
186
|
currentScreen,
|
|
187
|
+
appearance,
|
|
184
188
|
onEvent,
|
|
185
189
|
track: (name, data) => client.track(name, data),
|
|
186
190
|
identify: (user) => client.identify(user),
|
|
187
191
|
}), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
|
|
188
|
-
launcherVisible, launcherInset, currentScreen, onEvent]);
|
|
192
|
+
launcherVisible, launcherInset, currentScreen, onEvent, appearance]);
|
|
189
193
|
return _jsx(Ctx.Provider, { value: value, children: children });
|
|
190
194
|
}
|
|
191
195
|
function useGraine() {
|
|
@@ -7,6 +7,6 @@ export interface GraineAgentBarProps {
|
|
|
7
7
|
hidden?: boolean;
|
|
8
8
|
captionsOn?: boolean;
|
|
9
9
|
}
|
|
10
|
-
export declare function GraineAgentBar({ accent, name, avatarUrl, bottomInset, hidden, captionsOn, }: GraineAgentBarProps): React.JSX.Element | null;
|
|
10
|
+
export declare function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset, hidden, captionsOn, }: GraineAgentBarProps): React.JSX.Element | null;
|
|
11
11
|
export declare const GraineLauncher: typeof GraineAgentBar;
|
|
12
12
|
export type GraineLauncherProps = GraineAgentBarProps;
|
package/dist/react-native/ui.js
CHANGED
|
@@ -2,8 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
|
|
4
4
|
import { useGraineAgent } from "./index";
|
|
5
|
-
export function GraineAgentBar({ accent
|
|
6
|
-
const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, } = useGraineAgent();
|
|
5
|
+
export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset = 96, hidden = false, captionsOn = false, }) {
|
|
6
|
+
const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, appearance, } = useGraineAgent();
|
|
7
|
+
const accent = accentProp ?? appearance?.accent ?? "#318CE7";
|
|
8
|
+
const name = nameProp ?? appearance?.title ?? "Assistant";
|
|
9
|
+
const avatarUrl = avatarProp ?? appearance?.logo ?? undefined;
|
|
7
10
|
const [expanded, setExpanded] = useState(false);
|
|
8
11
|
const [captions, setCaptions] = useState(captionsOn);
|
|
9
12
|
const [draft, setDraft] = useState("");
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface GraineVoiceLauncherProps {
|
|
3
|
+
webView: React.ComponentType<any>;
|
|
4
|
+
autoStart?: boolean;
|
|
5
|
+
onCaption?: (c: {
|
|
6
|
+
role: string;
|
|
7
|
+
text: string;
|
|
8
|
+
live: boolean;
|
|
9
|
+
}) => void;
|
|
10
|
+
onCallState?: (s: {
|
|
11
|
+
connected: boolean;
|
|
12
|
+
connecting: boolean;
|
|
13
|
+
}) => void;
|
|
14
|
+
children?: (api: GraineVoiceApi) => React.ReactNode;
|
|
15
|
+
}
|
|
16
|
+
export interface GraineVoiceApi {
|
|
17
|
+
connected: boolean;
|
|
18
|
+
connecting: boolean;
|
|
19
|
+
muted: boolean;
|
|
20
|
+
start: () => void;
|
|
21
|
+
end: () => void;
|
|
22
|
+
setMuted: (m: boolean) => void;
|
|
23
|
+
}
|
|
24
|
+
export declare function GraineVoiceLauncher({ webView: WebView, autoStart, onCaption, onCallState, children, }: GraineVoiceLauncherProps): React.JSX.Element;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { View } from "react-native";
|
|
4
|
+
import { useGraineAgent } from "./index";
|
|
5
|
+
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCaption, onCallState, children, }) {
|
|
6
|
+
const { client, appearance } = useGraineAgent();
|
|
7
|
+
const ref = useRef(null);
|
|
8
|
+
const [connected, setConnected] = useState(false);
|
|
9
|
+
const [connecting, setConnecting] = useState(false);
|
|
10
|
+
const [muted, setMutedState] = useState(false);
|
|
11
|
+
const [ready, setReady] = useState(false);
|
|
12
|
+
const post = useCallback((msg) => {
|
|
13
|
+
ref.current?.injectJavaScript(`window.postMessage(${JSON.stringify(msg)}, "*"); true;`);
|
|
14
|
+
}, []);
|
|
15
|
+
const pushContext = useCallback(() => {
|
|
16
|
+
const screen = client.getScreen();
|
|
17
|
+
if (!screen)
|
|
18
|
+
return;
|
|
19
|
+
const { availableActions, ...rest } = screen;
|
|
20
|
+
post({
|
|
21
|
+
type: "graine:app-context",
|
|
22
|
+
context: rest,
|
|
23
|
+
availableActions: (availableActions ?? client.getAvailableActions()).filter((a) => client.getAvailableActions().includes(a)),
|
|
24
|
+
seq: Date.now(),
|
|
25
|
+
});
|
|
26
|
+
}, [client, post]);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (ready)
|
|
29
|
+
pushContext();
|
|
30
|
+
}, [ready, pushContext]);
|
|
31
|
+
const onMessage = useCallback(async (e) => {
|
|
32
|
+
let msg;
|
|
33
|
+
try {
|
|
34
|
+
msg = JSON.parse(e?.nativeEvent?.data ?? "{}");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
switch (msg.type) {
|
|
40
|
+
case "graine:ready":
|
|
41
|
+
setReady(true);
|
|
42
|
+
pushContext();
|
|
43
|
+
if (autoStart)
|
|
44
|
+
post({ type: "graine:start-call" });
|
|
45
|
+
return;
|
|
46
|
+
case "graine:call":
|
|
47
|
+
setConnected(!!msg.connected);
|
|
48
|
+
setConnecting(!!msg.connecting);
|
|
49
|
+
onCallState?.({ connected: !!msg.connected, connecting: !!msg.connecting });
|
|
50
|
+
return;
|
|
51
|
+
case "graine:muted":
|
|
52
|
+
setMutedState(!!msg.muted);
|
|
53
|
+
return;
|
|
54
|
+
case "graine:caption":
|
|
55
|
+
onCaption?.({ role: msg.role, text: msg.text, live: !!msg.live });
|
|
56
|
+
return;
|
|
57
|
+
case "graine:app-action": {
|
|
58
|
+
const action = msg.action || {};
|
|
59
|
+
const result = await client.executeAction(action.name, action.arguments || {});
|
|
60
|
+
post({
|
|
61
|
+
type: "graine:app-action-result",
|
|
62
|
+
actionId: action.action_id,
|
|
63
|
+
status: result.status,
|
|
64
|
+
message: result.message ?? "",
|
|
65
|
+
data: result.data ?? {},
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}, [autoStart, client, onCaption, onCallState, post, pushContext]);
|
|
71
|
+
const api = {
|
|
72
|
+
connected,
|
|
73
|
+
connecting,
|
|
74
|
+
muted,
|
|
75
|
+
start: useCallback(() => {
|
|
76
|
+
setConnecting(true);
|
|
77
|
+
post({ type: "graine:start-call" });
|
|
78
|
+
}, [post]),
|
|
79
|
+
end: useCallback(() => post({ type: "graine:end-call" }), [post]),
|
|
80
|
+
setMuted: useCallback((m) => {
|
|
81
|
+
setMutedState(m);
|
|
82
|
+
post({ type: "graine:set-muted", muted: m });
|
|
83
|
+
}, [post]),
|
|
84
|
+
};
|
|
85
|
+
useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
|
|
86
|
+
const agentId = client.config?.agentId;
|
|
87
|
+
if (!agentId)
|
|
88
|
+
return _jsx(_Fragment, { children: children?.(api) });
|
|
89
|
+
const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${agentId}` +
|
|
90
|
+
`?mode=voice&branding=0&embed=1` +
|
|
91
|
+
(appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
|
|
92
|
+
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)] }));
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graineai/inapp-react-native",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Graine in-app agent for React Native
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Graine in-app agent for React Native \u2014 an agent that sees the screen your customer is on and can act on it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"dist",
|
|
28
|
-
"README.md"
|
|
28
|
+
"README.md",
|
|
29
|
+
"INTEGRATION.md"
|
|
29
30
|
],
|
|
30
31
|
"scripts": {
|
|
31
32
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|