@graineai/inapp-react-native 0.1.0 → 0.3.1

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/README.md CHANGED
@@ -9,30 +9,57 @@ Android and iOS from one package.
9
9
  npm install @graineai/inapp-react-native
10
10
  ```
11
11
 
12
- Full documentation: **https://app.graine.ai/docs/in-app**
12
+ Full documentation: **https://www.graine.ai/docs/in-app**
13
+
14
+ ---
15
+
16
+ ## Before you write any code
17
+
18
+ **Add `app://` to the agent's Allowed domains** — dashboard → your agent →
19
+ Embed & Widgets → Allowed domains.
20
+
21
+ A mobile app sends no `Origin` header; there is no page, so there is nothing for
22
+ a browser to report. Without that entry every call is refused:
23
+
24
+ ```
25
+ 403 This endpoint must be called from a browser page.
26
+ ```
27
+
28
+ Nothing in your code fixes that, which is why it is the first thing on this
29
+ page. The SDK detects this specific failure and says so, rather than passing the
30
+ server's browser-shaped message through.
13
31
 
14
32
  ---
15
33
 
16
34
  ## Quick start
17
35
 
18
36
  ```tsx
19
- import { GraineProvider, GraineLauncher } from "@graineai/inapp-react-native";
37
+ import { GraineProvider, GraineAgentBar } from "@graineai/inapp-react-native";
20
38
 
21
39
  export default function App() {
22
40
  return (
23
41
  <GraineProvider
24
- baseUrl="https://app.graine.ai"
42
+ baseUrl="https://www.graine.ai"
25
43
  publishableKey="pk_live_…"
26
44
  variables={{ customer_name: user.firstName }}
27
45
  >
28
46
  <RootNavigator />
29
- <GraineLauncher accent="#318CE7" title="Assistant" />
47
+ <GraineAgentBar accent="#318CE7" name="Assistant" />
30
48
  </GraineProvider>
31
49
  );
32
50
  }
33
51
  ```
34
52
 
35
- Your publishable key is in the Graine dashboard under **Install & domains**.
53
+ Your publishable key is in the dashboard under **Embed & Widgets**.
54
+
55
+ **Mount the bar at your root, never inside a screen.** The agent's job is to
56
+ move the customer to the right screen — and a bar rendered inside one unmounts
57
+ the instant it does, killing the conversation on the very action that proves the
58
+ feature works.
59
+
60
+ It is a bar rather than a sheet for the same reason: a sheet covers the screen
61
+ the agent is helping with, so the moment it says "tap Continue", Continue is
62
+ behind it.
36
63
 
37
64
  ## Tell the agent what the customer can see
38
65
 
@@ -55,8 +82,17 @@ discuss a screen the customer has left.
55
82
 
56
83
  ## Let the agent act
57
84
 
58
- Declare the action on your agent in the dashboard, then implement it where it
59
- belongs:
85
+ Declare it in the dashboard first **Embed & Widgets What this agent may do
86
+ in your app**. Presets cover navigate, fill a field, highlight and retry. Saving
87
+ there also pushes the tools to the agent; a declaration that is saved but not
88
+ synced leaves the agent with the tools it had before, and looks like nothing
89
+ happened.
90
+
91
+ Declared server-side and nowhere else: a client that could name its own tools
92
+ could invent one and talk the model into calling it. The SDK may only narrow the
93
+ list, never extend it.
94
+
95
+ Then implement a handler with the same name, where it belongs:
60
96
 
