@graineai/inapp-react-native 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INTEGRATION.md +145 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react-native/index.d.ts +2 -1
- package/dist/react-native/index.js +25 -1
- package/dist/react-native/ui.d.ts +12 -1
- package/dist/react-native/ui.js +52 -25
- package/dist/react-native/voice-launcher.js +11 -1
- package/package.json +1 -1
package/INTEGRATION.md
CHANGED
|
@@ -18,6 +18,24 @@ Package: `@graineai/inapp-react-native`
|
|
|
18
18
|
| Peer install | `react-native-webview` — autolinks, no setup code |
|
|
19
19
|
| From Graine | your **publishable key**, from Agent → Embed & Widgets |
|
|
20
20
|
|
|
21
|
+
### Two dashboard steps first
|
|
22
|
+
|
|
23
|
+
Each takes a minute and each, skipped, produces a failure that looks like
|
|
24
|
+
something else.
|
|
25
|
+
|
|
26
|
+
**1. Give the app its own agent.** Clone the website agent rather than pointing
|
|
27
|
+
the app at it. Actions are declared per agent and every surface that agent serves
|
|
28
|
+
is offered all of them, so a shared agent hands your website tools that only
|
|
29
|
+
exist inside the app — and picks worse for having them. The clone carries the
|
|
30
|
+
prompt, knowledge base and voice across; change only what differs.
|
|
31
|
+
|
|
32
|
+
**2. Add `app://` to that agent's Allowed domains** (Agent → Embed & Widgets →
|
|
33
|
+
Install & domains). A native app has no `Origin` header, and there is no header a
|
|
34
|
+
client could send that we would trust instead — so `app://` is how you say "this
|
|
35
|
+
agent may also be reached from an app". Without it the connection is refused with
|
|
36
|
+
`no_origin` and **the launcher never renders**, which reads exactly like a bad
|
|
37
|
+
key. An empty allowlist refuses everything for the same reason.
|
|
38
|
+
|
|
21
39
|
**There is no native setup.** No `MainApplication.kt` edit, no `AppDelegate.swift`
|
|
22
40
|
edit, no ProGuard rules, no babel plugin ordering. The microphone and speaker run
|
|
23
41
|
inside a WebView pointed at your hosted agent page, so every fix to the audio
|
|
@@ -140,9 +158,33 @@ screen; when it fires it names the field that is `invalid` or `empty`, so the
|
|
|
140
158
|
agent opens with "the PAN isn't being accepted" rather than "need a hand?".
|
|
141
159
|
Without `status` it can only manage the second one.
|
|
142
160
|
|
|
161
|
+
| `status` | Means | What the agent does |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `empty` | Nothing entered | Offers to fill it |
|
|
164
|
+
| `filled` | Has a value | Leaves it alone |
|
|
165
|
+
| `invalid` | Rejected — pair with `error` | Names the problem, offers the fix |
|
|
166
|
+
| `pending` | In flight | Waits instead of claiming either outcome |
|
|
167
|
+
| `locked` | Not changeable from here | Routes elsewhere rather than offering to type |
|
|
168
|
+
|
|
169
|
+
`locked` is the one worth remembering. An OS permission the customer must grant
|
|
170
|
+
in system settings is not `empty` — nobody can fill it on this screen. Marked
|
|
171
|
+
`empty`, the agent offers to do it and fails; marked `locked`, it says where to go.
|
|
172
|
+
|
|
143
173
|
Report the real value. Masking happens on the way out and again on arrival — a
|
|
144
174
|
screen that pre-redacts just blinds the agent.
|
|
145
175
|
|
|
176
|
+
**Name every screen.** The route the SDK reports is an identifier, not a name.
|
|
177
|
+
Ship `kyc_documents` and the agent reads your routing table to your customers.
|
|
178
|
+
Keep a `Record<ScreenName, string>` of the words actually printed on each screen,
|
|
179
|
+
typed over your route union so the screen somebody adds next year fails the build
|
|
180
|
+
instead of reaching a customer as a slug.
|
|
181
|
+
|
|
182
|
+
**Where the hook goes.** Below every piece of state it reads — the object is
|
|
183
|
+
built during render, so a `const` declared under it is in its temporal dead zone
|
|
184
|
+
and throws — and above every early return, or the hook order changes between
|
|
185
|
+
renders. Action handlers have neither constraint: their bodies only run when the
|
|
186
|
+
agent calls them.
|
|
187
|
+
|
|
146
188
|
---
|
|
147
189
|
|
|
148
190
|
## Step 4 — Let it act
|
|
@@ -276,8 +318,111 @@ part. Everything else is unchanged.
|
|
|
276
318
|
|
|
277
319
|
---
|
|
278
320
|
|
|
321
|
+
## Making the bar yours
|
|
322
|
+
|
|
323
|
+
Three levels, and most apps stop at the first.
|
|
324
|
+
|
|
325
|
+
**1. The dashboard.** Accent, title and logo come from Appearance on the agent
|
|
326
|
+
and reach every surface without an app release. Set the colour in Graine and it
|
|
327
|
+
changes here. This is the level a brand team should be working at.
|
|
328
|
+
|
|
329
|
+
**2. Props, when the dashboard cannot express it.** Any prop passed wins over
|
|
330
|
+
the dashboard, so an app that needs something per-build still can.
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
<GraineAgentBar
|
|
334
|
+
scheme="auto" // "dark" | "light" | "auto" (default) — follows the device
|
|
335
|
+
accent="#318CE7" // overrides the dashboard
|
|
336
|
+
name="Ring"
|
|
337
|
+
bottomInset={110} // clear of your tab bar
|
|
338
|
+
captionsOn
|
|
339
|
+
/>
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`scheme` defaults to `auto` rather than dark: an agent that ignores the system
|
|
343
|
+
setting is the one piece of the screen that looks like it came from somewhere
|
|
344
|
+
else. Pin it only if your app pins its own.
|
|
345
|
+
|
|
346
|
+
**3. The palette, when your brand is not one of the two.** `theme` is merged
|
|
347
|
+
over the scheme's colours, so pass the one you care about:
|
|
348
|
+
|
|
349
|
+
```tsx
|
|
350
|
+
import type { GraineBarTheme } from "@graineai/inapp-react-native";
|
|
351
|
+
|
|
352
|
+
<GraineAgentBar theme={{ surface: "#0E1116", border: "#232A33" }} />
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
| Key | Where it lands |
|
|
356
|
+
|---|---|
|
|
357
|
+
| `surface` | The bar, the expanded panel, the caption bubble |
|
|
358
|
+
| `border` | Hairline around all three |
|
|
359
|
+
| `text` | The agent's name, its replies, what the customer types |
|
|
360
|
+
| `sub` | Status line, placeholder, empty state |
|
|
361
|
+
| `chip` | Quiet round buttons, and the agent's message bubbles |
|
|
362
|
+
| `onAccent` | Text and glyphs sitting on the accent, and on `danger` |
|
|
363
|
+
| `danger` | Muted — the one state worth its own colour |
|
|
364
|
+
|
|
365
|
+
**4. Or draw your own.** Everything the bar does is built on the public hooks,
|
|
366
|
+
so an app with a design system should ignore the component entirely:
|
|
367
|
+
|
|
368
|
+
```tsx
|
|
369
|
+
const { connected, muted, setMuted, messages, send, caption } = useGraineAgent();
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
That is the expected path for anyone with a brand team, and nothing else in the
|
|
373
|
+
SDK depends on this component.
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## Session lifecycle
|
|
378
|
+
|
|
379
|
+
One session per client, and it follows the app.
|
|
380
|
+
|
|
381
|
+
**Backgrounding ends the call.** Switching apps does not unmount a React tree,
|
|
382
|
+
so without this the WebView stays mounted with the microphone open and the
|
|
383
|
+
conversation still billing while nobody is listening. iOS makes it worse rather
|
|
384
|
+
than better: it suspends the WebView's audio without telling the socket, so the
|
|
385
|
+
call survives as a leg that can no longer hear anything and bills for silence.
|
|
386
|
+
|
|
387
|
+
The session ends on `background`, never on `inactive` — iOS reports `inactive`
|
|
388
|
+
for the app switcher, a pulled-down notification, and the moment a permission
|
|
389
|
+
dialog appears, so ending on it would mean the microphone prompt hangs up the
|
|
390
|
+
call that asked for it.
|
|
391
|
+
|
|
392
|
+
```tsx
|
|
393
|
+
<GraineProvider … endOnBackground={false}> {/* default: true */}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Turn it off only if a conversation should genuinely survive a switch to another
|
|
397
|
+
app, and you are prepared to explain the open microphone.
|
|
398
|
+
|
|
399
|
+
Text sessions reconnect when the app returns. **Voice does not auto-resume** — a
|
|
400
|
+
call is something a person chose to start, and starting one again because they
|
|
401
|
+
came back to the app is a microphone opening unasked. `connected` goes false;
|
|
402
|
+
draw from that.
|
|
403
|
+
|
|
404
|
+
**`connect()` is idempotent.** Calling it twice returns the same session rather
|
|
405
|
+
than opening a second socket. This matters under React 18 strict mode, which
|
|
406
|
+
mounts effects twice: previously the first socket was orphaned with no reference
|
|
407
|
+
to close it, and the runtime kept it open and billing until its own timeout.
|
|
408
|
+
|
|
409
|
+
**`close()` sends a stop frame** before dropping the socket, so the conversation
|
|
410
|
+
is filed as ended rather than as a customer who vanished mid-turn. It is called
|
|
411
|
+
for you on unmount and on backgrounding.
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
279
415
|
## Troubleshooting
|
|
280
416
|
|
|
417
|
+
**Nothing renders at all, and the console says `no_origin`.**
|
|
418
|
+
The agent's Allowed domains has no `app://` entry. `no_domains_configured` means
|
|
419
|
+
the allowlist is empty, which denies everything. Both are dashboard fixes, not
|
|
420
|
+
code.
|
|
421
|
+
|
|
422
|
+
**The agent offers to do things this app cannot.**
|
|
423
|
+
The website agent is being reused. Actions are declared per agent and offered on
|
|
424
|
+
every surface it serves — clone it and give the app its own.
|
|
425
|
+
|
|
281
426
|
**The agent talks but does not know what screen I am on.**
|
|
282
427
|
`navigationRef` must be the same ref you pass to `NavigationContainer`, and
|
|
283
428
|
`GraineProvider` must wrap it.
|
package/dist/client.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export declare class GraineInAppClient {
|
|
|
23
23
|
config: SessionConfig | null;
|
|
24
24
|
transport: "gateway" | "direct" | null;
|
|
25
25
|
private ws;
|
|
26
|
+
private connecting;
|
|
26
27
|
private listeners;
|
|
27
28
|
private actions;
|
|
28
29
|
private screen;
|
|
@@ -46,6 +47,7 @@ export declare class GraineInAppClient {
|
|
|
46
47
|
session(): Promise<SessionConfig>;
|
|
47
48
|
private ticket;
|
|
48
49
|
connect(): Promise<void>;
|
|
50
|
+
private openSocket;
|
|
49
51
|
private send;
|
|
50
52
|
private onFrame;
|
|
51
53
|
executeAction(name: string, args?: Record<string, unknown>): Promise<AppActionResult>;
|
package/dist/client.js
CHANGED
|
@@ -7,6 +7,7 @@ export class GraineInAppClient {
|
|
|
7
7
|
this.config = null;
|
|
8
8
|
this.transport = null;
|
|
9
9
|
this.ws = null;
|
|
10
|
+
this.connecting = null;
|
|
10
11
|
this.listeners = new Map();
|
|
11
12
|
this.actions = new Map();
|
|
12
13
|
this.screen = null;
|
|
@@ -87,6 +88,14 @@ export class GraineInAppClient {
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
async connect() {
|
|
91
|
+
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1))
|
|
92
|
+
return;
|
|
93
|
+
if (this.connecting)
|
|
94
|
+
return this.connecting;
|
|
95
|
+
this.connecting = this.openSocket().finally(() => { this.connecting = null; });
|
|
96
|
+
return this.connecting;
|
|
97
|
+
}
|
|
98
|
+
async openSocket() {
|
|
90
99
|
if (!this.config)
|
|
91
100
|
await this.session();
|
|
92
101
|
const agentId = this.config.webcallAgentId || this.config.agentId;
|
|
@@ -410,6 +419,7 @@ export class GraineInAppClient {
|
|
|
410
419
|
}
|
|
411
420
|
close() {
|
|
412
421
|
this.stopTimers();
|
|
422
|
+
this.connecting = null;
|
|
413
423
|
const sock = this.ws;
|
|
414
424
|
this.ws = null;
|
|
415
425
|
if (!sock)
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,6 @@ export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RAT
|
|
|
4
4
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio";
|
|
5
5
|
export { addMaskRule, maskDeep, maskString } from "./mask";
|
|
6
6
|
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
|
|
7
|
-
export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps } from "./react-native/ui";
|
|
7
|
+
export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui";
|
|
8
8
|
export { GraineVoiceLauncher, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher";
|
|
9
9
|
export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation";
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,6 @@ export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RAT
|
|
|
3
3
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio";
|
|
4
4
|
export { addMaskRule, maskDeep, maskString } from "./mask";
|
|
5
5
|
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, } from "./react-native/index";
|
|
6
|
-
export { GraineAgentBar, GraineLauncher } from "./react-native/ui";
|
|
6
|
+
export { GraineAgentBar, GraineLauncher, } from "./react-native/ui";
|
|
7
7
|
export { GraineVoiceLauncher, } from "./react-native/voice-launcher";
|
|
8
8
|
export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation";
|
|
@@ -64,6 +64,7 @@ export interface GraineProviderProps extends GraineInAppOptions {
|
|
|
64
64
|
children: React.ReactNode;
|
|
65
65
|
autoConnect?: boolean;
|
|
66
66
|
onProactive?: (text: string) => void;
|
|
67
|
+
endOnBackground?: boolean;
|
|
67
68
|
navigationRef?: {
|
|
68
69
|
current: any;
|
|
69
70
|
} | null;
|
|
@@ -71,7 +72,7 @@ export interface GraineProviderProps extends GraineInAppOptions {
|
|
|
71
72
|
launcherDelayMs?: number;
|
|
72
73
|
visibility?: LauncherVisibility;
|
|
73
74
|
}
|
|
74
|
-
export declare function GraineProvider({ children, autoConnect, onProactive, navigationRef, includeScreens, launcherDelayMs, visibility, ...options }: GraineProviderProps): React.JSX.Element;
|
|
75
|
+
export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, includeScreens, launcherDelayMs, visibility, ...options }: GraineProviderProps): React.JSX.Element;
|
|
75
76
|
export declare function useGraineAgent(): GraineContextValue;
|
|
76
77
|
export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
|
|
77
78
|
export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
|
+
import { AppState } from "react-native";
|
|
3
4
|
import { GraineInAppClient } from "../client";
|
|
4
5
|
import { VoiceSession } from "../voice";
|
|
5
6
|
import { LauncherVisibilityTracker, activeRouteName, } from "./navigation";
|
|
6
7
|
const Ctx = createContext(null);
|
|
7
|
-
export function GraineProvider({ children, autoConnect = true, onProactive, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
|
|
8
|
+
export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
|
|
8
9
|
const clientRef = useRef(null);
|
|
9
10
|
if (!clientRef.current)
|
|
10
11
|
clientRef.current = new GraineInAppClient(options);
|
|
@@ -158,6 +159,29 @@ export function GraineProvider({ children, autoConnect = true, onProactive, navi
|
|
|
158
159
|
client.close();
|
|
159
160
|
};
|
|
160
161
|
}, [client, autoConnect]);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!endOnBackground)
|
|
164
|
+
return;
|
|
165
|
+
let last = AppState.currentState;
|
|
166
|
+
const sub = AppState.addEventListener("change", (next) => {
|
|
167
|
+
const wasActive = last === "active";
|
|
168
|
+
last = next;
|
|
169
|
+
if (next === "background" && wasActive) {
|
|
170
|
+
client.close();
|
|
171
|
+
setConnected(false);
|
|
172
|
+
setConnecting(false);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (next === "active" && !wasActive && autoConnect) {
|
|
176
|
+
setConnecting(true);
|
|
177
|
+
client.connect().catch((err) => {
|
|
178
|
+
setConnecting(false);
|
|
179
|
+
console.error("[Graine]", err?.message ?? err);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
return () => sub.remove();
|
|
184
|
+
}, [client, endOnBackground, autoConnect]);
|
|
161
185
|
const value = useMemo(() => ({
|
|
162
186
|
client,
|
|
163
187
|
connected,
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
export interface GraineBarTheme {
|
|
3
|
+
surface: string;
|
|
4
|
+
border: string;
|
|
5
|
+
text: string;
|
|
6
|
+
sub: string;
|
|
7
|
+
chip: string;
|
|
8
|
+
onAccent: string;
|
|
9
|
+
danger: string;
|
|
10
|
+
}
|
|
2
11
|
export interface GraineAgentBarProps {
|
|
3
12
|
accent?: string;
|
|
4
13
|
name?: string;
|
|
@@ -6,7 +15,9 @@ export interface GraineAgentBarProps {
|
|
|
6
15
|
bottomInset?: number;
|
|
7
16
|
hidden?: boolean;
|
|
8
17
|
captionsOn?: boolean;
|
|
18
|
+
scheme?: "dark" | "light" | "auto";
|
|
19
|
+
theme?: Partial<GraineBarTheme>;
|
|
9
20
|
}
|
|
10
|
-
export declare function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset, hidden, captionsOn, }: GraineAgentBarProps): React.JSX.Element | null;
|
|
21
|
+
export declare function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset, hidden, captionsOn, scheme, theme: themeProp, }: GraineAgentBarProps): React.JSX.Element | null;
|
|
11
22
|
export declare const GraineLauncher: typeof GraineAgentBar;
|
|
12
23
|
export type GraineLauncherProps = GraineAgentBarProps;
|
package/dist/react-native/ui.js
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
|
|
4
4
|
import { useGraineAgent } from "./index";
|
|
5
|
-
|
|
5
|
+
const DARK = {
|
|
6
|
+
surface: "#17161A",
|
|
7
|
+
border: "rgba(255,255,255,0.09)",
|
|
8
|
+
text: "#F2F3F5",
|
|
9
|
+
sub: "#9aa0a6",
|
|
10
|
+
chip: "rgba(255,255,255,0.08)",
|
|
11
|
+
onAccent: "#FFFFFF",
|
|
12
|
+
danger: "#E5484D",
|
|
13
|
+
};
|
|
14
|
+
const LIGHT = {
|
|
15
|
+
surface: "#FFFFFF",
|
|
16
|
+
border: "rgba(0,0,0,0.10)",
|
|
17
|
+
text: "#16181C",
|
|
18
|
+
sub: "#61666D",
|
|
19
|
+
chip: "rgba(0,0,0,0.05)",
|
|
20
|
+
onAccent: "#FFFFFF",
|
|
21
|
+
danger: "#D32F2F",
|
|
22
|
+
};
|
|
23
|
+
export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset = 96, hidden = false, captionsOn = false, scheme = "auto", theme: themeProp, }) {
|
|
6
24
|
const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, appearance, } = useGraineAgent();
|
|
7
25
|
const accent = accentProp ?? appearance?.accent ?? "#318CE7";
|
|
8
26
|
const name = nameProp ?? appearance?.title ?? "Assistant";
|
|
9
27
|
const avatarUrl = avatarProp ?? appearance?.logo ?? undefined;
|
|
28
|
+
const deviceScheme = useColorScheme();
|
|
29
|
+
const styles = useMemo(() => {
|
|
30
|
+
const base = (scheme === "auto" ? deviceScheme === "light" : scheme === "light") ? LIGHT : DARK;
|
|
31
|
+
return makeStyles(themeProp ? { ...base, ...themeProp } : base);
|
|
32
|
+
}, [scheme, deviceScheme, themeProp]);
|
|
10
33
|
const [expanded, setExpanded] = useState(false);
|
|
11
34
|
const [captions, setCaptions] = useState(captionsOn);
|
|
12
35
|
const [draft, setDraft] = useState("");
|
|
@@ -18,7 +41,7 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
|
|
|
18
41
|
: muted ? "muted"
|
|
19
42
|
: agentSpeaking || (last?.role === "agent" && last.live) ? "speaking"
|
|
20
43
|
: connected ? "listening"
|
|
21
|
-
: "
|
|
44
|
+
: "offline";
|
|
22
45
|
useEffect(() => {
|
|
23
46
|
if (expanded)
|
|
24
47
|
requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
|
|
@@ -46,51 +69,55 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
|
|
|
46
69
|
], children: [expanded && (_jsxs(View, { style: styles.panel, children: [_jsxs(ScrollView, { ref: scrollRef, style: styles.transcript, contentContainerStyle: styles.transcriptInner, showsVerticalScrollIndicator: false, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children: "I can see what you're working on \u2014 ask me anything, or I'll step in if you get stuck." })), messages.map((m, i) => (_jsx(View, { style: [
|
|
47
70
|
styles.bubble,
|
|
48
71
|
m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
|
|
49
|
-
], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor:
|
|
72
|
+
], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: styles.placeholder.color, onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), captions && caption?.text ? (_jsx(View, { style: styles.captionWrap, children: _jsx(Text, { numberOfLines: 3, style: [styles.caption, caption.live && styles.captionLive], children: caption.text }) })) : null, _jsxs(View, { style: styles.bar, children: [avatarUrl ? (_jsx(Image, { source: { uri: avatarUrl }, style: styles.avatarImage, accessibilityIgnoresInvertColors: true })) : (_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) })), _jsxs(Pressable, { onPress: () => setExpanded((v) => !v), style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), _jsx(Text, { style: styles.status, children: status })] }), connecting ? _jsx(ActivityIndicator, { size: "small", color: accent }) : null, _jsx(Pressable, { onPress: () => setCaptions((v) => !v), hitSlop: 8, accessibilityLabel: captions ? "Hide captions" : "Show captions", style: [styles.round, captions && { backgroundColor: accent }], children: _jsx(Text, { style: [styles.glyph, captions && styles.glyphOnAccent], children: "CC" }) }), _jsx(Pressable, { onPress: () => setMuted(!muted), disabled: !connected, hitSlop: 8, accessibilityLabel: muted ? "Unmute" : "Mute", style: [styles.round, muted && styles.muted, !connected && { opacity: 0.4 }], children: _jsx(Text, { style: [styles.glyph, muted && styles.glyphOnAccent], children: muted ? "🔇" : "🎙" }) }), _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] })] }));
|
|
50
73
|
}
|
|
51
74
|
export const GraineLauncher = GraineAgentBar;
|
|
52
|
-
const
|
|
75
|
+
const makeStyles = (t) => StyleSheet.create({
|
|
53
76
|
root: { position: "absolute", left: 12, right: 12, zIndex: 900 },
|
|
54
77
|
panel: {
|
|
55
|
-
backgroundColor:
|
|
78
|
+
backgroundColor: t.surface, borderRadius: 22, borderWidth: 1, borderColor: t.border,
|
|
56
79
|
marginBottom: 8, overflow: "hidden",
|
|
57
80
|
},
|
|
58
81
|
transcript: { maxHeight: 240 },
|
|
59
82
|
transcriptInner: { padding: 14, gap: 8 },
|
|
60
|
-
empty: { color:
|
|
83
|
+
empty: { color: t.sub, fontSize: 14, lineHeight: 21 },
|
|
61
84
|
bubble: { maxWidth: "88%", paddingHorizontal: 12, paddingVertical: 9, borderRadius: 16 },
|
|
62
|
-
agentBubble: { alignSelf: "flex-start", backgroundColor:
|
|
85
|
+
agentBubble: { alignSelf: "flex-start", backgroundColor: t.chip, borderBottomLeftRadius: 5 },
|
|
63
86
|
customerBubble: { alignSelf: "flex-end", borderBottomRightRadius: 5 },
|
|
64
|
-
agentText: { color:
|
|
65
|
-
customerText: { color:
|
|
87
|
+
agentText: { color: t.text, fontSize: 14.5, lineHeight: 21 },
|
|
88
|
+
customerText: { color: t.onAccent, fontSize: 14.5, lineHeight: 21 },
|
|
66
89
|
composer: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingBottom: 12 },
|
|
67
90
|
input: {
|
|
68
|
-
flex: 1, borderWidth: 1, borderColor:
|
|
69
|
-
paddingHorizontal: 12, paddingVertical: Platform.OS === "ios" ? 10 : 7, color:
|
|
91
|
+
flex: 1, borderWidth: 1, borderColor: t.border, borderRadius: 16,
|
|
92
|
+
paddingHorizontal: 12, paddingVertical: Platform.OS === "ios" ? 10 : 7, color: t.text, fontSize: 14.5,
|
|
70
93
|
},
|
|
71
94
|
send: { width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center" },
|
|
72
|
-
sendLabel: { color:
|
|
95
|
+
sendLabel: { color: t.onAccent, fontSize: 17, lineHeight: 20 },
|
|
73
96
|
bar: {
|
|
74
97
|
flexDirection: "row", alignItems: "center", gap: 11,
|
|
75
|
-
backgroundColor:
|
|
98
|
+
backgroundColor: t.surface, borderRadius: 26, borderWidth: 1, borderColor: t.border,
|
|
76
99
|
paddingHorizontal: 8, paddingVertical: 8,
|
|
77
100
|
shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 16, shadowOffset: { width: 0, height: 6 }, elevation: 12,
|
|
78
101
|
},
|
|
79
102
|
avatar: { width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center" },
|
|
80
|
-
avatarImage: { width: 38, height: 38, borderRadius: 19, backgroundColor:
|
|
103
|
+
avatarImage: { width: 38, height: 38, borderRadius: 19, backgroundColor: t.chip },
|
|
81
104
|
captionWrap: {
|
|
82
|
-
backgroundColor:
|
|
105
|
+
backgroundColor: t.surface, borderRadius: 18, borderWidth: 1, borderColor: t.border,
|
|
106
|
+
paddingHorizontal: 14, paddingVertical: 11, marginBottom: 8,
|
|
107
|
+
shadowColor: "#000", shadowOpacity: 0.18, shadowRadius: 12, shadowOffset: { width: 0, height: 4 }, elevation: 6,
|
|
83
108
|
},
|
|
84
|
-
caption: { color:
|
|
109
|
+
caption: { color: t.text, fontSize: 15, lineHeight: 21, fontWeight: "600" },
|
|
85
110
|
captionLive: { opacity: 0.8 },
|
|
86
|
-
glyph: { color:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
111
|
+
glyph: { color: t.text, fontSize: 13, fontWeight: "800" },
|
|
112
|
+
glyphOnAccent: { color: t.onAccent },
|
|
113
|
+
placeholder: { color: t.sub },
|
|
114
|
+
muted: { backgroundColor: t.danger },
|
|
115
|
+
avatarGlyph: { color: t.onAccent, fontSize: 17, lineHeight: 20 },
|
|
116
|
+
name: { color: t.text, fontSize: 15, fontWeight: "800" },
|
|
117
|
+
status: { color: t.sub, fontSize: 12.5, marginTop: 1 },
|
|
91
118
|
round: {
|
|
92
119
|
width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center",
|
|
93
|
-
backgroundColor:
|
|
120
|
+
backgroundColor: t.chip,
|
|
94
121
|
},
|
|
95
|
-
chevron: { color:
|
|
122
|
+
chevron: { color: t.text, fontSize: 16, lineHeight: 18 },
|
|
96
123
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { View } from "react-native";
|
|
3
|
+
import { AppState, View } from "react-native";
|
|
4
4
|
import { useGraineAgent } from "./index";
|
|
5
5
|
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCaption, onCallState, onMicDenied, onEnded, children, }) {
|
|
6
6
|
const { client, appearance } = useGraineAgent();
|
|
@@ -91,6 +91,16 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
|
|
|
91
91
|
}, [post]),
|
|
92
92
|
};
|
|
93
93
|
useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
let last = AppState.currentState;
|
|
96
|
+
const sub = AppState.addEventListener("change", (next) => {
|
|
97
|
+
const wasActive = last === "active";
|
|
98
|
+
last = next;
|
|
99
|
+
if (next === "background" && wasActive)
|
|
100
|
+
post({ type: "graine:end-call" });
|
|
101
|
+
});
|
|
102
|
+
return () => sub.remove();
|
|
103
|
+
}, [post]);
|
|
94
104
|
const agentId = client.config?.agentId;
|
|
95
105
|
if (!agentId)
|
|
96
106
|
return _jsx(_Fragment, { children: children?.(api) });
|
package/package.json
CHANGED