@graineai/inapp-react-native 0.11.1 → 0.15.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 CHANGED
@@ -135,6 +135,34 @@ agent just does not know their name.
135
135
 
136
136
  ---
137
137
 
138
+ ### Metadata before the first word
139
+
140
+ Two places, and the difference is when you know the value.
141
+
142
+ ```tsx
143
+ // Known at build time — carried in the init frame, before the agent speaks.
144
+ <GraineProvider variables={{ tier: 'gold', region: 'IN' }} />
145
+
146
+ // Known after sign-in — merged into that same init frame if it lands before the
147
+ // socket opens, sent on the next context frame if it lands after.
148
+ identify({ name: user.name, plan: user.plan });
149
+ ```
150
+
151
+ `identify()` wins over `variables` on the same key: a signed-in customer is more
152
+ specific than a default. Both are masked on the device before they leave it.
153
+
154
+ ### It reaches the call record, not only the prompt
155
+
156
+ Traits are stored on the conversation as `app_user`, and the name labels the row
157
+ in Call History. A web call has no phone number to identify it by, so without
158
+ this every in-app conversation shows a session id.
159
+
160
+ Read from the LIVE identity rather than the init frame, so a customer who signs
161
+ in halfway through is identified from that moment rather than from a frame that
162
+ predates them.
163
+
164
+ ---
165
+
138
166
  ## Step 3 — Report what is ON the screen
139
167
 
140
168
  The route gives the agent a screen *name*. This gives it the contents.
@@ -218,6 +246,23 @@ updating the screen.
218
246
 
219
247
  ---
220
248
 
249
+ ### Declaring them: paste, do not retype
250
+
251
+ Keep the list in your repo — `docs/agent-actions.json` is the convention — and
252
+ load it with **Embed & Widgets → App actions → Import from JSON**. A bare array
253
+ works, so does `{ "appActions": [...] }`.
254
+
255
+ Names must match your `useGraineAction` handlers exactly, and that is the whole
256
+ argument for importing: a name one underscore out declares a tool your app will
257
+ refuse for the life of the release, and the agent keeps trying it. `enum`
258
+ constraints are preserved, so a value the model cannot get wrong stays that way.
259
+
260
+ Import replaces the panel's contents rather than merging — the file is the
261
+ source of truth, and a merge leaves actions declared here and implemented
262
+ nowhere. Save is what pushes the catalogue to the agent.
263
+
264
+ ---
265
+
221
266
  ## Step 5 — Product events, without interrupting
222
267
 
223
268
  ```tsx
@@ -235,6 +280,30 @@ and the runtime decides whether it is worth interrupting for.
235
280
 
236
281
  ---
237
282
 
283
+ ### When something should be spoken about
284
+
285
+ ```tsx
286
+ const { client } = useGraineAgent();
287
+ client.reportEvent({
288
+ name: 'mandate_failed',
289
+ detail: 'Their mandate was declined twice. Offer to switch to a card.',
290
+ });
291
+ ```
292
+
293
+ `track()` is "know this when they next ask". `reportEvent()` is "this may be
294
+ worth interrupting for". `detail` tells the agent what to OFFER — without it the
295
+ agent reads an event name aloud, which is worse than saying nothing.
296
+
297
+ Reserve it for a dead end the customer would want addressed without asking: a
298
+ save that never reached the server, an upload that failed twice, a payment
299
+ declined. Not a tap, not a navigation, not a save that worked.
300
+
301
+ The runtime still decides whether to speak: silent while it is talking, while a
302
+ reply is generating, for 20s after the last intervention, and after three in a
303
+ session.
304
+
305
+ ---
306
+
238
307
  ## Step 6 — React to the conversation
239
308
 
240
309
  ```tsx
@@ -318,6 +387,62 @@ part. Everything else is unchanged.
318
387
 
319
388
  ---
320
389
 
