@graineai/inapp-react-native 0.7.1 → 0.11.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;
@@ -48,7 +48,11 @@ export declare class GraineInAppClient {
48
48
  connect(): Promise<void>;
49
49
  private send;
50
50
  private onFrame;
51
+ executeAction(name: string, args?: Record<string, unknown>): Promise<AppActionResult>;
51
52
  private runAction;
53
+ private invokeAction;
54
+ getScreen(): ScreenContext | null;
55
+ getAvailableActions(): string[];
52
56
  registerAction(name: string, handler: ActionHandler): () => void;
53
57
  get availableActions(): string[];
54
58
  setScreen(context: ScreenContext | null): void;
package/dist/client.js CHANGED
@@ -215,15 +215,20 @@ export class GraineInAppClient {
215
215
  this.emit("frame", msg);
216
216
  }
217
217
  }
218
+ async executeAction(name, args = {}) {
219
+ return this.invokeAction({ action_id: "", name, arguments: args });
220
+ }
218
221
  async runAction(request) {
219
- const 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) {
220
226
  const handler = this.actions.get(request.name);
221
227
  if (!handler) {
222
- reply({
228
+ return {
223
229
  status: "refused",
224
230
  message: `This app build does not implement "${request.name}".`,
225
- });
226
- return;
231
+ };
227
232
  }
228
233
  this.emit("action", request);
229
234
  try {
@@ -231,20 +236,26 @@ export class GraineInAppClient {
231
236
  Promise.resolve(handler(request.arguments ?? {})),
232
237
  new Promise((_, rej) => setTimeout(() => rej(new Error("handler_timeout")), ACTION_DEADLINE_MS - 500)),
233
238
  ]);
234
- reply(result && typeof result === "object" ? result : { status: "ok" });
239
+ return result && typeof result === "object" ? result : { status: "ok" };
235
240
  }
236
241
  catch (err) {
237
242
  const timedOut = err?.message === "handler_timeout";
238
243
  if (!timedOut)
239
244
  console.error(`[Graine] action "${request.name}" threw`, err);
240
- reply({
245
+ return {
241
246
  status: "error",
242
247
  message: timedOut
243
248
  ? `"${request.name}" did not finish in time.`
244
249
  : `"${request.name}" failed: ${err?.message ?? "unknown error"}`,
245
- });
250
+ };
246
251
  }
247
252
  }
253
+ getScreen() {
254
+ return this.screen;
255
+ }
256
+ getAvailableActions() {
257
+ return [...this.actions.keys()];
258
+ }
248
259
  registerAction(name, handler) {
249
260
  this.actions.set(name, handler);
250
261
  this.scheduleScreen();
package/dist/index.d.ts CHANGED
@@ -3,6 +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, useGraineIdentify, useGraineEvents, useGraineTrack, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
6
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
7
7
  export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps } from "./react-native/ui";
8
+ export { GraineVoiceLauncher, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher";
8
9
  export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation";
package/dist/index.js CHANGED
@@ -2,6 +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, useGraineIdentify, useGraineEvents, useGraineTrack, } from "./react-native/index";
5
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, } from "./react-native/index";
6
6
  export { GraineAgentBar, GraineLauncher } from "./react-native/ui";
7
+ export { GraineVoiceLauncher, } from "./react-native/voice-launcher";
7
8
  export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation";
