@graineai/inapp-react-native 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/dist/client.d.ts +64 -0
- package/dist/client.js +330 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/mask.d.ts +4 -0
- package/dist/mask.js +38 -0
- package/dist/protocol.d.ts +60 -0
- package/dist/protocol.js +2 -0
- package/dist/react-native/index.d.ts +36 -0
- package/dist/react-native/index.js +132 -0
- package/dist/react-native/ui.d.ts +7 -0
- package/dist/react-native/ui.js +125 -0
- package/dist/voice.d.ts +25 -0
- package/dist/voice.js +84 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Graine AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# @graineai/inapp-react-native
|
|
2
|
+
|
|
3
|
+
A Graine AI agent that runs **inside** your React Native app — it sees the
|
|
4
|
+
screen your customer is on, and can act on it.
|
|
5
|
+
|
|
6
|
+
Android and iOS from one package.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @graineai/inapp-react-native
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Full documentation: **https://app.graine.ai/docs/in-app**
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
import { GraineProvider, GraineLauncher } from "@graineai/inapp-react-native";
|
|
20
|
+
|
|
21
|
+
export default function App() {
|
|
22
|
+
return (
|
|
23
|
+
<GraineProvider
|
|
24
|
+
baseUrl="https://app.graine.ai"
|
|
25
|
+
publishableKey="pk_live_…"
|
|
26
|
+
variables={{ customer_name: user.firstName }}
|
|
27
|
+
>
|
|
28
|
+
<RootNavigator />
|
|
29
|
+
<GraineLauncher accent="#318CE7" title="Assistant" />
|
|
30
|
+
</GraineProvider>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Your publishable key is in the Graine dashboard under **Install & domains**.
|
|
36
|
+
|
|
37
|
+
## Tell the agent what the customer can see
|
|
38
|
+
|
|
39
|
+
One hook, beside the state your screen already holds:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
useGraineScreen({
|
|
43
|
+
screen: "kyc_pan",
|
|
44
|
+
title: "PAN verification",
|
|
45
|
+
journey: { name: "KYC", step: 2, of: 4 },
|
|
46
|
+
fields: [
|
|
47
|
+
{ name: "pan", label: "PAN Number", value: pan,
|
|
48
|
+
status: error ? "invalid" : pan ? "filled" : "empty", error },
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The description is withdrawn when the component unmounts, so the agent can never
|
|
54
|
+
discuss a screen the customer has left.
|
|
55
|
+
|
|
56
|
+
## Let the agent act
|
|
57
|
+
|
|
58
|
+
Declare the action on your agent in the dashboard, then implement it where it
|
|
59
|
+
belongs:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
useGraineAction("fill_pan_from_aadhaar", async () => {
|
|
63
|
+
const value = await lookupPanFromAadhaar();
|
|
64
|
+
setPan(value);
|
|
65
|
+
return { status: "ok", data: { pan: value } };
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Answer honestly — `ok` makes the agent tell the customer the change is done.
|
|
70
|
+
A refusal with a reason is often more useful:
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
return { status: "refused", message: "PAN is locked after submission." };
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Answer within about five seconds. For slower work, return immediately and report
|
|
77
|
+
the outcome through the next `useGraineScreen` update.
|
|
78
|
+
|
|
79
|
+
## Voice
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
<GraineProvider baseUrl="…" publishableKey="pk_live_…" voice>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
const { listening, start, stop } = useGraineVoice(adapter, {
|
|
87
|
+
onTranscript: (text, final) => final && setHeard(text),
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Audio is a pluggable four-method `AudioAdapter`, so this package never forces a
|
|
92
|
+
particular native audio module into your build. `liveAudioStreamAdapter` wraps
|
|
93
|
+
`react-native-live-audio-stream`.
|
|
94
|
+
|
|
95
|
+
Capture must be **16 kHz mono PCM16**. Interruption is handled for you.
|
|
96
|
+
|
|
97
|
+
## Privacy
|
|
98
|
+
|
|
99
|
+
Identifiers are masked on the device before any screen data is sent — PAN,
|
|
100
|
+
Aadhaar, card and account numbers, phone numbers and email addresses. The agent
|
|
101
|
+
is told whether a field is filled, empty or rejected, not what is in it.
|
|
102
|
+
|
|
103
|
+
Add your own formats once, at startup:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { addMaskRule } from "@graineai/inapp-react-native/mask";
|
|
107
|
+
|
|
108
|
+
addMaskRule(/POL-\d{6}/, "[POLICY]");
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The SDK never reads the screen by itself — it reports only the fields you pass
|
|
112
|
+
to `useGraineScreen`. There is no screen recording and no accessibility
|
|
113
|
+
scraping. It never opens the microphone until you call `start()`, and never
|
|
114
|
+
requests a permission on your behalf.
|
|
115
|
+
|
|
116
|
+
> **A publishable key inside a mobile app is public.** Anyone can extract it
|
|
117
|
+
> from an APK or IPA, and the web domain allowlist does not apply to mobile
|
|
118
|
+
> apps. Rate limits, single-use connection tickets and your account's credit
|
|
119
|
+
> limits are what bound it. See the
|
|
120
|
+
> [security notes](https://app.graine.ai/docs/in-app/privacy) before launch.
|
|
121
|
+
|
|
122
|
+
## Requirements
|
|
123
|
+
|
|
124
|
+
- React Native 0.68+
|
|
125
|
+
- React 17+
|
|
126
|
+
|
|
127
|
+
## API
|
|
128
|
+
|
|
129
|
+
| | |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `GraineProvider` | Connects the agent. Set `voice` for spoken conversations. |
|
|
132
|
+
| `useGraineAgent()` | Connection state, transcript, and `send()`. |
|
|
133
|
+
| `useGraineScreen(context)` | Report what this screen shows. |
|
|
134
|
+
| `useGraineAction(name, handler)` | Implement one action the agent may call. |
|
|
135
|
+
| `useGraineVoice(adapter, opts)` | Microphone, playback and barge-in. |
|
|
136
|
+
| `GraineLauncher` | Optional default UI. Replace it with your own. |
|
|
137
|
+
| `addMaskRule(pattern, token)` | Mask an app-specific identifier format. |
|
|
138
|
+
|
|
139
|
+
`GraineInAppClient` is exported for apps that want the transport without the
|
|
140
|
+
React layer.
|
|
141
|
+
|
|
142
|
+
## Support
|
|
143
|
+
|
|
144
|
+
https://app.graine.ai/docs/in-app
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ActionHandler, type AppEvent, type ScreenContext } from "./protocol";
|
|
2
|
+
export declare const SUBPROTOCOL = "graine.embed.v1";
|
|
3
|
+
export interface GraineInAppOptions {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
publishableKey: string;
|
|
6
|
+
variables?: Record<string, string>;
|
|
7
|
+
stallAfterMs?: number;
|
|
8
|
+
voice?: boolean;
|
|
9
|
+
WebSocketImpl?: any;
|
|
10
|
+
fetchImpl?: typeof fetch;
|
|
11
|
+
}
|
|
12
|
+
type Listener = (payload: any) => void;
|
|
13
|
+
export interface SessionConfig {
|
|
14
|
+
agentId: string;
|
|
15
|
+
webcallAgentId?: string;
|
|
16
|
+
wsHost?: string;
|
|
17
|
+
appearance?: Record<string, any>;
|
|
18
|
+
variables?: Record<string, string>;
|
|
19
|
+
components?: any[];
|
|
20
|
+
}
|
|
21
|
+
export declare class GraineInAppClient {
|
|
22
|
+
readonly opts: GraineInAppOptions;
|
|
23
|
+
config: SessionConfig | null;
|
|
24
|
+
transport: "gateway" | "direct" | null;
|
|
25
|
+
private ws;
|
|
26
|
+
private listeners;
|
|
27
|
+
private actions;
|
|
28
|
+
private screen;
|
|
29
|
+
private screenSeq;
|
|
30
|
+
private screenSince;
|
|
31
|
+
private contextTimer;
|
|
32
|
+
private pendingScreen;
|
|
33
|
+
private stallTimer;
|
|
34
|
+
private stallReportedFor;
|
|
35
|
+
private pingTimer;
|
|
36
|
+
private agentSpeaking;
|
|
37
|
+
private bargedIn;
|
|
38
|
+
constructor(opts: GraineInAppOptions);
|
|
39
|
+
on(event: string, fn: Listener): () => void;
|
|
40
|
+
private emit;
|
|
41
|
+
private get fetch();
|
|
42
|
+
session(): Promise<SessionConfig>;
|
|
43
|
+
private ticket;
|
|
44
|
+
connect(): Promise<void>;
|
|
45
|
+
private send;
|
|
46
|
+
private onFrame;
|
|
47
|
+
private runAction;
|
|
48
|
+
registerAction(name: string, handler: ActionHandler): () => void;
|
|
49
|
+
get availableActions(): string[];
|
|
50
|
+
setScreen(context: ScreenContext | null): void;
|
|
51
|
+
reportEvent(event: AppEvent): void;
|
|
52
|
+
private scheduleScreen;
|
|
53
|
+
private flushScreen;
|
|
54
|
+
private armStall;
|
|
55
|
+
noteInteraction(): void;
|
|
56
|
+
say(text: string): boolean;
|
|
57
|
+
sendAudio(base64: string, sampleRate?: number): boolean;
|
|
58
|
+
endUtterance(): boolean;
|
|
59
|
+
get isAgentSpeaking(): boolean;
|
|
60
|
+
submitWidget(widgetId: string, data: Record<string, unknown>, summary: string): void;
|
|
61
|
+
private stopTimers;
|
|
62
|
+
close(): void;
|
|
63
|
+
}
|
|
64
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol";
|
|
2
|
+
import { maskDeep } from "./mask";
|
|
3
|
+
export const SUBPROTOCOL = "graine.embed.v1";
|
|
4
|
+
const PING_MS = 25000;
|
|
5
|
+
export class GraineInAppClient {
|
|
6
|
+
constructor(opts) {
|
|
7
|
+
this.config = null;
|
|
8
|
+
this.transport = null;
|
|
9
|
+
this.ws = null;
|
|
10
|
+
this.listeners = new Map();
|
|
11
|
+
this.actions = new Map();
|
|
12
|
+
this.screen = null;
|
|
13
|
+
this.screenSeq = 0;
|
|
14
|
+
this.screenSince = 0;
|
|
15
|
+
this.contextTimer = null;
|
|
16
|
+
this.pendingScreen = false;
|
|
17
|
+
this.stallTimer = null;
|
|
18
|
+
this.stallReportedFor = null;
|
|
19
|
+
this.pingTimer = null;
|
|
20
|
+
this.agentSpeaking = false;
|
|
21
|
+
this.bargedIn = false;
|
|
22
|
+
if (!opts?.publishableKey)
|
|
23
|
+
throw new Error("[Graine] publishableKey is required.");
|
|
24
|
+
if (!opts?.baseUrl)
|
|
25
|
+
throw new Error("[Graine] baseUrl is required.");
|
|
26
|
+
this.opts = { stallAfterMs: 45000, ...opts };
|
|
27
|
+
}
|
|
28
|
+
on(event, fn) {
|
|
29
|
+
if (!this.listeners.has(event))
|
|
30
|
+
this.listeners.set(event, new Set());
|
|
31
|
+
this.listeners.get(event).add(fn);
|
|
32
|
+
return () => this.listeners.get(event)?.delete(fn);
|
|
33
|
+
}
|
|
34
|
+
emit(event, payload) {
|
|
35
|
+
for (const fn of this.listeners.get(event) ?? []) {
|
|
36
|
+
try {
|
|
37
|
+
fn(payload);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
console.error(`[Graine] listener for "${event}" threw`, err);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
get fetch() {
|
|
45
|
+
return this.opts.fetchImpl ?? globalThis.fetch;
|
|
46
|
+
}
|
|
47
|
+
async session() {
|
|
48
|
+
const res = await this.fetch(`${this.opts.baseUrl}/api/embed/session`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify({ publishableKey: this.opts.publishableKey }),
|
|
52
|
+
});
|
|
53
|
+
const data = await res.json().catch(() => ({}));
|
|
54
|
+
if (!res.ok)
|
|
55
|
+
throw new Error(data?.error || `Embed rejected (${res.status})`);
|
|
56
|
+
this.config = data;
|
|
57
|
+
return this.config;
|
|
58
|
+
}
|
|
59
|
+
async ticket() {
|
|
60
|
+
try {
|
|
61
|
+
const res = await this.fetch(`${this.opts.baseUrl}/api/embed/ticket`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify({ publishableKey: this.opts.publishableKey }),
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok)
|
|
67
|
+
return null;
|
|
68
|
+
return await res.json();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async connect() {
|
|
75
|
+
if (!this.config)
|
|
76
|
+
await this.session();
|
|
77
|
+
const agentId = this.config.webcallAgentId || this.config.agentId;
|
|
78
|
+
const t = await this.ticket();
|
|
79
|
+
let url;
|
|
80
|
+
let protocols;
|
|
81
|
+
if (t?.wsUrl && t.ticket) {
|
|
82
|
+
url = t.wsUrl;
|
|
83
|
+
protocols = [SUBPROTOCOL, t.ticket];
|
|
84
|
+
this.transport = "gateway";
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const host = (this.config.wsHost || "wss://ws.graine.ai").replace(/\/$/, "");
|
|
88
|
+
const mode = this.opts.voice ? "webcall=1" : "text_chat=1";
|
|
89
|
+
url = `${host}/chat/v1/${encodeURIComponent(agentId)}?${mode}`;
|
|
90
|
+
this.transport = "direct";
|
|
91
|
+
}
|
|
92
|
+
const WS = this.opts.WebSocketImpl ?? globalThis.WebSocket;
|
|
93
|
+
const ws = new WS(url, protocols);
|
|
94
|
+
this.ws = ws;
|
|
95
|
+
await new Promise((resolve, reject) => {
|
|
96
|
+
const timer = setTimeout(() => reject(new Error("Socket did not open in time")), 15000);
|
|
97
|
+
ws.onopen = () => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
resolve();
|
|
100
|
+
};
|
|
101
|
+
ws.onerror = (e) => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
reject(new Error(e?.message || "Socket error"));
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
ws.onmessage = (evt) => this.onFrame(evt);
|
|
107
|
+
ws.onclose = (e) => {
|
|
108
|
+
this.stopTimers();
|
|
109
|
+
this.emit("close", { code: e?.code, reason: e?.reason });
|
|
110
|
+
};
|
|
111
|
+
this.send({
|
|
112
|
+
type: "init",
|
|
113
|
+
meta_data: { context_data: { ...(this.config.variables ?? {}), ...(this.opts.variables ?? {}) } },
|
|
114
|
+
});
|
|
115
|
+
this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
|
|
116
|
+
if (this.screen)
|
|
117
|
+
this.flushScreen();
|
|
118
|
+
this.emit("open", { transport: this.transport });
|
|
119
|
+
}
|
|
120
|
+
send(obj) {
|
|
121
|
+
if (!this.ws || this.ws.readyState !== 1)
|
|
122
|
+
return false;
|
|
123
|
+
try {
|
|
124
|
+
this.ws.send(JSON.stringify(obj));
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
console.error("[Graine] send failed", err);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
onFrame(evt) {
|
|
133
|
+
let msg;
|
|
134
|
+
try {
|
|
135
|
+
msg = JSON.parse(typeof evt?.data === "string" ? evt.data : String(evt?.data));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
switch (msg.type) {
|
|
141
|
+
case "text":
|
|
142
|
+
this.emit("chunk", msg.data);
|
|
143
|
+
break;
|
|
144
|
+
case "widget":
|
|
145
|
+
this.emit("widget", msg.widget);
|
|
146
|
+
break;
|
|
147
|
+
case "app_action":
|
|
148
|
+
void this.runAction(msg.action);
|
|
149
|
+
break;
|
|
150
|
+
case "audio":
|
|
151
|
+
this.emit("audio", {
|
|
152
|
+
data: msg.data,
|
|
153
|
+
sampleRate: msg.meta_info?.sample_rate ?? 16000,
|
|
154
|
+
});
|
|
155
|
+
break;
|
|
156
|
+
case "beginning_of_stream":
|
|
157
|
+
this.agentSpeaking = true;
|
|
158
|
+
this.emit("agent_speaking", true);
|
|
159
|
+
break;
|
|
160
|
+
case "end_of_stream":
|
|
161
|
+
this.agentSpeaking = false;
|
|
162
|
+
this.emit("agent_speaking", false);
|
|
163
|
+
break;
|
|
164
|
+
case "clear":
|
|
165
|
+
case "interruption":
|
|
166
|
+
this.agentSpeaking = false;
|
|
167
|
+
this.emit("clear");
|
|
168
|
+
break;
|
|
169
|
+
case "interim_transcript_received":
|
|
170
|
+
this.emit("transcript", { text: msg.data ?? "", final: false });
|
|
171
|
+
break;
|
|
172
|
+
case "transcript":
|
|
173
|
+
this.emit("transcript", { text: msg.data ?? "", final: true });
|
|
174
|
+
break;
|
|
175
|
+
case "pong":
|
|
176
|
+
case "ack":
|
|
177
|
+
break;
|
|
178
|
+
default:
|
|
179
|
+
this.emit("frame", msg);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async runAction(request) {
|
|
183
|
+
const reply = (result) => this.send({ type: "app_action_result", action_id: request.action_id, ...result });
|
|
184
|
+
const handler = this.actions.get(request.name);
|
|
185
|
+
if (!handler) {
|
|
186
|
+
reply({
|
|
187
|
+
status: "refused",
|
|
188
|
+
message: `This app build does not implement "${request.name}".`,
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.emit("action", request);
|
|
193
|
+
try {
|
|
194
|
+
const result = await Promise.race([
|
|
195
|
+
Promise.resolve(handler(request.arguments ?? {})),
|
|
196
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("handler_timeout")), ACTION_DEADLINE_MS - 500)),
|
|
197
|
+
]);
|
|
198
|
+
reply(result && typeof result === "object" ? result : { status: "ok" });
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
const timedOut = err?.message === "handler_timeout";
|
|
202
|
+
if (!timedOut)
|
|
203
|
+
console.error(`[Graine] action "${request.name}" threw`, err);
|
|
204
|
+
reply({
|
|
205
|
+
status: "error",
|
|
206
|
+
message: timedOut
|
|
207
|
+
? `"${request.name}" did not finish in time.`
|
|
208
|
+
: `"${request.name}" failed: ${err?.message ?? "unknown error"}`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
registerAction(name, handler) {
|
|
213
|
+
this.actions.set(name, handler);
|
|
214
|
+
this.scheduleScreen();
|
|
215
|
+
return () => {
|
|
216
|
+
this.actions.delete(name);
|
|
217
|
+
this.scheduleScreen();
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
get availableActions() {
|
|
221
|
+
return [...this.actions.keys()];
|
|
222
|
+
}
|
|
223
|
+
setScreen(context) {
|
|
224
|
+
const changedScreen = context?.screen !== this.screen?.screen;
|
|
225
|
+
this.screen = context;
|
|
226
|
+
if (changedScreen) {
|
|
227
|
+
this.screenSince = Date.now();
|
|
228
|
+
this.stallReportedFor = null;
|
|
229
|
+
this.armStall();
|
|
230
|
+
}
|
|
231
|
+
this.scheduleScreen();
|
|
232
|
+
}
|
|
233
|
+
reportEvent(event) {
|
|
234
|
+
this.send({ type: "app_event", event });
|
|
235
|
+
}
|
|
236
|
+
scheduleScreen() {
|
|
237
|
+
if (this.contextTimer) {
|
|
238
|
+
this.pendingScreen = true;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (!this.flushScreen())
|
|
242
|
+
return;
|
|
243
|
+
this.contextTimer = setTimeout(() => {
|
|
244
|
+
this.contextTimer = null;
|
|
245
|
+
if (this.pendingScreen) {
|
|
246
|
+
this.pendingScreen = false;
|
|
247
|
+
this.scheduleScreen();
|
|
248
|
+
}
|
|
249
|
+
}, CONTEXT_THROTTLE_MS);
|
|
250
|
+
}
|
|
251
|
+
flushScreen() {
|
|
252
|
+
if (!this.screen)
|
|
253
|
+
return false;
|
|
254
|
+
const { availableActions, ...rest } = this.screen;
|
|
255
|
+
return this.send({
|
|
256
|
+
type: "app_context",
|
|
257
|
+
context: maskDeep({ ...rest, idle_ms: Date.now() - this.screenSince }),
|
|
258
|
+
available_actions: (availableActions ?? this.availableActions).filter((a) => this.actions.has(a)),
|
|
259
|
+
seq: ++this.screenSeq,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
armStall() {
|
|
263
|
+
clearTimeout(this.stallTimer);
|
|
264
|
+
const after = this.opts.stallAfterMs ?? 0;
|
|
265
|
+
if (!after || !this.screen)
|
|
266
|
+
return;
|
|
267
|
+
const screenId = this.screen.screen;
|
|
268
|
+
this.stallTimer = setTimeout(() => {
|
|
269
|
+
if (this.stallReportedFor === screenId)
|
|
270
|
+
return;
|
|
271
|
+
this.stallReportedFor = screenId;
|
|
272
|
+
const title = this.screen?.title ?? screenId;
|
|
273
|
+
const stuck = (this.screen?.fields ?? []).find((f) => f.status === "invalid" || f.error || f.status === "empty");
|
|
274
|
+
this.reportEvent({
|
|
275
|
+
name: "stalled_on_step",
|
|
276
|
+
detail: stuck
|
|
277
|
+
? `The customer has not touched the "${title}" screen for ${Math.round(after / 1000)}s. ` +
|
|
278
|
+
`The "${stuck.label ?? stuck.name}" field is ${stuck.error ? `showing: ${stuck.error}` : stuck.status}.`
|
|
279
|
+
: `The customer has not touched the "${title}" screen for ${Math.round(after / 1000)}s.`,
|
|
280
|
+
});
|
|
281
|
+
}, after);
|
|
282
|
+
}
|
|
283
|
+
noteInteraction() {
|
|
284
|
+
this.screenSince = Date.now();
|
|
285
|
+
this.armStall();
|
|
286
|
+
}
|
|
287
|
+
say(text) {
|
|
288
|
+
return this.send({ type: "text", data: text });
|
|
289
|
+
}
|
|
290
|
+
sendAudio(base64, sampleRate = 16000) {
|
|
291
|
+
if (this.agentSpeaking && !this.bargedIn) {
|
|
292
|
+
this.bargedIn = true;
|
|
293
|
+
this.send({ type: "barge_in" });
|
|
294
|
+
}
|
|
295
|
+
else if (!this.agentSpeaking) {
|
|
296
|
+
this.bargedIn = false;
|
|
297
|
+
}
|
|
298
|
+
return this.send({
|
|
299
|
+
type: "audio",
|
|
300
|
+
data: base64,
|
|
301
|
+
meta_info: { format: "pcm", sample_rate: sampleRate, encoding: "base64" },
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
endUtterance() {
|
|
305
|
+
return this.send({ type: "utterance_end" });
|
|
306
|
+
}
|
|
307
|
+
get isAgentSpeaking() {
|
|
308
|
+
return this.agentSpeaking;
|
|
309
|
+
}
|
|
310
|
+
submitWidget(widgetId, data, summary) {
|
|
311
|
+
this.send({ type: "widget_result", widget_id: widgetId, data });
|
|
312
|
+
if (summary)
|
|
313
|
+
this.say(summary);
|
|
314
|
+
}
|
|
315
|
+
stopTimers() {
|
|
316
|
+
clearInterval(this.pingTimer);
|
|
317
|
+
clearTimeout(this.stallTimer);
|
|
318
|
+
clearTimeout(this.contextTimer);
|
|
319
|
+
this.pingTimer = this.stallTimer = this.contextTimer = null;
|
|
320
|
+
}
|
|
321
|
+
close() {
|
|
322
|
+
this.stopTimers();
|
|
323
|
+
try {
|
|
324
|
+
this.ws?.close();
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
}
|
|
328
|
+
this.ws = null;
|
|
329
|
+
}
|
|
330
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol";
|
|
2
|
+
export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "./client";
|
|
3
|
+
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice";
|
|
4
|
+
export { 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";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { GraineInAppClient } from "./client";
|
|
2
|
+
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice";
|
|
3
|
+
export { addMaskRule, maskDeep, maskString } from "./mask";
|
|
4
|
+
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, } from "./react-native/index";
|
|
5
|
+
export { GraineLauncher } from "./react-native/ui";
|
package/dist/mask.d.ts
ADDED
package/dist/mask.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const MASKS = [
|
|
2
|
+
[/\b[A-Z]{5}[0-9]{4}[A-Z]\b/g, "[PAN]"],
|
|
3
|
+
[/[\w.+-]+@[\w-]+\.[\w.]{2,}/g, "[EMAIL]"],
|
|
4
|
+
[/(^|[^\d-])((?:\d[ -]?){13,19})(?![\d-])/g, "$1[CARD]"],
|
|
5
|
+
[/(^|[^\d+])((?:\+91[-\s]?)?[6-9]\d{9})(?!\d)/g, "$1[PHONE]"],
|
|
6
|
+
[/(^|[^\d+])(\d{4}\s?\d{4}\s?\d{4})(?!\d)/g, "$1[AADHAAR]"],
|
|
7
|
+
];
|
|
8
|
+
export function maskString(input) {
|
|
9
|
+
let out = input;
|
|
10
|
+
for (const [pattern, token] of MASKS)
|
|
11
|
+
out = out.replace(pattern, token);
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
const custom = [];
|
|
15
|
+
export function addMaskRule(pattern, token = "[REDACTED]") {
|
|
16
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
|
|
17
|
+
custom.push([new RegExp(pattern.source, flags), token]);
|
|
18
|
+
}
|
|
19
|
+
export function clearMaskRules() {
|
|
20
|
+
custom.length = 0;
|
|
21
|
+
}
|
|
22
|
+
export function maskDeep(value) {
|
|
23
|
+
if (typeof value === "string") {
|
|
24
|
+
let out = maskString(value);
|
|
25
|
+
for (const [pattern, token] of custom)
|
|
26
|
+
out = out.replace(pattern, token);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
return value.map(maskDeep);
|
|
31
|
+
if (value && typeof value === "object") {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const [k, v] of Object.entries(value))
|
|
34
|
+
out[k] = maskDeep(v);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface ScreenField {
|
|
2
|
+
name: string;
|
|
3
|
+
label?: string;
|
|
4
|
+
value?: string | number | null;
|
|
5
|
+
status?: "empty" | "filled" | "invalid" | "pending" | "locked";
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ScreenContext {
|
|
9
|
+
screen: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
journey?: {
|
|
12
|
+
name: string;
|
|
13
|
+
step?: number;
|
|
14
|
+
of?: number;
|
|
15
|
+
};
|
|
16
|
+
fields?: ScreenField[];
|
|
17
|
+
error?: string;
|
|
18
|
+
data?: Record<string, unknown>;
|
|
19
|
+
availableActions?: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface AppContextFrame {
|
|
22
|
+
type: "app_context";
|
|
23
|
+
context: ScreenContext & {
|
|
24
|
+
idle_ms?: number;
|
|
25
|
+
};
|
|
26
|
+
available_actions?: string[];
|
|
27
|
+
seq: number;
|
|
28
|
+
}
|
|
29
|
+
export interface AppEvent {
|
|
30
|
+
name: string;
|
|
31
|
+
detail?: string;
|
|
32
|
+
cooldown_seconds?: number;
|
|
33
|
+
max_per_session?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface AppEventFrame {
|
|
36
|
+
type: "app_event";
|
|
37
|
+
event: AppEvent;
|
|
38
|
+
}
|
|
39
|
+
export interface AppActionRequest {
|
|
40
|
+
action_id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
kind?: string;
|
|
43
|
+
arguments: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
export interface AppActionFrame {
|
|
46
|
+
type: "app_action";
|
|
47
|
+
action: AppActionRequest;
|
|
48
|
+
}
|
|
49
|
+
export interface AppActionResult {
|
|
50
|
+
status: "ok" | "error" | "refused";
|
|
51
|
+
data?: Record<string, unknown>;
|
|
52
|
+
message?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface AppActionResultFrame extends AppActionResult {
|
|
55
|
+
type: "app_action_result";
|
|
56
|
+
action_id: string;
|
|
57
|
+
}
|
|
58
|
+
export type ActionHandler = (args: Record<string, any>) => AppActionResult | void | Promise<AppActionResult | void>;
|
|
59
|
+
export declare const ACTION_DEADLINE_MS = 6000;
|
|
60
|
+
export declare const CONTEXT_THROTTLE_MS = 400;
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { GraineInAppClient, type GraineInAppOptions } from "../client";
|
|
3
|
+
import { type AudioAdapter, type VoiceSessionOptions } from "../voice";
|
|
4
|
+
import type { ActionHandler, ScreenContext } from "../protocol";
|
|
5
|
+
interface GraineContextValue {
|
|
6
|
+
client: GraineInAppClient;
|
|
7
|
+
connected: boolean;
|
|
8
|
+
connecting: boolean;
|
|
9
|
+
error: string | null;
|
|
10
|
+
open: boolean;
|
|
11
|
+
setOpen: (open: boolean) => void;
|
|
12
|
+
messages: Turn[];
|
|
13
|
+
widgets: any[];
|
|
14
|
+
send: (text: string) => void;
|
|
15
|
+
}
|
|
16
|
+
export interface Turn {
|
|
17
|
+
role: "agent" | "customer";
|
|
18
|
+
text: string;
|
|
19
|
+
live?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface GraineProviderProps extends GraineInAppOptions {
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
autoConnect?: boolean;
|
|
24
|
+
onProactive?: (text: string) => void;
|
|
25
|
+
}
|
|
26
|
+
export declare function GraineProvider({ children, autoConnect, onProactive, ...options }: GraineProviderProps): React.JSX.Element;
|
|
27
|
+
export declare function useGraineAgent(): GraineContextValue;
|
|
28
|
+
export declare function useGraineVoice(adapter: AudioAdapter | null, options?: VoiceSessionOptions): {
|
|
29
|
+
listening: boolean;
|
|
30
|
+
agentSpeaking: boolean;
|
|
31
|
+
start: () => Promise<void>;
|
|
32
|
+
stop: () => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
export declare function useGraineScreen(context: ScreenContext | null): void;
|
|
35
|
+
export declare function useGraineAction(name: string, handler: ActionHandler): void;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
|
+
import { GraineInAppClient } from "../client";
|
|
4
|
+
import { VoiceSession } from "../voice";
|
|
5
|
+
const Ctx = createContext(null);
|
|
6
|
+
export function GraineProvider({ children, autoConnect = true, onProactive, ...options }) {
|
|
7
|
+
const clientRef = useRef(null);
|
|
8
|
+
if (!clientRef.current)
|
|
9
|
+
clientRef.current = new GraineInAppClient(options);
|
|
10
|
+
const client = clientRef.current;
|
|
11
|
+
const [connected, setConnected] = useState(false);
|
|
12
|
+
const [connecting, setConnecting] = useState(false);
|
|
13
|
+
const [error, setError] = useState(null);
|
|
14
|
+
const [open, setOpen] = useState(false);
|
|
15
|
+
const [messages, setMessages] = useState([]);
|
|
16
|
+
const [widgets, setWidgets] = useState([]);
|
|
17
|
+
const proactiveRef = useRef(onProactive);
|
|
18
|
+
proactiveRef.current = onProactive;
|
|
19
|
+
const openRef = useRef(open);
|
|
20
|
+
openRef.current = open;
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const offs = [
|
|
23
|
+
client.on("open", () => {
|
|
24
|
+
setConnected(true);
|
|
25
|
+
setConnecting(false);
|
|
26
|
+
setError(null);
|
|
27
|
+
}),
|
|
28
|
+
client.on("close", () => setConnected(false)),
|
|
29
|
+
client.on("chunk", (text) => setMessages((prev) => {
|
|
30
|
+
const last = prev[prev.length - 1];
|
|
31
|
+
if (last?.role === "agent" && last.live) {
|
|
32
|
+
return [...prev.slice(0, -1), { ...last, text: last.text + text }];
|
|
33
|
+
}
|
|
34
|
+
if (prev[prev.length - 1]?.role !== "customer") {
|
|
35
|
+
proactiveRef.current?.(text);
|
|
36
|
+
if (!openRef.current)
|
|
37
|
+
setOpen(true);
|
|
38
|
+
}
|
|
39
|
+
return [...prev, { role: "agent", text, live: true }];
|
|
40
|
+
})),
|
|
41
|
+
client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
|
|
42
|
+
];
|
|
43
|
+
if (autoConnect) {
|
|
44
|
+
setConnecting(true);
|
|
45
|
+
client.connect().catch((err) => {
|
|
46
|
+
setConnecting(false);
|
|
47
|
+
console.error("[Graine]", err?.message ?? err);
|
|
48
|
+
setError(err?.message ?? "Could not start the agent.");
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return () => {
|
|
52
|
+
offs.forEach((off) => off());
|
|
53
|
+
client.close();
|
|
54
|
+
};
|
|
55
|
+
}, [client, autoConnect]);
|
|
56
|
+
const value = useMemo(() => ({
|
|
57
|
+
client,
|
|
58
|
+
connected,
|
|
59
|
+
connecting,
|
|
60
|
+
error,
|
|
61
|
+
open,
|
|
62
|
+
setOpen,
|
|
63
|
+
messages,
|
|
64
|
+
widgets,
|
|
65
|
+
send: (text) => {
|
|
66
|
+
if (!text.trim())
|
|
67
|
+
return;
|
|
68
|
+
setMessages((prev) => [
|
|
69
|
+
...prev.map((m) => (m.live ? { ...m, live: false } : m)),
|
|
70
|
+
{ role: "customer", text },
|
|
71
|
+
]);
|
|
72
|
+
client.noteInteraction();
|
|
73
|
+
client.say(text);
|
|
74
|
+
},
|
|
75
|
+
}), [client, connected, connecting, error, open, messages, widgets]);
|
|
76
|
+
return _jsx(Ctx.Provider, { value: value, children: children });
|
|
77
|
+
}
|
|
78
|
+
function useGraine() {
|
|
79
|
+
const ctx = useContext(Ctx);
|
|
80
|
+
if (!ctx)
|
|
81
|
+
throw new Error("[Graine] useGraine* must be used inside <GraineProvider>.");
|
|
82
|
+
return ctx;
|
|
83
|
+
}
|
|
84
|
+
export function useGraineAgent() {
|
|
85
|
+
return useGraine();
|
|
86
|
+
}
|
|
87
|
+
export function useGraineVoice(adapter, options = {}) {
|
|
88
|
+
const { client } = useGraine();
|
|
89
|
+
const [listening, setListening] = useState(false);
|
|
90
|
+
const [agentSpeaking, setAgentSpeaking] = useState(false);
|
|
91
|
+
const optionsRef = useRef(options);
|
|
92
|
+
optionsRef.current = options;
|
|
93
|
+
const sessionRef = useRef(null);
|
|
94
|
+
if (adapter && !sessionRef.current) {
|
|
95
|
+
sessionRef.current = new VoiceSession(client, adapter, {
|
|
96
|
+
onTranscript: (t, f) => optionsRef.current.onTranscript?.(t, f),
|
|
97
|
+
onAgentSpeaking: (speaking) => {
|
|
98
|
+
setAgentSpeaking(speaking);
|
|
99
|
+
optionsRef.current.onAgentSpeaking?.(speaking);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
useEffect(() => () => { void sessionRef.current?.stop(); }, []);
|
|
104
|
+
return {
|
|
105
|
+
listening,
|
|
106
|
+
agentSpeaking,
|
|
107
|
+
start: async () => {
|
|
108
|
+
if (!sessionRef.current)
|
|
109
|
+
throw new Error("[Graine] useGraineVoice needs an audio adapter.");
|
|
110
|
+
await sessionRef.current.start();
|
|
111
|
+
setListening(true);
|
|
112
|
+
},
|
|
113
|
+
stop: async () => {
|
|
114
|
+
await sessionRef.current?.stop();
|
|
115
|
+
setListening(false);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function useGraineScreen(context) {
|
|
120
|
+
const { client } = useGraine();
|
|
121
|
+
const serialised = JSON.stringify(context ?? null);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
client.setScreen(context);
|
|
124
|
+
return () => client.setScreen(null);
|
|
125
|
+
}, [client, serialised]);
|
|
126
|
+
}
|
|
127
|
+
export function useGraineAction(name, handler) {
|
|
128
|
+
const { client } = useGraine();
|
|
129
|
+
const handlerRef = useRef(handler);
|
|
130
|
+
handlerRef.current = handler;
|
|
131
|
+
useEffect(() => client.registerAction(name, (args) => handlerRef.current(args)), [client, name]);
|
|
132
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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";
|
|
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();
|
|
7
|
+
const [draft, setDraft] = useState("");
|
|
8
|
+
const scrollRef = useRef(null);
|
|
9
|
+
const [unread, setUnread] = useState(0);
|
|
10
|
+
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)
|
|
18
|
+
requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
|
|
19
|
+
}, [messages, open]);
|
|
20
|
+
const slide = useRef(new Animated.Value(0)).current;
|
|
21
|
+
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 = () => {
|
|
32
|
+
const text = draft.trim();
|
|
33
|
+
if (!text)
|
|
34
|
+
return;
|
|
35
|
+
setDraft("");
|
|
36
|
+
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: [
|
|
46
|
+
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 })] })] }));
|
|
51
|
+
}
|
|
52
|
+
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
|
+
},
|
|
79
|
+
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 },
|
|
92
|
+
},
|
|
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 },
|
|
103
|
+
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
|
+
},
|
|
114
|
+
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",
|
|
122
|
+
},
|
|
123
|
+
sendButton: { width: 36, height: 36, borderRadius: 18, alignItems: "center", justifyContent: "center" },
|
|
124
|
+
sendLabel: { color: "#fff", fontSize: 17, lineHeight: 20 },
|
|
125
|
+
});
|
package/dist/voice.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GraineInAppClient } from "./client";
|
|
2
|
+
export declare const CAPTURE_SAMPLE_RATE = 16000;
|
|
3
|
+
export interface AudioAdapter {
|
|
4
|
+
startCapture(onChunk: (base64: string) => void): Promise<void> | void;
|
|
5
|
+
stopCapture(): Promise<void> | void;
|
|
6
|
+
play(pcm16: Int16Array, sampleRate: number): void;
|
|
7
|
+
clear(): void;
|
|
8
|
+
}
|
|
9
|
+
export declare function base64ToPcm16(b64: string): Int16Array;
|
|
10
|
+
export interface VoiceSessionOptions {
|
|
11
|
+
onTranscript?: (text: string, final: boolean) => void;
|
|
12
|
+
onAgentSpeaking?: (speaking: boolean) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare class VoiceSession {
|
|
15
|
+
private client;
|
|
16
|
+
private adapter;
|
|
17
|
+
private options;
|
|
18
|
+
private offs;
|
|
19
|
+
private capturing;
|
|
20
|
+
constructor(client: GraineInAppClient, adapter: AudioAdapter, options?: VoiceSessionOptions);
|
|
21
|
+
get active(): boolean;
|
|
22
|
+
start(): Promise<void>;
|
|
23
|
+
stop(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export declare function liveAudioStreamAdapter(LiveAudioStream: any, sink: Pick<AudioAdapter, "play" | "clear">): AudioAdapter;
|
package/dist/voice.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export const CAPTURE_SAMPLE_RATE = 16000;
|
|
2
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
3
|
+
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;
|
|
22
|
+
}
|
|
23
|
+
export class VoiceSession {
|
|
24
|
+
constructor(client, adapter, options = {}) {
|
|
25
|
+
this.client = client;
|
|
26
|
+
this.adapter = adapter;
|
|
27
|
+
this.options = options;
|
|
28
|
+
this.offs = [];
|
|
29
|
+
this.capturing = false;
|
|
30
|
+
}
|
|
31
|
+
get active() {
|
|
32
|
+
return this.capturing;
|
|
33
|
+
}
|
|
34
|
+
async start() {
|
|
35
|
+
if (this.capturing)
|
|
36
|
+
return;
|
|
37
|
+
this.capturing = true;
|
|
38
|
+
this.offs = [
|
|
39
|
+
this.client.on("audio", ({ data, sampleRate }) => {
|
|
40
|
+
this.adapter.play(base64ToPcm16(data), sampleRate);
|
|
41
|
+
}),
|
|
42
|
+
this.client.on("clear", () => {
|
|
43
|
+
this.adapter.clear();
|
|
44
|
+
this.options.onAgentSpeaking?.(false);
|
|
45
|
+
}),
|
|
46
|
+
this.client.on("agent_speaking", (speaking) => this.options.onAgentSpeaking?.(speaking)),
|
|
47
|
+
this.client.on("transcript", ({ text, final }) => {
|
|
48
|
+
if (final)
|
|
49
|
+
this.client.noteInteraction();
|
|
50
|
+
this.options.onTranscript?.(text, final);
|
|
51
|
+
}),
|
|
52
|
+
];
|
|
53
|
+
await this.adapter.startCapture((base64) => this.client.sendAudio(base64, CAPTURE_SAMPLE_RATE));
|
|
54
|
+
}
|
|
55
|
+
async stop() {
|
|
56
|
+
if (!this.capturing)
|
|
57
|
+
return;
|
|
58
|
+
this.capturing = false;
|
|
59
|
+
this.offs.forEach((off) => off());
|
|
60
|
+
this.offs = [];
|
|
61
|
+
this.adapter.clear();
|
|
62
|
+
await this.adapter.stopCapture();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function liveAudioStreamAdapter(LiveAudioStream, sink) {
|
|
66
|
+
return {
|
|
67
|
+
async startCapture(onChunk) {
|
|
68
|
+
LiveAudioStream.init({
|
|
69
|
+
sampleRate: CAPTURE_SAMPLE_RATE,
|
|
70
|
+
channels: 1,
|
|
71
|
+
bitsPerSample: 16,
|
|
72
|
+
audioSource: 6,
|
|
73
|
+
bufferSize: 1280,
|
|
74
|
+
});
|
|
75
|
+
LiveAudioStream.on("data", onChunk);
|
|
76
|
+
LiveAudioStream.start();
|
|
77
|
+
},
|
|
78
|
+
stopCapture() {
|
|
79
|
+
LiveAudioStream.stop();
|
|
80
|
+
},
|
|
81
|
+
play: sink.play.bind(sink),
|
|
82
|
+
clear: sink.clear.bind(sink),
|
|
83
|
+
};
|
|
84
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"module": "dist/index.js",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./mask": {
|
|
17
|
+
"types": "./dist/mask.d.ts",
|
|
18
|
+
"default": "./dist/mask.js"
|
|
19
|
+
},
|
|
20
|
+
"./client": {
|
|
21
|
+
"types": "./dist/client.d.ts",
|
|
22
|
+
"default": "./dist/client.js"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
32
|
+
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
33
|
+
"prepublishOnly": "npm run build && node scripts/verify-package-clean.mjs"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": ">=17",
|
|
37
|
+
"react-native": ">=0.68"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/react": "^18.2.0",
|
|
41
|
+
"react": "^18.2.0",
|
|
42
|
+
"react-native": "^0.74.0",
|
|
43
|
+
"typescript": "^5.4.0"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"graine",
|
|
47
|
+
"voice-ai",
|
|
48
|
+
"in-app-agent",
|
|
49
|
+
"react-native",
|
|
50
|
+
"android",
|
|
51
|
+
"ios"
|
|
52
|
+
],
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=18"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://app.graine.ai/docs/in-app",
|
|
60
|
+
"bugs": {
|
|
61
|
+
"url": "https://app.graine.ai/docs/in-app"
|
|
62
|
+
}
|
|
63
|
+
}
|