@graineai/inapp-react-native 0.4.0 → 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 +9 -1
- package/dist/client.js +55 -9
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/react-native/index.d.ts +43 -1
- package/dist/react-native/index.js +116 -5
- package/dist/react-native/navigation.d.ts +44 -0
- package/dist/react-native/navigation.js +98 -0
- package/dist/react-native/ui.d.ts +1 -1
- package/dist/react-native/ui.js +14 -4
- package/dist/react-native/voice-launcher.d.ts +24 -0
- package/dist/react-native/voice-launcher.js +93 -0
- package/dist/voice.d.ts +2 -0
- package/dist/voice.js +10 -3
- 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;
|
|
@@ -37,6 +37,8 @@ export declare class GraineInAppClient {
|
|
|
37
37
|
private bargedIn;
|
|
38
38
|
private muted;
|
|
39
39
|
private playoutClock;
|
|
40
|
+
private identity;
|
|
41
|
+
private recentEvents;
|
|
40
42
|
constructor(opts: GraineInAppOptions);
|
|
41
43
|
on(event: string, fn: Listener): () => void;
|
|
42
44
|
private emit;
|
|
@@ -46,14 +48,20 @@ export declare class GraineInAppClient {
|
|
|
46
48
|
connect(): Promise<void>;
|
|
47
49
|
private send;
|
|
48
50
|
private onFrame;
|
|
51
|
+
executeAction(name: string, args?: Record<string, unknown>): Promise<AppActionResult>;
|
|
49
52
|
private runAction;
|
|
53
|
+
private invokeAction;
|
|
54
|
+
getScreen(): ScreenContext | null;
|
|
55
|
+
getAvailableActions(): string[];
|
|
50
56
|
registerAction(name: string, handler: ActionHandler): () => void;
|
|
51
57
|
get availableActions(): string[];
|
|
52
58
|
setScreen(context: ScreenContext | null): void;
|
|
53
59
|
reportEvent(event: AppEvent): void;
|
|
60
|
+
track(name: string, data?: Record<string, unknown>): void;
|
|
54
61
|
private scheduleScreen;
|
|
55
62
|
private flushScreen;
|
|
56
63
|
private armStall;
|
|
64
|
+
identify(user: Record<string, unknown> | null): void;
|
|
57
65
|
noteInteraction(): void;
|
|
58
66
|
say(text: string): boolean;
|
|
59
67
|
sendAudio(base64: string, sampleRate?: number): boolean;
|
package/dist/client.js
CHANGED
|
@@ -21,6 +21,8 @@ export class GraineInAppClient {
|
|
|
21
21
|
this.bargedIn = false;
|
|
22
22
|
this.muted = false;
|
|
23
23
|
this.playoutClock = null;
|
|
24
|
+
this.identity = {};
|
|
25
|
+
this.recentEvents = [];
|
|
24
26
|
if (!opts?.publishableKey)
|
|
25
27
|
throw new Error("[Graine] publishableKey is required.");
|
|
26
28
|
if (!opts?.baseUrl)
|
|
@@ -126,7 +128,11 @@ export class GraineInAppClient {
|
|
|
126
128
|
event: "start",
|
|
127
129
|
meta_data: {
|
|
128
130
|
client: "graine-inapp-sdk",
|
|
129
|
-
context_data: {
|
|
131
|
+
context_data: {
|
|
132
|
+
...(this.config.variables ?? {}),
|
|
133
|
+
...(this.opts.variables ?? {}),
|
|
134
|
+
...this.identity,
|
|
135
|
+
},
|
|
130
136
|
},
|
|
131
137
|
});
|
|
132
138
|
this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
|
|
@@ -209,15 +215,20 @@ export class GraineInAppClient {
|
|
|
209
215
|
this.emit("frame", msg);
|
|
210
216
|
}
|
|
211
217
|
}
|
|
218
|
+
async executeAction(name, args = {}) {
|
|
219
|
+
return this.invokeAction({ action_id: "", name, arguments: args });
|
|
220
|
+
}
|
|
212
221
|
async runAction(request) {
|
|
213
|
-
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) {
|
|
214
226
|
const handler = this.actions.get(request.name);
|
|
215
227
|
if (!handler) {
|
|
216
|
-
|
|
228
|
+
return {
|
|
217
229
|
status: "refused",
|
|
218
230
|
message: `This app build does not implement "${request.name}".`,
|
|
219
|
-
}
|
|
220
|
-
return;
|
|
231
|
+
};
|
|
221
232
|
}
|
|
222
233
|
this.emit("action", request);
|
|
223
234
|
try {
|
|
@@ -225,20 +236,26 @@ export class GraineInAppClient {
|
|
|
225
236
|
Promise.resolve(handler(request.arguments ?? {})),
|
|
226
237
|
new Promise((_, rej) => setTimeout(() => rej(new Error("handler_timeout")), ACTION_DEADLINE_MS - 500)),
|
|
227
238
|
]);
|
|
228
|
-
|
|
239
|
+
return result && typeof result === "object" ? result : { status: "ok" };
|
|
229
240
|
}
|
|
230
241
|
catch (err) {
|
|
231
242
|
const timedOut = err?.message === "handler_timeout";
|
|
232
243
|
if (!timedOut)
|
|
233
244
|
console.error(`[Graine] action "${request.name}" threw`, err);
|
|
234
|
-
|
|
245
|
+
return {
|
|
235
246
|
status: "error",
|
|
236
247
|
message: timedOut
|
|
237
248
|
? `"${request.name}" did not finish in time.`
|
|
238
249
|
: `"${request.name}" failed: ${err?.message ?? "unknown error"}`,
|
|
239
|
-
}
|
|
250
|
+
};
|
|
240
251
|
}
|
|
241
252
|
}
|
|
253
|
+
getScreen() {
|
|
254
|
+
return this.screen;
|
|
255
|
+
}
|
|
256
|
+
getAvailableActions() {
|
|
257
|
+
return [...this.actions.keys()];
|
|
258
|
+
}
|
|
242
259
|
registerAction(name, handler) {
|
|
243
260
|
this.actions.set(name, handler);
|
|
244
261
|
this.scheduleScreen();
|
|
@@ -263,6 +280,15 @@ export class GraineInAppClient {
|
|
|
263
280
|
reportEvent(event) {
|
|
264
281
|
this.send({ type: "app_event", event });
|
|
265
282
|
}
|
|
283
|
+
track(name, data) {
|
|
284
|
+
if (!name)
|
|
285
|
+
return;
|
|
286
|
+
this.recentEvents.push({ name, at: Date.now(), ...(data ? { data } : {}) });
|
|
287
|
+
if (this.recentEvents.length > 10)
|
|
288
|
+
this.recentEvents.shift();
|
|
289
|
+
if (this.screen)
|
|
290
|
+
this.scheduleScreen();
|
|
291
|
+
}
|
|
266
292
|
scheduleScreen() {
|
|
267
293
|
if (this.contextTimer) {
|
|
268
294
|
this.pendingScreen = true;
|
|
@@ -284,7 +310,12 @@ export class GraineInAppClient {
|
|
|
284
310
|
const { availableActions, ...rest } = this.screen;
|
|
285
311
|
return this.send({
|
|
286
312
|
type: "app_context",
|
|
287
|
-
context: maskDeep({
|
|
313
|
+
context: maskDeep({
|
|
314
|
+
...rest,
|
|
315
|
+
idle_ms: Date.now() - this.screenSince,
|
|
316
|
+
...(Object.keys(this.identity).length ? { user: this.identity } : {}),
|
|
317
|
+
...(this.recentEvents.length ? { recent_events: this.recentEvents } : {}),
|
|
318
|
+
}),
|
|
288
319
|
available_actions: (availableActions ?? this.availableActions).filter((a) => this.actions.has(a)),
|
|
289
320
|
seq: ++this.screenSeq,
|
|
290
321
|
});
|
|
@@ -310,6 +341,21 @@ export class GraineInAppClient {
|
|
|
310
341
|
});
|
|
311
342
|
}, after);
|
|
312
343
|
}
|
|
344
|
+
identify(user) {
|
|
345
|
+
if (!user) {
|
|
346
|
+
this.identity = {};
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const flat = {};
|
|
350
|
+
for (const [k, v] of Object.entries(user)) {
|
|
351
|
+
if (v === undefined || v === null)
|
|
352
|
+
continue;
|
|
353
|
+
flat[k] = typeof v === "string" ? v : JSON.stringify(v);
|
|
354
|
+
}
|
|
355
|
+
this.identity = maskDeep(flat);
|
|
356
|
+
if (this.screen)
|
|
357
|
+
this.scheduleScreen();
|
|
358
|
+
}
|
|
313
359
|
noteInteraction() {
|
|
314
360
|
this.screenSince = Date.now();
|
|
315
361
|
this.armStall();
|
package/dist/index.d.ts
CHANGED
|
@@ -3,5 +3,7 @@ export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "
|
|
|
3
3
|
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice";
|
|
4
4
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio";
|
|
5
5
|
export { addMaskRule, maskDeep, maskString } from "./mask";
|
|
6
|
-
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
|
|
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";
|
|
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
|
@@ -2,5 +2,7 @@ export { GraineInAppClient } from "./client";
|
|
|
2
2
|
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice";
|
|
3
3
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio";
|
|
4
4
|
export { addMaskRule, maskDeep, maskString } from "./mask";
|
|
5
|
-
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, } from "./react-native/index";
|
|
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";
|
|
8
|
+
export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation";
|
|
@@ -2,6 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { GraineInAppClient, type GraineInAppOptions } from "../client";
|
|
3
3
|
import { type AudioAdapter, type VoiceSessionOptions } from "../voice";
|
|
4
4
|
import type { ActionHandler, ScreenContext } from "../protocol";
|
|
5
|
+
import { type LauncherInset, type LauncherVisibility } from "./navigation";
|
|
5
6
|
interface GraineContextValue {
|
|
6
7
|
client: GraineInAppClient;
|
|
7
8
|
connected: boolean;
|
|
@@ -16,7 +17,39 @@ interface GraineContextValue {
|
|
|
16
17
|
setMuted: (muted: boolean) => void;
|
|
17
18
|
agentSpeaking: boolean;
|
|
18
19
|
caption: Caption | null;
|
|
20
|
+
launcherVisible: boolean;
|
|
21
|
+
launcherInset: LauncherInset;
|
|
22
|
+
currentScreen: string | null;
|
|
23
|
+
appearance: Record<string, any> | null;
|
|
24
|
+
onEvent: (fn: (e: GraineEvent) => void) => () => void;
|
|
25
|
+
track: (name: string, data?: Record<string, unknown>) => void;
|
|
26
|
+
identify: (user: Record<string, unknown> | null) => void;
|
|
19
27
|
}
|
|
28
|
+
export type GraineEvent = {
|
|
29
|
+
type: "conversation_started";
|
|
30
|
+
} | {
|
|
31
|
+
type: "conversation_ended";
|
|
32
|
+
reason: string;
|
|
33
|
+
} | {
|
|
34
|
+
type: "agent_speaking";
|
|
35
|
+
speaking: boolean;
|
|
36
|
+
} | {
|
|
37
|
+
type: "agent_volunteered";
|
|
38
|
+
text: string;
|
|
39
|
+
} | {
|
|
40
|
+
type: "action_requested";
|
|
41
|
+
name: string;
|
|
42
|
+
} | {
|
|
43
|
+
type: "action_completed";
|
|
44
|
+
name: string;
|
|
45
|
+
status: string;
|
|
46
|
+
} | {
|
|
47
|
+
type: "muted";
|
|
48
|
+
muted: boolean;
|
|
49
|
+
} | {
|
|
50
|
+
type: "error";
|
|
51
|
+
message: string;
|
|
52
|
+
};
|
|
20
53
|
export interface Caption {
|
|
21
54
|
role: "agent" | "customer";
|
|
22
55
|
text: string;
|
|
@@ -31,9 +64,18 @@ export interface GraineProviderProps extends GraineInAppOptions {
|
|
|
31
64
|
children: React.ReactNode;
|
|
32
65
|
autoConnect?: boolean;
|
|
33
66
|
onProactive?: (text: string) => void;
|
|
67
|
+
navigationRef?: {
|
|
68
|
+
current: any;
|
|
69
|
+
} | null;
|
|
70
|
+
includeScreens?: string[];
|
|
71
|
+
launcherDelayMs?: number;
|
|
72
|
+
visibility?: LauncherVisibility;
|
|
34
73
|
}
|
|
35
|
-
export declare function GraineProvider({ children, autoConnect, onProactive, ...options }: GraineProviderProps): React.JSX.Element;
|
|
74
|
+
export declare function GraineProvider({ children, autoConnect, onProactive, navigationRef, includeScreens, launcherDelayMs, visibility, ...options }: GraineProviderProps): React.JSX.Element;
|
|
36
75
|
export declare function useGraineAgent(): GraineContextValue;
|
|
76
|
+
export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
|
|
77
|
+
export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
|
|
78
|
+
export declare function useGraineIdentify(): (user: Record<string, unknown> | null) => void;
|
|
37
79
|
export declare function useGraineVoice(adapter: AudioAdapter | null, options?: VoiceSessionOptions): {
|
|
38
80
|
listening: boolean;
|
|
39
81
|
agentSpeaking: boolean;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
3
|
import { GraineInAppClient } from "../client";
|
|
4
4
|
import { VoiceSession } from "../voice";
|
|
5
|
+
import { LauncherVisibilityTracker, activeRouteName, } from "./navigation";
|
|
5
6
|
const Ctx = createContext(null);
|
|
6
|
-
export function GraineProvider({ children, autoConnect = true, onProactive, ...options }) {
|
|
7
|
+
export function GraineProvider({ children, autoConnect = true, onProactive, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
|
|
7
8
|
const clientRef = useRef(null);
|
|
8
9
|
if (!clientRef.current)
|
|
9
10
|
clientRef.current = new GraineInAppClient(options);
|
|
@@ -19,6 +20,82 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
|
|
|
19
20
|
const [caption, setCaption] = useState(null);
|
|
20
21
|
const proactiveRef = useRef(onProactive);
|
|
21
22
|
proactiveRef.current = onProactive;
|
|
23
|
+
const listenersRef = useRef(new Set());
|
|
24
|
+
const emitEvent = useCallback((e) => {
|
|
25
|
+
listenersRef.current.forEach((fn) => {
|
|
26
|
+
try {
|
|
27
|
+
fn(e);
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
30
|
+
});
|
|
31
|
+
}, []);
|
|
32
|
+
const onEvent = useCallback((fn) => {
|
|
33
|
+
listenersRef.current.add(fn);
|
|
34
|
+
return () => { listenersRef.current.delete(fn); };
|
|
35
|
+
}, []);
|
|
36
|
+
const [appearance, setAppearance] = useState(null);
|
|
37
|
+
const [currentScreen, setCurrentScreen] = useState(null);
|
|
38
|
+
const [launcherVisible, setLauncherVisible] = useState(false);
|
|
39
|
+
const [launcherInset, setLauncherInset] = useState({ right: 16, bottom: 20 });
|
|
40
|
+
const trackerRef = useRef(null);
|
|
41
|
+
if (!trackerRef.current) {
|
|
42
|
+
trackerRef.current = new LauncherVisibilityTracker(includeScreens, visibility, launcherDelayMs);
|
|
43
|
+
}
|
|
44
|
+
const showTimerRef = useRef(null);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const nav = navigationRef?.current;
|
|
47
|
+
if (!nav || typeof nav.addListener !== "function")
|
|
48
|
+
return;
|
|
49
|
+
const apply = () => {
|
|
50
|
+
let route = null;
|
|
51
|
+
try {
|
|
52
|
+
route = activeRouteName(nav.getRootState?.());
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
route = null;
|
|
56
|
+
}
|
|
57
|
+
if (!route)
|
|
58
|
+
return;
|
|
59
|
+
setCurrentScreen(route);
|
|
60
|
+
try {
|
|
61
|
+
client.setScreen({ screen: route, title: route });
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
const decision = trackerRef.current.onRoute(route);
|
|
65
|
+
setLauncherInset(decision.inset);
|
|
66
|
+
if (showTimerRef.current) {
|
|
67
|
+
clearTimeout(showTimerRef.current);
|
|
68
|
+
showTimerRef.current = null;
|
|
69
|
+
}
|
|
70
|
+
if (!decision.eligible) {
|
|
71
|
+
setLauncherVisible(false);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (decision.delayMs <= 0) {
|
|
75
|
+
setLauncherVisible(true);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setLauncherVisible(false);
|
|
79
|
+
showTimerRef.current = setTimeout(() => {
|
|
80
|
+
showTimerRef.current = null;
|
|
81
|
+
setLauncherVisible(true);
|
|
82
|
+
}, decision.delayMs);
|
|
83
|
+
};
|
|
84
|
+
apply();
|
|
85
|
+
const off = nav.addListener("state", apply);
|
|
86
|
+
return () => {
|
|
87
|
+
if (showTimerRef.current)
|
|
88
|
+
clearTimeout(showTimerRef.current);
|
|
89
|
+
if (typeof off === "function")
|
|
90
|
+
off();
|
|
91
|
+
else if (typeof nav.removeListener === "function")
|
|
92
|
+
nav.removeListener("state", apply);
|
|
93
|
+
};
|
|
94
|
+
}, [navigationRef, client]);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!navigationRef?.current)
|
|
97
|
+
setLauncherVisible(true);
|
|
98
|
+
}, [navigationRef]);
|
|
22
99
|
const openRef = useRef(open);
|
|
23
100
|
openRef.current = open;
|
|
24
101
|
useEffect(() => {
|
|
@@ -27,8 +104,14 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
|
|
|
27
104
|
setConnected(true);
|
|
28
105
|
setConnecting(false);
|
|
29
106
|
setError(null);
|
|
107
|
+
if (client.config?.appearance)
|
|
108
|
+
setAppearance(client.config.appearance);
|
|
109
|
+
emitEvent({ type: "conversation_started" });
|
|
110
|
+
}),
|
|
111
|
+
client.on("close", (info) => {
|
|
112
|
+
setConnected(false);
|
|
113
|
+
emitEvent({ type: "conversation_ended", reason: info?.reason || String(info?.code ?? "closed") });
|
|
30
114
|
}),
|
|
31
|
-
client.on("close", () => setConnected(false)),
|
|
32
115
|
client.on("chunk", (text) => setMessages((prev) => {
|
|
33
116
|
const last = prev[prev.length - 1];
|
|
34
117
|
if (last?.role === "agent" && last.live) {
|
|
@@ -36,18 +119,24 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
|
|
|
36
119
|
}
|
|
37
120
|
if (prev[prev.length - 1]?.role !== "customer") {
|
|
38
121
|
proactiveRef.current?.(text);
|
|
122
|
+
emitEvent({ type: "agent_volunteered", text });
|
|
39
123
|
if (!openRef.current)
|
|
40
124
|
setOpen(true);
|
|
41
125
|
}
|
|
42
126
|
return [...prev, { role: "agent", text, live: true }];
|
|
43
127
|
})),
|
|
44
128
|
client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
|
|
45
|
-
client.on("muted", (m) =>
|
|
129
|
+
client.on("muted", (m) => {
|
|
130
|
+
setMutedState(m);
|
|
131
|
+
emitEvent({ type: "muted", muted: m });
|
|
132
|
+
}),
|
|
46
133
|
client.on("agent_speaking", (speaking) => {
|
|
47
134
|
setAgentSpeaking(speaking);
|
|
48
135
|
if (speaking)
|
|
49
136
|
setCaption(null);
|
|
137
|
+
emitEvent({ type: "agent_speaking", speaking });
|
|
50
138
|
}),
|
|
139
|
+
client.on("action", (req) => emitEvent({ type: "action_requested", name: req?.name ?? "" })),
|
|
51
140
|
client.on("transcript", ({ text, final }) => {
|
|
52
141
|
if (text)
|
|
53
142
|
setCaption({ role: "customer", text, live: !final });
|
|
@@ -92,7 +181,15 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
|
|
|
92
181
|
client.noteInteraction();
|
|
93
182
|
client.say(text);
|
|
94
183
|
},
|
|
95
|
-
|
|
184
|
+
launcherVisible,
|
|
185
|
+
launcherInset,
|
|
186
|
+
currentScreen,
|
|
187
|
+
appearance,
|
|
188
|
+
onEvent,
|
|
189
|
+
track: (name, data) => client.track(name, data),
|
|
190
|
+
identify: (user) => client.identify(user),
|
|
191
|
+
}), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
|
|
192
|
+
launcherVisible, launcherInset, currentScreen, onEvent, appearance]);
|
|
96
193
|
return _jsx(Ctx.Provider, { value: value, children: children });
|
|
97
194
|
}
|
|
98
195
|
function useGraine() {
|
|
@@ -104,6 +201,20 @@ function useGraine() {
|
|
|
104
201
|
export function useGraineAgent() {
|
|
105
202
|
return useGraine();
|
|
106
203
|
}
|
|
204
|
+
export function useGraineEvents(handler) {
|
|
205
|
+
const { onEvent } = useGraine();
|
|
206
|
+
const ref = useRef(handler);
|
|
207
|
+
ref.current = handler;
|
|
208
|
+
useEffect(() => onEvent((e) => ref.current(e)), [onEvent]);
|
|
209
|
+
}
|
|
210
|
+
export function useGraineTrack() {
|
|
211
|
+
const { track } = useGraine();
|
|
212
|
+
return track;
|
|
213
|
+
}
|
|
214
|
+
export function useGraineIdentify() {
|
|
215
|
+
const { client } = useGraine();
|
|
216
|
+
return useCallback((user) => client.identify(user), [client]);
|
|
217
|
+
}
|
|
107
218
|
export function useGraineVoice(adapter, options = {}) {
|
|
108
219
|
const { client } = useGraine();
|
|
109
220
|
const [listening, setListening] = useState(false);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface LauncherInset {
|
|
2
|
+
top?: number;
|
|
3
|
+
right?: number;
|
|
4
|
+
bottom?: number;
|
|
5
|
+
left?: number;
|
|
6
|
+
}
|
|
7
|
+
export type LauncherContinuity = "continuous" | "perScreen";
|
|
8
|
+
export type LauncherDelayPolicy = "perScreen" | "oncePerGroupEntry" | "oncePerAppSession";
|
|
9
|
+
export interface LauncherGroup {
|
|
10
|
+
id: string;
|
|
11
|
+
screens: string[];
|
|
12
|
+
continuity: LauncherContinuity;
|
|
13
|
+
inset?: LauncherInset;
|
|
14
|
+
delayMs?: number;
|
|
15
|
+
delayPolicy?: LauncherDelayPolicy;
|
|
16
|
+
}
|
|
17
|
+
export interface LauncherVisibility {
|
|
18
|
+
defaultDelayMs?: number;
|
|
19
|
+
defaultInset?: LauncherInset;
|
|
20
|
+
groups?: LauncherGroup[];
|
|
21
|
+
}
|
|
22
|
+
export interface VisibilityDecision {
|
|
23
|
+
eligible: boolean;
|
|
24
|
+
delayMs: number;
|
|
25
|
+
inset: LauncherInset;
|
|
26
|
+
groupId: string | null;
|
|
27
|
+
continuous: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare class LauncherVisibilityTracker {
|
|
30
|
+
private includeScreens;
|
|
31
|
+
private visibility;
|
|
32
|
+
private defaultDelayMs;
|
|
33
|
+
private enteredGroups;
|
|
34
|
+
private sessionDelayedGroups;
|
|
35
|
+
private lastGroupId;
|
|
36
|
+
private lastRoute;
|
|
37
|
+
constructor(includeScreens: string[] | undefined, visibility: LauncherVisibility | undefined, defaultDelayMs?: number);
|
|
38
|
+
reset(): void;
|
|
39
|
+
private groupFor;
|
|
40
|
+
private isEligible;
|
|
41
|
+
onRoute(route: string): VisibilityDecision;
|
|
42
|
+
}
|
|
43
|
+
export declare function activeRouteName(state: any): string | null;
|
|
44
|
+
export declare function routeParams(state: any): Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const DEFAULT_INSET = { right: 16, bottom: 20 };
|
|
2
|
+
export class LauncherVisibilityTracker {
|
|
3
|
+
constructor(includeScreens, visibility, defaultDelayMs = 0) {
|
|
4
|
+
this.includeScreens = includeScreens;
|
|
5
|
+
this.visibility = visibility;
|
|
6
|
+
this.defaultDelayMs = defaultDelayMs;
|
|
7
|
+
this.enteredGroups = new Set();
|
|
8
|
+
this.sessionDelayedGroups = new Set();
|
|
9
|
+
this.lastGroupId = null;
|
|
10
|
+
this.lastRoute = null;
|
|
11
|
+
}
|
|
12
|
+
reset() {
|
|
13
|
+
this.enteredGroups.clear();
|
|
14
|
+
this.sessionDelayedGroups.clear();
|
|
15
|
+
this.lastGroupId = null;
|
|
16
|
+
this.lastRoute = null;
|
|
17
|
+
}
|
|
18
|
+
groupFor(route) {
|
|
19
|
+
for (const g of this.visibility?.groups || []) {
|
|
20
|
+
if (g.screens.includes(route))
|
|
21
|
+
return g;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
isEligible(route) {
|
|
26
|
+
const groups = this.visibility?.groups || [];
|
|
27
|
+
const hasAllowlist = Array.isArray(this.includeScreens) && this.includeScreens.length > 0;
|
|
28
|
+
if (!hasAllowlist && groups.length === 0)
|
|
29
|
+
return true;
|
|
30
|
+
if (hasAllowlist && this.includeScreens.includes(route))
|
|
31
|
+
return true;
|
|
32
|
+
return groups.some((g) => g.screens.includes(route));
|
|
33
|
+
}
|
|
34
|
+
onRoute(route) {
|
|
35
|
+
const eligible = this.isEligible(route);
|
|
36
|
+
const group = this.groupFor(route);
|
|
37
|
+
const inset = group?.inset || this.visibility?.defaultInset || DEFAULT_INSET;
|
|
38
|
+
if (!eligible) {
|
|
39
|
+
this.lastGroupId = null;
|
|
40
|
+
this.lastRoute = route;
|
|
41
|
+
return { eligible: false, delayMs: 0, inset, groupId: group?.id ?? null, continuous: false };
|
|
42
|
+
}
|
|
43
|
+
const configuredDelay = group?.delayMs ?? this.visibility?.defaultDelayMs ?? this.defaultDelayMs;
|
|
44
|
+
const policy = group?.delayPolicy ?? "perScreen";
|
|
45
|
+
const continuity = group?.continuity ?? "perScreen";
|
|
46
|
+
const stayingInGroup = group != null && this.lastGroupId === group.id;
|
|
47
|
+
const movedScreen = this.lastRoute !== route;
|
|
48
|
+
const continuous = continuity === "continuous" && stayingInGroup && movedScreen;
|
|
49
|
+
let delayMs = configuredDelay;
|
|
50
|
+
if (continuous) {
|
|
51
|
+
delayMs = 0;
|
|
52
|
+
}
|
|
53
|
+
else if (group) {
|
|
54
|
+
if (policy === "oncePerGroupEntry" && stayingInGroup) {
|
|
55
|
+
delayMs = 0;
|
|
56
|
+
}
|
|
57
|
+
else if (policy === "oncePerAppSession") {
|
|
58
|
+
delayMs = this.sessionDelayedGroups.has(group.id) ? 0 : configuredDelay;
|
|
59
|
+
this.sessionDelayedGroups.add(group.id);
|
|
60
|
+
}
|
|
61
|
+
if (!stayingInGroup)
|
|
62
|
+
this.enteredGroups.add(group.id);
|
|
63
|
+
}
|
|
64
|
+
this.lastGroupId = group?.id ?? null;
|
|
65
|
+
this.lastRoute = route;
|
|
66
|
+
return { eligible: true, delayMs, inset, groupId: group?.id ?? null, continuous };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function activeRouteName(state) {
|
|
70
|
+
if (!state || !Array.isArray(state.routes) || typeof state.index !== "number")
|
|
71
|
+
return null;
|
|
72
|
+
let route = state.routes[state.index];
|
|
73
|
+
while (route?.state) {
|
|
74
|
+
const child = route.state;
|
|
75
|
+
if (!Array.isArray(child.routes))
|
|
76
|
+
break;
|
|
77
|
+
const idx = typeof child.index === "number" ? child.index : child.routes.length - 1;
|
|
78
|
+
const next = child.routes[idx];
|
|
79
|
+
if (!next)
|
|
80
|
+
break;
|
|
81
|
+
route = next;
|
|
82
|
+
}
|
|
83
|
+
return route?.name ?? null;
|
|
84
|
+
}
|
|
85
|
+
export function routeParams(state) {
|
|
86
|
+
if (!state || !Array.isArray(state.routes) || typeof state.index !== "number")
|
|
87
|
+
return undefined;
|
|
88
|
+
let route = state.routes[state.index];
|
|
89
|
+
while (route?.state?.routes) {
|
|
90
|
+
const child = route.state;
|
|
91
|
+
const idx = typeof child.index === "number" ? child.index : child.routes.length - 1;
|
|
92
|
+
const next = child.routes[idx];
|
|
93
|
+
if (!next)
|
|
94
|
+
break;
|
|
95
|
+
route = next;
|
|
96
|
+
}
|
|
97
|
+
return route?.params && typeof route.params === "object" ? route.params : undefined;
|
|
98
|
+
}
|
|
@@ -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 } = 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("");
|
|
@@ -31,9 +34,16 @@ export function GraineAgentBar({ accent = "#318CE7", name = "Assistant", avatarU
|
|
|
31
34
|
setDraft("");
|
|
32
35
|
send(text);
|
|
33
36
|
}, [draft, send]);
|
|
34
|
-
if (hidden || error)
|
|
37
|
+
if (hidden || error || !launcherVisible)
|
|
35
38
|
return null;
|
|
36
|
-
return (_jsxs(View, { pointerEvents: "box-none", style: [
|
|
39
|
+
return (_jsxs(View, { pointerEvents: "box-none", style: [
|
|
40
|
+
styles.root,
|
|
41
|
+
{
|
|
42
|
+
bottom: launcherInset.bottom ?? bottomInset,
|
|
43
|
+
right: launcherInset.right ?? 12,
|
|
44
|
+
left: launcherInset.left ?? 12,
|
|
45
|
+
},
|
|
46
|
+
], children: [expanded && (_jsxs(View, { style: styles.panel, children: [_jsxs(ScrollView, { ref: scrollRef, style: styles.transcript, contentContainerStyle: styles.transcriptInner, showsVerticalScrollIndicator: false, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children: "I can see what you're working on \u2014 ask me anything, or I'll step in if you get stuck." })), messages.map((m, i) => (_jsx(View, { style: [
|
|
37
47
|
styles.bubble,
|
|
38
48
|
m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
|
|
39
49
|
], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: "#8a8f98", onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), captions && caption?.text ? (_jsx(View, { style: styles.captionWrap, children: _jsx(Text, { numberOfLines: 3, style: [styles.caption, caption.live && styles.captionLive], children: caption.text }) })) : null, _jsxs(View, { style: styles.bar, children: [avatarUrl ? (_jsx(Image, { source: { uri: avatarUrl }, style: styles.avatarImage, accessibilityIgnoresInvertColors: true })) : (_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) })), _jsxs(Pressable, { onPress: () => setExpanded((v) => !v), style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), _jsx(Text, { style: styles.status, children: status })] }), connecting ? _jsx(ActivityIndicator, { size: "small", color: accent }) : null, _jsx(Pressable, { onPress: () => setCaptions((v) => !v), hitSlop: 8, accessibilityLabel: captions ? "Hide captions" : "Show captions", style: [styles.round, captions && { backgroundColor: accent }], children: _jsx(Text, { style: [styles.glyph, captions && { color: "#fff" }], children: "CC" }) }), _jsx(Pressable, { onPress: () => setMuted(!muted), disabled: !connected, hitSlop: 8, accessibilityLabel: muted ? "Unmute" : "Mute", style: [styles.round, muted && styles.muted, !connected && { opacity: 0.4 }], children: _jsx(Text, { style: [styles.glyph, muted && { color: "#fff" }], children: muted ? "🔇" : "🎙" }) }), _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] })] }));
|
|
@@ -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/dist/voice.d.ts
CHANGED
|
@@ -22,9 +22,11 @@ export declare class VoiceSession {
|
|
|
22
22
|
private format;
|
|
23
23
|
private echo;
|
|
24
24
|
private agentAudible;
|
|
25
|
+
private lastAudioAt;
|
|
25
26
|
constructor(client: GraineInAppClient, adapter: AudioAdapter, options?: VoiceSessionOptions);
|
|
26
27
|
get active(): boolean;
|
|
27
28
|
start(): Promise<void>;
|
|
29
|
+
private agentIsAudible;
|
|
28
30
|
stop(): Promise<void>;
|
|
29
31
|
}
|
|
30
32
|
export declare function liveAudioStreamAdapter(LiveAudioStream: any, sink: Pick<AudioAdapter, "play" | "clear">): AudioAdapter;
|
package/dist/voice.js
CHANGED
|
@@ -14,6 +14,7 @@ export class VoiceSession {
|
|
|
14
14
|
this.format = null;
|
|
15
15
|
this.echo = new EchoGuard();
|
|
16
16
|
this.agentAudible = false;
|
|
17
|
+
this.lastAudioAt = 0;
|
|
17
18
|
}
|
|
18
19
|
get active() {
|
|
19
20
|
return this.capturing;
|
|
@@ -29,6 +30,7 @@ export class VoiceSession {
|
|
|
29
30
|
return;
|
|
30
31
|
if (decoded.latch)
|
|
31
32
|
this.format = decoded.latch;
|
|
33
|
+
this.lastAudioAt = Date.now();
|
|
32
34
|
this.adapter.play(decoded.pcm16, decoded.sampleRate);
|
|
33
35
|
}),
|
|
34
36
|
this.client.on("clear", () => {
|
|
@@ -51,19 +53,24 @@ export class VoiceSession {
|
|
|
51
53
|
await this.adapter.startCapture((base64) => {
|
|
52
54
|
const pcm = base64ToPcm16(base64);
|
|
53
55
|
const frameMs = (pcm.length / CAPTURE_SAMPLE_RATE) * 1000;
|
|
54
|
-
const
|
|
55
|
-
const audible = buffered !== undefined ? buffered > 0.02 || this.agentAudible : this.agentAudible;
|
|
56
|
-
for (const frame of this.echo.filter(base64, pcm, audible, frameMs)) {
|
|
56
|
+
for (const frame of this.echo.filter(base64, pcm, this.agentIsAudible(), frameMs)) {
|
|
57
57
|
this.client.sendAudio(frame, CAPTURE_SAMPLE_RATE);
|
|
58
58
|
}
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
|
+
agentIsAudible() {
|
|
62
|
+
if (Date.now() - this.lastAudioAt > 1500)
|
|
63
|
+
return false;
|
|
64
|
+
const buffered = this.adapter.bufferedSeconds?.();
|
|
65
|
+
return buffered !== undefined ? buffered > 0.02 || this.agentAudible : this.agentAudible;
|
|
66
|
+
}
|
|
61
67
|
async stop() {
|
|
62
68
|
if (!this.capturing)
|
|
63
69
|
return;
|
|
64
70
|
this.capturing = false;
|
|
65
71
|
this.format = null;
|
|
66
72
|
this.agentAudible = false;
|
|
73
|
+
this.lastAudioAt = 0;
|
|
67
74
|
this.echo.reset();
|
|
68
75
|
this.offs.forEach((off) => off());
|
|
69
76
|
this.offs = [];
|
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",
|