390
+ ## Making the bar yours
391
+
392
+ Three levels, and most apps stop at the first.
393
+
394
+ **1. The dashboard.** Accent, title and logo come from Appearance on the agent
395
+ and reach every surface without an app release. Set the colour in Graine and it
396
+ changes here. This is the level a brand team should be working at.
397
+
398
+ **2. Props, when the dashboard cannot express it.** Any prop passed wins over
399
+ the dashboard, so an app that needs something per-build still can.
400
+
401
+ ```tsx
402
+ <GraineAgentBar
403
+ scheme="auto" // "dark" | "light" | "auto" (default) — follows the device
404
+ accent="#318CE7" // overrides the dashboard
405
+ name="Ring"
406
+ bottomInset={110} // clear of your tab bar
407
+ captionsOn
408
+ />
409
+ ```
410
+
411
+ `scheme` defaults to `auto` rather than dark: an agent that ignores the system
412
+ setting is the one piece of the screen that looks like it came from somewhere
413
+ else. Pin it only if your app pins its own.
414
+
415
+ **3. The palette, when your brand is not one of the two.** `theme` is merged
416
+ over the scheme's colours, so pass the one you care about:
417
+
418
+ ```tsx
419
+ import type { GraineBarTheme } from "@graineai/inapp-react-native";
420
+
421
+ <GraineAgentBar theme={{ surface: "#0E1116", border: "#232A33" }} />
422
+ ```
423
+
424
+ | Key | Where it lands |
425
+ |---|---|
426
+ | `surface` | The bar, the expanded panel, the caption bubble |
427
+ | `border` | Hairline around all three |
428
+ | `text` | The agent's name, its replies, what the customer types |
429
+ | `sub` | Status line, placeholder, empty state |
430
+ | `chip` | Quiet round buttons, and the agent's message bubbles |
431
+ | `onAccent` | Text and glyphs sitting on the accent, and on `danger` |
432
+ | `danger` | Muted — the one state worth its own colour |
433
+
434
+ **4. Or draw your own.** Everything the bar does is built on the public hooks,
435
+ so an app with a design system should ignore the component entirely:
436
+
437
+ ```tsx
438
+ const { connected, muted, setMuted, messages, send, caption } = useGraineAgent();
439
+ ```
440
+
441
+ That is the expected path for anyone with a brand team, and nothing else in the
442
+ SDK depends on this component.
443
+
444
+ ---
445
+
321
446
  ## Session lifecycle
322
447
 
323
448
  One session per client, and it follows the app.
@@ -350,6 +475,12 @@ than opening a second socket. This matters under React 18 strict mode, which
350
475
  mounts effects twice: previously the first socket was orphaned with no reference
351
476
  to close it, and the runtime kept it open and billing until its own timeout.
352
477
 
478
+ **Nothing is left holding the call.** An outstanding action's waiter is released
479
+ in a `finally`, so a customer closing the app mid-action releases it too, and
480
+ every wait has a deadline — an app that never answers cannot hold the turn open
481
+ against someone who has already gone. A timeout is reported rather than
482
+ swallowed, because silence would have the agent claim the change landed.
483
+
353
484
  **`close()` sends a stop frame** before dropping the socket, so the conversation
354
485
  is filed as ended rather than as a customer who vanished mid-turn. It is called
355
486
  for you on unmount and on backgrounding.
package/dist/audio.js CHANGED
@@ -60,7 +60,8 @@ export function decodeAgentAudio(bytes, declared, latched, fallbackPcmRate = 160
60
60
  const pcm16 = muLawWins ? asMuLaw : asPcm;
61
61
  const kind = muLawWins ? "mulaw" : "pcm";
62
62
  const sampleRate = muLawWins ? 8000 : declared?.sampleRate || fallbackPcmRate;
63
- const confident = rms(pcm16) >= CONFIDENT_RMS;
63
+ const confident = rms(asPcm) >= CONFIDENT_RMS &&
64
+ zeroCrossingRate(asMuLaw) !== zeroCrossingRate(asPcm);
64
65
  return { pcm16, sampleRate, kind, latch: confident ? { kind, sampleRate } : null };
65
66
  }
66
67
  const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext } from "./protocol";
1
+ import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext } from "./protocol.js";
2
2
  export declare const SUBPROTOCOL = "graine.embed.v1";