61
97
  ```tsx
62
98
  useGraineAction("fill_pan_from_aadhaar", async () => {
@@ -94,6 +130,17 @@ particular native audio module into your build. `liveAudioStreamAdapter` wraps
94
130
 
95
131
  Capture must be **16 kHz mono PCM16**. Interruption is handled for you.
96
132
 
133
+ **Buffer before you play.** A player that plays each frame as it arrives will
134
+ break up: a WebSocket gives no timing guarantee, so the gap between frames
135
+ varies with the network and a player with no cushion runs dry in that gap. Hold
136
+ **at least 150ms** (`MIN_PLAYOUT_BUFFER_SECONDS`) before starting, and when the
137
+ queue does run dry, refill to that again before resuming — otherwise one hiccup
138
+ becomes continuous stuttering for the rest of the turn.
139
+
140
+ Report what you are holding through `bufferedSeconds()` and the SDK will
141
+ acknowledge the agent's audio at playout rather than on arrival, which is what
142
+ the runtime uses to know when it has finished speaking.
143
+
97
144
  ## Privacy
98
145
 
99
146
  Identifiers are masked on the device before any screen data is sent — PAN,
@@ -117,7 +164,7 @@ requests a permission on your behalf.
117
164
  > from an APK or IPA, and the web domain allowlist does not apply to mobile
118
165
  > apps. Rate limits, single-use connection tickets and your account's credit
119
166
  > limits are what bound it. See the
120
- > [security notes](https://app.graine.ai/docs/in-app/privacy) before launch.
167
+ > [security notes](https://www.graine.ai/docs/in-app/privacy) before launch.
121
168
 
122
169
  ## Requirements
123
170
 
@@ -133,7 +180,7 @@ requests a permission on your behalf.
133
180
  | `useGraineScreen(context)` | Report what this screen shows. |
134
181
  | `useGraineAction(name, handler)` | Implement one action the agent may call. |
135
182
  | `useGraineVoice(adapter, opts)` | Microphone, playback and barge-in. |
136
- | `GraineLauncher` | Optional default UI. Replace it with your own. |
183
+ | `GraineAgentBar` | Optional default UI. Mount at the root. Replace it with your own. |
137
184
  | `addMaskRule(pattern, token)` | Mask an app-specific identifier format. |
138
185
 
139
186
  `GraineInAppClient` is exported for apps that want the transport without the
@@ -141,7 +188,7 @@ React layer.
141
188
 
142
189
  ## Support
143
190
 
144
- https://app.graine.ai/docs/in-app
191
+ https://www.graine.ai/docs/in-app
145
192
 
146
193
  ## License
147
194
 
@@ -0,0 +1,17 @@
1
+ export interface AudioFormat {
2
+ kind: "mulaw" | "pcm";
3
+ sampleRate: number;
4
+ }
5
+ export declare function decodeMuLaw(bytes: Uint8Array): Int16Array;
6
+ export declare function decodePcm16(bytes: Uint8Array): Int16Array;
7
+ export interface DecodedAudio {
8
+ pcm16: Int16Array;
9
+ sampleRate: number;
10
+ kind: "mulaw" | "pcm";
11
+ latch: AudioFormat | null;
12
+ }
13
+ export declare function decodeAgentAudio(bytes: Uint8Array, declared: {
14
+ format?: string | null;
15
+ sampleRate?: number | null;
16
+ } | null, latched: AudioFormat | null, fallbackPcmRate?: number): DecodedAudio | null;
17
+ export declare function base64ToBytes(b64: string): Uint8Array;
package/dist/audio.js ADDED
@@ -0,0 +1,82 @@
1
+ export function decodeMuLaw(bytes) {
2
+ const out = new Int16Array(bytes.length);
3
+ const BIAS = 0x84;
4
+ for (let i = 0; i < bytes.length; i++) {
5
+ const mu = ~bytes[i] & 0xff;
6
+ const sign = mu & 0x80;
7
+ const exponent = (mu >> 4) & 0x07;
8
+ const mantissa = mu & 0x0f;
9
+ const t = ((mantissa << 3) + BIAS) << exponent;
10
+ out[i] = sign ? BIAS - t : t - BIAS;
11
+ }
12
+ return out;
13
+ }
14
+ export function decodePcm16(bytes) {
15
+ const out = new Int16Array(bytes.length >> 1);
16
+ for (let i = 0; i < out.length; i++) {
17
+ out[i] = ((bytes[i * 2] | (bytes[i * 2 + 1] << 8)) << 16) >> 16;
18
+ }
19
+ return out;
20
+ }
21
+ function zeroCrossingRate(pcm) {
22
+ if (pcm.length < 2)
23
+ return 1;
24
+ let crossings = 0;
25
+ for (let i = 1; i < pcm.length; i++) {
26
+ if ((pcm[i] < 0) !== (pcm[i - 1] < 0))
27
+ crossings++;
28
+ }
29
+ return crossings / (pcm.length - 1);
30
+ }
31
+ function rms(pcm) {
32
+ if (pcm.length === 0)
33
+ return 0;
34
+ let sum = 0;
35
+ for (let i = 0; i < pcm.length; i++)
36
+ sum += pcm[i] * pcm[i];
37
+ return Math.sqrt(sum / pcm.length);
38
+ }
39
+ const CONFIDENT_RMS = 500;
40
+ export function decodeAgentAudio(bytes, declared, latched, fallbackPcmRate = 16000) {
41
+ if (bytes.length === 0)
42
+ return null;
43
+ const fmt = declared?.format?.toLowerCase();
44
+ if (fmt === "mulaw" || fmt === "ulaw" || fmt === "g711_ulaw" || fmt === "pcmu") {
45
+ const sampleRate = declared?.sampleRate || 8000;
46
+ return { pcm16: decodeMuLaw(bytes), sampleRate, kind: "mulaw", latch: { kind: "mulaw", sampleRate } };
47
+ }
48
+ if (fmt === "pcm" || fmt === "linear16" || fmt === "raw") {
49
+ const sampleRate = declared?.sampleRate || fallbackPcmRate;
50
+ return { pcm16: decodePcm16(bytes), sampleRate, kind: "pcm", latch: { kind: "pcm", sampleRate } };
51
+ }
52
+ if (latched) {
53
+ return latched.kind === "mulaw"
54
+ ? { pcm16: decodeMuLaw(bytes), sampleRate: latched.sampleRate, kind: "mulaw", latch: latched }
55
+ : { pcm16: decodePcm16(bytes), sampleRate: latched.sampleRate, kind: "pcm", latch: latched };
56
+ }
57
+ const asMuLaw = decodeMuLaw(bytes);
58
+ const asPcm = decodePcm16(bytes);
59
+ const muLawWins = zeroCrossingRate(asMuLaw) <= zeroCrossingRate(asPcm);
60
+ const pcm16 = muLawWins ? asMuLaw : asPcm;
61
+ const kind = muLawWins ? "mulaw" : "pcm";
62
+ const sampleRate = muLawWins ? 8000 : declared?.sampleRate || fallbackPcmRate;
63
+ const confident = rms(pcm16) >= CONFIDENT_RMS;
64
+ return { pcm16, sampleRate, kind, latch: confident ? { kind, sampleRate } : null };
65
+ }
66
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
67
+ export function base64ToBytes(b64) {
68
+ const clean = b64.replace(/[^A-Za-z0-9+/]/g, "");
69
+ const bytes = new Uint8Array((clean.length * 3) >> 2);
70
+ let byte = 0;
71
+ let acc = 0;
72
+ let bits = 0;
73
+ for (let i = 0; i < clean.length; i++) {
74
+ acc = (acc << 6) | B64.indexOf(clean[i]);
75
+ bits += 6;
76
+ if (bits >= 8) {
77
+ bits -= 8;
78
+ bytes[byte++] = (acc >> bits) & 0xff;
79
+ }
80
+ }
81
+ return bytes.subarray(0, byte);
82
+ }
package/dist/client.d.ts CHANGED
@@ -35,6 +35,8 @@ export declare class GraineInAppClient {
35
35
  private pingTimer;
36
36
  private agentSpeaking;
37
37
  private bargedIn;
38
+ private muted;
39
+ private playoutClock;
38
40
  constructor(opts: GraineInAppOptions);
39
41
  on(event: string, fn: Listener): () => void;
40
42
  private emit;
@@ -56,6 +58,9 @@ export declare class GraineInAppClient {
56
58
  say(text: string): boolean;
57
59
  sendAudio(base64: string, sampleRate?: number): boolean;
58
60
  endUtterance(): boolean;
61
+ setMuted(muted: boolean): void;
62
+ get isMuted(): boolean;
63
+ setPlayoutClock(fn: (() => number) | null): void;
59
64
  get isAgentSpeaking(): boolean;
60
65
  submitWidget(widgetId: string, data: Record<string, unknown>, summary: string): void;
61
66
  private stopTimers;
package/dist/client.js CHANGED
@@ -19,6 +19,8 @@ export class GraineInAppClient {
19
19
  this.pingTimer = null;
20
20
  this.agentSpeaking = false;
21
21
  this.bargedIn = false;
22
+ this.muted = false;
23
+ this.playoutClock = null;
22
24
  if (!opts?.publishableKey)
23
25
  throw new Error("[Graine] publishableKey is required.");
24
26
  if (!opts?.baseUrl)
@@ -51,8 +53,16 @@ export class GraineInAppClient {
51
53
  body: JSON.stringify({ publishableKey: this.opts.publishableKey }),
52
54
  });
53
55
  const data = await res.json().catch(() => ({}));
54
- if (!res.ok)
56
+ if (!res.ok) {
57
+ if (res.status === 403) {
58
+ throw new Error(`This agent does not accept connections from a mobile app yet. Add "app://" to its ` +
59
+ `Allowed domains in the dashboard, under Embed & Widgets. (server said: ${data?.error ?? "forbidden"})`);
60
+ }
61
+ if (res.status === 401) {
62
+ throw new Error(`Publishable key was rejected. Check it against Embed & Widgets in the dashboard.`);
63
+ }
55
64
  throw new Error(data?.error || `Embed rejected (${res.status})`);
65
+ }
56
66
  this.config = data;
57
67
  return this.config;
58
68
  }
@@ -61,7 +71,10 @@ export class GraineInAppClient {
61
71
  const res = await this.fetch(`${this.opts.baseUrl}/api/embed/ticket`, {
62
72
  method: "POST",
63
73
  headers: { "Content-Type": "application/json" },
64
- body: JSON.stringify({ publishableKey: this.opts.publishableKey }),
74
+ body: JSON.stringify({
75
+ publishableKey: this.opts.publishableKey,
76
+ mode: this.opts.voice ? "voice" : "text",
77
+ }),
65
78
  });
66
79
  if (!res.ok)
67
80
  return null;
@@ -110,7 +123,11 @@ export class GraineInAppClient {
110
123
  };
111
124
  this.send({
112
125
  type: "init",
113
- meta_data: { context_data: { ...(this.config.variables ?? {}), ...(this.opts.variables ?? {}) } },
126
+ event: "start",
127
+ meta_data: {
128
+ client: "graine-inapp-sdk",
129
+ context_data: { ...(this.config.variables ?? {}), ...(this.opts.variables ?? {}) },
130
+ },
114
131
  });
115
132
  this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
116
133
  if (this.screen)
@@ -150,7 +167,8 @@ export class GraineInAppClient {
150
167
  case "audio":
151
168
  this.emit("audio", {
152
169
  data: msg.data,
153
- sampleRate: msg.meta_info?.sample_rate ?? 16000,
170
+ format: msg.meta_info?.format ?? null,
171
+ sampleRate: msg.meta_info?.sample_rate ?? null,
154
172
  });
155
173
  break;
156
174
  case "beginning_of_stream":
@@ -172,6 +190,18 @@ export class GraineInAppClient {
172
190
  case "transcript":
173
191
  this.emit("transcript", { text: msg.data ?? "", final: true });
174
192
  break;
193
+ case "mark": {
194
+ const name = msg.name;
195
+ if (!name)
196
+ break;
197
+ const waitMs = Math.min(Math.max(0, (this.playoutClock?.() ?? 0) * 1000), 10000);
198
+ const ack = () => this.send({ type: "mark", name });
199
+ if (waitMs <= 0)
200
+ ack();
201
+ else
202
+ setTimeout(ack, waitMs);
203
+ break;
204
+ }
175
205
  case "pong":
176
206
  case "ack":
177
207
  break;
@@ -288,6 +318,8 @@ export class GraineInAppClient {
288
318
  return this.send({ type: "text", data: text });
289
319
  }
290
320
  sendAudio(base64, sampleRate = 16000) {
321
+ if (this.muted)
322
+ return false;
291
323
  if (this.agentSpeaking && !this.bargedIn) {
292
324
  this.bargedIn = true;
293
325
  this.send({ type: "barge_in" });
@@ -304,6 +336,18 @@ export class GraineInAppClient {
304
336
  endUtterance() {
305
337
  return this.send({ type: "utterance_end" });
306
338
  }
339
+ setMuted(muted) {
340
+ if (this.muted === muted)
341
+ return;
342
+ this.muted = muted;
343
+ this.emit("muted", muted);
344
+ }
345
+ get isMuted() {
346
+ return this.muted;
347
+ }
348
+ setPlayoutClock(fn) {
349
+ this.playoutClock = fn;
350
+ }
307
351
  get isAgentSpeaking() {
308
352
  return this.agentSpeaking;
309
353
  }
@@ -320,11 +364,22 @@ export class GraineInAppClient {
320
364
  }
321
365
  close() {
322
366
  this.stopTimers();
367
+ const sock = this.ws;
368
+ this.ws = null;
369
+ if (!sock)
370
+ return;
323
371
  try {
324
- this.ws?.close();
372
+ if (sock.readyState === 1) {
373
+ sock.send(JSON.stringify({ type: "stop", event: "stop", data: "client_hangup" }));
374
+ setTimeout(() => { try {
375
+ sock.close();
376
+ }
377
+ catch { } }, 200);
378
+ return;
379
+ }
380
+ sock.close();
325
381
  }
326
382
  catch {
327
383
  }
328
- this.ws = null;
329
384
  }
330
385
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol";
2
2
  export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "./client";
3
3
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice";
4
+ export { decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio";
4
5
  export { addMaskRule, maskDeep, maskString } from "./mask";
5
- export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, type GraineProviderProps, type Turn, } from "./react-native/index";
6
- export { GraineLauncher, type GraineLauncherProps } from "./react-native/ui";
6
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
7
+ export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps } from "./react-native/ui";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { GraineInAppClient } from "./client";
2
2
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice";
3
+ export { decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio";
3
4
  export { addMaskRule, maskDeep, maskString } from "./mask";
4
5
  export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, } from "./react-native/index";
5
- export { GraineLauncher } from "./react-native/ui";
6
+ export { GraineAgentBar, GraineLauncher } from "./react-native/ui";
@@ -12,6 +12,15 @@ interface GraineContextValue {
12
12
  messages: Turn[];
13
13
  widgets: any[];
14
14
  send: (text: string) => void;
15
+ muted: boolean;
16
+ setMuted: (muted: boolean) => void;
17
+ agentSpeaking: boolean;
18
+ caption: Caption | null;
19
+ }
20
+ export interface Caption {
21
+ role: "agent" | "customer";
22
+ text: string;
23
+ live: boolean;
15
24
  }
16
25
  export interface Turn {
17
26
  role: "agent" | "customer";
@@ -14,6 +14,9 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
14
14
  const [open, setOpen] = useState(false);
15
15
  const [messages, setMessages] = useState([]);
16
16
  const [widgets, setWidgets] = useState([]);
17
+ const [muted, setMutedState] = useState(false);
18
+ const [agentSpeaking, setAgentSpeaking] = useState(false);
19
+ const [caption, setCaption] = useState(null);
17
20
  const proactiveRef = useRef(onProactive);
18
21
  proactiveRef.current = onProactive;
19
22
  const openRef = useRef(open);
@@ -39,6 +42,19 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
39
42
  return [...prev, { role: "agent", text, live: true }];
40
43
  })),
41
44
  client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
45
+ client.on("muted", (m) => setMutedState(m)),
46
+ client.on("agent_speaking", (speaking) => {
47
+ setAgentSpeaking(speaking);
48
+ if (speaking)
49
+ setCaption(null);
50
+ }),
51
+ client.on("transcript", ({ text, final }) => {
52
+ if (text)
53
+ setCaption({ role: "customer", text, live: !final });
54
+ }),
55
+ client.on("chunk", (text) => setCaption((prev) => prev && prev.role === "agent" && prev.live
56
+ ? { ...prev, text: prev.text + text }
57
+ : { role: "agent", text, live: true })),
42
58
  ];
43
59
  if (autoConnect) {
44
60
  setConnecting(true);
@@ -62,6 +78,10 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
62
78
  setOpen,
63
79
  messages,
64
80
  widgets,
81
+ muted,
82
+ agentSpeaking,
83
+ caption,
84
+ setMuted: (m) => client.setMuted(m),
65
85
  send: (text) => {
66
86
  if (!text.trim())
67
87
  return;
@@ -72,7 +92,7 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
72
92
  client.noteInteraction();
73
93
  client.say(text);
74
94
  },
75
- }), [client, connected, connecting, error, open, messages, widgets]);
95
+ }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption]);
76
96
  return _jsx(Ctx.Provider, { value: value, children: children });
77
97
  }
78
98
  function useGraine() {
@@ -1,7 +1,12 @@
1
1
  import React from "react";
2
- export interface GraineLauncherProps {
2
+ export interface GraineAgentBarProps {
3
3
  accent?: string;
4
- title?: string;
5
- bottom?: number;
4
+ name?: string;
5
+ avatarUrl?: string;
6
+ bottomInset?: number;
7
+ hidden?: boolean;
8
+ captionsOn?: boolean;
6
9
  }
7
- export declare function GraineLauncher({ accent, title, bottom, }: GraineLauncherProps): React.JSX.Element | null;
10
+ export declare function GraineAgentBar({ accent, name, avatarUrl, bottomInset, hidden, captionsOn, }: GraineAgentBarProps): React.JSX.Element | null;
11
+ export declare const GraineLauncher: typeof GraineAgentBar;
12
+ export type GraineLauncherProps = GraineAgentBarProps;
@@ -1,125 +1,86 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from "react";
3
- import { ActivityIndicator, Animated, Easing, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
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
4
  import { useGraineAgent } from "./index";
5
- export function GraineLauncher({ accent = "#318CE7", title = "Assistant", bottom = 24, }) {
6
- const { connected, connecting, error, open, setOpen, messages, send } = useGraineAgent();
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();
7
+ const [expanded, setExpanded] = useState(false);
8
+ const [captions, setCaptions] = useState(captionsOn);
7
9
  const [draft, setDraft] = useState("");
8
10
  const scrollRef = useRef(null);
9
- const [unread, setUnread] = useState(0);
11
+ const last = messages[messages.length - 1];
12
+ const status = error
13
+ ? "unavailable"
14
+ : connecting ? "connecting…"
15
+ : muted ? "muted"
16
+ : agentSpeaking || (last?.role === "agent" && last.live) ? "speaking"
17
+ : connected ? "listening"
18
+ : "starting…";
10
19
  useEffect(() => {
11
- if (!open && messages[messages.length - 1]?.role === "agent")
12
- setUnread((n) => n + 1);
13
- if (open)
14
- setUnread(0);
15
- }, [messages, open]);
16
- useEffect(() => {
17
- if (open)
20
+ if (expanded)
18
21
  requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
19
- }, [messages, open]);
20
- const slide = useRef(new Animated.Value(0)).current;
22
+ }, [messages, expanded]);
21
23
  useEffect(() => {
22
- Animated.timing(slide, {
23
- toValue: open ? 1 : 0,
24
- duration: 180,
25
- easing: Easing.out(Easing.quad),
26
- useNativeDriver: true,
27
- }).start();
28
- }, [open, slide]);
29
- if (error)
30
- return null;
31
- const submit = () => {
24
+ if (!expanded && last?.role === "agent")
25
+ setExpanded(true);
26
+ }, [messages.length]);
27
+ const submit = useCallback(() => {
32
28
  const text = draft.trim();
33
29
  if (!text)
34
30
  return;
35
31
  setDraft("");
36
32
  send(text);
37
- };
38
- return (_jsxs(_Fragment, { children: [open && (_jsxs(Animated.View, { style: [
39
- styles.panel,
40
- {
41
- bottom: bottom + 68,
42
- opacity: slide,
43
- transform: [{ translateY: slide.interpolate({ inputRange: [0, 1], outputRange: [12, 0] }) }],
44
- },
45
- ], children: [_jsxs(View, { style: [styles.header, { backgroundColor: accent }], children: [_jsx(Text, { style: styles.headerTitle, children: title }), _jsxs(View, { style: styles.headerRight, children: [connected ? _jsx(View, { style: styles.liveDot }) : connecting ? _jsx(ActivityIndicator, { size: "small", color: "#fff" }) : null, _jsx(Pressable, { onPress: () => setOpen(false), hitSlop: 12, accessibilityLabel: "Close assistant", children: _jsx(Text, { style: styles.close, children: "\u2715" }) })] })] }), _jsxs(ScrollView, { ref: scrollRef, style: styles.transcript, contentContainerStyle: styles.transcriptInner, 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: [
33
+ }, [draft, send]);
34
+ if (hidden || error)
35
+ 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: [
46
37
  styles.bubble,
47
- m.role === "agent"
48
- ? styles.agentBubble
49
- : [styles.customerBubble, { backgroundColor: accent }],
50
- ], 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: "#9aa0a6", onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), style: [styles.sendButton, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], accessibilityLabel: "Send", children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), _jsxs(Pressable, { onPress: () => setOpen(!open), style: [styles.launcher, { backgroundColor: accent, bottom }], accessibilityLabel: open ? "Close assistant" : "Open assistant", accessibilityRole: "button", children: [_jsx(Text, { style: styles.launcherIcon, children: open ? "✕" : "✦" }), !open && unread > 0 && _jsx(View, { style: styles.badge })] })] }));
38
+ m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
39
+ ], 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 ? "⌄" : "⌃" }) })] })] }));
51
40
  }
41
+ export const GraineLauncher = GraineAgentBar;
52
42
  const styles = StyleSheet.create({
53
- launcher: {
54
- position: "absolute",
55
- right: 20,
56
- width: 56,
57
- height: 56,
58
- borderRadius: 28,
59
- alignItems: "center",
60
- justifyContent: "center",
61
- elevation: 8,
62
- shadowColor: "#000",
63
- shadowOpacity: 0.22,
64
- shadowRadius: 10,
65
- shadowOffset: { width: 0, height: 4 },
66
- },
67
- launcherIcon: { color: "#fff", fontSize: 22, lineHeight: 26 },
68
- badge: {
69
- position: "absolute",
70
- top: 12,
71
- right: 12,
72
- width: 10,
73
- height: 10,
74
- borderRadius: 5,
75
- backgroundColor: "#ff3b30",
76
- borderWidth: 2,
77
- borderColor: "#fff",
78
- },
43
+ root: { position: "absolute", left: 12, right: 12, zIndex: 900 },
79
44
  panel: {
80
- position: "absolute",
81
- right: 20,
82
- left: 20,
83
- maxHeight: 460,
84
- backgroundColor: "#fff",
85
- borderRadius: 18,
86
- overflow: "hidden",
87
- elevation: 12,
88
- shadowColor: "#000",
89
- shadowOpacity: 0.18,
90
- shadowRadius: 24,
91
- shadowOffset: { width: 0, height: 10 },
45
+ backgroundColor: "#17161A", borderRadius: 22, borderWidth: 1, borderColor: "rgba(255,255,255,0.09)",
46
+ marginBottom: 8, overflow: "hidden",
92
47
  },
93
- header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 14 },
94
- headerTitle: { color: "#fff", fontWeight: "600", fontSize: 15 },
95
- headerRight: { flexDirection: "row", alignItems: "center", gap: 12 },
96
- liveDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: "#34d399" },
97
- close: { color: "#fff", fontSize: 16 },
98
- transcript: { maxHeight: 320 },
99
- transcriptInner: { padding: 12, gap: 8 },
100
- empty: { color: "#9aa0a6", fontSize: 13, lineHeight: 19, padding: 8 },
101
- bubble: { maxWidth: "86%", paddingHorizontal: 12, paddingVertical: 9, borderRadius: 16 },
102
- agentBubble: { alignSelf: "flex-start", backgroundColor: "#f1f3f4", borderBottomLeftRadius: 5 },
48
+ transcript: { maxHeight: 240 },
49
+ transcriptInner: { padding: 14, gap: 8 },
50
+ empty: { color: "#9aa0a6", fontSize: 14, lineHeight: 21 },
51
+ bubble: { maxWidth: "88%", paddingHorizontal: 12, paddingVertical: 9, borderRadius: 16 },
52
+ agentBubble: { alignSelf: "flex-start", backgroundColor: "rgba(255,255,255,0.08)", borderBottomLeftRadius: 5 },
103
53
  customerBubble: { alignSelf: "flex-end", borderBottomRightRadius: 5 },
104
- agentText: { color: "#202124", fontSize: 14, lineHeight: 20 },
105
- customerText: { color: "#fff", fontSize: 14, lineHeight: 20 },
106
- composer: {
107
- flexDirection: "row",
108
- alignItems: "center",
109
- gap: 8,
110
- padding: 10,
111
- borderTopWidth: StyleSheet.hairlineWidth,
112
- borderTopColor: "#e8eaed",
113
- },
54
+ agentText: { color: "#F2F3F5", fontSize: 14.5, lineHeight: 21 },
55
+ customerText: { color: "#fff", fontSize: 14.5, lineHeight: 21 },
56
+ composer: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingBottom: 12 },
114
57
  input: {
115
- flex: 1,
116
- paddingHorizontal: 12,
117
- paddingVertical: Platform.OS === "ios" ? 10 : 6,
118
- backgroundColor: "#f1f3f4",
119
- borderRadius: 20,
120
- fontSize: 14,
121
- color: "#202124",
58
+ flex: 1, borderWidth: 1, borderColor: "rgba(255,255,255,0.12)", borderRadius: 16,
59
+ paddingHorizontal: 12, paddingVertical: Platform.OS === "ios" ? 10 : 7, color: "#F2F3F5", fontSize: 14.5,
122
60
  },
123
- sendButton: { width: 36, height: 36, borderRadius: 18, alignItems: "center", justifyContent: "center" },
61
+ send: { width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center" },
124
62
  sendLabel: { color: "#fff", fontSize: 17, lineHeight: 20 },
63
+ bar: {
64
+ flexDirection: "row", alignItems: "center", gap: 11,
65
+ backgroundColor: "#17161A", borderRadius: 26, borderWidth: 1, borderColor: "rgba(255,255,255,0.09)",
66
+ paddingHorizontal: 8, paddingVertical: 8,
67
+ shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 16, shadowOffset: { width: 0, height: 6 }, elevation: 12,
68
+ },
69
+ avatar: { width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center" },
70
+ avatarImage: { width: 38, height: 38, borderRadius: 19, backgroundColor: "rgba(255,255,255,0.08)" },
71
+ captionWrap: {
72
+ backgroundColor: "rgba(0,0,0,0.72)", borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, marginBottom: 8,
73
+ },
74
+ caption: { color: "#fff", fontSize: 15, lineHeight: 21, fontWeight: "600" },
75
+ captionLive: { opacity: 0.8 },
76
+ glyph: { color: "#F2F3F5", fontSize: 13, fontWeight: "800" },
77
+ muted: { backgroundColor: "#E5484D" },
78
+ avatarGlyph: { color: "#fff", fontSize: 17, lineHeight: 20 },
79
+ name: { color: "#F2F3F5", fontSize: 15, fontWeight: "800" },
80
+ status: { color: "#9aa0a6", fontSize: 12.5, marginTop: 1 },
81
+ round: {
82
+ width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center",
83
+ backgroundColor: "rgba(255,255,255,0.08)",
84
+ },
85
+ chevron: { color: "#F2F3F5", fontSize: 16, lineHeight: 18 },
125
86
  });
package/dist/voice.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { GraineInAppClient } from "./client";
2
2
  export declare const CAPTURE_SAMPLE_RATE = 16000;
3
+ export declare const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
3
4
  export interface AudioAdapter {
4
5
  startCapture(onChunk: (base64: string) => void): Promise<void> | void;
5
6
  stopCapture(): Promise<void> | void;
6
7
  play(pcm16: Int16Array, sampleRate: number): void;
7
8
  clear(): void;
9
+ bufferedSeconds?(): number;
8
10
  }
9
11
  export declare function base64ToPcm16(b64: string): Int16Array;
10
12
  export interface VoiceSessionOptions {
@@ -17,6 +19,7 @@ export declare class VoiceSession {
17
19
  private options;
18
20
  private offs;
19
21
  private capturing;
22
+ private format;
20
23
  constructor(client: GraineInAppClient, adapter: AudioAdapter, options?: VoiceSessionOptions);
21
24
  get active(): boolean;
22
25
  start(): Promise<void>;
package/dist/voice.js CHANGED
@@ -1,24 +1,8 @@
1
+ import { base64ToBytes, decodeAgentAudio, decodePcm16 } from "./audio";
1
2
  export const CAPTURE_SAMPLE_RATE = 16000;
2
- const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3
+ export const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
3
4
  export function base64ToPcm16(b64) {
4
- const clean = b64.replace(/[^A-Za-z0-9+/]/g, "");
5
- const bytes = new Uint8Array((clean.length * 3) >> 2);
6
- let byte = 0;
7
- let acc = 0;
8
- let bits = 0;
9
- for (let i = 0; i < clean.length; i++) {
10
- acc = (acc << 6) | B64.indexOf(clean[i]);
11
- bits += 6;
12
- if (bits >= 8) {
13
- bits -= 8;
14
- bytes[byte++] = (acc >> bits) & 0xff;
15
- }
16
- }
17
- const samples = new Int16Array(byte >> 1);
18
- for (let i = 0; i < samples.length; i++) {
19
- samples[i] = (bytes[i * 2] | (bytes[i * 2 + 1] << 8)) << 16 >> 16;
20
- }
21
- return samples;
5
+ return decodePcm16(base64ToBytes(b64));
22
6
  }
23
7
  export class VoiceSession {
24
8
  constructor(client, adapter, options = {}) {
@@ -27,6 +11,7 @@ export class VoiceSession {
27
11
  this.options = options;
28
12
  this.offs = [];
29
13
  this.capturing = false;
14
+ this.format = null;
30
15
  }
31
16
  get active() {
32
17
  return this.capturing;
@@ -36,8 +21,13 @@ export class VoiceSession {
36
21
  return;
37
22
  this.capturing = true;
38
23
  this.offs = [
39
- this.client.on("audio", ({ data, sampleRate }) => {
40
- this.adapter.play(base64ToPcm16(data), sampleRate);
24
+ this.client.on("audio", ({ data, format, sampleRate, }) => {
25
+ const decoded = decodeAgentAudio(base64ToBytes(data), { format, sampleRate }, this.format);
26
+ if (!decoded)
27
+ return;
28
+ if (decoded.latch)
29
+ this.format = decoded.latch;
30
+ this.adapter.play(decoded.pcm16, decoded.sampleRate);
41
31
  }),
42
32
  this.client.on("clear", () => {
43
33
  this.adapter.clear();
@@ -50,12 +40,14 @@ export class VoiceSession {
50
40
  this.options.onTranscript?.(text, final);
51
41
  }),
52
42
  ];
43
+ this.client.setPlayoutClock(() => this.adapter.bufferedSeconds?.() ?? 0);
53
44
  await this.adapter.startCapture((base64) => this.client.sendAudio(base64, CAPTURE_SAMPLE_RATE));
54
45
  }
55
46
  async stop() {
56
47
  if (!this.capturing)
57
48
  return;
58
49
  this.capturing = false;
50
+ this.format = null;
59
51
  this.offs.forEach((off) => off());
60
52
  this.offs = [];
61
53
  this.adapter.clear();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.1.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.",
3
+ "version": "0.3.1",
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,
7
7
  "main": "dist/index.js",
@@ -56,8 +56,8 @@
56
56
  "engines": {
57
57
  "node": ">=18"
58
58
  },
59
- "homepage": "https://app.graine.ai/docs/in-app",
59
+ "homepage": "https://www.graine.ai/docs/in-app",
60
60
  "bugs": {
61
- "url": "https://app.graine.ai/docs/in-app"
61
+ "url": "https://www.graine.ai/docs/in-app"
62
62
  }
63
63
  }