@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 +57 -10
- package/dist/audio.d.ts +17 -0
- package/dist/audio.js +82 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +61 -6
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/react-native/index.d.ts +9 -0
- package/dist/react-native/index.js +21 -1
- package/dist/react-native/ui.d.ts +9 -4
- package/dist/react-native/ui.js +65 -104
- package/dist/voice.d.ts +3 -0
- package/dist/voice.js +13 -21
- package/package.json +4 -4
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://
|
|
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,
|
|
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://
|
|
42
|
+
baseUrl="https://www.graine.ai"
|
|
25
43
|
publishableKey="pk_live_…"
|
|
26
44
|
variables={{ customer_name: user.firstName }}
|
|
27
45
|
>
|
|
28
46
|
<RootNavigator />
|
|
29
|
-
<
|
|
47
|
+
<GraineAgentBar accent="#318CE7" name="Assistant" />
|
|
30
48
|
</GraineProvider>
|
|
31
49
|
);
|
|
32
50
|
}
|
|
33
51
|
```
|
|
34
52
|
|
|
35
|
-
Your publishable key is in the
|
|
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
|
|
59
|
-
|
|
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://
|
|
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
|
-
| `
|
|
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://
|
|
191
|
+
https://www.graine.ai/docs/in-app
|
|
145
192
|
|
|
146
193
|
## License
|
|
147
194
|
|
package/dist/audio.d.ts
ADDED
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2
|
+
export interface GraineAgentBarProps {
|
|
3
3
|
accent?: string;
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
name?: string;
|
|
5
|
+
avatarUrl?: string;
|
|
6
|
+
bottomInset?: number;
|
|
7
|
+
hidden?: boolean;
|
|
8
|
+
captionsOn?: boolean;
|
|
6
9
|
}
|
|
7
|
-
export declare function
|
|
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;
|
package/dist/react-native/ui.js
CHANGED
|
@@ -1,125 +1,86 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
2
|
-
import { useEffect, useRef, useState } from "react";
|
|
3
|
-
import { ActivityIndicator,
|
|
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
|
|
6
|
-
const { connected, connecting, error,
|
|
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 [
|
|
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 (
|
|
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,
|
|
20
|
-
const slide = useRef(new Animated.Value(0)).current;
|
|
22
|
+
}, [messages, expanded]);
|
|
21
23
|
useEffect(() => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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: "#
|
|
105
|
-
customerText: { color: "#fff", fontSize: 14, lineHeight:
|
|
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
|
-
|
|
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
|
|
3
|
+
export const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
|
|
3
4
|
export function base64ToPcm16(b64) {
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
"description": "Graine in-app agent for React Native
|
|
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://
|
|
59
|
+
"homepage": "https://www.graine.ai/docs/in-app",
|
|
60
60
|
"bugs": {
|
|
61
|
-
"url": "https://
|
|
61
|
+
"url": "https://www.graine.ai/docs/in-app"
|
|
62
62
|
}
|
|
63
63
|
}
|