3
3
  export interface GraineInAppOptions {
4
4
  baseUrl: string;
@@ -54,6 +54,12 @@ export declare class GraineInAppClient {
54
54
  private runAction;
55
55
  private invokeAction;
56
56
  getScreen(): ScreenContext | null;
57
+ getIdentity(): Record<string, string>;
58
+ getRecentEvents(): Array<{
59
+ name: string;
60
+ at: number;
61
+ data?: Record<string, unknown>;
62
+ }>;
57
63
  getAvailableActions(): string[];
58
64
  registerAction(name: string, handler: ActionHandler): () => void;
59
65
  get availableActions(): string[];
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
- import { ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol";
2
- import { maskDeep } from "./mask";
1
+ import { ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
2
+ import { maskDeep } from "./mask.js";
3
3
  export const SUBPROTOCOL = "graine.embed.v1";
4
4
  const PING_MS = 25000;
5
5
  export class GraineInAppClient {
@@ -262,6 +262,12 @@ export class GraineInAppClient {
262
262
  getScreen() {
263
263
  return this.screen;
264
264
  }
265
+ getIdentity() {
266
+ return { ...this.identity };
267
+ }
268
+ getRecentEvents() {
269
+ return [...this.recentEvents];
270
+ }
265
271
  getAvailableActions() {
266
272
  return [...this.actions.keys()];
267
273
  }
@@ -299,6 +305,7 @@ export class GraineInAppClient {
299
305
  this.scheduleScreen();
300
306
  }
301
307
  scheduleScreen() {
308
+ this.emit("screen", { screen: this.screen, actions: this.getAvailableActions() });
302
309
  if (this.contextTimer) {
303
310
  this.pendingScreen = true;
304
311
  return;
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
- export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol";
2
- export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "./client";
3
- export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice";
4
- export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio";
5
- export { addMaskRule, maskDeep, maskString } from "./mask";
6
- export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
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";
1
+ export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol.js";
2
+ export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "./client.js";
3
+ export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
4
+ export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
5
+ export { addMaskRule, maskDeep, maskString } from "./mask.js";
6
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
7
+ export { RtcVoiceSessionController, RtcVoiceError, type RtcVoiceState, type RtcVoiceErrorCode, type RtcVoiceSession, type RtcVoiceOptions, } from "./react-native/voice-rtc.js";
8
+ export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui.js";
9
+ export { GraineVoiceLauncher, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher.js";
10
+ export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation.js";
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
- export { GraineInAppClient } from "./client";
2
- export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice";
3
- export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio";
4
- export { addMaskRule, maskDeep, maskString } from "./mask";
5
- export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, } from "./react-native/index";
6
- export { GraineAgentBar, GraineLauncher } from "./react-native/ui";
7
- export { GraineVoiceLauncher, } from "./react-native/voice-launcher";
8
- export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation";
1
+ export { GraineInAppClient } from "./client.js";
2
+ export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
3
+ export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
4
+ export { addMaskRule, maskDeep, maskString } from "./mask.js";
5
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, } from "./react-native/index.js";
6
+ export { RtcVoiceSessionController, RtcVoiceError, } from "./react-native/voice-rtc.js";
7
+ export { GraineAgentBar, GraineLauncher, } from "./react-native/ui.js";
8
+ export { GraineVoiceLauncher, } from "./react-native/voice-launcher.js";
9
+ export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation.js";
@@ -1,8 +1,8 @@
1
1
  import React from "react";
2
- import { GraineInAppClient, type GraineInAppOptions } from "../client";
3
- import { type AudioAdapter, type VoiceSessionOptions } from "../voice";
4
- import type { ActionHandler, ScreenContext } from "../protocol";
5
- import { type LauncherInset, type LauncherVisibility } from "./navigation";
2
+ import { GraineInAppClient, type GraineInAppOptions } from "../client.js";
3
+ import { type AudioAdapter, type VoiceSessionOptions } from "../voice.js";
4
+ import type { ActionHandler, ScreenContext } from "../protocol.js";
5
+ import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
6
6
  interface GraineContextValue {
7
7
  client: GraineInAppClient;
8
8
  connected: boolean;
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
3
3
  import { AppState } from "react-native";
4
- import { GraineInAppClient } from "../client";
5
- import { VoiceSession } from "../voice";
6
- import { LauncherVisibilityTracker, activeRouteName, } from "./navigation";
4
+ import { GraineInAppClient } from "../client.js";
5
+ import { VoiceSession } from "../voice.js";
6
+ import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
7
7
  const Ctx = createContext(null);
8
8
  export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
9
9
  const clientRef = useRef(null);
@@ -1,4 +1,13 @@
1
1
  import React from "react";
2
+ export interface GraineBarTheme {
3
+ surface: string;
4
+ border: string;
5
+ text: string;
6
+ sub: string;
7
+ chip: string;
8
+ onAccent: string;
9
+ danger: string;
10
+ }
2
11
  export interface GraineAgentBarProps {
3
12
  accent?: string;
4
13
  name?: string;
@@ -6,7 +15,9 @@ export interface GraineAgentBarProps {
6
15
  bottomInset?: number;
7
16
  hidden?: boolean;
8
17
  captionsOn?: boolean;
18
+ scheme?: "dark" | "light" | "auto";
19
+ theme?: Partial<GraineBarTheme>;
9
20
  }
10
- export declare function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset, hidden, captionsOn, }: GraineAgentBarProps): React.JSX.Element | null;
21
+ export declare function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset, hidden, captionsOn, scheme, theme: themeProp, }: GraineAgentBarProps): React.JSX.Element | null;
11
22
  export declare const GraineLauncher: typeof GraineAgentBar;
12
23
  export type GraineLauncherProps = GraineAgentBarProps;
@@ -1,12 +1,35 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState } from "react";
3
- import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
4
- import { useGraineAgent } from "./index";
5
- export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset = 96, hidden = false, captionsOn = false, }) {
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
4
+ import { useGraineAgent } from "./index.js";
5
+ const DARK = {
6
+ surface: "#17161A",
7
+ border: "rgba(255,255,255,0.09)",
8
+ text: "#F2F3F5",
9
+ sub: "#9aa0a6",
10
+ chip: "rgba(255,255,255,0.08)",
11
+ onAccent: "#FFFFFF",
12
+ danger: "#E5484D",
13
+ };
14
+ const LIGHT = {
15
+ surface: "#FFFFFF",
16
+ border: "rgba(0,0,0,0.10)",
17
+ text: "#16181C",
18
+ sub: "#61666D",
19
+ chip: "rgba(0,0,0,0.05)",
20
+ onAccent: "#FFFFFF",
21
+ danger: "#D32F2F",
22
+ };
23
+ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset = 96, hidden = false, captionsOn = false, scheme = "auto", theme: themeProp, }) {
6
24
  const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, appearance, } = useGraineAgent();
7
25
  const accent = accentProp ?? appearance?.accent ?? "#318CE7";
8
26
  const name = nameProp ?? appearance?.title ?? "Assistant";
9
27
  const avatarUrl = avatarProp ?? appearance?.logo ?? undefined;
28
+ const deviceScheme = useColorScheme();
29
+ const styles = useMemo(() => {
30
+ const base = (scheme === "auto" ? deviceScheme === "light" : scheme === "light") ? LIGHT : DARK;
31
+ return makeStyles(themeProp ? { ...base, ...themeProp } : base);
32
+ }, [scheme, deviceScheme, themeProp]);
10
33
  const [expanded, setExpanded] = useState(false);
11
34
  const [captions, setCaptions] = useState(captionsOn);
12
35
  const [draft, setDraft] = useState("");
@@ -18,7 +41,7 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
18
41
  : muted ? "muted"
19
42
  : agentSpeaking || (last?.role === "agent" && last.live) ? "speaking"
20
43
  : connected ? "listening"
21
- : "starting…";
44
+ : "offline";
22
45
  useEffect(() => {
23
46
  if (expanded)
24
47
  requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
@@ -46,51 +69,55 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
46
69
  ], 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: [
47
70
  styles.bubble,
48
71
  m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
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 ? "⌄" : "⌃" }) })] })] }));
72
+ ], 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: styles.placeholder.color, 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 && styles.glyphOnAccent], 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 && styles.glyphOnAccent], 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 ? "⌄" : "⌃" }) })] })] }));
50
73
  }