@@ -20,6 +20,7 @@ interface GraineContextValue {
20
20
  launcherVisible: boolean;
21
21
  launcherInset: LauncherInset;
22
22
  currentScreen: string | null;
23
+ appearance: Record<string, any> | null;
23
24
  onEvent: (fn: (e: GraineEvent) => void) => () => void;
24
25
  track: (name: string, data?: Record<string, unknown>) => void;
25
26
  identify: (user: Record<string, unknown> | null) => void;
@@ -74,6 +75,7 @@ export declare function GraineProvider({ children, autoConnect, onProactive, nav
74
75
  export declare function useGraineAgent(): GraineContextValue;
75
76
  export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
76
77
  export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
78
+ export declare function useGraineTap<T extends (...args: any[]) => any>(name: string, handler?: T, data?: Record<string, unknown>): (...args: Parameters<T>) => ReturnType<T> | undefined;
77
79
  export declare function useGraineIdentify(): (user: Record<string, unknown> | null) => void;
78
80
  export declare function useGraineVoice(adapter: AudioAdapter | null, options?: VoiceSessionOptions): {
79
81
  listening: boolean;
@@ -33,6 +33,7 @@ export function GraineProvider({ children, autoConnect = true, onProactive, navi
33
33
  listenersRef.current.add(fn);
34
34
  return () => { listenersRef.current.delete(fn); };
35
35
  }, []);
36
+ const [appearance, setAppearance] = useState(null);
36
37
  const [currentScreen, setCurrentScreen] = useState(null);
37
38
  const [launcherVisible, setLauncherVisible] = useState(false);
38
39
  const [launcherInset, setLauncherInset] = useState({ right: 16, bottom: 20 });
@@ -103,6 +104,8 @@ export function GraineProvider({ children, autoConnect = true, onProactive, navi
103
104
  setConnected(true);
104
105
  setConnecting(false);
105
106
  setError(null);
107
+ if (client.config?.appearance)
108
+ setAppearance(client.config.appearance);
106
109
  emitEvent({ type: "conversation_started" });
107
110
  }),
108
111
  client.on("close", (info) => {
@@ -181,11 +184,12 @@ export function GraineProvider({ children, autoConnect = true, onProactive, navi
181
184
  launcherVisible,
182
185
  launcherInset,
183
186
  currentScreen,
187
+ appearance,
184
188
  onEvent,
185
189
  track: (name, data) => client.track(name, data),
186
190
  identify: (user) => client.identify(user),
187
191
  }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
188
- launcherVisible, launcherInset, currentScreen, onEvent]);
192
+ launcherVisible, launcherInset, currentScreen, onEvent, appearance]);
189
193
  return _jsx(Ctx.Provider, { value: value, children: children });
190
194
  }
191
195
  function useGraine() {
@@ -207,6 +211,21 @@ export function useGraineTrack() {
207
211
  const { track } = useGraine();
208
212
  return track;
209
213
  }
214
+ export function useGraineTap(name, handler, data) {
215
+ const { track } = useGraine();
216
+ const handlerRef = useRef(handler);
217
+ handlerRef.current = handler;
218
+ const dataRef = useRef(data);
219
+ dataRef.current = data;
220
+ return useCallback((...args) => {
221
+ try {
222
+ track(`tap:${name}`, dataRef.current);
223
+ }
224
+ catch {
225
+ }
226
+ return handlerRef.current?.(...args);
227
+ }, [name, track]);
228
+ }
210
229
  export function useGraineIdentify() {
211
230
  const { client } = useGraine();
212
231
  return useCallback((user) => client.identify(user), [client]);
@@ -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, launcherVisible, launcherInset, } = useGraineAgent();
5
+ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset = 96, hidden = false, captionsOn = false, }) {
6
+ const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, appearance, } = useGraineAgent();
7
+ const accent = accentProp ?? appearance?.accent ?? "#318CE7";
8
+ const name = nameProp ?? appearance?.title ?? "Assistant";
9
+ const avatarUrl = avatarProp ?? appearance?.logo ?? undefined;
7
10
  const [expanded, setExpanded] = useState(false);
8
11
  const [captions, setCaptions] = useState(captionsOn);
9
12
  const [draft, setDraft] = useState("");
@@ -0,0 +1,26 @@
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
+ onEnded?: () => void;
15
+ onMicDenied?: (reason: string) => void;
16
+ children?: (api: GraineVoiceApi) => React.ReactNode;
17
+ }
18
+ export interface GraineVoiceApi {
19
+ connected: boolean;
20
+ connecting: boolean;
21
+ muted: boolean;
22
+ start: () => void;
23
+ end: () => void;
24
+ setMuted: (m: boolean) => void;
25
+ }
26
+ export declare function GraineVoiceLauncher({ webView: WebView, autoStart, onCaption, onCallState, onMicDenied, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
@@ -0,0 +1,101 @@
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, onMicDenied, onEnded, 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:ended":
58
+ setConnected(false);
59
+ setConnecting(false);
60
+ onEnded?.();
61
+ return;
62
+ case "graine:mic-denied":
63
+ onMicDenied?.(String(msg.reason || "denied"));
64
+ return;
65
+ case "graine:app-action": {
66
+ const action = msg.action || {};
67
+ const result = await client.executeAction(action.name, action.arguments || {});
68
+ post({
69
+ type: "graine:app-action-result",
70
+ actionId: action.action_id,
71
+ status: result.status,
72
+ message: result.message ?? "",
73
+ data: result.data ?? {},
74
+ });
75
+ return;
76
+ }
77
+ }
78
+ }, [autoStart, client, onCaption, onCallState, onMicDenied, onEnded, post, pushContext]);
79
+ const api = {
80
+ connected,
81
+ connecting,
82
+ muted,
83
+ start: useCallback(() => {
84
+ setConnecting(true);
85
+ post({ type: "graine:start-call" });
86
+ }, [post]),
87
+ end: useCallback(() => post({ type: "graine:end-call" }), [post]),
88
+ setMuted: useCallback((m) => {
89
+ setMutedState(m);
90
+ post({ type: "graine:set-muted", muted: m });
91
+ }, [post]),
92
+ };
93
+ useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
94
+ const agentId = client.config?.agentId;
95
+ if (!agentId)
96
+ return _jsx(_Fragment, { children: children?.(api) });
97
+ const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${agentId}` +
98
+ `?mode=voice&branding=0&embed=1` +
99
+ (appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
100
+ 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)] }));
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.7.1",
3
+ "version": "0.11.0",
4
4
  "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -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",