@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 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: { ...(this.config.variables ?? {}), ...(this.opts.variables ?? {}) },
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 reply = (result) => this.send({ type: "app_action_result", action_id: request.action_id, ...result });
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
- reply({
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
- reply(result && typeof result === "object" ? result : { status: "ok" });
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
- reply({
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({ ...rest, idle_ms: Date.now() - this.screenSince }),
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) => setMutedState(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
- }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption]);
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;
@@ -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 = "#318CE7", name = "Assistant", avatarUrl, bottomInset = 96, hidden = false, captionsOn = false, }) {
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: [styles.root, { bottom: bottomInset }], 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: [
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 buffered = this.adapter.bufferedSeconds?.();
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.0",
4
- "description": "Graine in-app agent for React Native an agent that sees the screen your customer is on and can act on it.",
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",