@graineai/inapp-react-native 0.4.0 → 0.7.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/dist/client.d.ts +4 -0
- package/dist/client.js +37 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/react-native/index.d.ts +42 -1
- package/dist/react-native/index.js +112 -5
- package/dist/react-native/navigation.d.ts +44 -0
- package/dist/react-native/navigation.js +98 -0
- package/dist/react-native/ui.js +10 -3
- package/dist/voice.d.ts +2 -0
- package/dist/voice.js +10 -3
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export declare class GraineInAppClient {
|
|
|
37
37
|
private bargedIn;
|
|
38
38
|
private muted;
|
|
39
39
|
private playoutClock;
|
|
40
|
+
private identity;
|
|
41
|
+
private recentEvents;
|
|
40
42
|
constructor(opts: GraineInAppOptions);
|
|
41
43
|
on(event: string, fn: Listener): () => void;
|
|
42
44
|
private emit;
|
|
@@ -51,9 +53,11 @@ export declare class GraineInAppClient {
|
|
|
51
53
|
get availableActions(): string[];
|
|
52
54
|
setScreen(context: ScreenContext | null): void;
|
|
53
55
|
reportEvent(event: AppEvent): void;
|
|
56
|
+
track(name: string, data?: Record<string, unknown>): void;
|
|
54
57
|
private scheduleScreen;
|
|
55
58
|
private flushScreen;
|
|
56
59
|
private armStall;
|
|
60
|
+
identify(user: Record<string, unknown> | null): void;
|
|
57
61
|
noteInteraction(): void;
|
|
58
62
|
say(text: string): boolean;
|
|
59
63
|
sendAudio(base64: string, sampleRate?: number): boolean;
|
package/dist/client.js
CHANGED
|
@@ -21,6 +21,8 @@ export class GraineInAppClient {
|
|
|
21
21
|
this.bargedIn = false;
|
|
22
22
|
this.muted = false;
|
|
23
23
|
this.playoutClock = null;
|
|
24
|
+
this.identity = {};
|
|
25
|
+
this.recentEvents = [];
|
|
24
26
|
if (!opts?.publishableKey)
|
|
25
27
|
throw new Error("[Graine] publishableKey is required.");
|
|
26
28
|
if (!opts?.baseUrl)
|
|
@@ -126,7 +128,11 @@ export class GraineInAppClient {
|
|
|
126
128
|
event: "start",
|
|
127
129
|
meta_data: {
|
|
128
130
|
client: "graine-inapp-sdk",
|
|
129
|
-
context_data: {
|
|
131
|
+
context_data: {
|
|
132
|
+
...(this.config.variables ?? {}),
|
|
133
|
+
...(this.opts.variables ?? {}),
|
|
134
|
+
...this.identity,
|
|
135
|
+
},
|
|
130
136
|
},
|
|
131
137
|
});
|
|
132
138
|
this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
|
|
@@ -263,6 +269,15 @@ export class GraineInAppClient {
|
|
|
263
269
|
reportEvent(event) {
|
|
264
270
|
this.send({ type: "app_event", event });
|
|
265
271
|
}
|
|
272
|
+
track(name, data) {
|
|
273
|
+
if (!name)
|
|
274
|
+
return;
|
|
275
|
+
this.recentEvents.push({ name, at: Date.now(), ...(data ? { data } : {}) });
|
|
276
|
+
if (this.recentEvents.length > 10)
|
|
277
|
+
this.recentEvents.shift();
|
|
278
|
+
if (this.screen)
|
|
279
|
+
this.scheduleScreen();
|
|
280
|
+
}
|
|
266
281
|
scheduleScreen() {
|
|
267
282
|
if (this.contextTimer) {
|
|
268
283
|
this.pendingScreen = true;
|
|
@@ -284,7 +299,12 @@ export class GraineInAppClient {
|
|
|
284
299
|
const { availableActions, ...rest } = this.screen;
|
|
285
300
|
return this.send({
|
|
286
301
|
type: "app_context",
|
|
287
|
-
context: maskDeep({
|
|
302
|
+
context: maskDeep({
|
|
303
|
+
...rest,
|
|
304
|
+
idle_ms: Date.now() - this.screenSince,
|
|
305
|
+
...(Object.keys(this.identity).length ? { user: this.identity } : {}),
|
|
306
|
+
...(this.recentEvents.length ? { recent_events: this.recentEvents } : {}),
|
|
307
|
+
}),
|
|
288
308
|
available_actions: (availableActions ?? this.availableActions).filter((a) => this.actions.has(a)),
|
|
289
309
|
seq: ++this.screenSeq,
|
|
290
310
|
});
|
|
@@ -310,6 +330,21 @@ export class GraineInAppClient {
|
|
|
310
330
|
});
|
|
311
331
|
}, after);
|
|
312
332
|
}
|
|
333
|
+
identify(user) {
|
|
334
|
+
if (!user) {
|
|
335
|
+
this.identity = {};
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const flat = {};
|
|
339
|
+
for (const [k, v] of Object.entries(user)) {
|
|
340
|
+
if (v === undefined || v === null)
|
|
341
|
+
continue;
|
|
342
|
+
flat[k] = typeof v === "string" ? v : JSON.stringify(v);
|
|
343
|
+
}
|
|
344
|
+
this.identity = maskDeep(flat);
|
|
345
|
+
if (this.screen)
|
|
346
|
+
this.scheduleScreen();
|
|
347
|
+
}
|
|
313
348
|
noteInteraction() {
|
|
314
349
|
this.screenSince = Date.now();
|
|
315
350
|
this.armStall();
|
package/dist/index.d.ts
CHANGED
|
@@ -3,5 +3,6 @@ export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "
|
|
|
3
3
|
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice";
|
|
4
4
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio";
|
|
5
5
|
export { addMaskRule, maskDeep, maskString } from "./mask";
|
|
6
|
-
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
|
|
6
|
+
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
|
|
7
7
|
export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps } from "./react-native/ui";
|
|
8
|
+
export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation";
|
package/dist/index.js
CHANGED
|
@@ -2,5 +2,6 @@ export { GraineInAppClient } from "./client";
|
|
|
2
2
|
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice";
|
|
3
3
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio";
|
|
4
4
|
export { addMaskRule, maskDeep, maskString } from "./mask";
|
|
5
|
-
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, } from "./react-native/index";
|
|
5
|
+
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, } from "./react-native/index";
|
|
6
6
|
export { GraineAgentBar, GraineLauncher } from "./react-native/ui";
|
|
7
|
+
export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation";
|
|
@@ -2,6 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { GraineInAppClient, type GraineInAppOptions } from "../client";
|
|
3
3
|
import { type AudioAdapter, type VoiceSessionOptions } from "../voice";
|
|
4
4
|
import type { ActionHandler, ScreenContext } from "../protocol";
|
|
5
|
+
import { type LauncherInset, type LauncherVisibility } from "./navigation";
|
|
5
6
|
interface GraineContextValue {
|
|
6
7
|
client: GraineInAppClient;
|
|
7
8
|
connected: boolean;
|
|
@@ -16,7 +17,38 @@ interface GraineContextValue {
|
|
|
16
17
|
setMuted: (muted: boolean) => void;
|
|
17
18
|
agentSpeaking: boolean;
|
|
18
19
|
caption: Caption | null;
|
|
20
|
+
launcherVisible: boolean;
|
|
21
|
+
launcherInset: LauncherInset;
|
|
22
|
+
currentScreen: string | null;
|
|
23
|
+
onEvent: (fn: (e: GraineEvent) => void) => () => void;
|
|
24
|
+
track: (name: string, data?: Record<string, unknown>) => void;
|
|
25
|
+
identify: (user: Record<string, unknown> | null) => void;
|
|
19
26
|
}
|
|
27
|
+
export type GraineEvent = {
|
|
28
|
+
type: "conversation_started";
|
|
29
|
+
} | {
|
|
30
|
+
type: "conversation_ended";
|
|
31
|
+
reason: string;
|
|
32
|
+
} | {
|
|
33
|
+
type: "agent_speaking";
|
|
34
|
+
speaking: boolean;
|
|
35
|
+
} | {
|
|
36
|
+
type: "agent_volunteered";
|
|
37
|
+
text: string;
|
|
38
|
+
} | {
|
|
39
|
+
type: "action_requested";
|
|
40
|
+
name: string;
|
|
41
|
+
} | {
|
|
42
|
+
type: "action_completed";
|
|
43
|
+
name: string;
|
|
44
|
+
status: string;
|
|
45
|
+
} | {
|
|
46
|
+
type: "muted";
|
|
47
|
+
muted: boolean;
|
|
48
|
+
} | {
|
|
49
|
+
type: "error";
|
|
50
|
+
message: string;
|
|
51
|
+
};
|
|
20
52
|
export interface Caption {
|
|
21
53
|
role: "agent" | "customer";
|
|
22
54
|
text: string;
|
|
@@ -31,9 +63,18 @@ export interface GraineProviderProps extends GraineInAppOptions {
|
|
|
31
63
|
children: React.ReactNode;
|
|
32
64
|
autoConnect?: boolean;
|
|
33
65
|
onProactive?: (text: string) => void;
|
|
66
|
+
navigationRef?: {
|
|
67
|
+
current: any;
|
|
68
|
+
} | null;
|
|
69
|
+
includeScreens?: string[];
|
|
70
|
+
launcherDelayMs?: number;
|
|
71
|
+
visibility?: LauncherVisibility;
|
|
34
72
|
}
|
|
35
|
-
export declare function GraineProvider({ children, autoConnect, onProactive, ...options }: GraineProviderProps): React.JSX.Element;
|
|
73
|
+
export declare function GraineProvider({ children, autoConnect, onProactive, navigationRef, includeScreens, launcherDelayMs, visibility, ...options }: GraineProviderProps): React.JSX.Element;
|
|
36
74
|
export declare function useGraineAgent(): GraineContextValue;
|
|
75
|
+
export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
|
|
76
|
+
export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
|
|
77
|
+
export declare function useGraineIdentify(): (user: Record<string, unknown> | null) => void;
|
|
37
78
|
export declare function useGraineVoice(adapter: AudioAdapter | null, options?: VoiceSessionOptions): {
|
|
38
79
|
listening: boolean;
|
|
39
80
|
agentSpeaking: boolean;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
3
|
import { GraineInAppClient } from "../client";
|
|
4
4
|
import { VoiceSession } from "../voice";
|
|
5
|
+
import { LauncherVisibilityTracker, activeRouteName, } from "./navigation";
|
|
5
6
|
const Ctx = createContext(null);
|
|
6
|
-
export function GraineProvider({ children, autoConnect = true, onProactive, ...options }) {
|
|
7
|
+
export function GraineProvider({ children, autoConnect = true, onProactive, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
|
|
7
8
|
const clientRef = useRef(null);
|
|
8
9
|
if (!clientRef.current)
|
|
9
10
|
clientRef.current = new GraineInAppClient(options);
|
|
@@ -19,6 +20,81 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
|
|
|
19
20
|
const [caption, setCaption] = useState(null);
|
|
20
21
|
const proactiveRef = useRef(onProactive);
|
|
21
22
|
proactiveRef.current = onProactive;
|
|
23
|
+
const listenersRef = useRef(new Set());
|
|
24
|
+
const emitEvent = useCallback((e) => {
|
|
25
|
+
listenersRef.current.forEach((fn) => {
|
|
26
|
+
try {
|
|
27
|
+
fn(e);
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
30
|
+
});
|
|
31
|
+
}, []);
|
|
32
|
+
const onEvent = useCallback((fn) => {
|
|
33
|
+
listenersRef.current.add(fn);
|
|
34
|
+
return () => { listenersRef.current.delete(fn); };
|
|
35
|
+
}, []);
|
|
36
|
+
const [currentScreen, setCurrentScreen] = useState(null);
|
|
37
|
+
const [launcherVisible, setLauncherVisible] = useState(false);
|
|
38
|
+
const [launcherInset, setLauncherInset] = useState({ right: 16, bottom: 20 });
|
|
39
|
+
const trackerRef = useRef(null);
|
|
40
|
+
if (!trackerRef.current) {
|
|
41
|
+
trackerRef.current = new LauncherVisibilityTracker(includeScreens, visibility, launcherDelayMs);
|
|
42
|
+
}
|
|
43
|
+
const showTimerRef = useRef(null);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const nav = navigationRef?.current;
|
|
46
|
+
if (!nav || typeof nav.addListener !== "function")
|
|
47
|
+
return;
|
|
48
|
+
const apply = () => {
|
|
49
|
+
let route = null;
|
|
50
|
+
try {
|
|
51
|
+
route = activeRouteName(nav.getRootState?.());
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
route = null;
|
|
55
|
+
}
|
|
56
|
+
if (!route)
|
|
57
|
+
return;
|
|
58
|
+
setCurrentScreen(route);
|
|
59
|
+
try {
|
|
60
|
+
client.setScreen({ screen: route, title: route });
|
|
61
|
+
}
|
|
62
|
+
catch { }
|
|
63
|
+
const decision = trackerRef.current.onRoute(route);
|
|
64
|
+
setLauncherInset(decision.inset);
|
|
65
|
+
if (showTimerRef.current) {
|
|
66
|
+
clearTimeout(showTimerRef.current);
|
|
67
|
+
showTimerRef.current = null;
|
|
68
|
+
}
|
|
69
|
+
if (!decision.eligible) {
|
|
70
|
+
setLauncherVisible(false);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (decision.delayMs <= 0) {
|
|
74
|
+
setLauncherVisible(true);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
setLauncherVisible(false);
|
|
78
|
+
showTimerRef.current = setTimeout(() => {
|
|
79
|
+
showTimerRef.current = null;
|
|
80
|
+
setLauncherVisible(true);
|
|
81
|
+
}, decision.delayMs);
|
|
82
|
+
};
|
|
83
|
+
apply();
|
|
84
|
+
const off = nav.addListener("state", apply);
|
|
85
|
+
return () => {
|
|
86
|
+
if (showTimerRef.current)
|
|
87
|
+
clearTimeout(showTimerRef.current);
|
|
88
|
+
if (typeof off === "function")
|
|
89
|
+
off();
|
|
90
|
+
else if (typeof nav.removeListener === "function")
|
|
91
|
+
nav.removeListener("state", apply);
|
|
92
|
+
};
|
|
93
|
+
}, [navigationRef, client]);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!navigationRef?.current)
|
|
96
|
+
setLauncherVisible(true);
|
|
97
|
+
}, [navigationRef]);
|
|
22
98
|
const openRef = useRef(open);
|
|
23
99
|
openRef.current = open;
|
|
24
100
|
useEffect(() => {
|
|
@@ -27,8 +103,12 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
|
|
|
27
103
|
setConnected(true);
|
|
28
104
|
setConnecting(false);
|
|
29
105
|
setError(null);
|
|
106
|
+
emitEvent({ type: "conversation_started" });
|
|
107
|
+
}),
|
|
108
|
+
client.on("close", (info) => {
|
|
109
|
+
setConnected(false);
|
|
110
|
+
emitEvent({ type: "conversation_ended", reason: info?.reason || String(info?.code ?? "closed") });
|
|
30
111
|
}),
|
|
31
|
-
client.on("close", () => setConnected(false)),
|
|
32
112
|
client.on("chunk", (text) => setMessages((prev) => {
|
|
33
113
|
const last = prev[prev.length - 1];
|
|
34
114
|
if (last?.role === "agent" && last.live) {
|
|
@@ -36,18 +116,24 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
|
|
|
36
116
|
}
|
|
37
117
|
if (prev[prev.length - 1]?.role !== "customer") {
|
|
38
118
|
proactiveRef.current?.(text);
|
|
119
|
+
emitEvent({ type: "agent_volunteered", text });
|
|
39
120
|
if (!openRef.current)
|
|
40
121
|
setOpen(true);
|
|
41
122
|
}
|
|
42
123
|
return [...prev, { role: "agent", text, live: true }];
|
|
43
124
|
})),
|
|
44
125
|
client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
|
|
45
|
-
client.on("muted", (m) =>
|
|
126
|
+
client.on("muted", (m) => {
|
|
127
|
+
setMutedState(m);
|
|
128
|
+
emitEvent({ type: "muted", muted: m });
|
|
129
|
+
}),
|
|
46
130
|
client.on("agent_speaking", (speaking) => {
|
|
47
131
|
setAgentSpeaking(speaking);
|
|
48
132
|
if (speaking)
|
|
49
133
|
setCaption(null);
|
|
134
|
+
emitEvent({ type: "agent_speaking", speaking });
|
|
50
135
|
}),
|
|
136
|
+
client.on("action", (req) => emitEvent({ type: "action_requested", name: req?.name ?? "" })),
|
|
51
137
|
client.on("transcript", ({ text, final }) => {
|
|
52
138
|
if (text)
|
|
53
139
|
setCaption({ role: "customer", text, live: !final });
|
|
@@ -92,7 +178,14 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
|
|
|
92
178
|
client.noteInteraction();
|
|
93
179
|
client.say(text);
|
|
94
180
|
},
|
|
95
|
-
|
|
181
|
+
launcherVisible,
|
|
182
|
+
launcherInset,
|
|
183
|
+
currentScreen,
|
|
184
|
+
onEvent,
|
|
185
|
+
track: (name, data) => client.track(name, data),
|
|
186
|
+
identify: (user) => client.identify(user),
|
|
187
|
+
}), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
|
|
188
|
+
launcherVisible, launcherInset, currentScreen, onEvent]);
|
|
96
189
|
return _jsx(Ctx.Provider, { value: value, children: children });
|
|
97
190
|
}
|
|
98
191
|
function useGraine() {
|
|
@@ -104,6 +197,20 @@ function useGraine() {
|
|
|
104
197
|
export function useGraineAgent() {
|
|
105
198
|
return useGraine();
|
|
106
199
|
}
|
|
200
|
+
export function useGraineEvents(handler) {
|
|
201
|
+
const { onEvent } = useGraine();
|
|
202
|
+
const ref = useRef(handler);
|
|
203
|
+
ref.current = handler;
|
|
204
|
+
useEffect(() => onEvent((e) => ref.current(e)), [onEvent]);
|
|
205
|
+
}
|
|
206
|
+
export function useGraineTrack() {
|
|
207
|
+
const { track } = useGraine();
|
|
208
|
+
return track;
|
|
209
|
+
}
|
|
210
|
+
export function useGraineIdentify() {
|
|
211
|
+
const { client } = useGraine();
|
|
212
|
+
return useCallback((user) => client.identify(user), [client]);
|
|
213
|
+
}
|
|
107
214
|
export function useGraineVoice(adapter, options = {}) {
|
|
108
215
|
const { client } = useGraine();
|
|
109
216
|
const [listening, setListening] = useState(false);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface LauncherInset {
|
|
2
|
+
top?: number;
|
|
3
|
+
right?: number;
|
|
4
|
+
bottom?: number;
|
|
5
|
+
left?: number;
|
|
6
|
+
}
|
|
7
|
+
export type LauncherContinuity = "continuous" | "perScreen";
|
|
8
|
+
export type LauncherDelayPolicy = "perScreen" | "oncePerGroupEntry" | "oncePerAppSession";
|
|
9
|
+
export interface LauncherGroup {
|
|
10
|
+
id: string;
|
|
11
|
+
screens: string[];
|
|
12
|
+
continuity: LauncherContinuity;
|
|
13
|
+
inset?: LauncherInset;
|
|
14
|
+
delayMs?: number;
|
|
15
|
+
delayPolicy?: LauncherDelayPolicy;
|
|
16
|
+
}
|
|
17
|
+
export interface LauncherVisibility {
|
|
18
|
+
defaultDelayMs?: number;
|
|
19
|
+
defaultInset?: LauncherInset;
|
|
20
|
+
groups?: LauncherGroup[];
|
|
21
|
+
}
|
|
22
|
+
export interface VisibilityDecision {
|
|
23
|
+
eligible: boolean;
|
|
24
|
+
delayMs: number;
|
|
25
|
+
inset: LauncherInset;
|
|
26
|
+
groupId: string | null;
|
|
27
|
+
continuous: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare class LauncherVisibilityTracker {
|
|
30
|
+
private includeScreens;
|
|
31
|
+
private visibility;
|
|
32
|
+
private defaultDelayMs;
|
|
33
|
+
private enteredGroups;
|
|
34
|
+
private sessionDelayedGroups;
|
|
35
|
+
private lastGroupId;
|
|
36
|
+
private lastRoute;
|
|
37
|
+
constructor(includeScreens: string[] | undefined, visibility: LauncherVisibility | undefined, defaultDelayMs?: number);
|
|
38
|
+
reset(): void;
|
|
39
|
+
private groupFor;
|
|
40
|
+
private isEligible;
|
|
41
|
+
onRoute(route: string): VisibilityDecision;
|
|
42
|
+
}
|
|
43
|
+
export declare function activeRouteName(state: any): string | null;
|
|
44
|
+
export declare function routeParams(state: any): Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const DEFAULT_INSET = { right: 16, bottom: 20 };
|
|
2
|
+
export class LauncherVisibilityTracker {
|
|
3
|
+
constructor(includeScreens, visibility, defaultDelayMs = 0) {
|
|
4
|
+
this.includeScreens = includeScreens;
|
|
5
|
+
this.visibility = visibility;
|
|
6
|
+
this.defaultDelayMs = defaultDelayMs;
|
|
7
|
+
this.enteredGroups = new Set();
|
|
8
|
+
this.sessionDelayedGroups = new Set();
|
|
9
|
+
this.lastGroupId = null;
|
|
10
|
+
this.lastRoute = null;
|
|
11
|
+
}
|
|
12
|
+
reset() {
|
|
13
|
+
this.enteredGroups.clear();
|
|
14
|
+
this.sessionDelayedGroups.clear();
|
|
15
|
+
this.lastGroupId = null;
|
|
16
|
+
this.lastRoute = null;
|
|
17
|
+
}
|
|
18
|
+
groupFor(route) {
|
|
19
|
+
for (const g of this.visibility?.groups || []) {
|
|
20
|
+
if (g.screens.includes(route))
|
|
21
|
+
return g;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
isEligible(route) {
|
|
26
|
+
const groups = this.visibility?.groups || [];
|
|
27
|
+
const hasAllowlist = Array.isArray(this.includeScreens) && this.includeScreens.length > 0;
|
|
28
|
+
if (!hasAllowlist && groups.length === 0)
|
|
29
|
+
return true;
|
|
30
|
+
if (hasAllowlist && this.includeScreens.includes(route))
|
|
31
|
+
return true;
|
|
32
|
+
return groups.some((g) => g.screens.includes(route));
|
|
33
|
+
}
|
|
34
|
+
onRoute(route) {
|
|
35
|
+
const eligible = this.isEligible(route);
|
|
36
|
+
const group = this.groupFor(route);
|
|
37
|
+
const inset = group?.inset || this.visibility?.defaultInset || DEFAULT_INSET;
|
|
38
|
+
if (!eligible) {
|
|
39
|
+
this.lastGroupId = null;
|
|
40
|
+
this.lastRoute = route;
|
|
41
|
+
return { eligible: false, delayMs: 0, inset, groupId: group?.id ?? null, continuous: false };
|
|
42
|
+
}
|
|
43
|
+
const configuredDelay = group?.delayMs ?? this.visibility?.defaultDelayMs ?? this.defaultDelayMs;
|
|
44
|
+
const policy = group?.delayPolicy ?? "perScreen";
|
|
45
|
+
const continuity = group?.continuity ?? "perScreen";
|
|
46
|
+
const stayingInGroup = group != null && this.lastGroupId === group.id;
|
|
47
|
+
const movedScreen = this.lastRoute !== route;
|
|
48
|
+
const continuous = continuity === "continuous" && stayingInGroup && movedScreen;
|
|
49
|
+
let delayMs = configuredDelay;
|
|
50
|
+
if (continuous) {
|
|
51
|
+
delayMs = 0;
|
|
52
|
+
}
|
|
53
|
+
else if (group) {
|
|
54
|
+
if (policy === "oncePerGroupEntry" && stayingInGroup) {
|
|
55
|
+
delayMs = 0;
|
|
56
|
+
}
|
|
57
|
+
else if (policy === "oncePerAppSession") {
|
|
58
|
+
delayMs = this.sessionDelayedGroups.has(group.id) ? 0 : configuredDelay;
|
|
59
|
+
this.sessionDelayedGroups.add(group.id);
|
|
60
|
+
}
|
|
61
|
+
if (!stayingInGroup)
|
|
62
|
+
this.enteredGroups.add(group.id);
|
|
63
|
+
}
|
|
64
|
+
this.lastGroupId = group?.id ?? null;
|
|
65
|
+
this.lastRoute = route;
|
|
66
|
+
return { eligible: true, delayMs, inset, groupId: group?.id ?? null, continuous };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function activeRouteName(state) {
|
|
70
|
+
if (!state || !Array.isArray(state.routes) || typeof state.index !== "number")
|
|
71
|
+
return null;
|
|
72
|
+
let route = state.routes[state.index];
|
|
73
|
+
while (route?.state) {
|
|
74
|
+
const child = route.state;
|
|
75
|
+
if (!Array.isArray(child.routes))
|
|
76
|
+
break;
|
|
77
|
+
const idx = typeof child.index === "number" ? child.index : child.routes.length - 1;
|
|
78
|
+
const next = child.routes[idx];
|
|
79
|
+
if (!next)
|
|
80
|
+
break;
|
|
81
|
+
route = next;
|
|
82
|
+
}
|
|
83
|
+
return route?.name ?? null;
|
|
84
|
+
}
|
|
85
|
+
export function routeParams(state) {
|
|
86
|
+
if (!state || !Array.isArray(state.routes) || typeof state.index !== "number")
|
|
87
|
+
return undefined;
|
|
88
|
+
let route = state.routes[state.index];
|
|
89
|
+
while (route?.state?.routes) {
|
|
90
|
+
const child = route.state;
|
|
91
|
+
const idx = typeof child.index === "number" ? child.index : child.routes.length - 1;
|
|
92
|
+
const next = child.routes[idx];
|
|
93
|
+
if (!next)
|
|
94
|
+
break;
|
|
95
|
+
route = next;
|
|
96
|
+
}
|
|
97
|
+
return route?.params && typeof route.params === "object" ? route.params : undefined;
|
|
98
|
+
}
|
package/dist/react-native/ui.js
CHANGED
|
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
|
|
4
4
|
import { useGraineAgent } from "./index";
|
|
5
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();
|
|
6
|
+
const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, } = useGraineAgent();
|
|
7
7
|
const [expanded, setExpanded] = useState(false);
|
|
8
8
|
const [captions, setCaptions] = useState(captionsOn);
|
|
9
9
|
const [draft, setDraft] = useState("");
|
|
@@ -31,9 +31,16 @@ export function GraineAgentBar({ accent = "#318CE7", name = "Assistant", avatarU
|
|
|
31
31
|
setDraft("");
|
|
32
32
|
send(text);
|
|
33
33
|
}, [draft, send]);
|
|
34
|
-
if (hidden || error)
|
|
34
|
+
if (hidden || error || !launcherVisible)
|
|
35
35
|
return null;
|
|
36
|
-
return (_jsxs(View, { pointerEvents: "box-none", style: [
|
|
36
|
+
return (_jsxs(View, { pointerEvents: "box-none", style: [
|
|
37
|
+
styles.root,
|
|
38
|
+
{
|
|
39
|
+
bottom: launcherInset.bottom ?? bottomInset,
|
|
40
|
+
right: launcherInset.right ?? 12,
|
|
41
|
+
left: launcherInset.left ?? 12,
|
|
42
|
+
},
|
|
43
|
+
], 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: [
|
|
37
44
|
styles.bubble,
|
|
38
45
|
m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
|
|
39
46
|
], 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 ? "⌄" : "⌃" }) })] })] }));
|
package/dist/voice.d.ts
CHANGED
|
@@ -22,9 +22,11 @@ export declare class VoiceSession {
|
|
|
22
22
|
private format;
|
|
23
23
|
private echo;
|
|
24
24
|
private agentAudible;
|
|
25
|
+
private lastAudioAt;
|
|
25
26
|
constructor(client: GraineInAppClient, adapter: AudioAdapter, options?: VoiceSessionOptions);
|
|
26
27
|
get active(): boolean;
|
|
27
28
|
start(): Promise<void>;
|
|
29
|
+
private agentIsAudible;
|
|
28
30
|
stop(): Promise<void>;
|
|
29
31
|
}
|
|
30
32
|
export declare function liveAudioStreamAdapter(LiveAudioStream: any, sink: Pick<AudioAdapter, "play" | "clear">): AudioAdapter;
|
package/dist/voice.js
CHANGED
|
@@ -14,6 +14,7 @@ export class VoiceSession {
|
|
|
14
14
|
this.format = null;
|
|
15
15
|
this.echo = new EchoGuard();
|
|
16
16
|
this.agentAudible = false;
|
|
17
|
+
this.lastAudioAt = 0;
|
|
17
18
|
}
|
|
18
19
|
get active() {
|
|
19
20
|
return this.capturing;
|
|
@@ -29,6 +30,7 @@ export class VoiceSession {
|
|
|
29
30
|
return;
|
|
30
31
|
if (decoded.latch)
|
|
31
32
|
this.format = decoded.latch;
|
|
33
|
+
this.lastAudioAt = Date.now();
|
|
32
34
|
this.adapter.play(decoded.pcm16, decoded.sampleRate);
|
|
33
35
|
}),
|
|
34
36
|
this.client.on("clear", () => {
|
|
@@ -51,19 +53,24 @@ export class VoiceSession {
|
|
|
51
53
|
await this.adapter.startCapture((base64) => {
|
|
52
54
|
const pcm = base64ToPcm16(base64);
|
|
53
55
|
const frameMs = (pcm.length / CAPTURE_SAMPLE_RATE) * 1000;
|
|
54
|
-
const
|
|
55
|
-
const audible = buffered !== undefined ? buffered > 0.02 || this.agentAudible : this.agentAudible;
|
|
56
|
-
for (const frame of this.echo.filter(base64, pcm, audible, frameMs)) {
|
|
56
|
+
for (const frame of this.echo.filter(base64, pcm, this.agentIsAudible(), frameMs)) {
|
|
57
57
|
this.client.sendAudio(frame, CAPTURE_SAMPLE_RATE);
|
|
58
58
|
}
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
|
+
agentIsAudible() {
|
|
62
|
+
if (Date.now() - this.lastAudioAt > 1500)
|
|
63
|
+
return false;
|
|
64
|
+
const buffered = this.adapter.bufferedSeconds?.();
|
|
65
|
+
return buffered !== undefined ? buffered > 0.02 || this.agentAudible : this.agentAudible;
|
|
66
|
+
}
|
|
61
67
|
async stop() {
|
|
62
68
|
if (!this.capturing)
|
|
63
69
|
return;
|
|
64
70
|
this.capturing = false;
|
|
65
71
|
this.format = null;
|
|
66
72
|
this.agentAudible = false;
|
|
73
|
+
this.lastAudioAt = 0;
|
|
67
74
|
this.echo.reset();
|
|
68
75
|
this.offs.forEach((off) => off());
|
|
69
76
|
this.offs = [];
|
package/package.json
CHANGED