@graineai/inapp-react-native 0.2.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 +11 -0
- package/dist/audio.d.ts +17 -0
- package/dist/audio.js +82 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +52 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/react-native/index.d.ts +9 -0
- package/dist/react-native/index.js +21 -1
- package/dist/react-native/ui.d.ts +3 -1
- package/dist/react-native/ui.js +17 -7
- package/dist/voice.d.ts +3 -0
- package/dist/voice.js +13 -21
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -130,6 +130,17 @@ particular native audio module into your build. `liveAudioStreamAdapter` wraps
|
|
|
130
130
|
|
|
131
131
|
Capture must be **16 kHz mono PCM16**. Interruption is handled for you.
|
|
132
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
|
+
|
|
133
144
|
## Privacy
|
|
134
145
|
|
|
135
146
|
Identifiers are masked on the device before any screen data is sent — PAN,
|
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)
|
|
@@ -69,7 +71,10 @@ export class GraineInAppClient {
|
|
|
69
71
|
const res = await this.fetch(`${this.opts.baseUrl}/api/embed/ticket`, {
|
|
70
72
|
method: "POST",
|
|
71
73
|
headers: { "Content-Type": "application/json" },
|
|
72
|
-
body: JSON.stringify({
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
publishableKey: this.opts.publishableKey,
|
|
76
|
+
mode: this.opts.voice ? "voice" : "text",
|
|
77
|
+
}),
|
|
73
78
|
});
|
|
74
79
|
if (!res.ok)
|
|
75
80
|
return null;
|
|
@@ -118,7 +123,11 @@ export class GraineInAppClient {
|
|
|
118
123
|
};
|
|
119
124
|
this.send({
|
|
120
125
|
type: "init",
|
|
121
|
-
|
|
126
|
+
event: "start",
|
|
127
|
+
meta_data: {
|
|
128
|
+
client: "graine-inapp-sdk",
|
|
129
|
+
context_data: { ...(this.config.variables ?? {}), ...(this.opts.variables ?? {}) },
|
|
130
|
+
},
|
|
122
131
|
});
|
|
123
132
|
this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
|
|
124
133
|
if (this.screen)
|
|
@@ -158,7 +167,8 @@ export class GraineInAppClient {
|
|
|
158
167
|
case "audio":
|
|
159
168
|
this.emit("audio", {
|
|
160
169
|
data: msg.data,
|
|
161
|
-
|
|
170
|
+
format: msg.meta_info?.format ?? null,
|
|
171
|
+
sampleRate: msg.meta_info?.sample_rate ?? null,
|
|
162
172
|
});
|
|
163
173
|
break;
|
|
164
174
|
case "beginning_of_stream":
|
|
@@ -180,6 +190,18 @@ export class GraineInAppClient {
|
|
|
180
190
|
case "transcript":
|
|
181
191
|
this.emit("transcript", { text: msg.data ?? "", final: true });
|
|
182
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
|
+
}
|
|
183
205
|
case "pong":
|
|
184
206
|
case "ack":
|
|
185
207
|
break;
|
|
@@ -296,6 +318,8 @@ export class GraineInAppClient {
|
|
|
296
318
|
return this.send({ type: "text", data: text });
|
|
297
319
|
}
|
|
298
320
|
sendAudio(base64, sampleRate = 16000) {
|
|
321
|
+
if (this.muted)
|
|
322
|
+
return false;
|
|
299
323
|
if (this.agentSpeaking && !this.bargedIn) {
|
|
300
324
|
this.bargedIn = true;
|
|
301
325
|
this.send({ type: "barge_in" });
|
|
@@ -312,6 +336,18 @@ export class GraineInAppClient {
|
|
|
312
336
|
endUtterance() {
|
|
313
337
|
return this.send({ type: "utterance_end" });
|
|
314
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
|
+
}
|
|
315
351
|
get isAgentSpeaking() {
|
|
316
352
|
return this.agentSpeaking;
|
|
317
353
|
}
|
|
@@ -328,11 +364,22 @@ export class GraineInAppClient {
|
|
|
328
364
|
}
|
|
329
365
|
close() {
|
|
330
366
|
this.stopTimers();
|
|
367
|
+
const sock = this.ws;
|
|
368
|
+
this.ws = null;
|
|
369
|
+
if (!sock)
|
|
370
|
+
return;
|
|
331
371
|
try {
|
|
332
|
-
|
|
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();
|
|
333
381
|
}
|
|
334
382
|
catch {
|
|
335
383
|
}
|
|
336
|
-
this.ws = null;
|
|
337
384
|
}
|
|
338
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 { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
|
|
6
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
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() {
|
|
@@ -2,9 +2,11 @@ import React from "react";
|
|
|
2
2
|
export interface GraineAgentBarProps {
|
|
3
3
|
accent?: string;
|
|
4
4
|
name?: string;
|
|
5
|
+
avatarUrl?: string;
|
|
5
6
|
bottomInset?: number;
|
|
6
7
|
hidden?: boolean;
|
|
8
|
+
captionsOn?: boolean;
|
|
7
9
|
}
|
|
8
|
-
export declare function GraineAgentBar({ accent, name, bottomInset, hidden, }: GraineAgentBarProps): React.JSX.Element | null;
|
|
10
|
+
export declare function GraineAgentBar({ accent, name, avatarUrl, bottomInset, hidden, captionsOn, }: GraineAgentBarProps): React.JSX.Element | null;
|
|
9
11
|
export declare const GraineLauncher: typeof GraineAgentBar;
|
|
10
12
|
export type GraineLauncherProps = GraineAgentBarProps;
|
package/dist/react-native/ui.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
|
|
3
|
+
import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
|
|
4
4
|
import { useGraineAgent } from "./index";
|
|
5
|
-
export function GraineAgentBar({ accent = "#318CE7", name = "Assistant", bottomInset = 96, hidden = false, }) {
|
|
6
|
-
const { connected, connecting, error, 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
7
|
const [expanded, setExpanded] = useState(false);
|
|
8
|
+
const [captions, setCaptions] = useState(captionsOn);
|
|
8
9
|
const [draft, setDraft] = useState("");
|
|
9
10
|
const scrollRef = useRef(null);
|
|
10
11
|
const last = messages[messages.length - 1];
|
|
11
12
|
const status = error
|
|
12
13
|
? "unavailable"
|
|
13
14
|
: connecting ? "connecting…"
|
|
14
|
-
:
|
|
15
|
-
:
|
|
16
|
-
: "
|
|
15
|
+
: muted ? "muted"
|
|
16
|
+
: agentSpeaking || (last?.role === "agent" && last.live) ? "speaking"
|
|
17
|
+
: connected ? "listening"
|
|
18
|
+
: "starting…";
|
|
17
19
|
useEffect(() => {
|
|
18
20
|
if (expanded)
|
|
19
21
|
requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
|
|
@@ -34,7 +36,7 @@ export function GraineAgentBar({ accent = "#318CE7", name = "Assistant", bottomI
|
|
|
34
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: [
|
|
35
37
|
styles.bubble,
|
|
36
38
|
m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
|
|
37
|
-
], 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" }) })] }) })] })), _jsxs(View, { style: styles.bar, children: [_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: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] })] }));
|
|
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 ? "⌄" : "⌃" }) })] })] }));
|
|
38
40
|
}
|
|
39
41
|
export const GraineLauncher = GraineAgentBar;
|
|
40
42
|
const styles = StyleSheet.create({
|
|
@@ -65,6 +67,14 @@ const styles = StyleSheet.create({
|
|
|
65
67
|
shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 16, shadowOffset: { width: 0, height: 6 }, elevation: 12,
|
|
66
68
|
},
|
|
67
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" },
|
|
68
78
|
avatarGlyph: { color: "#fff", fontSize: 17, lineHeight: 20 },
|
|
69
79
|
name: { color: "#F2F3F5", fontSize: 15, fontWeight: "800" },
|
|
70
80
|
status: { color: "#9aa0a6", fontSize: 12.5, marginTop: 1 },
|
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.
|
|
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",
|