51
74
  export const GraineLauncher = GraineAgentBar;
52
- const styles = StyleSheet.create({
75
+ const makeStyles = (t) => StyleSheet.create({
53
76
  root: { position: "absolute", left: 12, right: 12, zIndex: 900 },
54
77
  panel: {
55
- backgroundColor: "#17161A", borderRadius: 22, borderWidth: 1, borderColor: "rgba(255,255,255,0.09)",
78
+ backgroundColor: t.surface, borderRadius: 22, borderWidth: 1, borderColor: t.border,
56
79
  marginBottom: 8, overflow: "hidden",
57
80
  },
58
81
  transcript: { maxHeight: 240 },
59
82
  transcriptInner: { padding: 14, gap: 8 },
60
- empty: { color: "#9aa0a6", fontSize: 14, lineHeight: 21 },
83
+ empty: { color: t.sub, fontSize: 14, lineHeight: 21 },
61
84
  bubble: { maxWidth: "88%", paddingHorizontal: 12, paddingVertical: 9, borderRadius: 16 },
62
- agentBubble: { alignSelf: "flex-start", backgroundColor: "rgba(255,255,255,0.08)", borderBottomLeftRadius: 5 },
85
+ agentBubble: { alignSelf: "flex-start", backgroundColor: t.chip, borderBottomLeftRadius: 5 },
63
86
  customerBubble: { alignSelf: "flex-end", borderBottomRightRadius: 5 },
64
- agentText: { color: "#F2F3F5", fontSize: 14.5, lineHeight: 21 },
65
- customerText: { color: "#fff", fontSize: 14.5, lineHeight: 21 },
87
+ agentText: { color: t.text, fontSize: 14.5, lineHeight: 21 },
88
+ customerText: { color: t.onAccent, fontSize: 14.5, lineHeight: 21 },
66
89
  composer: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingBottom: 12 },
67
90
  input: {
68
- flex: 1, borderWidth: 1, borderColor: "rgba(255,255,255,0.12)", borderRadius: 16,
69
- paddingHorizontal: 12, paddingVertical: Platform.OS === "ios" ? 10 : 7, color: "#F2F3F5", fontSize: 14.5,
91
+ flex: 1, borderWidth: 1, borderColor: t.border, borderRadius: 16,
92
+ paddingHorizontal: 12, paddingVertical: Platform.OS === "ios" ? 10 : 7, color: t.text, fontSize: 14.5,
70
93
  },
71
94
  send: { width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center" },
72
- sendLabel: { color: "#fff", fontSize: 17, lineHeight: 20 },
95
+ sendLabel: { color: t.onAccent, fontSize: 17, lineHeight: 20 },
73
96
  bar: {
74
97
  flexDirection: "row", alignItems: "center", gap: 11,
75
- backgroundColor: "#17161A", borderRadius: 26, borderWidth: 1, borderColor: "rgba(255,255,255,0.09)",
98
+ backgroundColor: t.surface, borderRadius: 26, borderWidth: 1, borderColor: t.border,
76
99
  paddingHorizontal: 8, paddingVertical: 8,
77
100
  shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 16, shadowOffset: { width: 0, height: 6 }, elevation: 12,
78
101
  },
79
102
  avatar: { width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center" },
80
- avatarImage: { width: 38, height: 38, borderRadius: 19, backgroundColor: "rgba(255,255,255,0.08)" },
103
+ avatarImage: { width: 38, height: 38, borderRadius: 19, backgroundColor: t.chip },
81
104
  captionWrap: {
82
- backgroundColor: "rgba(0,0,0,0.72)", borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, marginBottom: 8,
105
+ backgroundColor: t.surface, borderRadius: 18, borderWidth: 1, borderColor: t.border,
106
+ paddingHorizontal: 14, paddingVertical: 11, marginBottom: 8,
107
+ shadowColor: "#000", shadowOpacity: 0.18, shadowRadius: 12, shadowOffset: { width: 0, height: 4 }, elevation: 6,
83
108
  },
84
- caption: { color: "#fff", fontSize: 15, lineHeight: 21, fontWeight: "600" },
109
+ caption: { color: t.text, fontSize: 15, lineHeight: 21, fontWeight: "600" },
85
110
  captionLive: { opacity: 0.8 },
86
- glyph: { color: "#F2F3F5", fontSize: 13, fontWeight: "800" },
87
- muted: { backgroundColor: "#E5484D" },
88
- avatarGlyph: { color: "#fff", fontSize: 17, lineHeight: 20 },
89
- name: { color: "#F2F3F5", fontSize: 15, fontWeight: "800" },
90
- status: { color: "#9aa0a6", fontSize: 12.5, marginTop: 1 },
111
+ glyph: { color: t.text, fontSize: 13, fontWeight: "800" },
112
+ glyphOnAccent: { color: t.onAccent },
113
+ placeholder: { color: t.sub },
114
+ muted: { backgroundColor: t.danger },
115
+ avatarGlyph: { color: t.onAccent, fontSize: 17, lineHeight: 20 },
116
+ name: { color: t.text, fontSize: 15, fontWeight: "800" },
117
+ status: { color: t.sub, fontSize: 12.5, marginTop: 1 },
91
118
  round: {
92
119
  width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center",
93
- backgroundColor: "rgba(255,255,255,0.08)",
120
+ backgroundColor: t.chip,
94
121
  },
95
- chevron: { color: "#F2F3F5", fontSize: 16, lineHeight: 18 },
122
+ chevron: { color: t.text, fontSize: 16, lineHeight: 18 },
96
123
  });
@@ -1,10 +1,11 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { AppState, View } from "react-native";
4
- import { useGraineAgent } from "./index";
4
+ import { useGraineAgent } from "./index.js";
5
5
  export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCaption, onCallState, onMicDenied, onEnded, children, }) {
6
6
  const { client, appearance } = useGraineAgent();
7
7
  const ref = useRef(null);
8
+ const startedRef = useRef(false);
8
9
  const [connected, setConnected] = useState(false);
9
10
  const [connecting, setConnecting] = useState(false);
10
11
  const [muted, setMutedState] = useState(false);
@@ -40,12 +41,16 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
40
41
  case "graine:ready":
41
42
  setReady(true);
42
43
  pushContext();
43
- if (autoStart)
44
+ if (autoStart && !startedRef.current) {
45
+ startedRef.current = true;
44
46
  post({ type: "graine:start-call" });
47
+ }
45
48
  return;
46
49
  case "graine:call":
47
50
  setConnected(!!msg.connected);
48
51
  setConnecting(!!msg.connecting);
52
+ if (!msg.connected && !msg.connecting)
53
+ startedRef.current = false;
49
54
  onCallState?.({ connected: !!msg.connected, connecting: !!msg.connecting });
50
55
  return;
51
56
  case "graine:muted":
@@ -93,13 +98,28 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
93
98
  useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
94
99
  useEffect(() => {
95
100
  let last = AppState.currentState;
101
+ let pending = null;
102
+ const CONFIRM_MS = 2000;
96
103
  const sub = AppState.addEventListener("change", (next) => {
97
104
  const wasActive = last === "active";
98
105
  last = next;
99
- if (next === "background" && wasActive)
100
- post({ type: "graine:end-call" });
106
+ if (next === "active") {
107
+ if (pending) {
108
+ clearTimeout(pending);
109
+ pending = null;
110
+ }
111
+ return;
112
+ }
113
+ if (next === "background" && wasActive && !pending) {
114
+ pending = setTimeout(() => {
115
+ pending = null;
116
+ if (AppState.currentState !== "active")
117
+ post({ type: "graine:end-call" });
118
+ }, CONFIRM_MS);
119
+ }
101
120
  });
102
- return () => sub.remove();
121
+ return () => { if (pending)
122
+ clearTimeout(pending); sub.remove(); };
103
123
  }, [post]);
104
124
  const agentId = client.config?.agentId;
105
125
  if (!agentId)
@@ -0,0 +1,41 @@
1
+ export type RtcVoiceState = "idle" | "connecting" | "ringing" | "active" | "ended";
2
+ export type RtcVoiceErrorCode = "mint_failed" | "microphone_denied" | "connect_failed" | "already_active";
3
+ export declare class RtcVoiceError extends Error {
4
+ readonly code: RtcVoiceErrorCode;
5
+ readonly cause?: unknown;
6
+ constructor(code: RtcVoiceErrorCode, message: string, cause?: unknown);
7
+ }
8
+ export interface RtcVoiceSession {
9
+ server: string;
10
+ username: string;
11
+ password: string;
12
+ realm: string;
13
+ application_sid: string;
14
+ agent_id?: string;
15
+ expires_at?: string;
16
+ }
17
+ export interface RtcVoiceOptions {
18
+ publishableKey?: string;
19
+ baseUrl?: string;
20
+ getSession?: () => Promise<RtcVoiceSession>;
21
+ onState?: (s: RtcVoiceState) => void;
22
+ onEnded?: (reason: string) => void;
23
+ onLog?: (line: string) => void;
24
+ sessionId?: string;
25
+ }
26
+ export declare class RtcVoiceSessionController {
27
+ private client;
28
+ private call;
29
+ private state;
30
+ private startToken;
31
+ readonly sessionId: string;
32
+ private opts;
33
+ constructor(opts: RtcVoiceOptions);
34
+ getState(): RtcVoiceState;
35
+ private set;
36
+ private log;
37
+ private mint;
38
+ start(): Promise<void>;
39
+ setMuted(muted: boolean): void;
40
+ stop(): Promise<void>;
41
+ }
@@ -0,0 +1,143 @@
1
+ export class RtcVoiceError extends Error {
2
+ constructor(code, message, cause) {
3
+ super(message);
4
+ this.name = "RtcVoiceError";
5
+ this.code = code;
6
+ this.cause = cause;
7
+ }
8
+ }
9
+ function newSessionId() {
10
+ return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
11
+ }
12
+ export class RtcVoiceSessionController {
13
+ constructor(opts) {
14
+ this.client = null;
15
+ this.call = null;
16
+ this.state = "idle";
17
+ this.startToken = null;
18
+ this.opts = opts;
19
+ this.sessionId = opts.sessionId || newSessionId();
20
+ }
21
+ getState() {
22
+ return this.state;
23
+ }
24
+ set(s) {
25
+ this.state = s;
26
+ this.opts.onState?.(s);
27
+ }
28
+ log(l) {
29
+ this.opts.onLog?.(l);
30
+ }
31
+ async mint() {
32
+ if (this.opts.getSession)
33
+ return this.opts.getSession();
34
+ if (!this.opts.publishableKey) {
35
+ throw new RtcVoiceError("mint_failed", "Pass publishableKey (or getSession for a custom backend).");
36
+ }
37
+ const base = (this.opts.baseUrl || "https://www.graine.ai").replace(/\/$/, "");
38
+ let res;
39
+ try {
40
+ res = await fetch(`${base}/api/embed/rtc-session`, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify({ publishableKey: this.opts.publishableKey }),
44
+ });
45
+ }
46
+ catch (e) {
47
+ throw new RtcVoiceError("mint_failed", "Could not reach the call service.", e);
48
+ }
49
+ if (!res.ok) {
50
+ let detail = "";
51
+ try {
52
+ detail = (await res.json())?.error || "";
53
+ }
54
+ catch {
55
+ }
56
+ throw new RtcVoiceError("mint_failed", detail || `Could not start a call (${res.status}).`);
57
+ }
58
+ return res.json();
59
+ }
60
+ async start() {
61
+ if (this.state !== "idle" && this.state !== "ended") {
62
+ throw new RtcVoiceError("already_active", "A call is already in progress.");
63
+ }
64
+ const token = (this.startToken = Symbol("start"));
65
+ this.set("connecting");
66
+ try {
67
+ const session = await this.mint();
68
+ if (token !== this.startToken)
69
+ return;
70
+ this.log(`session for ${session.realm}`);
71
+ const { createJambonzClient } = await import("@jambonz/client-sdk-react-native");
72
+ this.client = createJambonzClient({
73
+ server: session.server,
74
+ username: session.username,
75
+ password: session.password,
76
+ realm: session.realm,
77
+ });
78
+ this.client.on("error", (e) => this.log(`client error: ${e?.message ?? e}`));
79
+ await this.client.connect();
80
+ if (token !== this.startToken) {
81
+ await this.stop();
82
+ return;
83
+ }
84
+ this.log("registered");
85
+ this.call = this.client.callApplication(session.application_sid, {
86
+ headers: {
87
+ "X-Graine-Session-Id": this.sessionId,
88
+ ...(session.agent_id ? { "X-Graine-Agent-Id": session.agent_id } : {}),
89
+ },
90
+ });
91
+ this.call.on("stateChanged", (s) => this.log(`call: ${s}`));
92
+ this.call.on("accepted", () => {
93
+ this.set("active");
94
+ this.log("audio flowing");
95
+ });
96
+ this.call.on("ended", (c) => {
97
+ this.set("ended");
98
+ this.opts.onEnded?.(c?.reason || "ended");
99
+ });
100
+ this.call.on("failed", (c) => {
101
+ this.set("ended");
102
+ this.opts.onEnded?.(c?.reason || "failed");
103
+ });
104
+ this.set("ringing");
105
+ }
106
+ catch (err) {
107
+ if (token !== this.startToken)
108
+ return;
109
+ this.set("ended");
110
+ throw err instanceof RtcVoiceError
111
+ ? err
112
+ : new RtcVoiceError("connect_failed", err?.message || "Could not connect.", err);
113
+ }
114
+ }
115
+ setMuted(muted) {
116
+ try {
117
+ if (muted)
118
+ this.call?.mute?.();
119
+ else
120
+ this.call?.unmute?.();
121
+ }
122
+ catch {
123
+ }
124
+ }
125
+ async stop() {
126
+ if (this.state === "idle" || this.state === "ended")
127
+ return;
128
+ this.startToken = null;
129
+ try {
130
+ this.call?.hangup?.();
131
+ }
132
+ catch {
133
+ }
134
+ try {
135
+ await this.client?.disconnect?.();
136
+ }
137
+ catch {
138
+ }
139
+ this.call = null;
140
+ this.client = null;
141
+ this.set("ended");
142
+ }
143
+ }
package/dist/voice.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { GraineInAppClient } from "./client";
1
+ import type { GraineInAppClient } from "./client.js";
2
2
  export declare const CAPTURE_SAMPLE_RATE = 16000;
3
3
  export declare const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
4
4
  export interface AudioAdapter {
package/dist/voice.js CHANGED
@@ -1,4 +1,4 @@
1
- import { EchoGuard, base64ToBytes, decodeAgentAudio, decodePcm16 } from "./audio";
1
+ import { EchoGuard, base64ToBytes, decodeAgentAudio, decodePcm16 } from "./audio.js";
2
2
  export const CAPTURE_SAMPLE_RATE = 16000;
3
3
  export const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
4
4
  export function base64ToPcm16(b64) {
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.11.1",
3
+ "version": "0.15.0",
4
+ "type": "module",
4
5
  "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
5
6
  "license": "MIT",
6
7
  "private": false,
@@ -35,7 +36,8 @@
35
36
  },
36
37
  "peerDependencies": {
37
38
  "react": ">=17",
38
- "react-native": ">=0.68"
39
+ "react-native": ">=0.68",
40
+ "@jambonz/client-sdk-react-native": "^0.1.4"
39
41
  },
40
42
  "devDependencies": {
41
43
  "@types/react": "^18.2.0",
@@ -60,5 +62,10 @@
60
62
  "homepage": "https://www.graine.ai/docs/in-app",
61
63
  "bugs": {
62
64
  "url": "https://www.graine.ai/docs/in-app"
65
+ },
66
+ "peerDependenciesMeta": {
67
+ "@jambonz/client-sdk-react-native": {
68
+ "optional": true
69
+ }
63
70
  }
64
71
  }